mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: Sprint US-1200 Refactoring batch — 13 tasks complete
Completed refactors: - RF-02: Fix dependency vulnerabilities (xlsx → exceljs, Hono updates) - RF-05: Add React error boundaries (FeatureErrorBoundary wrapper) - RF-06: Server error handling middleware (AppError classes, asyncHandler) - RF-10: Split shared types.ts into domain modules (6 files) - RF-11: Consolidate frontend API layer (hooks now use api.ts) - RF-13: TaskConfigContext — eliminate prop drilling - RF-14: Split god components (GitSection, TaskDetailPanel, CreateTaskDialog, DiffViewer) - RF-16: Frontend accessibility (ARIA labels, sr-only text) - RF-17: Modularize CLI (899 → commands/ structure) - RF-18: Modularize MCP (843 → tools/ structure) - RF-19: Create shared API client library - RF-21: Server performance (batch loading, memory limits, timeouts, graceful shutdown) - RF-23: Extract shared utilities (path, format, constants) Stats: ~59 files changed, significant code reduction through modularization
This commit is contained in:
parent
01e0e29eb9
commit
39eccf3556
98 changed files with 7591 additions and 7108 deletions
|
|
@ -1,11 +1,11 @@
|
|||
[
|
||||
{
|
||||
"id": "brain",
|
||||
"label": "🧠 Knowledge Base",
|
||||
"label": "🧠 Knowledge",
|
||||
"color": "bg-green-500/20",
|
||||
"order": 1,
|
||||
"created": "2026-01-28T04:30:34.608Z",
|
||||
"updated": "2026-01-28T04:41:00.155Z"
|
||||
"updated": "2026-01-28T10:28:09.303Z"
|
||||
},
|
||||
{
|
||||
"id": "platform",
|
||||
|
|
@ -17,11 +17,11 @@
|
|||
},
|
||||
{
|
||||
"id": "veritas-kanban",
|
||||
"label": "🤖 veritas-kanban",
|
||||
"label": "🤖 Kanban",
|
||||
"color": "bg-orange-500/20",
|
||||
"order": 3,
|
||||
"created": "2026-01-28T04:30:34.608Z",
|
||||
"updated": "2026-01-28T04:41:18.261Z",
|
||||
"updated": "2026-01-28T10:27:55.479Z",
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -74,5 +74,14 @@
|
|||
"order": 7,
|
||||
"created": "2026-01-28T08:31:13.845Z",
|
||||
"updated": "2026-01-28T08:31:41.818Z"
|
||||
},
|
||||
{
|
||||
"label": "Refactor",
|
||||
"icon": "Package",
|
||||
"color": "border-l-yellow-400",
|
||||
"id": "refactor-saXoty",
|
||||
"order": 8,
|
||||
"created": "2026-01-28T10:27:13.983Z",
|
||||
"updated": "2026-01-28T10:27:33.257Z"
|
||||
}
|
||||
]
|
||||
|
|
@ -535,3 +535,62 @@
|
|||
{"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_4rB1Mba649ZE","timestamp":"2026-01-28T10:03:19.726Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_v40bF7Xzb8sF","timestamp":"2026-01-28T10:05:18.411Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_pO7WDomJvI6K","timestamp":"2026-01-28T10:05:29.028Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_0qPmbpsm4XLb","timestamp":"2026-01-28T10:09:02.221Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_F-XLhL","project":"veritas-kanban","status":"done","id":"evt_sAAFqdT13Z3t","timestamp":"2026-01-28T10:29:11.930Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_8HU2XD","project":"veritas-kanban","status":"done","id":"evt_hpjGBnt06qoe","timestamp":"2026-01-28T10:29:11.936Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_CnogQO","project":"veritas-kanban","status":"done","id":"evt_a6ZN8YTCPAjV","timestamp":"2026-01-28T10:29:11.940Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_-KNOy3","project":"veritas-kanban","status":"done","id":"evt_6U9P-_9DvO7B","timestamp":"2026-01-28T10:29:11.944Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_6SbOnv","project":"veritas-kanban","status":"done","id":"evt_zZkjizRzYmrA","timestamp":"2026-01-28T10:29:11.949Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_NrvUWe","project":"veritas-kanban","status":"done","id":"evt_kFivXp6pBFX2","timestamp":"2026-01-28T10:29:11.953Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_SFb0-u","project":"veritas-kanban","status":"done","id":"evt_z1-nQikB754G","timestamp":"2026-01-28T10:29:11.956Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_Q78_6y","project":"veritas-kanban","status":"done","id":"evt_QyI4Nn4DmM3T","timestamp":"2026-01-28T10:29:11.958Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_XvmMga","project":"brain","status":"done","id":"evt_7FIIo19FoxHM","timestamp":"2026-01-28T10:29:45.336Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_8KRThU","project":"veritas-kanban","status":"done","id":"evt_o6SR71L1HpMP","timestamp":"2026-01-28T10:29:45.336Z"}
|
||||
{"type":"task.archived","taskId":"task_20260128_iG-ISb","project":"platform","status":"done","id":"evt_6AOv5VehaRju","timestamp":"2026-01-28T10:29:45.336Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_v5QxzF","project":"veritas-kanban","status":"todo","id":"evt_1jTx0Bq7Kmi1","timestamp":"2026-01-28T10:44:54.237Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_jS7k9o","project":"veritas-kanban","status":"todo","id":"evt_t7ODPkWGDVDL","timestamp":"2026-01-28T10:44:54.252Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XIY4cW","project":"veritas-kanban","status":"todo","id":"evt_4tHbT__j5xxI","timestamp":"2026-01-28T10:44:54.256Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_eib_Vw","project":"veritas-kanban","status":"todo","id":"evt_lNMtAeCY9jBq","timestamp":"2026-01-28T10:44:54.259Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_HtCf-y","project":"veritas-kanban","status":"todo","id":"evt_u-LROX2DsU0X","timestamp":"2026-01-28T10:44:54.263Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"todo","id":"evt_zi0vWyh98OrI","timestamp":"2026-01-28T10:44:54.266Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_-8OO8l","project":"veritas-kanban","status":"todo","id":"evt_oofbYVVrTwKl","timestamp":"2026-01-28T10:44:54.270Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_hfzlfd","project":"veritas-kanban","status":"todo","id":"evt_kmKBp7EE3HH6","timestamp":"2026-01-28T10:44:54.273Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_wgJpvX","project":"veritas-kanban","status":"todo","id":"evt_JjhVIUair2Ty","timestamp":"2026-01-28T10:44:54.277Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_IWTxzN","project":"veritas-kanban","status":"todo","id":"evt_4aCh0AEU1S9_","timestamp":"2026-01-28T10:44:54.281Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_UMDZyh","project":"veritas-kanban","status":"todo","id":"evt_NRz5XBdc7z3J","timestamp":"2026-01-28T10:44:54.284Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_VnO-L8","project":"veritas-kanban","status":"todo","id":"evt_aY6TNKXKGXCY","timestamp":"2026-01-28T10:44:54.287Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_fylTjw","project":"veritas-kanban","status":"todo","id":"evt_EzBJX5gmaWpM","timestamp":"2026-01-28T10:44:54.290Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"todo","id":"evt_uppmWo7g1XR4","timestamp":"2026-01-28T10:44:54.293Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_1yHkWF","project":"veritas-kanban","status":"todo","id":"evt_xHZsqywicMEu","timestamp":"2026-01-28T10:44:54.295Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"todo","id":"evt_MwgoSSwo_m92","timestamp":"2026-01-28T10:44:54.296Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"todo","id":"evt_i4ysKEBkfDQD","timestamp":"2026-01-28T10:44:54.300Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"todo","id":"evt_unvqZpKzJX3O","timestamp":"2026-01-28T10:44:54.302Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"todo","id":"evt_aMpsytSZMOo6","timestamp":"2026-01-28T10:44:54.304Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_ksQUvs","project":"veritas-kanban","status":"todo","id":"evt_N83fRTOYEBHG","timestamp":"2026-01-28T10:44:54.306Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"todo","id":"evt_OJ8EtGRv9V6-","timestamp":"2026-01-28T10:44:54.309Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_Qh60Ao","project":"veritas-kanban","status":"todo","id":"evt_V6kWTM2LC0ie","timestamp":"2026-01-28T10:44:54.311Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"todo","id":"evt_P9fBuSg3csVc","timestamp":"2026-01-28T10:44:54.314Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XAxJXn","project":"veritas-kanban","status":"todo","id":"evt_MikNl2Svcwmt","timestamp":"2026-01-28T10:44:54.317Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_oeprdJ","project":"veritas-kanban","status":"todo","id":"evt_butwC_KmOMmn","timestamp":"2026-01-28T10:44:54.321Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_fylTjw","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_WcXGWzWrcU90","timestamp":"2026-01-28T10:51:55.037Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_IWTxzN","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_rnZ6ccqWwc9r","timestamp":"2026-01-28T10:52:57.896Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_jS7k9o","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_4go0YTJfit_y","timestamp":"2026-01-28T10:53:54.241Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_HtCf-y","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_wN4WxdLBFIkK","timestamp":"2026-01-28T10:54:03.660Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_K24bfiTBA_yS","timestamp":"2026-01-28T10:55:22.842Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_UMDZyh","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_lzdyWjezvrPc","timestamp":"2026-01-28T10:55:23.912Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_r2wQhOyoD5M8","timestamp":"2026-01-28T10:55:25.591Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_w3u7pnJjO9du","timestamp":"2026-01-28T10:55:25.797Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_o1z5wYacm1EU","timestamp":"2026-01-28T10:55:27.572Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_OFkc0Z","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_7ClmHA-F1aDL","timestamp":"2026-01-28T10:57:25.934Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_9ljJmb","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_CbrCzzIFQ7p2","timestamp":"2026-01-28T10:57:27.748Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_q-NnH4","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_8ndm1pEvWOOn","timestamp":"2026-01-28T10:57:59.076Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt__vAuEl4fjCtO","timestamp":"2026-01-28T10:58:54.952Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_K9gpEtMdckR6","timestamp":"2026-01-28T10:58:56.244Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_5-z0bxNtXB0t","timestamp":"2026-01-28T11:00:22.880Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_ge6c13Z6uuYt","timestamp":"2026-01-28T11:00:23.288Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_WYNpAQ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_sqshClbzr6UD","timestamp":"2026-01-28T11:00:55.417Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_XP9oDM","project":"brain","status":"todo","id":"evt_syQ4LV-O9a8d","timestamp":"2026-01-28T11:01:28.501Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_DmkA14","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_6F-GefLq-Npu","timestamp":"2026-01-28T11:01:29.736Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_lePtYQ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_3Iyqk4tXewur","timestamp":"2026-01-28T11:03:09.268Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_80Bww2","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_lhxfDcirN7Nm","timestamp":"2026-01-28T11:03:10.942Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_bIPwKK","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_qF5oHf8YG7Ha","timestamp":"2026-01-28T11:07:35.701Z"}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 88bf96a77f887086d7d88a535ceb44deb512a469
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"commander": "^12.1.0",
|
||||
"chalk": "^5.3.0"
|
||||
},
|
||||
|
|
|
|||
189
cli/src/commands/agents.ts
Normal file
189
cli/src/commands/agents.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import type { Task } from '../utils/types.js';
|
||||
|
||||
export function registerAgentCommands(program: Command): void {
|
||||
// Start agent on task
|
||||
program
|
||||
.command('start <id>')
|
||||
.description('Start an agent on a task')
|
||||
.option('-a, --agent <agent>', 'Agent to use (claude-code, amp, copilot, gemini)', 'claude-code')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (task.type !== 'code') {
|
||||
console.error(chalk.red('Can only start agents on code tasks'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!task.git?.worktreePath) {
|
||||
console.error(chalk.red('Task needs a worktree first. Create one via the UI.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent: options.agent }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Agent started: ${options.agent}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
console.log(chalk.dim(`Working in: ${task.git.worktreePath}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop agent
|
||||
program
|
||||
.command('stop <id>')
|
||||
.description('Stop a running agent')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ stopped: true }));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Agent stopped'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending agent requests (for Veritas to process)
|
||||
program
|
||||
.command('agents:pending')
|
||||
.description('List pending agent requests waiting for Clawdbot to process')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const pending = await api<{
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
prompt: string;
|
||||
requestedAt: string;
|
||||
callbackUrl: string;
|
||||
}[]>('/api/agents/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(pending, null, 2));
|
||||
} else if (pending.length === 0) {
|
||||
console.log(chalk.dim('No pending agent requests'));
|
||||
} else {
|
||||
console.log(chalk.bold(`\n🤖 ${pending.length} Pending Agent Request(s)\n`));
|
||||
|
||||
pending.forEach(req => {
|
||||
console.log(chalk.cyan(`Task: ${req.taskId}`));
|
||||
console.log(chalk.dim(` Attempt: ${req.attemptId}`));
|
||||
console.log(chalk.dim(` Requested: ${new Date(req.requestedAt).toLocaleString()}`));
|
||||
console.log(chalk.dim(` Callback: ${req.callbackUrl}`));
|
||||
console.log();
|
||||
|
||||
// Print first few lines of prompt
|
||||
const promptLines = req.prompt.split('\n').slice(0, 10);
|
||||
console.log(chalk.dim('─'.repeat(50)));
|
||||
promptLines.forEach(line => console.log(chalk.dim(` ${line}`)));
|
||||
if (req.prompt.split('\n').length > 10) {
|
||||
console.log(chalk.dim(' ...'));
|
||||
}
|
||||
console.log(chalk.dim('─'.repeat(50)));
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Complete an agent request (called by Clawdbot after sub-agent finishes)
|
||||
program
|
||||
.command('agents:complete <taskId>')
|
||||
.description('Mark an agent request as complete')
|
||||
.option('-s, --success', 'Mark as successful (default)')
|
||||
.option('-f, --failed', 'Mark as failed')
|
||||
.option('-m, --summary <text>', 'Summary of what was done')
|
||||
.option('-e, --error <text>', 'Error message (if failed)')
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const success = !options.failed;
|
||||
const body = {
|
||||
success,
|
||||
summary: options.summary,
|
||||
error: options.error,
|
||||
};
|
||||
|
||||
await api(`/api/agents/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (success) {
|
||||
console.log(chalk.green(`✓ Task ${taskId} marked as complete`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`⚠ Task ${taskId} marked as failed`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get agent status for a task
|
||||
program
|
||||
.command('agents:status <taskId>')
|
||||
.description('Get agent status for a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const status = await api<{
|
||||
running: boolean;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
}>(`/api/agents/${taskId}/status`);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
} else if (!status.running) {
|
||||
console.log(chalk.dim('No agent running for this task'));
|
||||
} else {
|
||||
console.log(chalk.yellow(`🤖 Agent Running`));
|
||||
console.log(` Task: ${status.taskId}`);
|
||||
console.log(` Attempt: ${status.attemptId}`);
|
||||
console.log(` Agent: ${status.agent}`);
|
||||
console.log(` Started: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
133
cli/src/commands/automation.ts
Normal file
133
cli/src/commands/automation.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import { formatTask, formatTasksJson } from '../utils/format.js';
|
||||
import type { Task } from '../utils/types.js';
|
||||
|
||||
export function registerAutomationCommands(program: Command): void {
|
||||
// List pending automation tasks
|
||||
program
|
||||
.command('automation:pending')
|
||||
.alias('ap')
|
||||
.description('List automation tasks pending execution')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/automation/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(tasks));
|
||||
} else if (tasks.length === 0) {
|
||||
console.log(chalk.dim('No pending automation tasks'));
|
||||
} else {
|
||||
console.log(chalk.bold('Pending Automation Tasks:\n'));
|
||||
tasks.forEach(task => console.log(formatTask(task, true)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// List running automation tasks
|
||||
program
|
||||
.command('automation:running')
|
||||
.alias('ar')
|
||||
.description('List currently running automation tasks')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/automation/running');
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(tasks));
|
||||
} else if (tasks.length === 0) {
|
||||
console.log(chalk.dim('No running automation tasks'));
|
||||
} else {
|
||||
console.log(chalk.bold('Running Automation Tasks:\n'));
|
||||
tasks.forEach(task => console.log(formatTask(task, true)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Start automation task (for Veritas to call)
|
||||
program
|
||||
.command('automation:start <id>')
|
||||
.alias('as')
|
||||
.description('Start an automation task via Veritas sub-agent')
|
||||
.option('-s, --session <key>', 'Clawdbot session key')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(`/api/automation/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionKey: options.session }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Automation started'));
|
||||
console.log(chalk.dim(`Task: ${result.title}`));
|
||||
console.log(chalk.dim(`Attempt: ${result.attemptId}`));
|
||||
if (result.description) {
|
||||
console.log(chalk.dim(`\nDescription:\n${result.description}`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Complete automation task
|
||||
program
|
||||
.command('automation:complete <id>')
|
||||
.alias('ac')
|
||||
.description('Mark an automation task as complete')
|
||||
.option('-r, --result <text>', 'Result summary')
|
||||
.option('-f, --failed', 'Mark as failed instead of complete')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; status: string }>(`/api/automation/${task.id}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
result: options.result,
|
||||
status: options.failed ? 'failed' : 'complete',
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.status === 'complete') {
|
||||
console.log(chalk.green('✓ Automation completed'));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Automation marked as failed'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
152
cli/src/commands/notifications.ts
Normal file
152
cli/src/commands/notifications.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export function registerNotificationCommands(program: Command): void {
|
||||
// Send a notification
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description('Create a notification')
|
||||
.option('-t, --type <type>', 'Notification type (info, error, milestone, high_priority)', 'info')
|
||||
.option('--title <title>', 'Notification title')
|
||||
.option('--task <id>', 'Related task ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (message, options) => {
|
||||
try {
|
||||
const notification = await api<{ id: string; type: string; title: string }>('/api/notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: options.type,
|
||||
title: options.title || (options.type === 'error' ? 'Error' : options.type === 'milestone' ? 'Milestone' : 'Notification'),
|
||||
message,
|
||||
taskId: options.task,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(notification, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Notification created'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for notifications
|
||||
program
|
||||
.command('notify:check')
|
||||
.description('Check for tasks that need notifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<{ checked: number; created: number }>('/api/notifications/check', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.dim(`Checked ${result.checked} tasks`));
|
||||
if (result.created > 0) {
|
||||
console.log(chalk.yellow(`Created ${result.created} new notifications`));
|
||||
} else {
|
||||
console.log(chalk.dim('No new notifications'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending notifications
|
||||
program
|
||||
.command('notify:pending')
|
||||
.description('Get pending notifications formatted for Teams')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.option('--mark-sent', 'Mark notifications as sent after output')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<{ count: number; messages: { id: string; type: string; text: string; timestamp: string }[] }>('/api/notifications/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.count === 0) {
|
||||
console.log(chalk.dim('No pending notifications'));
|
||||
} else {
|
||||
result.messages.forEach(msg => {
|
||||
console.log(msg.text);
|
||||
console.log(chalk.dim('─'.repeat(40)));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.markSent && result.count > 0) {
|
||||
const ids = result.messages.map(m => m.id);
|
||||
await api('/api/notifications/mark-sent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
console.log(chalk.dim(`Marked ${ids.length} notifications as sent`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// List all notifications
|
||||
program
|
||||
.command('notify:list')
|
||||
.description('List all notifications')
|
||||
.option('-u, --unsent', 'Show only unsent notifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const url = options.unsent ? '/api/notifications?unsent=true' : '/api/notifications';
|
||||
const notifications = await api<{ id: string; type: string; title: string; message: string; sent: boolean; timestamp: string }[]>(url);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(notifications, null, 2));
|
||||
} else if (notifications.length === 0) {
|
||||
console.log(chalk.dim('No notifications'));
|
||||
} else {
|
||||
const typeIcons: Record<string, string> = {
|
||||
agent_complete: '✅',
|
||||
agent_failed: '❌',
|
||||
needs_review: '👀',
|
||||
task_done: '🎉',
|
||||
high_priority: '🔴',
|
||||
error: '⚠️',
|
||||
milestone: '🏆',
|
||||
info: 'ℹ️',
|
||||
};
|
||||
|
||||
notifications.forEach(n => {
|
||||
const icon = typeIcons[n.type] || '•';
|
||||
const sent = n.sent ? chalk.dim('[sent]') : chalk.yellow('[pending]');
|
||||
console.log(`${icon} ${chalk.bold(n.title)} ${sent}`);
|
||||
console.log(chalk.dim(` ${n.message.slice(0, 60)}${n.message.length > 60 ? '...' : ''}`));
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear notifications
|
||||
program
|
||||
.command('notify:clear')
|
||||
.description('Clear all notifications')
|
||||
.action(async () => {
|
||||
try {
|
||||
await api('/api/notifications', { method: 'DELETE' });
|
||||
console.log(chalk.green('✓ Notifications cleared'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
93
cli/src/commands/summary.ts
Normal file
93
cli/src/commands/summary.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api, API_BASE } from '../utils/api.js';
|
||||
|
||||
export function registerSummaryCommands(program: Command): void {
|
||||
// Show summary
|
||||
program
|
||||
.command('summary')
|
||||
.description('Show task summary')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const summary = await api<{
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
byProject: Record<string, { total: number; done: number; inProgress: number }>;
|
||||
highPriority: { id: string; title: string; status: string; project?: string }[];
|
||||
}>('/api/summary');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} else {
|
||||
console.log(chalk.bold('\n📊 Veritas Kanban Summary\n'));
|
||||
|
||||
console.log(chalk.dim('Status:'));
|
||||
console.log(` To Do: ${summary.byStatus.todo}`);
|
||||
console.log(` In Progress: ${summary.byStatus['in-progress']}`);
|
||||
console.log(` Review: ${summary.byStatus.review}`);
|
||||
console.log(` Done: ${summary.byStatus.done}`);
|
||||
|
||||
const projects = Object.entries(summary.byProject);
|
||||
if (projects.length > 0) {
|
||||
console.log(chalk.dim('\nProjects:'));
|
||||
projects.forEach(([name, stats]) => {
|
||||
const percent = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
|
||||
console.log(` ${name}: ${stats.done}/${stats.total} (${percent}%)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.highPriority.length > 0) {
|
||||
console.log(chalk.red('\n🔴 High Priority:'));
|
||||
summary.highPriority.forEach(t => {
|
||||
console.log(` ${t.title} [${t.status}]${t.project ? ` #${t.project}` : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get memory-formatted summary
|
||||
program
|
||||
.command('memory')
|
||||
.description('Get task summary formatted for memory files')
|
||||
.option('-h, --hours <n>', 'Hours to look back', '24')
|
||||
.option('-o, --output <file>', 'Append to file instead of stdout')
|
||||
.option('--json', 'Output recent tasks as JSON instead')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (options.json) {
|
||||
const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`);
|
||||
console.log(JSON.stringify(recent, null, 2));
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`);
|
||||
const markdown = await res.text();
|
||||
|
||||
if (options.output) {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
|
||||
// Expand ~ to home dir
|
||||
let outputPath = options.output;
|
||||
if (outputPath.startsWith('~')) {
|
||||
outputPath = path.join(process.env.HOME || '', outputPath.slice(1));
|
||||
}
|
||||
|
||||
// Append to file
|
||||
await fs.appendFile(outputPath, '\n' + markdown);
|
||||
console.log(chalk.green(`✓ Appended to ${outputPath}`));
|
||||
} else {
|
||||
console.log(markdown);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
218
cli/src/commands/tasks.ts
Normal file
218
cli/src/commands/tasks.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import { formatTask, formatTaskJson, formatTasksJson } from '../utils/format.js';
|
||||
import type { Task } from '../utils/types.js';
|
||||
|
||||
export function registerTaskCommands(program: Command): void {
|
||||
// List tasks
|
||||
program
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List tasks')
|
||||
.option('-s, --status <status>', 'Filter by status (todo, in-progress, review, done)')
|
||||
.option('-t, --type <type>', 'Filter by type (code, research, content, automation)')
|
||||
.option('-p, --project <project>', 'Filter by project')
|
||||
.option('-v, --verbose', 'Show more details')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
|
||||
let filtered = tasks;
|
||||
if (options.status) {
|
||||
filtered = filtered.filter(t => t.status === options.status);
|
||||
}
|
||||
if (options.type) {
|
||||
filtered = filtered.filter(t => t.type === options.type);
|
||||
}
|
||||
if (options.project) {
|
||||
filtered = filtered.filter(t => t.project === options.project);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(filtered));
|
||||
} else if (filtered.length === 0) {
|
||||
console.log(chalk.dim('No tasks found'));
|
||||
} else {
|
||||
filtered.forEach(task => console.log(formatTask(task, options.verbose)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Show task details
|
||||
program
|
||||
.command('show <id>')
|
||||
.description('Show task details')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(formatTask(task, true));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
console.log(chalk.dim(`ID: ${task.id}`));
|
||||
console.log(chalk.dim(`Created: ${new Date(task.created).toLocaleString()}`));
|
||||
console.log(chalk.dim(`Updated: ${new Date(task.updated).toLocaleString()}`));
|
||||
// Tags feature not yet implemented in shared types
|
||||
// if (task.tags?.length) {
|
||||
// console.log(chalk.dim(`Tags: ${task.tags.join(', ')}`));
|
||||
// }
|
||||
if (task.description) {
|
||||
console.log('\n' + task.description);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create task
|
||||
program
|
||||
.command('create <title>')
|
||||
.description('Create a new task')
|
||||
.option('-t, --type <type>', 'Task type (code, research, content, automation)', 'code')
|
||||
.option('-p, --project <project>', 'Project name')
|
||||
.option('-d, --description <desc>', 'Task description')
|
||||
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (title, options) => {
|
||||
try {
|
||||
const task = await api<Task>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
type: options.type,
|
||||
project: options.project,
|
||||
description: options.description || '',
|
||||
priority: options.priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task created'));
|
||||
console.log(formatTask(task, true));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Update task
|
||||
program
|
||||
.command('update <id>')
|
||||
.description('Update a task')
|
||||
.option('-s, --status <status>', 'New status')
|
||||
.option('-t, --type <type>', 'New type')
|
||||
.option('-p, --project <project>', 'New project')
|
||||
.option('--priority <priority>', 'New priority')
|
||||
.option('--title <title>', 'New title')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const existing = await findTask(id);
|
||||
|
||||
if (!existing) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (options.status) updates.status = options.status;
|
||||
if (options.type) updates.type = options.type;
|
||||
if (options.project) updates.project = options.project;
|
||||
if (options.priority) updates.priority = options.priority;
|
||||
if (options.title) updates.title = options.title;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.error(chalk.yellow('No updates specified'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = await api<Task>(`/api/tasks/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task updated'));
|
||||
console.log(formatTask(task, true));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Archive task
|
||||
program
|
||||
.command('archive <id>')
|
||||
.description('Archive a completed task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}/archive`, { method: 'POST' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ archived: true }));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task archived'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete task
|
||||
program
|
||||
.command('delete <id>')
|
||||
.description('Delete a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ deleted: true }));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task deleted'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
898
cli/src/index.ts
898
cli/src/index.ts
|
|
@ -1,123 +1,10 @@
|
|||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
|
||||
const API_BASE = process.env.VK_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'code' | 'research' | 'content' | 'automation';
|
||||
status: 'todo' | 'in-progress' | 'review' | 'done';
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
git?: {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath?: string;
|
||||
};
|
||||
attempt?: {
|
||||
id: string;
|
||||
agent: string;
|
||||
status: string;
|
||||
started?: string;
|
||||
ended?: string;
|
||||
};
|
||||
review?: {
|
||||
decision?: 'approved' | 'changes-requested' | 'rejected';
|
||||
decidedAt?: string;
|
||||
summary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(error.error || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatTask(task: Task, verbose = false): string {
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
'todo': chalk.gray,
|
||||
'in-progress': chalk.yellow,
|
||||
'review': chalk.blue,
|
||||
'done': chalk.green,
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, (s: string) => string> = {
|
||||
'low': chalk.dim,
|
||||
'medium': chalk.white,
|
||||
'high': chalk.red,
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
'code': '💻',
|
||||
'research': '🔍',
|
||||
'content': '📝',
|
||||
'automation': '⚡',
|
||||
};
|
||||
|
||||
const statusColor = statusColors[task.status] || chalk.white;
|
||||
const priorityColor = priorityColors[task.priority] || chalk.white;
|
||||
|
||||
let line = `${typeIcons[task.type] || '•'} ${chalk.cyan(task.id.slice(-8))} `;
|
||||
line += statusColor(`[${task.status}]`) + ' ';
|
||||
line += priorityColor(`(${task.priority})`) + ' ';
|
||||
line += chalk.bold(task.title);
|
||||
|
||||
if (task.project) {
|
||||
line += chalk.dim(` #${task.project}`);
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
line += '\n';
|
||||
if (task.description) {
|
||||
line += chalk.dim(` ${task.description.slice(0, 80)}${task.description.length > 80 ? '...' : ''}\n`);
|
||||
}
|
||||
if (task.git?.branch) {
|
||||
line += chalk.dim(` 🌿 ${task.git.branch}\n`);
|
||||
}
|
||||
if (task.attempt?.status === 'running') {
|
||||
line += chalk.yellow(` 🤖 Agent running (${task.attempt.agent})\n`);
|
||||
}
|
||||
if (task.review?.decision) {
|
||||
const decisionColors: Record<string, (s: string) => string> = {
|
||||
'approved': chalk.green,
|
||||
'changes-requested': chalk.yellow,
|
||||
'rejected': chalk.red,
|
||||
};
|
||||
const color = decisionColors[task.review.decision] || chalk.white;
|
||||
line += color(` ✓ ${task.review.decision}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function formatTaskJson(task: Task): string {
|
||||
return JSON.stringify(task, null, 2);
|
||||
}
|
||||
|
||||
function formatTasksJson(tasks: Task[]): string {
|
||||
return JSON.stringify(tasks, null, 2);
|
||||
}
|
||||
import { registerTaskCommands } from './commands/tasks.js';
|
||||
import { registerAgentCommands } from './commands/agents.js';
|
||||
import { registerAutomationCommands } from './commands/automation.js';
|
||||
import { registerNotificationCommands } from './commands/notifications.js';
|
||||
import { registerSummaryCommands } from './commands/summary.js';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
|
|
@ -126,774 +13,11 @@ program
|
|||
.description('Veritas Kanban CLI - Task management for AI agents')
|
||||
.version('0.1.0');
|
||||
|
||||
// List tasks
|
||||
program
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List tasks')
|
||||
.option('-s, --status <status>', 'Filter by status (todo, in-progress, review, done)')
|
||||
.option('-t, --type <type>', 'Filter by type (code, research, content, automation)')
|
||||
.option('-p, --project <project>', 'Filter by project')
|
||||
.option('-v, --verbose', 'Show more details')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
|
||||
let filtered = tasks;
|
||||
if (options.status) {
|
||||
filtered = filtered.filter(t => t.status === options.status);
|
||||
}
|
||||
if (options.type) {
|
||||
filtered = filtered.filter(t => t.type === options.type);
|
||||
}
|
||||
if (options.project) {
|
||||
filtered = filtered.filter(t => t.project === options.project);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(filtered));
|
||||
} else if (filtered.length === 0) {
|
||||
console.log(chalk.dim('No tasks found'));
|
||||
} else {
|
||||
filtered.forEach(task => console.log(formatTask(task, options.verbose)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Show task details
|
||||
program
|
||||
.command('show <id>')
|
||||
.description('Show task details')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
// Support partial ID matching
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(formatTask(task, true));
|
||||
console.log(chalk.dim('─'.repeat(60)));
|
||||
console.log(chalk.dim(`ID: ${task.id}`));
|
||||
console.log(chalk.dim(`Created: ${new Date(task.created).toLocaleString()}`));
|
||||
console.log(chalk.dim(`Updated: ${new Date(task.updated).toLocaleString()}`));
|
||||
if (task.tags?.length) {
|
||||
console.log(chalk.dim(`Tags: ${task.tags.join(', ')}`));
|
||||
}
|
||||
if (task.description) {
|
||||
console.log('\n' + task.description);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Create task
|
||||
program
|
||||
.command('create <title>')
|
||||
.description('Create a new task')
|
||||
.option('-t, --type <type>', 'Task type (code, research, content, automation)', 'code')
|
||||
.option('-p, --project <project>', 'Project name')
|
||||
.option('-d, --description <desc>', 'Task description')
|
||||
.option('--priority <priority>', 'Priority (low, medium, high)', 'medium')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (title, options) => {
|
||||
try {
|
||||
const task = await api<Task>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
type: options.type,
|
||||
project: options.project,
|
||||
description: options.description || '',
|
||||
priority: options.priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task created'));
|
||||
console.log(formatTask(task, true));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Update task
|
||||
program
|
||||
.command('update <id>')
|
||||
.description('Update a task')
|
||||
.option('-s, --status <status>', 'New status')
|
||||
.option('-t, --type <type>', 'New type')
|
||||
.option('-p, --project <project>', 'New project')
|
||||
.option('--priority <priority>', 'New priority')
|
||||
.option('--title <title>', 'New title')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
// Support partial ID matching
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const existing = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!existing) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (options.status) updates.status = options.status;
|
||||
if (options.type) updates.type = options.type;
|
||||
if (options.project) updates.project = options.project;
|
||||
if (options.priority) updates.priority = options.priority;
|
||||
if (options.title) updates.title = options.title;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
console.error(chalk.yellow('No updates specified'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = await api<Task>(`/api/tasks/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTaskJson(task));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task updated'));
|
||||
console.log(formatTask(task, true));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Start agent on task
|
||||
program
|
||||
.command('start <id>')
|
||||
.description('Start an agent on a task')
|
||||
.option('-a, --agent <agent>', 'Agent to use (claude-code, amp, copilot, gemini)', 'claude-code')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
// Support partial ID matching
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (task.type !== 'code') {
|
||||
console.error(chalk.red('Can only start agents on code tasks'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!task.git?.worktreePath) {
|
||||
console.error(chalk.red('Task needs a worktree first. Create one via the UI.'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent: options.agent }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`✓ Agent started: ${options.agent}`));
|
||||
console.log(chalk.dim(`Attempt ID: ${result.attemptId}`));
|
||||
console.log(chalk.dim(`Working in: ${task.git.worktreePath}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Stop agent
|
||||
program
|
||||
.command('stop <id>')
|
||||
.description('Stop a running agent')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ stopped: true }));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Agent stopped'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Archive task
|
||||
program
|
||||
.command('archive <id>')
|
||||
.description('Archive a completed task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}/archive`, { method: 'POST' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ archived: true }));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task archived'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete task
|
||||
program
|
||||
.command('delete <id>')
|
||||
.description('Delete a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ deleted: true }));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Task deleted'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// === Automation Commands ===
|
||||
|
||||
// List pending automation tasks
|
||||
program
|
||||
.command('automation:pending')
|
||||
.alias('ap')
|
||||
.description('List automation tasks pending execution')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/automation/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(tasks));
|
||||
} else if (tasks.length === 0) {
|
||||
console.log(chalk.dim('No pending automation tasks'));
|
||||
} else {
|
||||
console.log(chalk.bold('Pending Automation Tasks:\n'));
|
||||
tasks.forEach(task => console.log(formatTask(task, true)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// List running automation tasks
|
||||
program
|
||||
.command('automation:running')
|
||||
.alias('ar')
|
||||
.description('List currently running automation tasks')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/automation/running');
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatTasksJson(tasks));
|
||||
} else if (tasks.length === 0) {
|
||||
console.log(chalk.dim('No running automation tasks'));
|
||||
} else {
|
||||
console.log(chalk.bold('Running Automation Tasks:\n'));
|
||||
tasks.forEach(task => console.log(formatTask(task, true)));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Start automation task (for Veritas to call)
|
||||
program
|
||||
.command('automation:start <id>')
|
||||
.alias('as')
|
||||
.description('Start an automation task via Veritas sub-agent')
|
||||
.option('-s, --session <key>', 'Clawdbot session key')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(`/api/automation/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionKey: options.session }),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Automation started'));
|
||||
console.log(chalk.dim(`Task: ${result.title}`));
|
||||
console.log(chalk.dim(`Attempt: ${result.attemptId}`));
|
||||
if (result.description) {
|
||||
console.log(chalk.dim(`\nDescription:\n${result.description}`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Complete automation task
|
||||
program
|
||||
.command('automation:complete <id>')
|
||||
.alias('ac')
|
||||
.description('Mark an automation task as complete')
|
||||
.option('-r, --result <text>', 'Result summary')
|
||||
.option('-f, --failed', 'Mark as failed instead of complete')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (id, options) => {
|
||||
try {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
const task = tasks.find(t => t.id === id || t.id.endsWith(id));
|
||||
|
||||
if (!task) {
|
||||
console.error(chalk.red(`Task not found: ${id}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; status: string }>(`/api/automation/${task.id}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
result: options.result,
|
||||
status: options.failed ? 'failed' : 'complete',
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.status === 'complete') {
|
||||
console.log(chalk.green('✓ Automation completed'));
|
||||
} else {
|
||||
console.log(chalk.yellow('✓ Automation marked as failed'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// === Notification Commands ===
|
||||
|
||||
// Send a notification
|
||||
program
|
||||
.command('notify <message>')
|
||||
.description('Create a notification')
|
||||
.option('-t, --type <type>', 'Notification type (info, error, milestone, high_priority)', 'info')
|
||||
.option('--title <title>', 'Notification title')
|
||||
.option('--task <id>', 'Related task ID')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (message, options) => {
|
||||
try {
|
||||
const notification = await api<{ id: string; type: string; title: string }>('/api/notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: options.type,
|
||||
title: options.title || (options.type === 'error' ? 'Error' : options.type === 'milestone' ? 'Milestone' : 'Notification'),
|
||||
message,
|
||||
taskId: options.task,
|
||||
}),
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(notification, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green('✓ Notification created'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for notifications
|
||||
program
|
||||
.command('notify:check')
|
||||
.description('Check for tasks that need notifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<{ checked: number; created: number }>('/api/notifications/check', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.dim(`Checked ${result.checked} tasks`));
|
||||
if (result.created > 0) {
|
||||
console.log(chalk.yellow(`Created ${result.created} new notifications`));
|
||||
} else {
|
||||
console.log(chalk.dim('No new notifications'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get pending notifications
|
||||
program
|
||||
.command('notify:pending')
|
||||
.description('Get pending notifications formatted for Teams')
|
||||
.option('--json', 'Output raw JSON')
|
||||
.option('--mark-sent', 'Mark notifications as sent after output')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const result = await api<{ count: number; messages: { id: string; type: string; text: string; timestamp: string }[] }>('/api/notifications/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.count === 0) {
|
||||
console.log(chalk.dim('No pending notifications'));
|
||||
} else {
|
||||
result.messages.forEach(msg => {
|
||||
console.log(msg.text);
|
||||
console.log(chalk.dim('─'.repeat(40)));
|
||||
});
|
||||
}
|
||||
|
||||
if (options.markSent && result.count > 0) {
|
||||
const ids = result.messages.map(m => m.id);
|
||||
await api('/api/notifications/mark-sent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
console.log(chalk.dim(`Marked ${ids.length} notifications as sent`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// List all notifications
|
||||
program
|
||||
.command('notify:list')
|
||||
.description('List all notifications')
|
||||
.option('-u, --unsent', 'Show only unsent notifications')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const url = options.unsent ? '/api/notifications?unsent=true' : '/api/notifications';
|
||||
const notifications = await api<{ id: string; type: string; title: string; message: string; sent: boolean; timestamp: string }[]>(url);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(notifications, null, 2));
|
||||
} else if (notifications.length === 0) {
|
||||
console.log(chalk.dim('No notifications'));
|
||||
} else {
|
||||
const typeIcons: Record<string, string> = {
|
||||
agent_complete: '✅',
|
||||
agent_failed: '❌',
|
||||
needs_review: '👀',
|
||||
task_done: '🎉',
|
||||
high_priority: '🔴',
|
||||
error: '⚠️',
|
||||
milestone: '🏆',
|
||||
info: 'ℹ️',
|
||||
};
|
||||
|
||||
notifications.forEach(n => {
|
||||
const icon = typeIcons[n.type] || '•';
|
||||
const sent = n.sent ? chalk.dim('[sent]') : chalk.yellow('[pending]');
|
||||
console.log(`${icon} ${chalk.bold(n.title)} ${sent}`);
|
||||
console.log(chalk.dim(` ${n.message.slice(0, 60)}${n.message.length > 60 ? '...' : ''}`));
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear notifications
|
||||
program
|
||||
.command('notify:clear')
|
||||
.description('Clear all notifications')
|
||||
.action(async () => {
|
||||
try {
|
||||
await api('/api/notifications', { method: 'DELETE' });
|
||||
console.log(chalk.green('✓ Notifications cleared'));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// === Memory Sync Commands ===
|
||||
|
||||
// Show summary
|
||||
program
|
||||
.command('summary')
|
||||
.description('Show task summary')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const summary = await api<{
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
byProject: Record<string, { total: number; done: number; inProgress: number }>;
|
||||
highPriority: { id: string; title: string; status: string; project?: string }[];
|
||||
}>('/api/summary');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} else {
|
||||
console.log(chalk.bold('\n📊 Veritas Kanban Summary\n'));
|
||||
|
||||
console.log(chalk.dim('Status:'));
|
||||
console.log(` To Do: ${summary.byStatus.todo}`);
|
||||
console.log(` In Progress: ${summary.byStatus['in-progress']}`);
|
||||
console.log(` Review: ${summary.byStatus.review}`);
|
||||
console.log(` Done: ${summary.byStatus.done}`);
|
||||
|
||||
const projects = Object.entries(summary.byProject);
|
||||
if (projects.length > 0) {
|
||||
console.log(chalk.dim('\nProjects:'));
|
||||
projects.forEach(([name, stats]) => {
|
||||
const percent = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0;
|
||||
console.log(` ${name}: ${stats.done}/${stats.total} (${percent}%)`);
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.highPriority.length > 0) {
|
||||
console.log(chalk.red('\n🔴 High Priority:'));
|
||||
summary.highPriority.forEach(t => {
|
||||
console.log(` ${t.title} [${t.status}]${t.project ? ` #${t.project}` : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get memory-formatted summary
|
||||
program
|
||||
.command('memory')
|
||||
.description('Get task summary formatted for memory files')
|
||||
.option('-h, --hours <n>', 'Hours to look back', '24')
|
||||
.option('-o, --output <file>', 'Append to file instead of stdout')
|
||||
.option('--json', 'Output recent tasks as JSON instead')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
if (options.json) {
|
||||
const recent = await api<unknown>(`/api/summary/recent?hours=${options.hours}`);
|
||||
console.log(JSON.stringify(recent, null, 2));
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${options.hours}`);
|
||||
const markdown = await res.text();
|
||||
|
||||
if (options.output) {
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
|
||||
// Expand ~ to home dir
|
||||
let outputPath = options.output;
|
||||
if (outputPath.startsWith('~')) {
|
||||
outputPath = path.join(process.env.HOME || '', outputPath.slice(1));
|
||||
}
|
||||
|
||||
// Append to file
|
||||
await fs.appendFile(outputPath, '\n' + markdown);
|
||||
console.log(chalk.green(`✓ Appended to ${outputPath}`));
|
||||
} else {
|
||||
console.log(markdown);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// === Agent Commands (Clawdbot Integration) ===
|
||||
|
||||
// Get pending agent requests (for Veritas to process)
|
||||
program
|
||||
.command('agents:pending')
|
||||
.description('List pending agent requests waiting for Clawdbot to process')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options) => {
|
||||
try {
|
||||
const pending = await api<{
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
prompt: string;
|
||||
requestedAt: string;
|
||||
callbackUrl: string;
|
||||
}[]>('/api/agents/pending');
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(pending, null, 2));
|
||||
} else if (pending.length === 0) {
|
||||
console.log(chalk.dim('No pending agent requests'));
|
||||
} else {
|
||||
console.log(chalk.bold(`\n🤖 ${pending.length} Pending Agent Request(s)\n`));
|
||||
|
||||
pending.forEach(req => {
|
||||
console.log(chalk.cyan(`Task: ${req.taskId}`));
|
||||
console.log(chalk.dim(` Attempt: ${req.attemptId}`));
|
||||
console.log(chalk.dim(` Requested: ${new Date(req.requestedAt).toLocaleString()}`));
|
||||
console.log(chalk.dim(` Callback: ${req.callbackUrl}`));
|
||||
console.log();
|
||||
|
||||
// Print first few lines of prompt
|
||||
const promptLines = req.prompt.split('\n').slice(0, 10);
|
||||
console.log(chalk.dim('─'.repeat(50)));
|
||||
promptLines.forEach(line => console.log(chalk.dim(` ${line}`)));
|
||||
if (req.prompt.split('\n').length > 10) {
|
||||
console.log(chalk.dim(' ...'));
|
||||
}
|
||||
console.log(chalk.dim('─'.repeat(50)));
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Complete an agent request (called by Clawdbot after sub-agent finishes)
|
||||
program
|
||||
.command('agents:complete <taskId>')
|
||||
.description('Mark an agent request as complete')
|
||||
.option('-s, --success', 'Mark as successful (default)')
|
||||
.option('-f, --failed', 'Mark as failed')
|
||||
.option('-m, --summary <text>', 'Summary of what was done')
|
||||
.option('-e, --error <text>', 'Error message (if failed)')
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const success = !options.failed;
|
||||
const body = {
|
||||
success,
|
||||
summary: options.summary,
|
||||
error: options.error,
|
||||
};
|
||||
|
||||
await api(`/api/agents/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (success) {
|
||||
console.log(chalk.green(`✓ Task ${taskId} marked as complete`));
|
||||
} else {
|
||||
console.log(chalk.yellow(`⚠ Task ${taskId} marked as failed`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Get agent status for a task
|
||||
program
|
||||
.command('agents:status <taskId>')
|
||||
.description('Get agent status for a task')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (taskId, options) => {
|
||||
try {
|
||||
const status = await api<{
|
||||
running: boolean;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
agent?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
}>(`/api/agents/${taskId}/status`);
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
} else if (!status.running) {
|
||||
console.log(chalk.dim('No agent running for this task'));
|
||||
} else {
|
||||
console.log(chalk.yellow(`🤖 Agent Running`));
|
||||
console.log(` Task: ${status.taskId}`);
|
||||
console.log(` Attempt: ${status.attemptId}`);
|
||||
console.log(` Agent: ${status.agent}`);
|
||||
console.log(` Started: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
// Register all command groups
|
||||
registerTaskCommands(program);
|
||||
registerAgentCommands(program);
|
||||
registerAutomationCommands(program);
|
||||
registerNotificationCommands(program);
|
||||
registerSummaryCommands(program);
|
||||
|
||||
program.parse();
|
||||
|
|
|
|||
2
cli/src/utils/api.ts
Normal file
2
cli/src/utils/api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared API client
|
||||
export { api, createApiClient, API_BASE } from '@veritas-kanban/shared';
|
||||
2
cli/src/utils/find.ts
Normal file
2
cli/src/utils/find.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared findTask
|
||||
export { findTask } from '@veritas-kanban/shared';
|
||||
68
cli/src/utils/format.ts
Normal file
68
cli/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import chalk from 'chalk';
|
||||
import type { Task } from './types.js';
|
||||
|
||||
export function formatTask(task: Task, verbose = false): string {
|
||||
const statusColors: Record<string, (s: string) => string> = {
|
||||
'todo': chalk.gray,
|
||||
'in-progress': chalk.yellow,
|
||||
'review': chalk.blue,
|
||||
'done': chalk.green,
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, (s: string) => string> = {
|
||||
'low': chalk.dim,
|
||||
'medium': chalk.white,
|
||||
'high': chalk.red,
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, string> = {
|
||||
'code': '💻',
|
||||
'research': '🔍',
|
||||
'content': '📝',
|
||||
'automation': '⚡',
|
||||
};
|
||||
|
||||
const statusColor = statusColors[task.status] || chalk.white;
|
||||
const priorityColor = priorityColors[task.priority] || chalk.white;
|
||||
|
||||
let line = `${typeIcons[task.type] || '•'} ${chalk.cyan(task.id.slice(-8))} `;
|
||||
line += statusColor(`[${task.status}]`) + ' ';
|
||||
line += priorityColor(`(${task.priority})`) + ' ';
|
||||
line += chalk.bold(task.title);
|
||||
|
||||
if (task.project) {
|
||||
line += chalk.dim(` #${task.project}`);
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
line += '\n';
|
||||
if (task.description) {
|
||||
line += chalk.dim(` ${task.description.slice(0, 80)}${task.description.length > 80 ? '...' : ''}\n`);
|
||||
}
|
||||
if (task.git?.branch) {
|
||||
line += chalk.dim(` 🌿 ${task.git.branch}\n`);
|
||||
}
|
||||
if (task.attempt?.status === 'running') {
|
||||
line += chalk.yellow(` 🤖 Agent running (${task.attempt.agent})\n`);
|
||||
}
|
||||
if (task.review?.decision) {
|
||||
const decisionColors: Record<string, (s: string) => string> = {
|
||||
'approved': chalk.green,
|
||||
'changes-requested': chalk.yellow,
|
||||
'rejected': chalk.red,
|
||||
};
|
||||
const color = decisionColors[task.review.decision] || chalk.white;
|
||||
line += color(` ✓ ${task.review.decision}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
export function formatTaskJson(task: Task): string {
|
||||
return JSON.stringify(task, null, 2);
|
||||
}
|
||||
|
||||
export function formatTasksJson(tasks: Task[]): string {
|
||||
return JSON.stringify(tasks, null, 2);
|
||||
}
|
||||
2
cli/src/utils/types.ts
Normal file
2
cli/src/utils/types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared types
|
||||
export type { Task } from '@veritas-kanban/shared';
|
||||
|
|
@ -13,12 +13,14 @@
|
|||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||
"hono": "^4.11.7",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"typescript": "^5.7.0",
|
||||
"tsx": "^4.19.0"
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
741
mcp/src/index.ts
741
mcp/src/index.ts
|
|
@ -7,105 +7,18 @@ import {
|
|||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
const API_BASE = process.env.VK_API_URL || 'http://localhost:3001';
|
||||
// Import utilities
|
||||
import { api } from './utils/api.js';
|
||||
import { findTask } from './utils/find.js';
|
||||
import { Task } from './utils/types.js';
|
||||
|
||||
// Task type definition
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'code' | 'research' | 'content' | 'automation';
|
||||
status: 'todo' | 'in-progress' | 'review' | 'done';
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
git?: {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath?: string;
|
||||
};
|
||||
attempt?: {
|
||||
id: string;
|
||||
agent: string;
|
||||
status: string;
|
||||
started?: string;
|
||||
ended?: string;
|
||||
};
|
||||
review?: {
|
||||
decision?: string;
|
||||
decidedAt?: string;
|
||||
summary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// API helper
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(error.error || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Tool input schemas
|
||||
const ListTasksSchema = z.object({
|
||||
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const CreateTaskSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).default('code'),
|
||||
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const UpdateTaskSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const TaskIdSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const StartAgentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).default('claude-code'),
|
||||
});
|
||||
|
||||
const StartAutomationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
sessionKey: z.string().optional(),
|
||||
});
|
||||
|
||||
const CompleteAutomationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
result: z.string().optional(),
|
||||
failed: z.boolean().default(false),
|
||||
});
|
||||
// Import tool modules
|
||||
import { taskTools, handleTaskTool } from './tools/tasks.js';
|
||||
import { agentTools, handleAgentTool } from './tools/agents.js';
|
||||
import { automationTools, handleAutomationTool } from './tools/automation.js';
|
||||
import { notificationTools, handleNotificationTool } from './tools/notifications.js';
|
||||
import { summaryTools, handleSummaryTool } from './tools/summary.js';
|
||||
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
|
|
@ -121,308 +34,19 @@ const server = new Server(
|
|||
}
|
||||
);
|
||||
|
||||
// Find task by ID (supports partial matching)
|
||||
async function findTask(id: string): Promise<Task | null> {
|
||||
const tasks = await api<Task[]>('/api/tasks');
|
||||
return tasks.find(t => t.id === id || t.id.endsWith(id)) || null;
|
||||
}
|
||||
// Combine all tools
|
||||
const allTools = [
|
||||
...taskTools,
|
||||
...agentTools,
|
||||
...automationTools,
|
||||
...notificationTools,
|
||||
...summaryTools,
|
||||
];
|
||||
|
||||
// List available tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: 'List all tasks in Veritas Kanban. Can filter by status, type, or project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'review', 'done'],
|
||||
description: 'Filter by task status',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'Filter by task type',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'Filter by project name',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_task',
|
||||
description: 'Get details of a specific task by ID (supports partial ID matching)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID (last 6+ characters)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_task',
|
||||
description: 'Create a new task in Veritas Kanban',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Task title',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Task description',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'Task type (default: code)',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'Task priority (default: medium)',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'Project name',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_task',
|
||||
description: 'Update an existing task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'New title',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'New description',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'review', 'done'],
|
||||
description: 'New status',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'New type',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'New priority',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'New project',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'start_agent',
|
||||
description: 'Start an AI coding agent on a code task (requires worktree)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
agent: {
|
||||
type: 'string',
|
||||
enum: ['claude-code', 'amp', 'copilot', 'gemini'],
|
||||
description: 'Agent to use (default: claude-code)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stop_agent',
|
||||
description: 'Stop a running agent on a task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'archive_task',
|
||||
description: 'Archive a completed task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_task',
|
||||
description: 'Delete a task permanently',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_pending_automation',
|
||||
description: 'List automation tasks waiting to be executed by Veritas',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_running_automation',
|
||||
description: 'List automation tasks currently being executed',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'start_automation',
|
||||
description: 'Start an automation task via Veritas sub-agent',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
sessionKey: {
|
||||
type: 'string',
|
||||
description: 'Clawdbot session key (optional)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'complete_automation',
|
||||
description: 'Mark an automation task as complete or failed',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
result: {
|
||||
type: 'string',
|
||||
description: 'Result summary',
|
||||
},
|
||||
failed: {
|
||||
type: 'boolean',
|
||||
description: 'Mark as failed instead of complete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_summary',
|
||||
description: 'Get overall kanban summary (status counts, projects, high-priority)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_memory_summary',
|
||||
description: 'Get task summary formatted for memory files (completed tasks, active high-priority, project progress)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: {
|
||||
type: 'number',
|
||||
description: 'Hours to look back (default: 24)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_notification',
|
||||
description: 'Create a notification (for Teams delivery)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['info', 'error', 'milestone', 'high_priority', 'agent_complete', 'agent_failed', 'needs_review', 'task_done'],
|
||||
description: 'Notification type',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Notification title',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Notification message',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Related task ID (optional)',
|
||||
},
|
||||
},
|
||||
required: ['type', 'title', 'message'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_pending_notifications',
|
||||
description: 'Get pending notifications formatted for Teams',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'check_notifications',
|
||||
description: 'Check for tasks that need notifications (review ready, agent failed, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: allTools,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -431,314 +55,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'list_tasks': {
|
||||
const params = ListTasksSchema.parse(args || {});
|
||||
let tasks = await api<Task[]>('/api/tasks');
|
||||
|
||||
if (params.status) {
|
||||
tasks = tasks.filter(t => t.status === params.status);
|
||||
}
|
||||
if (params.type) {
|
||||
tasks = tasks.filter(t => t.type === params.type);
|
||||
}
|
||||
if (params.project) {
|
||||
tasks = tasks.filter(t => t.project === params.project);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(tasks, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(task, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'create_task': {
|
||||
const params = CreateTaskSchema.parse(args);
|
||||
const task = await api<Task>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Task created: ${task.id}\n${JSON.stringify(task, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'update_task': {
|
||||
const { id, ...updates } = UpdateTaskSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await api<Task>(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Task updated: ${updated.id}\n${JSON.stringify(updated, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'start_agent': {
|
||||
const { id, agent } = StartAgentSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (task.type !== 'code') {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Can only start agents on code tasks' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!task.git?.worktreePath) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Task needs a worktree first' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent }),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Agent started: ${agent}\nAttempt ID: ${result.attemptId}\nWorking in: ${task.git.worktreePath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'stop_agent': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Agent stopped' }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'archive_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}/archive`, { method: 'POST' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task archived: ${task.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task deleted: ${task.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'list_pending_automation': {
|
||||
const tasks = await api<Task[]>('/api/automation/pending');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'list_running_automation': {
|
||||
const tasks = await api<Task[]>('/api/automation/running');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'start_automation': {
|
||||
const { id, sessionKey } = StartAutomationSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(
|
||||
`/api/automation/${task.id}/start`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionKey }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Automation started\nTask: ${result.title}\nAttempt: ${result.attemptId}\n\nDescription:\n${result.description}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'complete_automation': {
|
||||
const { id, result, failed } = CompleteAutomationSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await api<{ taskId: string; status: string }>(
|
||||
`/api/automation/${task.id}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ result, status: failed ? 'failed' : 'complete' }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Automation ${response.status === 'complete' ? 'completed' : 'marked as failed'}: ${task.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_summary': {
|
||||
const summary = await api<unknown>('/api/summary');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_memory_summary': {
|
||||
const hours = (args as { hours?: number })?.hours || 24;
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`);
|
||||
const markdown = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text', text: markdown }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'create_notification': {
|
||||
const { type, title, message, taskId } = args as { type: string; title: string; message: string; taskId?: string };
|
||||
const notification = await api<{ id: string }>('/api/notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, title, message, taskId }),
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `Notification created: ${notification.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_pending_notifications': {
|
||||
const result = await api<{ count: number; messages: { text: string }[] }>('/api/notifications/pending');
|
||||
if (result.count === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No pending notifications' }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: result.messages.map(m => m.text).join('\n\n---\n\n') }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'check_notifications': {
|
||||
const result = await api<{ checked: number; created: number; notifications: { title: string }[] }>('/api/notifications/check', {
|
||||
method: 'POST',
|
||||
});
|
||||
let text = `Checked ${result.checked} tasks. Created ${result.created} new notifications.`;
|
||||
if (result.notifications.length > 0) {
|
||||
text += '\n\nNew notifications:\n' + result.notifications.map(n => `- ${n.title}`).join('\n');
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
// Route to appropriate handler
|
||||
if (taskTools.some(t => t.name === name)) {
|
||||
return await handleTaskTool(name, args);
|
||||
}
|
||||
if (agentTools.some(t => t.name === name)) {
|
||||
return await handleAgentTool(name, args);
|
||||
}
|
||||
if (automationTools.some(t => t.name === name)) {
|
||||
return await handleAutomationTool(name, args);
|
||||
}
|
||||
if (notificationTools.some(t => t.name === name)) {
|
||||
return await handleNotificationTool(name, args);
|
||||
}
|
||||
if (summaryTools.some(t => t.name === name)) {
|
||||
return await handleSummaryTool(name, args);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
|
|
|
|||
113
mcp/src/tools/agents.ts
Normal file
113
mcp/src/tools/agents.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { z } from 'zod';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
|
||||
const StartAgentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).default('claude-code'),
|
||||
});
|
||||
|
||||
const TaskIdSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const agentTools = [
|
||||
{
|
||||
name: 'start_agent',
|
||||
description: 'Start an AI coding agent on a code task (requires worktree)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
agent: {
|
||||
type: 'string',
|
||||
enum: ['claude-code', 'amp', 'copilot', 'gemini'],
|
||||
description: 'Agent to use (default: claude-code)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stop_agent',
|
||||
description: 'Stop a running agent on a task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function handleAgentTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'start_agent': {
|
||||
const { id, agent } = StartAgentSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (task.type !== 'code') {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Can only start agents on code tasks' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!task.git?.worktreePath) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Task needs a worktree first' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await api<{ attemptId: string }>(`/api/agents/${task.id}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent }),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Agent started: ${agent}\nAttempt ID: ${result.attemptId}\nWorking in: ${task.git.worktreePath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'stop_agent': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/agents/${task.id}/stop`, { method: 'POST' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Agent stopped' }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown agent tool: ${name}`);
|
||||
}
|
||||
}
|
||||
153
mcp/src/tools/automation.ts
Normal file
153
mcp/src/tools/automation.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { z } from 'zod';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import { Task } from '../utils/types.js';
|
||||
|
||||
const StartAutomationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
sessionKey: z.string().optional(),
|
||||
});
|
||||
|
||||
const CompleteAutomationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
result: z.string().optional(),
|
||||
failed: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const automationTools = [
|
||||
{
|
||||
name: 'list_pending_automation',
|
||||
description: 'List automation tasks waiting to be executed by Veritas',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_running_automation',
|
||||
description: 'List automation tasks currently being executed',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'start_automation',
|
||||
description: 'Start an automation task via Veritas sub-agent',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
sessionKey: {
|
||||
type: 'string',
|
||||
description: 'Clawdbot session key (optional)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'complete_automation',
|
||||
description: 'Mark an automation task as complete or failed',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
result: {
|
||||
type: 'string',
|
||||
description: 'Result summary',
|
||||
},
|
||||
failed: {
|
||||
type: 'boolean',
|
||||
description: 'Mark as failed instead of complete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function handleAutomationTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'list_pending_automation': {
|
||||
const tasks = await api<Task[]>('/api/automation/pending');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'list_running_automation': {
|
||||
const tasks = await api<Task[]>('/api/automation/running');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(tasks, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'start_automation': {
|
||||
const { id, sessionKey } = StartAutomationSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await api<{ taskId: string; attemptId: string; title: string; description: string }>(
|
||||
`/api/automation/${task.id}/start`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionKey }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Automation started\nTask: ${result.title}\nAttempt: ${result.attemptId}\n\nDescription:\n${result.description}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'complete_automation': {
|
||||
const { id, result, failed } = CompleteAutomationSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await api<{ taskId: string; status: string }>(
|
||||
`/api/automation/${task.id}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ result, status: failed ? 'failed' : 'complete' }),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Automation ${response.status === 'complete' ? 'completed' : 'marked as failed'}: ${task.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown automation tool: ${name}`);
|
||||
}
|
||||
}
|
||||
90
mcp/src/tools/notifications.ts
Normal file
90
mcp/src/tools/notifications.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { api } from '../utils/api.js';
|
||||
|
||||
export const notificationTools = [
|
||||
{
|
||||
name: 'create_notification',
|
||||
description: 'Create a notification (for Teams delivery)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['info', 'error', 'milestone', 'high_priority', 'agent_complete', 'agent_failed', 'needs_review', 'task_done'],
|
||||
description: 'Notification type',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Notification title',
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'Notification message',
|
||||
},
|
||||
taskId: {
|
||||
type: 'string',
|
||||
description: 'Related task ID (optional)',
|
||||
},
|
||||
},
|
||||
required: ['type', 'title', 'message'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_pending_notifications',
|
||||
description: 'Get pending notifications formatted for Teams',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'check_notifications',
|
||||
description: 'Check for tasks that need notifications (review ready, agent failed, etc.)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function handleNotificationTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'create_notification': {
|
||||
const { type, title, message, taskId } = args as { type: string; title: string; message: string; taskId?: string };
|
||||
const notification = await api<{ id: string }>('/api/notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, title, message, taskId }),
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `Notification created: ${notification.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_pending_notifications': {
|
||||
const result = await api<{ count: number; messages: { text: string }[] }>('/api/notifications/pending');
|
||||
if (result.count === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No pending notifications' }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: result.messages.map(m => m.text).join('\n\n---\n\n') }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'check_notifications': {
|
||||
const result = await api<{ checked: number; created: number; notifications: { title: string }[] }>('/api/notifications/check', {
|
||||
method: 'POST',
|
||||
});
|
||||
let text = `Checked ${result.checked} tasks. Created ${result.created} new notifications.`;
|
||||
if (result.notifications.length > 0) {
|
||||
text += '\n\nNew notifications:\n' + result.notifications.map(n => `- ${n.title}`).join('\n');
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown notification tool: ${name}`);
|
||||
}
|
||||
}
|
||||
48
mcp/src/tools/summary.ts
Normal file
48
mcp/src/tools/summary.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { api, API_BASE } from '../utils/api.js';
|
||||
|
||||
export const summaryTools = [
|
||||
{
|
||||
name: 'get_summary',
|
||||
description: 'Get overall kanban summary (status counts, projects, high-priority)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_memory_summary',
|
||||
description: 'Get task summary formatted for memory files (completed tasks, active high-priority, project progress)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hours: {
|
||||
type: 'number',
|
||||
description: 'Hours to look back (default: 24)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function handleSummaryTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'get_summary': {
|
||||
const summary = await api<unknown>('/api/summary');
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_memory_summary': {
|
||||
const hours = (args as { hours?: number })?.hours || 24;
|
||||
const res = await fetch(`${API_BASE}/api/summary/memory?hours=${hours}`);
|
||||
const markdown = await res.text();
|
||||
return {
|
||||
content: [{ type: 'text', text: markdown }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown summary tool: ${name}`);
|
||||
}
|
||||
}
|
||||
300
mcp/src/tools/tasks.ts
Normal file
300
mcp/src/tools/tasks.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { z } from 'zod';
|
||||
import { api } from '../utils/api.js';
|
||||
import { findTask } from '../utils/find.js';
|
||||
import { Task } from '../utils/types.js';
|
||||
|
||||
// Tool input schemas
|
||||
const ListTasksSchema = z.object({
|
||||
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const CreateTaskSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).default('code'),
|
||||
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const UpdateTaskSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
});
|
||||
|
||||
const TaskIdSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
export const taskTools = [
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: 'List all tasks in Veritas Kanban. Can filter by status, type, or project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'review', 'done'],
|
||||
description: 'Filter by task status',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'Filter by task type',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'Filter by project name',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_task',
|
||||
description: 'Get details of a specific task by ID (supports partial ID matching)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID (last 6+ characters)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_task',
|
||||
description: 'Create a new task in Veritas Kanban',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Task title',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Task description',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'Task type (default: code)',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'Task priority (default: medium)',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'Project name',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'update_task',
|
||||
description: 'Update an existing task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'New title',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'New description',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['todo', 'in-progress', 'review', 'done'],
|
||||
description: 'New status',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['code', 'research', 'content', 'automation'],
|
||||
description: 'New type',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['low', 'medium', 'high'],
|
||||
description: 'New priority',
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'New project',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'archive_task',
|
||||
description: 'Archive a completed task',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_task',
|
||||
description: 'Delete a task permanently',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Task ID or partial ID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function handleTaskTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'list_tasks': {
|
||||
const params = ListTasksSchema.parse(args || {});
|
||||
let tasks = await api<Task[]>('/api/tasks');
|
||||
|
||||
if (params.status) {
|
||||
tasks = tasks.filter(t => t.status === params.status);
|
||||
}
|
||||
if (params.type) {
|
||||
tasks = tasks.filter(t => t.type === params.type);
|
||||
}
|
||||
if (params.project) {
|
||||
tasks = tasks.filter(t => t.project === params.project);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(tasks, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(task, null, 2) }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'create_task': {
|
||||
const params = CreateTaskSchema.parse(args);
|
||||
const task = await api<Task>('/api/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Task created: ${task.id}\n${JSON.stringify(task, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'update_task': {
|
||||
const { id, ...updates } = UpdateTaskSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await api<Task>(`/api/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Task updated: ${updated.id}\n${JSON.stringify(updated, null, 2)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'archive_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}/archive`, { method: 'POST' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task archived: ${task.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete_task': {
|
||||
const { id } = TaskIdSchema.parse(args);
|
||||
const task = await findTask(id);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task not found: ${id}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
await api(`/api/tasks/${task.id}`, { method: 'DELETE' });
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Task deleted: ${task.id}` }],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown task tool: ${name}`);
|
||||
}
|
||||
}
|
||||
2
mcp/src/utils/api.ts
Normal file
2
mcp/src/utils/api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared API client
|
||||
export { api, createApiClient, API_BASE } from '@veritas-kanban/shared';
|
||||
2
mcp/src/utils/find.ts
Normal file
2
mcp/src/utils/find.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared findTask
|
||||
export { findTask } from '@veritas-kanban/shared';
|
||||
2
mcp/src/utils/types.ts
Normal file
2
mcp/src/utils/types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Re-export shared types
|
||||
export type { Task } from '@veritas-kanban/shared';
|
||||
|
|
@ -25,5 +25,10 @@
|
|||
"prettier": "^3.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"hono": ">=4.11.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
545
pnpm-lock.yaml
generated
545
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"cors": "^2.8.5",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
@ -26,11 +27,11 @@
|
|||
"simple-git": "^3.27.0",
|
||||
"unpdf": "^1.4.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/exceljs": "^1.3.2",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { TextExtractionService } from '../services/text-extraction-service.js';
|
||||
import * as XLSX from 'xlsx';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
describe('TextExtractionService', () => {
|
||||
let service: TextExtractionService;
|
||||
|
|
@ -77,18 +77,17 @@ describe('TextExtractionService', () => {
|
|||
it('should extract text from XLSX as CSV', async () => {
|
||||
const filepath = path.join(testRoot, 'test.xlsx');
|
||||
|
||||
// Create a real XLSX file using the xlsx library
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const data = [
|
||||
['Name', 'Age', 'City'],
|
||||
['Alice', 30, 'New York'],
|
||||
['Bob', 25, 'London'],
|
||||
];
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(data);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
|
||||
// Create a real XLSX file using exceljs
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('Sheet1');
|
||||
|
||||
// Add data
|
||||
worksheet.addRow(['Name', 'Age', 'City']);
|
||||
worksheet.addRow(['Alice', 30, 'New York']);
|
||||
worksheet.addRow(['Bob', 25, 'London']);
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filepath, XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||
await workbook.xlsx.writeFile(filepath);
|
||||
|
||||
const extracted = await service.extractText(filepath, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
|
||||
|
|
@ -101,19 +100,19 @@ describe('TextExtractionService', () => {
|
|||
it('should handle multiple sheets in XLSX', async () => {
|
||||
const filepath = path.join(testRoot, 'multi-sheet.xlsx');
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
// Sheet 1
|
||||
const data1 = [['Column1'], ['Value1']];
|
||||
const worksheet1 = XLSX.utils.aoa_to_sheet(data1);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet1, 'Sheet1');
|
||||
const worksheet1 = workbook.addWorksheet('Sheet1');
|
||||
worksheet1.addRow(['Column1']);
|
||||
worksheet1.addRow(['Value1']);
|
||||
|
||||
// Sheet 2
|
||||
const data2 = [['Column2'], ['Value2']];
|
||||
const worksheet2 = XLSX.utils.aoa_to_sheet(data2);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet2, 'Sheet2');
|
||||
const worksheet2 = workbook.addWorksheet('Sheet2');
|
||||
worksheet2.addRow(['Column2']);
|
||||
worksheet2.addRow(['Value2']);
|
||||
|
||||
await fs.writeFile(filepath, XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }));
|
||||
await workbook.xlsx.writeFile(filepath);
|
||||
|
||||
const extracted = await service.extractText(filepath, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { settingsRoutes, syncSettingsToServices } from './routes/settings.js';
|
|||
import { getTelemetryService } from './services/telemetry-service.js';
|
||||
import { ConfigService } from './services/config-service.js';
|
||||
import { initBroadcast } from './services/broadcast-service.js';
|
||||
import { errorHandler } from './middleware/error-handler.js';
|
||||
import type { AgentOutput } from './services/agent-service.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -61,6 +62,9 @@ app.use('/api/metrics', metricsRoutes);
|
|||
app.use('/api/traces', tracesRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// Initialize telemetry service and sync with feature settings
|
||||
(async () => {
|
||||
try {
|
||||
|
|
@ -195,6 +199,33 @@ wss.on('connection', (ws) => {
|
|||
// Export for use in other modules
|
||||
export { wss };
|
||||
|
||||
// Graceful shutdown handler
|
||||
function gracefulShutdown(signal: string) {
|
||||
console.log(`\n${signal} received. Shutting down gracefully...`);
|
||||
|
||||
// Close WebSocket connections
|
||||
console.log('Closing WebSocket connections...');
|
||||
wss.clients.forEach((client) => {
|
||||
client.close(1000, 'Server shutting down');
|
||||
});
|
||||
|
||||
// Close HTTP server
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed.');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.error('Forced shutdown after timeout.');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Register shutdown handlers
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
console.log(`
|
||||
|
|
|
|||
7
server/src/middleware/async-handler.ts
Normal file
7
server/src/middleware/async-handler.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
export const asyncHandler = (
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
||||
): RequestHandler =>
|
||||
(req, res, next) =>
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
54
server/src/middleware/error-handler.ts
Normal file
54
server/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
// Custom error classes
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Not found') {
|
||||
super(404, message, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, details?: unknown) {
|
||||
super(400, message, 'VALIDATION_ERROR', details);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(409, message, 'CONFLICT');
|
||||
}
|
||||
}
|
||||
|
||||
// Express error handling middleware (4 args)
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
const response: { error: string; code?: string; details?: unknown } = {
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
};
|
||||
if (err.details) {
|
||||
response.details = err.details;
|
||||
}
|
||||
return res.status(err.statusCode).json(response);
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
|
@ -1,29 +1,20 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { activityService } from '../services/activity-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
// GET /api/activity - Get recent activities
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const activities = await activityService.getActivities(limit);
|
||||
res.json(activities);
|
||||
} catch (error) {
|
||||
console.error('Error getting activities:', error);
|
||||
res.status(500).json({ error: 'Failed to get activities' });
|
||||
}
|
||||
});
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const activities = await activityService.getActivities(limit);
|
||||
res.json(activities);
|
||||
}));
|
||||
|
||||
// DELETE /api/activity - Clear all activities
|
||||
router.delete('/', async (_req, res) => {
|
||||
try {
|
||||
await activityService.clearActivities();
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error clearing activities:', error);
|
||||
res.status(500).json({ error: 'Failed to clear activities' });
|
||||
}
|
||||
});
|
||||
router.delete('/', asyncHandler(async (_req, res) => {
|
||||
await activityService.clearActivities();
|
||||
res.status(204).send();
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -2,152 +2,114 @@ import { Router, type Router as RouterType } from 'express';
|
|||
import { ClawdbotAgentService, clawdbotAgentService } from '../services/clawdbot-agent-service.js';
|
||||
import { getTelemetryService } from '../services/telemetry-service.js';
|
||||
import type { AgentType, TokenTelemetryEvent } from '@veritas-kanban/shared';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
// POST /api/agents/:taskId/start - Start agent on task (delegates to Clawdbot)
|
||||
router.post('/:taskId/start', async (req, res) => {
|
||||
try {
|
||||
const { agent } = req.body as { agent?: AgentType };
|
||||
const status = await clawdbotAgentService.startAgent(req.params.taskId, agent);
|
||||
res.status(201).json(status);
|
||||
} catch (error: any) {
|
||||
console.error('Error starting agent:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to start agent' });
|
||||
}
|
||||
});
|
||||
router.post('/:taskId/start', asyncHandler(async (req, res) => {
|
||||
const { agent } = req.body as { agent?: AgentType };
|
||||
const status = await clawdbotAgentService.startAgent((req.params.taskId as string), agent);
|
||||
res.status(201).json(status);
|
||||
}));
|
||||
|
||||
// POST /api/agents/:taskId/complete - Callback from Clawdbot when agent finishes
|
||||
router.post('/:taskId/complete', async (req, res) => {
|
||||
try {
|
||||
const { success, summary, error } = req.body as {
|
||||
success: boolean;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
await clawdbotAgentService.completeAgent(req.params.taskId, { success, summary, error });
|
||||
res.json({ received: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error completing agent:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to complete agent' });
|
||||
}
|
||||
});
|
||||
router.post('/:taskId/complete', asyncHandler(async (req, res) => {
|
||||
const { success, summary, error } = req.body as {
|
||||
success: boolean;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
await clawdbotAgentService.completeAgent((req.params.taskId as string), { success, summary, error });
|
||||
res.json({ received: true });
|
||||
}));
|
||||
|
||||
// POST /api/agents/:taskId/stop - Stop running agent
|
||||
router.post('/:taskId/stop', async (req, res) => {
|
||||
try {
|
||||
await clawdbotAgentService.stopAgent(req.params.taskId);
|
||||
res.json({ stopped: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error stopping agent:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to stop agent' });
|
||||
}
|
||||
});
|
||||
router.post('/:taskId/stop', asyncHandler(async (req, res) => {
|
||||
await clawdbotAgentService.stopAgent(req.params.taskId as string);
|
||||
res.json({ stopped: true });
|
||||
}));
|
||||
|
||||
// GET /api/agents/:taskId/status - Get agent status
|
||||
router.get('/:taskId/status', async (req, res) => {
|
||||
try {
|
||||
const status = clawdbotAgentService.getAgentStatus(req.params.taskId);
|
||||
if (!status) {
|
||||
return res.json({ running: false });
|
||||
}
|
||||
res.json({ running: true, ...status });
|
||||
} catch (error: any) {
|
||||
console.error('Error getting status:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to get status' });
|
||||
router.get('/:taskId/status', asyncHandler(async (req, res) => {
|
||||
const status = clawdbotAgentService.getAgentStatus(req.params.taskId as string);
|
||||
if (!status) {
|
||||
return res.json({ running: false });
|
||||
}
|
||||
});
|
||||
res.json({ running: true, ...status });
|
||||
}));
|
||||
|
||||
// GET /api/agents/pending - List pending agent requests (for Veritas to poll)
|
||||
router.get('/pending', async (_req, res) => {
|
||||
try {
|
||||
const requests = await clawdbotAgentService.listPendingRequests();
|
||||
res.json(requests);
|
||||
} catch (error: any) {
|
||||
console.error('Error listing pending:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list pending requests' });
|
||||
}
|
||||
});
|
||||
router.get('/pending', asyncHandler(async (_req, res) => {
|
||||
const requests = await clawdbotAgentService.listPendingRequests();
|
||||
res.json(requests);
|
||||
}));
|
||||
|
||||
// GET /api/agents/:taskId/attempts - List attempts for task
|
||||
router.get('/:taskId/attempts', async (req, res) => {
|
||||
try {
|
||||
const attempts = await clawdbotAgentService.listAttempts(req.params.taskId);
|
||||
res.json(attempts);
|
||||
} catch (error: any) {
|
||||
console.error('Error listing attempts:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list attempts' });
|
||||
}
|
||||
});
|
||||
router.get('/:taskId/attempts', asyncHandler(async (req, res) => {
|
||||
const attempts = await clawdbotAgentService.listAttempts(req.params.taskId as string);
|
||||
res.json(attempts);
|
||||
}));
|
||||
|
||||
// GET /api/agents/:taskId/attempts/:attemptId/log - Get attempt log
|
||||
router.get('/:taskId/attempts/:attemptId/log', async (req, res) => {
|
||||
try {
|
||||
const log = await clawdbotAgentService.getAttemptLog(req.params.taskId, req.params.attemptId);
|
||||
res.type('text/markdown').send(log);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting log:', error);
|
||||
res.status(404).json({ error: error.message || 'Log not found' });
|
||||
}
|
||||
});
|
||||
router.get('/:taskId/attempts/:attemptId/log', asyncHandler(async (req, res) => {
|
||||
const log = await clawdbotAgentService.getAttemptLog((req.params.taskId as string), (req.params.attemptId as string));
|
||||
res.type('text/markdown').send(log);
|
||||
}));
|
||||
|
||||
// POST /api/agents/:taskId/tokens - Report token usage for a run
|
||||
router.post('/:taskId/tokens', async (req, res) => {
|
||||
try {
|
||||
const { attemptId, inputTokens, outputTokens, totalTokens, model, agent } = req.body as {
|
||||
attemptId?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
router.post('/:taskId/tokens', asyncHandler(async (req, res) => {
|
||||
const { attemptId, inputTokens, outputTokens, totalTokens, model, agent } = req.body as {
|
||||
attemptId?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens?: number;
|
||||
model?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
|
||||
return res.status(400).json({ error: 'inputTokens and outputTokens are required numbers' });
|
||||
}
|
||||
|
||||
const taskId = req.params.taskId;
|
||||
|
||||
// Get task to find project and current attempt
|
||||
const { TaskService } = await import('../services/task-service.js');
|
||||
const taskService = new TaskService();
|
||||
const task = await taskService.getTask(taskId);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
|
||||
// Use provided attemptId or current attempt
|
||||
const resolvedAttemptId = attemptId || task.attempt?.id || 'unknown';
|
||||
const resolvedAgent = agent || task.attempt?.agent || 'claude-code';
|
||||
|
||||
// Emit telemetry event
|
||||
const telemetry = getTelemetryService();
|
||||
const event = await telemetry.emit<TokenTelemetryEvent>({
|
||||
type: 'run.tokens',
|
||||
taskId,
|
||||
attemptId: resolvedAttemptId,
|
||||
agent: resolvedAgent,
|
||||
project: task.project,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: totalTokens ?? (inputTokens + outputTokens),
|
||||
model,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
recorded: true,
|
||||
eventId: event.id,
|
||||
totalTokens: event.totalTokens,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error recording tokens:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to record token usage' });
|
||||
// Validate required fields
|
||||
if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number') {
|
||||
throw new ValidationError('inputTokens and outputTokens are required numbers');
|
||||
}
|
||||
});
|
||||
|
||||
const taskId = req.params.taskId as string;
|
||||
|
||||
// Get task to find project and current attempt
|
||||
const { TaskService } = await import('../services/task-service.js');
|
||||
const taskService = new TaskService();
|
||||
const task = await taskService.getTask(taskId);
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundError('Task not found');
|
||||
}
|
||||
|
||||
// Use provided attemptId or current attempt
|
||||
const resolvedAttemptId = attemptId || task.attempt?.id || 'unknown';
|
||||
const resolvedAgent = agent || task.attempt?.agent || 'claude-code';
|
||||
|
||||
// Emit telemetry event
|
||||
const telemetry = getTelemetryService();
|
||||
const event = await telemetry.emit<TokenTelemetryEvent>({
|
||||
type: 'run.tokens',
|
||||
taskId,
|
||||
attemptId: resolvedAttemptId,
|
||||
agent: resolvedAgent,
|
||||
project: task.project,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: totalTokens ?? (inputTokens + outputTokens),
|
||||
model,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
recorded: true,
|
||||
eventId: event.id,
|
||||
totalTokens: event.totalTokens,
|
||||
});
|
||||
}));
|
||||
|
||||
// Export service for WebSocket use
|
||||
export { router as agentRoutes, clawdbotAgentService as agentService };
|
||||
|
|
|
|||
|
|
@ -1,44 +1,31 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { DiffService } from '../services/diff-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const diffService = new DiffService();
|
||||
|
||||
// GET /api/diff/:taskId - Get diff summary for task
|
||||
router.get('/:taskId', async (req, res) => {
|
||||
try {
|
||||
const summary = await diffService.getDiffSummary(req.params.taskId);
|
||||
res.json(summary);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting diff summary:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get diff summary' });
|
||||
}
|
||||
});
|
||||
router.get('/:taskId', asyncHandler(async (req, res) => {
|
||||
const summary = await diffService.getDiffSummary(req.params.taskId as string);
|
||||
res.json(summary);
|
||||
}));
|
||||
|
||||
// GET /api/diff/:taskId/file - Get diff for specific file
|
||||
router.get('/:taskId/file', async (req, res) => {
|
||||
try {
|
||||
const { path } = req.query;
|
||||
if (!path || typeof path !== 'string') {
|
||||
return res.status(400).json({ error: 'File path is required' });
|
||||
}
|
||||
const diff = await diffService.getFileDiff(req.params.taskId, path);
|
||||
res.json(diff);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting file diff:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get file diff' });
|
||||
router.get('/:taskId/file', asyncHandler(async (req, res) => {
|
||||
const path = req.query.path as string;
|
||||
if (!path || typeof path !== 'string') {
|
||||
throw new ValidationError('File path is required');
|
||||
}
|
||||
});
|
||||
const diff = await diffService.getFileDiff((req.params.taskId as string), path);
|
||||
res.json(diff);
|
||||
}));
|
||||
|
||||
// GET /api/diff/:taskId/full - Get full diff for all files
|
||||
router.get('/:taskId/full', async (req, res) => {
|
||||
try {
|
||||
const diffs = await diffService.getFullDiff(req.params.taskId);
|
||||
res.json(diffs);
|
||||
} catch (error: any) {
|
||||
console.error('Error getting full diff:', error);
|
||||
res.status(400).json({ error: error.message || 'Failed to get full diff' });
|
||||
}
|
||||
});
|
||||
router.get('/:taskId/full', asyncHandler(async (req, res) => {
|
||||
const diffs = await diffService.getFullDiff(req.params.taskId as string);
|
||||
res.json(diffs);
|
||||
}));
|
||||
|
||||
export { router as diffRoutes };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { GitHubService } from '../services/github-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const githubService = new GitHubService();
|
||||
|
|
@ -15,40 +17,30 @@ const createPRSchema = z.object({
|
|||
});
|
||||
|
||||
// GET /api/github/status - Check gh CLI status
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
const status = await githubService.checkGhCli();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error checking GitHub CLI status:', error);
|
||||
res.status(500).json({ error: 'Failed to check GitHub CLI status' });
|
||||
}
|
||||
});
|
||||
router.get('/status', asyncHandler(async (_req, res) => {
|
||||
const status = await githubService.checkGhCli();
|
||||
res.json(status);
|
||||
}));
|
||||
|
||||
// POST /api/github/pr - Create a PR for a task
|
||||
router.post('/pr', async (req, res) => {
|
||||
router.post('/pr', asyncHandler(async (req, res) => {
|
||||
let input;
|
||||
try {
|
||||
const input = createPRSchema.parse(req.body);
|
||||
const pr = await githubService.createPR(input);
|
||||
res.status(201).json(pr);
|
||||
} catch (error: any) {
|
||||
input = createPRSchema.parse(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
console.error('Error creating PR:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to create PR' });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const pr = await githubService.createPR(input);
|
||||
res.status(201).json(pr);
|
||||
}));
|
||||
|
||||
// POST /api/github/pr/:taskId/open - Open PR in browser
|
||||
router.post('/pr/:taskId/open', async (req, res) => {
|
||||
try {
|
||||
await githubService.openPRInBrowser(req.params.taskId);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error opening PR:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to open PR' });
|
||||
}
|
||||
});
|
||||
router.post('/pr/:taskId/open', asyncHandler(async (req, res) => {
|
||||
await githubService.openPRInBrowser(req.params.taskId as string);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { z } from 'zod';
|
|||
import { TaskService } from '../services/task-service.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { ValidationError } from '../middleware/error-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const taskService = new TaskService();
|
||||
|
|
@ -75,203 +77,184 @@ const createSchema = z.object({
|
|||
taskId: z.string().optional(),
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
router.post('/', asyncHandler(async (req, res) => {
|
||||
let input;
|
||||
try {
|
||||
const input = createSchema.parse(req.body);
|
||||
|
||||
let taskTitle: string | undefined;
|
||||
let project: string | undefined;
|
||||
|
||||
if (input.taskId) {
|
||||
const task = await taskService.getTask(input.taskId);
|
||||
if (task) {
|
||||
taskTitle = task.title;
|
||||
project = task.project;
|
||||
}
|
||||
}
|
||||
|
||||
const notification = await addNotification({
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
taskId: input.taskId,
|
||||
taskTitle,
|
||||
project,
|
||||
});
|
||||
|
||||
res.status(201).json(notification);
|
||||
input = createSchema.parse(req.body);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
console.error('Error creating notification:', error);
|
||||
res.status(500).json({ error: 'Failed to create notification' });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
let taskTitle: string | undefined;
|
||||
let project: string | undefined;
|
||||
|
||||
if (input.taskId) {
|
||||
const task = await taskService.getTask(input.taskId);
|
||||
if (task) {
|
||||
taskTitle = task.title;
|
||||
project = task.project;
|
||||
}
|
||||
}
|
||||
|
||||
const notification = await addNotification({
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
taskId: input.taskId,
|
||||
taskTitle,
|
||||
project,
|
||||
});
|
||||
|
||||
res.status(201).json(notification);
|
||||
}));
|
||||
|
||||
// GET /api/notifications - List notifications
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const unsent = req.query.unsent === 'true';
|
||||
let notifications = await loadNotifications();
|
||||
|
||||
if (unsent) {
|
||||
notifications = notifications.filter(n => !n.sent);
|
||||
}
|
||||
|
||||
// Most recent first
|
||||
notifications.reverse();
|
||||
|
||||
res.json(notifications);
|
||||
} catch (error) {
|
||||
console.error('Error listing notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to list notifications' });
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const unsent = req.query.unsent === 'true';
|
||||
let notifications = await loadNotifications();
|
||||
|
||||
if (unsent) {
|
||||
notifications = notifications.filter(n => !n.sent);
|
||||
}
|
||||
});
|
||||
|
||||
// Most recent first
|
||||
notifications.reverse();
|
||||
|
||||
res.json(notifications);
|
||||
}));
|
||||
|
||||
// GET /api/notifications/pending - Get unsent notifications formatted for Teams
|
||||
router.get('/pending', async (_req, res) => {
|
||||
try {
|
||||
const notifications = await loadNotifications();
|
||||
const unsent = notifications.filter(n => !n.sent);
|
||||
router.get('/pending', asyncHandler(async (_req, res) => {
|
||||
const notifications = await loadNotifications();
|
||||
const unsent = notifications.filter(n => !n.sent);
|
||||
|
||||
if (unsent.length === 0) {
|
||||
return res.json({ count: 0, messages: [] });
|
||||
}
|
||||
|
||||
// Format for Teams
|
||||
const messages = unsent.map(n => {
|
||||
const icon = typeIcons[n.type];
|
||||
let text = `${icon} **${n.title}**\n${n.message}`;
|
||||
|
||||
if (unsent.length === 0) {
|
||||
return res.json({ count: 0, messages: [] });
|
||||
if (n.taskTitle) {
|
||||
text += `\n\n📋 Task: ${n.taskTitle}`;
|
||||
if (n.project) text += ` (#${n.project})`;
|
||||
text += `\n🔗 \`vk show ${n.taskId?.slice(-8)}\``;
|
||||
}
|
||||
|
||||
// Format for Teams
|
||||
const messages = unsent.map(n => {
|
||||
const icon = typeIcons[n.type];
|
||||
let text = `${icon} **${n.title}**\n${n.message}`;
|
||||
|
||||
if (n.taskTitle) {
|
||||
text += `\n\n📋 Task: ${n.taskTitle}`;
|
||||
if (n.project) text += ` (#${n.project})`;
|
||||
text += `\n🔗 \`vk show ${n.taskId?.slice(-8)}\``;
|
||||
}
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
text,
|
||||
timestamp: n.timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ count: unsent.length, messages });
|
||||
} catch (error) {
|
||||
console.error('Error getting pending notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to get pending notifications' });
|
||||
}
|
||||
});
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
text,
|
||||
timestamp: n.timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ count: unsent.length, messages });
|
||||
}));
|
||||
|
||||
// POST /api/notifications/mark-sent - Mark notifications as sent
|
||||
const markSentSchema = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
router.post('/mark-sent', async (req, res) => {
|
||||
router.post('/mark-sent', asyncHandler(async (req, res) => {
|
||||
let ids;
|
||||
try {
|
||||
const { ids } = markSentSchema.parse(req.body);
|
||||
const notifications = await loadNotifications();
|
||||
|
||||
let marked = 0;
|
||||
notifications.forEach(n => {
|
||||
if (ids.includes(n.id)) {
|
||||
n.sent = true;
|
||||
marked++;
|
||||
}
|
||||
});
|
||||
|
||||
await saveNotifications(notifications);
|
||||
res.json({ marked });
|
||||
({ ids } = markSentSchema.parse(req.body));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
throw new ValidationError('Validation failed', error.errors);
|
||||
}
|
||||
console.error('Error marking notifications sent:', error);
|
||||
res.status(500).json({ error: 'Failed to mark notifications sent' });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const notifications = await loadNotifications();
|
||||
|
||||
let marked = 0;
|
||||
notifications.forEach(n => {
|
||||
if (ids.includes(n.id)) {
|
||||
n.sent = true;
|
||||
marked++;
|
||||
}
|
||||
});
|
||||
|
||||
await saveNotifications(notifications);
|
||||
res.json({ marked });
|
||||
}));
|
||||
|
||||
// POST /api/notifications/check - Check for tasks that need notifications
|
||||
router.post('/check', async (_req, res) => {
|
||||
try {
|
||||
const tasks = await taskService.listTasks();
|
||||
const created: Notification[] = [];
|
||||
|
||||
// Check for tasks in review (needs review notification)
|
||||
const inReview = tasks.filter(t =>
|
||||
t.status === 'review' &&
|
||||
t.attempt?.status === 'complete' &&
|
||||
t.attempt?.agent !== 'veritas'
|
||||
router.post('/check', asyncHandler(async (_req, res) => {
|
||||
const tasks = await taskService.listTasks();
|
||||
const created: Notification[] = [];
|
||||
|
||||
// Check for tasks in review (needs review notification)
|
||||
const inReview = tasks.filter(t =>
|
||||
t.status === 'review' &&
|
||||
t.attempt?.status === 'complete' &&
|
||||
t.attempt?.agent !== 'veritas'
|
||||
);
|
||||
|
||||
for (const task of inReview) {
|
||||
// Check if we already notified about this
|
||||
const existing = await loadNotifications();
|
||||
const alreadyNotified = existing.some(n =>
|
||||
n.taskId === task.id &&
|
||||
n.type === 'needs_review' &&
|
||||
new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) // Within 24h
|
||||
);
|
||||
|
||||
for (const task of inReview) {
|
||||
// Check if we already notified about this
|
||||
const existing = await loadNotifications();
|
||||
const alreadyNotified = existing.some(n =>
|
||||
n.taskId === task.id &&
|
||||
n.type === 'needs_review' &&
|
||||
new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000) // Within 24h
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
const notification = await addNotification({
|
||||
type: 'needs_review',
|
||||
title: 'Code Ready for Review',
|
||||
message: `Agent completed work on "${task.title}". Please review the changes.`,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
project: task.project,
|
||||
});
|
||||
created.push(notification);
|
||||
}
|
||||
if (!alreadyNotified) {
|
||||
const notification = await addNotification({
|
||||
type: 'needs_review',
|
||||
title: 'Code Ready for Review',
|
||||
message: `Agent completed work on "${task.title}". Please review the changes.`,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
project: task.project,
|
||||
});
|
||||
created.push(notification);
|
||||
}
|
||||
|
||||
// Check for failed agent attempts
|
||||
const failed = tasks.filter(t =>
|
||||
t.attempt?.status === 'failed' &&
|
||||
t.status !== 'done'
|
||||
);
|
||||
|
||||
for (const task of failed) {
|
||||
const existing = await loadNotifications();
|
||||
const alreadyNotified = existing.some(n =>
|
||||
n.taskId === task.id &&
|
||||
n.type === 'agent_failed' &&
|
||||
new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
const notification = await addNotification({
|
||||
type: 'agent_failed',
|
||||
title: 'Agent Failed',
|
||||
message: `${task.attempt?.agent} failed on "${task.title}". May need manual intervention.`,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
project: task.project,
|
||||
});
|
||||
created.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ checked: tasks.length, created: created.length, notifications: created });
|
||||
} catch (error) {
|
||||
console.error('Error checking for notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to check for notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check for failed agent attempts
|
||||
const failed = tasks.filter(t =>
|
||||
t.attempt?.status === 'failed' &&
|
||||
t.status !== 'done'
|
||||
);
|
||||
|
||||
for (const task of failed) {
|
||||
const existing = await loadNotifications();
|
||||
const alreadyNotified = existing.some(n =>
|
||||
n.taskId === task.id &&
|
||||
n.type === 'agent_failed' &&
|
||||
new Date(n.timestamp) > new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
if (!alreadyNotified) {
|
||||
const notification = await addNotification({
|
||||
type: 'agent_failed',
|
||||
title: 'Agent Failed',
|
||||
message: `${task.attempt?.agent} failed on "${task.title}". May need manual intervention.`,
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
project: task.project,
|
||||
});
|
||||
created.push(notification);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ checked: tasks.length, created: created.length, notifications: created });
|
||||
}));
|
||||
|
||||
// DELETE /api/notifications - Clear all notifications
|
||||
router.delete('/', async (_req, res) => {
|
||||
try {
|
||||
await saveNotifications([]);
|
||||
res.json({ cleared: true });
|
||||
} catch (error) {
|
||||
console.error('Error clearing notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||
}
|
||||
});
|
||||
router.delete('/', asyncHandler(async (_req, res) => {
|
||||
await saveNotifications([]);
|
||||
res.json({ cleared: true });
|
||||
}));
|
||||
|
||||
export { router as notificationRoutes };
|
||||
|
|
|
|||
|
|
@ -1,66 +1,42 @@
|
|||
import { Router, type Router as RouterType } from 'express';
|
||||
import { PreviewService } from '../services/preview-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
const previewService = new PreviewService();
|
||||
|
||||
// GET /api/preview - List all running previews
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const previews = previewService.getAllPreviews();
|
||||
res.json(previews);
|
||||
} catch (error) {
|
||||
console.error('Error listing previews:', error);
|
||||
res.status(500).json({ error: 'Failed to list previews' });
|
||||
}
|
||||
});
|
||||
router.get('/', asyncHandler(async (_req, res) => {
|
||||
const previews = previewService.getAllPreviews();
|
||||
res.json(previews);
|
||||
}));
|
||||
|
||||
// GET /api/preview/:taskId - Get preview status for a task
|
||||
router.get('/:taskId', async (req, res) => {
|
||||
try {
|
||||
const status = previewService.getPreviewStatus(req.params.taskId);
|
||||
if (!status) {
|
||||
return res.json({ status: 'stopped' });
|
||||
}
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting preview status:', error);
|
||||
res.status(500).json({ error: 'Failed to get preview status' });
|
||||
router.get('/:taskId', asyncHandler(async (req, res) => {
|
||||
const status = previewService.getPreviewStatus(req.params.taskId as string);
|
||||
if (!status) {
|
||||
return res.json({ status: 'stopped' });
|
||||
}
|
||||
});
|
||||
res.json(status);
|
||||
}));
|
||||
|
||||
// GET /api/preview/:taskId/output - Get preview server output
|
||||
router.get('/:taskId/output', async (req, res) => {
|
||||
try {
|
||||
const lines = parseInt(req.query.lines as string) || 50;
|
||||
const output = previewService.getPreviewOutput(req.params.taskId, lines);
|
||||
res.json({ output });
|
||||
} catch (error) {
|
||||
console.error('Error getting preview output:', error);
|
||||
res.status(500).json({ error: 'Failed to get preview output' });
|
||||
}
|
||||
});
|
||||
router.get('/:taskId/output', asyncHandler(async (req, res) => {
|
||||
const lines = parseInt(req.query.lines as string) || 50;
|
||||
const output = previewService.getPreviewOutput((req.params.taskId as string), lines);
|
||||
res.json({ output });
|
||||
}));
|
||||
|
||||
// POST /api/preview/:taskId/start - Start preview for a task
|
||||
router.post('/:taskId/start', async (req, res) => {
|
||||
try {
|
||||
const preview = await previewService.startPreview(req.params.taskId);
|
||||
res.status(201).json(preview);
|
||||
} catch (error: any) {
|
||||
console.error('Error starting preview:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to start preview' });
|
||||
}
|
||||
});
|
||||
router.post('/:taskId/start', asyncHandler(async (req, res) => {
|
||||
const preview = await previewService.startPreview(req.params.taskId as string);
|
||||
res.status(201).json(preview);
|
||||
}));
|
||||
|
||||
// POST /api/preview/:taskId/stop - Stop preview for a task
|
||||
router.post('/:taskId/stop', async (req, res) => {
|
||||
try {
|
||||
await previewService.stopPreview(req.params.taskId);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Error stopping preview:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to stop preview' });
|
||||
}
|
||||
});
|
||||
router.post('/:taskId/stop', asyncHandler(async (req, res) => {
|
||||
await previewService.stopPreview(req.params.taskId as string);
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ export interface Activity {
|
|||
|
||||
export class ActivityService {
|
||||
private activityFile: string;
|
||||
private maxActivities: number = 100;
|
||||
private readonly MAX_ACTIVITIES = 1000; // Limit to prevent unbounded memory growth
|
||||
|
||||
constructor() {
|
||||
this.activityFile = join(process.cwd(), '.veritas-kanban', 'activity.json');
|
||||
|
|
@ -88,8 +88,12 @@ export class ActivityService {
|
|||
}
|
||||
}
|
||||
|
||||
// Prepend new activity and limit to maxActivities
|
||||
activities = [activity, ...activities].slice(0, this.maxActivities);
|
||||
// Prepend new activity and limit to MAX_ACTIVITIES
|
||||
activities = [activity, ...activities].slice(0, this.MAX_ACTIVITIES);
|
||||
|
||||
if (activities.length >= this.MAX_ACTIVITIES) {
|
||||
console.warn(`[Activity] Activity limit reached (${this.MAX_ACTIVITIES}), trimming oldest entries`);
|
||||
}
|
||||
|
||||
await writeFile(this.activityFile, JSON.stringify(activities, null, 2), 'utf-8');
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { TaskService } from './task-service.js';
|
|||
import { getTelemetryService, type TelemetryService } from './telemetry-service.js';
|
||||
import { getTraceService, type TraceService } from './trace-service.js';
|
||||
import type { Task, AgentType, TaskAttempt, AttemptStatus, RunTelemetryEvent } from '@veritas-kanban/shared';
|
||||
import { expandPath } from '@veritas-kanban/shared';
|
||||
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
const LOGS_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'logs');
|
||||
|
|
@ -60,10 +61,6 @@ export class AgentService {
|
|||
await fs.mkdir(this.logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
private expandPath(p: string): string {
|
||||
return p.replace(/^~/, process.env.HOME || '');
|
||||
}
|
||||
|
||||
async startAgent(taskId: string, agentType?: AgentType): Promise<AgentStatus> {
|
||||
// Get task
|
||||
const task = await this.taskService.getTask(taskId);
|
||||
|
|
@ -113,7 +110,7 @@ export class AgentService {
|
|||
const emitter = new EventEmitter();
|
||||
|
||||
// Spawn agent process
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const worktreePath = expandPath(task.git.worktreePath);
|
||||
const args = [...agentConfig.args];
|
||||
|
||||
const childProcess = spawn(agentConfig.command, args, {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { spawn, ChildProcess } from 'child_process';
|
|||
import { ConfigService } from './config-service.js';
|
||||
import { TaskService } from './task-service.js';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { expandPath } from '@veritas-kanban/shared';
|
||||
|
||||
export interface PreviewServer {
|
||||
taskId: string;
|
||||
|
|
@ -24,16 +25,13 @@ const runningServers = new Map<string, {
|
|||
export class PreviewService {
|
||||
private configService: ConfigService;
|
||||
private taskService: TaskService;
|
||||
private readonly MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB limit for output
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService();
|
||||
this.taskService = new TaskService();
|
||||
}
|
||||
|
||||
private expandPath(p: string): string {
|
||||
return p.replace(/^~/, process.env.HOME || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract port from output using common patterns
|
||||
*/
|
||||
|
|
@ -110,7 +108,7 @@ export class PreviewService {
|
|||
}
|
||||
|
||||
// Determine working directory (worktree or main repo)
|
||||
const workDir = task.git.worktreePath || this.expandPath(repoConfig.path);
|
||||
const workDir = task.git.worktreePath || expandPath(repoConfig.path);
|
||||
|
||||
// Parse command
|
||||
const [cmd, ...args] = repoConfig.devServer.command.split(' ');
|
||||
|
|
@ -141,7 +139,20 @@ export class PreviewService {
|
|||
const text = data.toString();
|
||||
info.output.push(text);
|
||||
|
||||
// Keep only last 100 lines
|
||||
// Calculate total output size
|
||||
const totalSize = info.output.reduce((sum, line) => sum + line.length, 0);
|
||||
|
||||
// Enforce size limit
|
||||
if (totalSize > this.MAX_OUTPUT_SIZE) {
|
||||
// Truncate from the beginning, keep recent output
|
||||
while (info.output.length > 0 &&
|
||||
info.output.reduce((sum, line) => sum + line.length, 0) > this.MAX_OUTPUT_SIZE * 0.8) {
|
||||
info.output.shift();
|
||||
}
|
||||
info.output.unshift('...[output truncated due to size limit]...\n');
|
||||
}
|
||||
|
||||
// Keep only last 100 lines as secondary limit
|
||||
if (info.output.length > 100) {
|
||||
info.output = info.output.slice(-100);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,35 @@ export class TaskService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-resolve task dependencies to avoid N+1 queries
|
||||
* Loads all tasks once, then resolves dependencies from memory
|
||||
*/
|
||||
async getTasksWithDependencies(taskIds?: string[]): Promise<Task[]> {
|
||||
const allTasks = await this.listTasks();
|
||||
const taskMap = new Map(allTasks.map(t => [t.id, t]));
|
||||
|
||||
const tasksToResolve = taskIds
|
||||
? allTasks.filter(t => taskIds.includes(t.id))
|
||||
: allTasks;
|
||||
|
||||
return tasksToResolve.map(task => {
|
||||
if (!task.blockedBy || task.blockedBy.length === 0) {
|
||||
return task;
|
||||
}
|
||||
|
||||
// Resolve dependencies from the in-memory map
|
||||
const resolvedDependencies = task.blockedBy
|
||||
.map(depId => taskMap.get(depId))
|
||||
.filter((t): t is Task => t !== undefined);
|
||||
|
||||
return {
|
||||
...task,
|
||||
_dependencies: resolvedDependencies, // Add resolved deps without modifying schema
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task | null> {
|
||||
const tasks = await this.listTasks();
|
||||
return tasks.find(t => t.id === id) || null;
|
||||
|
|
@ -358,7 +387,7 @@ export class TaskService {
|
|||
// Find sprints where ALL tasks are done
|
||||
const suggestions: { sprint: string; taskCount: number; tasks: Task[] }[] = [];
|
||||
|
||||
for (const [sprint, sprintTaskList] of sprintTasks) {
|
||||
for (const [sprint, sprintTaskList] of Array.from(sprintTasks.entries())) {
|
||||
const allDone = sprintTaskList.every(t => t.status === 'done');
|
||||
if (allDone && sprintTaskList.length > 0) {
|
||||
suggestions.push({
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export class TelemetryService {
|
|||
private config: TelemetryConfig;
|
||||
private initialized: boolean = false;
|
||||
private writeQueue: Promise<void> = Promise.resolve();
|
||||
private pendingWrites: Array<TelemetryEvent> = [];
|
||||
private readonly MAX_QUEUE_SIZE = 10000;
|
||||
|
||||
constructor(options: TelemetryServiceOptions = {}) {
|
||||
this.telemetryDir = options.telemetryDir || TELEMETRY_DIR;
|
||||
|
|
@ -99,9 +101,21 @@ export class TelemetryService {
|
|||
timestamp: new Date().toISOString(),
|
||||
} as T;
|
||||
|
||||
// Add to queue with size limit - drop oldest if exceeded
|
||||
this.pendingWrites.push(fullEvent);
|
||||
if (this.pendingWrites.length > this.MAX_QUEUE_SIZE) {
|
||||
const dropped = this.pendingWrites.shift();
|
||||
console.warn(`[Telemetry] Queue size exceeded (${this.MAX_QUEUE_SIZE}), dropped event:`, dropped?.type);
|
||||
}
|
||||
|
||||
// Queue the write to prevent concurrent file access issues
|
||||
const writePromise = this.writeQueue
|
||||
.then(() => this.writeEvent(fullEvent))
|
||||
.then(() => {
|
||||
const eventToWrite = this.pendingWrites.shift();
|
||||
if (eventToWrite) {
|
||||
return this.writeEvent(eventToWrite);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Telemetry] Failed to write event:', err);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { extractText as unpdfExtract } from 'unpdf';
|
||||
import mammoth from 'mammoth';
|
||||
import * as XLSX from 'xlsx';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
export interface TextExtractionResult {
|
||||
text: string | null;
|
||||
|
|
@ -115,23 +115,49 @@ export class TextExtractionService {
|
|||
|
||||
/**
|
||||
* Extract text from Excel files (convert to CSV-like format)
|
||||
* Note: Only supports .xlsx format (Excel 2007+). Legacy .xls format is not supported.
|
||||
*/
|
||||
private async extractXLSX(filepath: string): Promise<string | null> {
|
||||
try {
|
||||
const buffer = await fs.readFile(filepath);
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
// Read the workbook directly from file
|
||||
await workbook.xlsx.readFile(filepath);
|
||||
|
||||
// Extract all sheets
|
||||
const sheets: string[] = [];
|
||||
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const csv = XLSX.utils.sheet_to_csv(worksheet);
|
||||
workbook.eachSheet((worksheet, sheetId) => {
|
||||
const rows: string[] = [];
|
||||
|
||||
if (csv.trim()) {
|
||||
sheets.push(`=== Sheet: ${sheetName} ===\n${csv}`);
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
const values: string[] = [];
|
||||
|
||||
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
||||
// Get cell value as string
|
||||
const value = cell.value;
|
||||
|
||||
// Handle different value types
|
||||
if (value === null || value === undefined) {
|
||||
values.push('');
|
||||
} else if (typeof value === 'object' && 'text' in value) {
|
||||
// Rich text
|
||||
values.push(value.text || '');
|
||||
} else if (typeof value === 'object' && 'formula' in value) {
|
||||
// Formula - use result if available
|
||||
values.push(value.result?.toString() || '');
|
||||
} else {
|
||||
values.push(value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
rows.push(values.join(','));
|
||||
});
|
||||
|
||||
if (rows.length > 0) {
|
||||
sheets.push(`=== Sheet: ${worksheet.name} ===\n${rows.join('\n')}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return sheets.length > 0 ? sheets.join('\n\n') : null;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { simpleGit, SimpleGit } from 'simple-git';
|
|||
import { ConfigService } from './config-service.js';
|
||||
import { TaskService } from './task-service.js';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
// Default paths
|
||||
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
|
||||
|
|
@ -29,6 +31,7 @@ export class WorktreeService {
|
|||
private worktreesDir: string;
|
||||
private configService: ConfigService;
|
||||
private taskService: TaskService;
|
||||
private readonly GIT_TIMEOUT = 30000; // 30 seconds timeout for git operations
|
||||
|
||||
constructor(options: WorktreeServiceOptions = {}) {
|
||||
this.worktreesDir = options.worktreesDir || WORKTREES_DIR;
|
||||
|
|
@ -36,6 +39,56 @@ export class WorktreeService {
|
|||
this.taskService = new TaskService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a git command with timeout
|
||||
*/
|
||||
private async execGitWithTimeout(
|
||||
repoPath: string,
|
||||
args: string[]
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('git', args, {
|
||||
cwd: repoPath,
|
||||
timeout: this.GIT_TIMEOUT,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
reject(new Error(`Git command failed: ${error.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else if (code === null) {
|
||||
reject(new Error(`Git command timed out after ${this.GIT_TIMEOUT}ms`));
|
||||
} else {
|
||||
reject(new Error(`Git command failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout manually as backup
|
||||
const timeoutId = setTimeout(() => {
|
||||
proc.kill('SIGTERM');
|
||||
reject(new Error(`Git operation timed out after ${this.GIT_TIMEOUT}ms`));
|
||||
}, this.GIT_TIMEOUT);
|
||||
|
||||
proc.on('exit', () => {
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureWorktreesDir(): Promise<void> {
|
||||
await fs.mkdir(this.worktreesDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -84,12 +137,12 @@ export class WorktreeService {
|
|||
throw new Error('Worktree already exists for this task');
|
||||
}
|
||||
|
||||
// Fetch latest from remote
|
||||
// Fetch latest from remote with timeout
|
||||
try {
|
||||
await git.fetch();
|
||||
} catch (e) {
|
||||
await this.execGitWithTimeout(repoPath, ['fetch']);
|
||||
} catch (e: any) {
|
||||
// Ignore fetch errors (might be offline)
|
||||
console.warn('Could not fetch from remote:', e);
|
||||
console.warn('Could not fetch from remote:', e.message);
|
||||
}
|
||||
|
||||
// Check if branch already exists
|
||||
|
|
@ -97,11 +150,13 @@ export class WorktreeService {
|
|||
const branchExists = branches.all.includes(task.git.branch);
|
||||
|
||||
if (branchExists) {
|
||||
// Use existing branch
|
||||
await git.raw(['worktree', 'add', worktreePath, task.git.branch]);
|
||||
// Use existing branch with timeout
|
||||
await this.execGitWithTimeout(repoPath, ['worktree', 'add', worktreePath, task.git.branch]);
|
||||
} else {
|
||||
// Create new branch from base
|
||||
await git.raw(['worktree', 'add', '-b', task.git.branch, worktreePath, task.git.baseBranch]);
|
||||
// Create new branch from base with timeout
|
||||
await this.execGitWithTimeout(repoPath, [
|
||||
'worktree', 'add', '-b', task.git.branch, worktreePath, task.git.baseBranch
|
||||
]);
|
||||
}
|
||||
|
||||
// Update task with worktree path
|
||||
|
|
@ -131,13 +186,13 @@ export class WorktreeService {
|
|||
// Get ahead/behind info
|
||||
let aheadBehind = { ahead: 0, behind: 0 };
|
||||
try {
|
||||
const { git: repoGit } = await this.getRepoGit(task.git.repo);
|
||||
const { git: repoGit, repoPath: mainRepoPath } = await this.getRepoGit(task.git.repo);
|
||||
|
||||
// Fetch to get latest
|
||||
await repoGit.fetch().catch(() => {});
|
||||
// Fetch to get latest with timeout
|
||||
await this.execGitWithTimeout(mainRepoPath, ['fetch']).catch(() => {});
|
||||
|
||||
// Compare with base branch
|
||||
const log = await worktreeGit.raw([
|
||||
// Compare with base branch with timeout
|
||||
const log = await this.execGitWithTimeout(worktreePath, [
|
||||
'rev-list',
|
||||
'--left-right',
|
||||
'--count',
|
||||
|
|
@ -145,8 +200,8 @@ export class WorktreeService {
|
|||
]);
|
||||
const [behind, ahead] = log.trim().split('\t').map(Number);
|
||||
aheadBehind = { ahead: ahead || 0, behind: behind || 0 };
|
||||
} catch (e) {
|
||||
console.warn('Could not get ahead/behind info:', e);
|
||||
} catch (e: any) {
|
||||
console.warn('Could not get ahead/behind info:', e.message);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -178,10 +233,12 @@ export class WorktreeService {
|
|||
}
|
||||
|
||||
// Get main repo git
|
||||
const { git: repoGit } = await this.getRepoGit(task.git.repo);
|
||||
const { git: repoGit, repoPath } = await this.getRepoGit(task.git.repo);
|
||||
|
||||
// Remove worktree
|
||||
await repoGit.raw(['worktree', 'remove', worktreePath, force ? '--force' : ''].filter(Boolean));
|
||||
// Remove worktree with timeout
|
||||
const args = ['worktree', 'remove', worktreePath];
|
||||
if (force) args.push('--force');
|
||||
await this.execGitWithTimeout(repoPath, args);
|
||||
|
||||
// Update task to remove worktree path
|
||||
await this.taskService.updateTask(taskId, {
|
||||
|
|
@ -198,13 +255,13 @@ export class WorktreeService {
|
|||
throw new Error('Task does not have an active worktree');
|
||||
}
|
||||
|
||||
const worktreeGit = simpleGit(task.git.worktreePath);
|
||||
const worktreePath = task.git.worktreePath;
|
||||
|
||||
// Fetch latest
|
||||
await worktreeGit.fetch();
|
||||
// Fetch latest with timeout
|
||||
await this.execGitWithTimeout(worktreePath, ['fetch']);
|
||||
|
||||
// Rebase onto base branch
|
||||
await worktreeGit.rebase([`origin/${task.git.baseBranch}`]);
|
||||
// Rebase onto base branch with timeout
|
||||
await this.execGitWithTimeout(worktreePath, ['rebase', `origin/${task.git.baseBranch}`]);
|
||||
|
||||
return this.getWorktreeStatus(taskId);
|
||||
}
|
||||
|
|
@ -217,17 +274,17 @@ export class WorktreeService {
|
|||
|
||||
const { git: repoGit, repoPath } = await this.getRepoGit(task.git.repo);
|
||||
|
||||
// Checkout base branch in main repo
|
||||
await repoGit.checkout(task.git.baseBranch);
|
||||
// Checkout base branch in main repo with timeout
|
||||
await this.execGitWithTimeout(repoPath, ['checkout', task.git.baseBranch]);
|
||||
|
||||
// Pull latest
|
||||
await repoGit.pull();
|
||||
// Pull latest with timeout
|
||||
await this.execGitWithTimeout(repoPath, ['pull']);
|
||||
|
||||
// Merge feature branch
|
||||
await repoGit.merge([task.git.branch]);
|
||||
// Merge feature branch with timeout
|
||||
await this.execGitWithTimeout(repoPath, ['merge', task.git.branch]);
|
||||
|
||||
// Push
|
||||
await repoGit.push();
|
||||
// Push with timeout
|
||||
await this.execGitWithTimeout(repoPath, ['push']);
|
||||
|
||||
// Delete worktree
|
||||
await this.deleteWorktree(taskId, true);
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@
|
|||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// Veritas Kanban - Shared Types
|
||||
|
||||
export * from './types.js';
|
||||
export * from './utils/index.js';
|
||||
|
|
|
|||
|
|
@ -1,591 +1,2 @@
|
|||
// Task Types
|
||||
|
||||
export type TaskType = string;
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
|
||||
export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed';
|
||||
|
||||
export interface TaskGit {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
id: string;
|
||||
agent: AgentType;
|
||||
status: AttemptStatus;
|
||||
started?: string;
|
||||
ended?: string;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime?: string; // Undefined if timer is running
|
||||
duration?: number; // Duration in seconds (calculated when stopped)
|
||||
description?: string; // Optional note for the entry
|
||||
manual?: boolean; // True if manually entered
|
||||
}
|
||||
|
||||
export interface TimeTracking {
|
||||
entries: TimeEntry[];
|
||||
totalSeconds: number; // Total tracked time in seconds
|
||||
isRunning: boolean; // Is timer currently running
|
||||
activeEntryId?: string; // ID of the currently running entry
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string; // Sanitized filename stored on disk
|
||||
originalName: string; // Original filename from upload
|
||||
mimeType: string;
|
||||
size: number; // File size in bytes
|
||||
uploaded: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface AttachmentLimits {
|
||||
maxFileSize: number; // Max size per file in bytes
|
||||
maxFilesPerTask: number; // Max number of attachments per task
|
||||
maxTotalSize: number; // Max total size for all attachments per task
|
||||
}
|
||||
|
||||
// Default attachment limits
|
||||
export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = {
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB per file
|
||||
maxFilesPerTask: 20, // 20 files per task
|
||||
maxTotalSize: 50 * 1024 * 1024, // 50MB total per task
|
||||
};
|
||||
|
||||
// Allowed MIME types for attachments
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
|
||||
// Images
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
|
||||
// Code & Config
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'application/yaml',
|
||||
'text/yaml',
|
||||
];
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
|
||||
// Code task specific
|
||||
git?: TaskGit;
|
||||
|
||||
// Current attempt
|
||||
attempt?: TaskAttempt;
|
||||
|
||||
// Attempt history
|
||||
attempts?: TaskAttempt[];
|
||||
|
||||
// Review comments (for code tasks)
|
||||
reviewComments?: ReviewComment[];
|
||||
|
||||
// Review state
|
||||
review?: ReviewState;
|
||||
|
||||
// Subtasks
|
||||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done
|
||||
|
||||
// Dependencies
|
||||
blockedBy?: string[]; // Array of task IDs that block this task
|
||||
|
||||
// Automation task specific (for veritas sub-agent)
|
||||
automation?: {
|
||||
sessionKey?: string; // Clawdbot session key
|
||||
spawnedAt?: string; // When sub-agent was spawned
|
||||
completedAt?: string; // When sub-agent finished
|
||||
result?: string; // Result summary from sub-agent
|
||||
};
|
||||
|
||||
// Time tracking
|
||||
timeTracking?: TimeTracking;
|
||||
|
||||
// Comments
|
||||
comments?: Comment[];
|
||||
|
||||
// Attachments
|
||||
attachments?: Attachment[];
|
||||
|
||||
// Position within column (for drag-and-drop ordering)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type ReviewDecision = 'approved' | 'changes-requested' | 'rejected';
|
||||
|
||||
export interface ReviewState {
|
||||
decision?: ReviewDecision;
|
||||
decidedAt?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// API Types
|
||||
|
||||
export interface CreateTaskInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
subtasks?: Subtask[]; // Can be provided when creating from a template
|
||||
blockedBy?: string[]; // Can be provided when creating from a blueprint
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
git?: Partial<TaskGit>;
|
||||
attempt?: TaskAttempt;
|
||||
reviewComments?: ReviewComment[];
|
||||
review?: ReviewState;
|
||||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean;
|
||||
blockedBy?: string[];
|
||||
automation?: {
|
||||
sessionKey?: string;
|
||||
spawnedAt?: string;
|
||||
completedAt?: string;
|
||||
result?: string;
|
||||
};
|
||||
timeTracking?: TimeTracking;
|
||||
comments?: Comment[];
|
||||
attachments?: Attachment[];
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
type?: TaskType | TaskType[];
|
||||
project?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Config Types
|
||||
|
||||
export interface DevServerConfig {
|
||||
command: string; // e.g., "pnpm dev" or "npm run dev"
|
||||
port?: number; // Expected port (auto-detected if not specified)
|
||||
readyPattern?: string; // Regex pattern to detect when server is ready
|
||||
}
|
||||
|
||||
export interface RepoConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
defaultBranch: string;
|
||||
devServer?: DevServerConfig;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
type: AgentType;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
repos: RepoConfig[];
|
||||
agents: AgentConfig[];
|
||||
defaultAgent: AgentType;
|
||||
telemetry?: TelemetryConfig;
|
||||
features?: FeatureSettings;
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
|
||||
export type WSMessageType =
|
||||
| 'agent:output'
|
||||
| 'agent:status'
|
||||
| 'agent:complete'
|
||||
| 'task:updated'
|
||||
| 'error';
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AgentOutputMessage extends WSMessage {
|
||||
type: 'agent:output';
|
||||
data: {
|
||||
stream: 'stdout' | 'stderr';
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentStatusMessage extends WSMessage {
|
||||
type: 'agent:status';
|
||||
data: {
|
||||
status: AttemptStatus;
|
||||
exitCode?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Telemetry Types ============
|
||||
|
||||
export type TelemetryEventType =
|
||||
| 'task.created'
|
||||
| 'task.status_changed'
|
||||
| 'task.archived'
|
||||
| 'task.restored'
|
||||
| 'run.started'
|
||||
| 'run.completed'
|
||||
| 'run.error'
|
||||
| 'run.tokens';
|
||||
|
||||
/** Base telemetry event - all events extend this */
|
||||
export interface TelemetryEvent {
|
||||
id: string;
|
||||
type: TelemetryEventType;
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
/** Task lifecycle events */
|
||||
export interface TaskTelemetryEvent extends TelemetryEvent {
|
||||
type: 'task.created' | 'task.status_changed' | 'task.archived' | 'task.restored';
|
||||
taskId: string;
|
||||
status?: TaskStatus;
|
||||
previousStatus?: TaskStatus;
|
||||
}
|
||||
|
||||
/** Agent run events */
|
||||
export interface RunTelemetryEvent extends TelemetryEvent {
|
||||
type: 'run.started' | 'run.completed' | 'run.error';
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
agent: AgentType;
|
||||
durationMs?: number;
|
||||
exitCode?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Token usage events */
|
||||
export interface TokenTelemetryEvent extends TelemetryEvent {
|
||||
type: 'run.tokens';
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
agent: AgentType;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Union type for all telemetry events */
|
||||
export type AnyTelemetryEvent = TaskTelemetryEvent | RunTelemetryEvent | TokenTelemetryEvent;
|
||||
|
||||
/** Telemetry configuration */
|
||||
export interface TelemetryConfig {
|
||||
enabled: boolean;
|
||||
retention: number; // Days to retain events
|
||||
traces?: boolean; // Optional trace collection (future)
|
||||
}
|
||||
|
||||
// ============ Feature Settings Types ============
|
||||
|
||||
/** Board display settings */
|
||||
export interface BoardSettings {
|
||||
showDashboard: boolean;
|
||||
showArchiveSuggestions: boolean;
|
||||
cardDensity: 'normal' | 'compact';
|
||||
showPriorityIndicators: boolean;
|
||||
showProjectBadges: boolean;
|
||||
showSprintBadges: boolean;
|
||||
enableDragAndDrop: boolean;
|
||||
}
|
||||
|
||||
/** Task behavior settings */
|
||||
export interface TaskBehaviorSettings {
|
||||
enableTimeTracking: boolean;
|
||||
enableSubtaskAutoComplete: boolean;
|
||||
enableDependencies: boolean;
|
||||
enableAttachments: boolean;
|
||||
attachmentMaxFileSize: number; // bytes
|
||||
attachmentMaxPerTask: number;
|
||||
attachmentMaxTotalSize: number; // bytes
|
||||
enableComments: boolean;
|
||||
defaultPriority: TaskPriority;
|
||||
}
|
||||
|
||||
/** Agent & git settings */
|
||||
export interface AgentBehaviorSettings {
|
||||
timeoutMinutes: number; // 5-480
|
||||
autoCommitOnComplete: boolean;
|
||||
autoCleanupWorktrees: boolean;
|
||||
enablePreview: boolean;
|
||||
}
|
||||
|
||||
/** Telemetry & activity settings */
|
||||
export interface TelemetryFeatureSettings {
|
||||
enabled: boolean;
|
||||
retentionDays: number; // 7-365
|
||||
enableTraces: boolean;
|
||||
enableActivityTracking: boolean;
|
||||
}
|
||||
|
||||
/** Notification settings */
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
onTaskComplete: boolean;
|
||||
onAgentFailure: boolean;
|
||||
onReviewNeeded: boolean;
|
||||
channel: string; // Teams channel ID
|
||||
}
|
||||
|
||||
/** Archive settings */
|
||||
export interface ArchiveSettings {
|
||||
autoArchiveEnabled: boolean;
|
||||
autoArchiveAfterDays: number;
|
||||
}
|
||||
|
||||
/** All feature settings combined */
|
||||
export interface FeatureSettings {
|
||||
board: BoardSettings;
|
||||
tasks: TaskBehaviorSettings;
|
||||
agents: AgentBehaviorSettings;
|
||||
telemetry: TelemetryFeatureSettings;
|
||||
notifications: NotificationSettings;
|
||||
archive: ArchiveSettings;
|
||||
}
|
||||
|
||||
/** Default feature settings — matches current app behavior */
|
||||
export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
|
||||
board: {
|
||||
showDashboard: true,
|
||||
showArchiveSuggestions: true,
|
||||
cardDensity: 'normal',
|
||||
showPriorityIndicators: true,
|
||||
showProjectBadges: true,
|
||||
showSprintBadges: true,
|
||||
enableDragAndDrop: true,
|
||||
},
|
||||
tasks: {
|
||||
enableTimeTracking: true,
|
||||
enableSubtaskAutoComplete: true,
|
||||
enableDependencies: true,
|
||||
enableAttachments: true,
|
||||
attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
attachmentMaxPerTask: 20,
|
||||
attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB
|
||||
enableComments: true,
|
||||
defaultPriority: 'medium',
|
||||
},
|
||||
agents: {
|
||||
timeoutMinutes: 30,
|
||||
autoCommitOnComplete: false,
|
||||
autoCleanupWorktrees: false,
|
||||
enablePreview: true,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
retentionDays: 90,
|
||||
enableTraces: false,
|
||||
enableActivityTracking: true,
|
||||
},
|
||||
notifications: {
|
||||
enabled: false,
|
||||
onTaskComplete: true,
|
||||
onAgentFailure: true,
|
||||
onReviewNeeded: true,
|
||||
channel: '',
|
||||
},
|
||||
archive: {
|
||||
autoArchiveEnabled: false,
|
||||
autoArchiveAfterDays: 30,
|
||||
},
|
||||
};
|
||||
|
||||
/** Query options for fetching events */
|
||||
export interface TelemetryQueryOptions {
|
||||
type?: TelemetryEventType | TelemetryEventType[];
|
||||
since?: string; // ISO timestamp
|
||||
until?: string; // ISO timestamp
|
||||
taskId?: string;
|
||||
project?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ============ Managed List Types ============
|
||||
|
||||
/** Base interface for managed list items */
|
||||
export interface ManagedListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
isDefault?: boolean;
|
||||
isHidden?: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/** Configuration options for ManagedListService */
|
||||
export interface ManagedListServiceOptions {
|
||||
filename: string;
|
||||
configDir: string;
|
||||
defaults: ManagedListItem[];
|
||||
}
|
||||
|
||||
/** Task type configuration with icon and color */
|
||||
export interface TaskTypeConfig extends ManagedListItem {
|
||||
icon: string; // Lucide icon name (e.g., "Code", "Search")
|
||||
color?: string; // Tailwind border color class (e.g., "border-l-violet-500")
|
||||
}
|
||||
|
||||
/** Project configuration with description and badge color */
|
||||
export interface ProjectConfig extends ManagedListItem {
|
||||
description?: string;
|
||||
color?: string; // Tailwind bg color class for badges (e.g., "bg-blue-500/20")
|
||||
}
|
||||
|
||||
/** Sprint configuration */
|
||||
export interface SprintConfig extends ManagedListItem {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============ Template Types ============
|
||||
|
||||
/** Subtask template for pre-defined subtask lists */
|
||||
export interface SubtaskTemplate {
|
||||
title: string; // Supports variables: "Review {{project}} PR"
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Blueprint task for multi-task template creation */
|
||||
export interface BlueprintTask {
|
||||
refId: string; // Local reference for dependency wiring
|
||||
title: string; // Supports variables
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blockedByRefs?: string[]; // References to other BlueprintTask.refIds
|
||||
}
|
||||
|
||||
/** Task template with enhanced features */
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string; // Template category: "sprint", "bug", "feature", etc.
|
||||
version: number; // Schema version for migration (0 = legacy, 1 = enhanced)
|
||||
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType; // NEW in v1: preferred agent
|
||||
};
|
||||
|
||||
// NEW in v1: Pre-defined subtasks
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
|
||||
// NEW in v1: For multi-task blueprints
|
||||
blueprint?: BlueprintTask[];
|
||||
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new template */
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blueprint?: BlueprintTask[];
|
||||
}
|
||||
|
||||
/** Input for updating an existing template */
|
||||
export interface UpdateTemplateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
taskDefaults?: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blueprint?: BlueprintTask[];
|
||||
}
|
||||
// Backward compatibility — all types now in types/ directory
|
||||
export * from './types/index.js';
|
||||
|
|
|
|||
147
shared/src/types/config.types.ts
Normal file
147
shared/src/types/config.types.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Config Types
|
||||
|
||||
import type { AgentType, TaskPriority } from './task.types.js';
|
||||
import type { TelemetryConfig } from './telemetry.types.js';
|
||||
|
||||
export interface DevServerConfig {
|
||||
command: string; // e.g., "pnpm dev" or "npm run dev"
|
||||
port?: number; // Expected port (auto-detected if not specified)
|
||||
readyPattern?: string; // Regex pattern to detect when server is ready
|
||||
}
|
||||
|
||||
export interface RepoConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
defaultBranch: string;
|
||||
devServer?: DevServerConfig;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
type: AgentType;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
repos: RepoConfig[];
|
||||
agents: AgentConfig[];
|
||||
defaultAgent: AgentType;
|
||||
telemetry?: TelemetryConfig;
|
||||
features?: FeatureSettings;
|
||||
}
|
||||
|
||||
// ============ Feature Settings Types ============
|
||||
|
||||
/** Board display settings */
|
||||
export interface BoardSettings {
|
||||
showDashboard: boolean;
|
||||
showArchiveSuggestions: boolean;
|
||||
cardDensity: 'normal' | 'compact';
|
||||
showPriorityIndicators: boolean;
|
||||
showProjectBadges: boolean;
|
||||
showSprintBadges: boolean;
|
||||
enableDragAndDrop: boolean;
|
||||
}
|
||||
|
||||
/** Task behavior settings */
|
||||
export interface TaskBehaviorSettings {
|
||||
enableTimeTracking: boolean;
|
||||
enableSubtaskAutoComplete: boolean;
|
||||
enableDependencies: boolean;
|
||||
enableAttachments: boolean;
|
||||
attachmentMaxFileSize: number; // bytes
|
||||
attachmentMaxPerTask: number;
|
||||
attachmentMaxTotalSize: number; // bytes
|
||||
enableComments: boolean;
|
||||
defaultPriority: TaskPriority;
|
||||
}
|
||||
|
||||
/** Agent & git settings */
|
||||
export interface AgentBehaviorSettings {
|
||||
timeoutMinutes: number; // 5-480
|
||||
autoCommitOnComplete: boolean;
|
||||
autoCleanupWorktrees: boolean;
|
||||
enablePreview: boolean;
|
||||
}
|
||||
|
||||
/** Telemetry & activity settings */
|
||||
export interface TelemetryFeatureSettings {
|
||||
enabled: boolean;
|
||||
retentionDays: number; // 7-365
|
||||
enableTraces: boolean;
|
||||
enableActivityTracking: boolean;
|
||||
}
|
||||
|
||||
/** Notification settings */
|
||||
export interface NotificationSettings {
|
||||
enabled: boolean;
|
||||
onTaskComplete: boolean;
|
||||
onAgentFailure: boolean;
|
||||
onReviewNeeded: boolean;
|
||||
channel: string; // Teams channel ID
|
||||
}
|
||||
|
||||
/** Archive settings */
|
||||
export interface ArchiveSettings {
|
||||
autoArchiveEnabled: boolean;
|
||||
autoArchiveAfterDays: number;
|
||||
}
|
||||
|
||||
/** All feature settings combined */
|
||||
export interface FeatureSettings {
|
||||
board: BoardSettings;
|
||||
tasks: TaskBehaviorSettings;
|
||||
agents: AgentBehaviorSettings;
|
||||
telemetry: TelemetryFeatureSettings;
|
||||
notifications: NotificationSettings;
|
||||
archive: ArchiveSettings;
|
||||
}
|
||||
|
||||
/** Default feature settings — matches current app behavior */
|
||||
export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = {
|
||||
board: {
|
||||
showDashboard: true,
|
||||
showArchiveSuggestions: true,
|
||||
cardDensity: 'normal',
|
||||
showPriorityIndicators: true,
|
||||
showProjectBadges: true,
|
||||
showSprintBadges: true,
|
||||
enableDragAndDrop: true,
|
||||
},
|
||||
tasks: {
|
||||
enableTimeTracking: true,
|
||||
enableSubtaskAutoComplete: true,
|
||||
enableDependencies: true,
|
||||
enableAttachments: true,
|
||||
attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB
|
||||
attachmentMaxPerTask: 20,
|
||||
attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB
|
||||
enableComments: true,
|
||||
defaultPriority: 'medium',
|
||||
},
|
||||
agents: {
|
||||
timeoutMinutes: 30,
|
||||
autoCommitOnComplete: false,
|
||||
autoCleanupWorktrees: false,
|
||||
enablePreview: true,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
retentionDays: 90,
|
||||
enableTraces: false,
|
||||
enableActivityTracking: true,
|
||||
},
|
||||
notifications: {
|
||||
enabled: false,
|
||||
onTaskComplete: true,
|
||||
onAgentFailure: true,
|
||||
onReviewNeeded: true,
|
||||
channel: '',
|
||||
},
|
||||
archive: {
|
||||
autoArchiveEnabled: false,
|
||||
autoArchiveAfterDays: 30,
|
||||
},
|
||||
};
|
||||
8
shared/src/types/index.ts
Normal file
8
shared/src/types/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Barrel file - re-exports all types from domain-specific files
|
||||
|
||||
export * from './task.types.js';
|
||||
export * from './config.types.js';
|
||||
export * from './telemetry.types.js';
|
||||
export * from './template.types.js';
|
||||
export * from './websocket.types.js';
|
||||
export * from './managed-list.types.js';
|
||||
36
shared/src/types/managed-list.types.ts
Normal file
36
shared/src/types/managed-list.types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Managed List Types
|
||||
|
||||
/** Base interface for managed list items */
|
||||
export interface ManagedListItem {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
isDefault?: boolean;
|
||||
isHidden?: boolean;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/** Configuration options for ManagedListService */
|
||||
export interface ManagedListServiceOptions {
|
||||
filename: string;
|
||||
configDir: string;
|
||||
defaults: ManagedListItem[];
|
||||
}
|
||||
|
||||
/** Task type configuration with icon and color */
|
||||
export interface TaskTypeConfig extends ManagedListItem {
|
||||
icon: string; // Lucide icon name (e.g., "Code", "Search")
|
||||
color?: string; // Tailwind border color class (e.g., "border-l-violet-500")
|
||||
}
|
||||
|
||||
/** Project configuration with description and badge color */
|
||||
export interface ProjectConfig extends ManagedListItem {
|
||||
description?: string;
|
||||
color?: string; // Tailwind bg color class for badges (e.g., "bg-blue-500/20")
|
||||
}
|
||||
|
||||
/** Sprint configuration */
|
||||
export interface SprintConfig extends ManagedListItem {
|
||||
description?: string;
|
||||
}
|
||||
222
shared/src/types/task.types.ts
Normal file
222
shared/src/types/task.types.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Task Types
|
||||
|
||||
export type TaskType = string;
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini' | 'veritas';
|
||||
export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed';
|
||||
|
||||
export interface TaskGit {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
id: string;
|
||||
agent: AgentType;
|
||||
status: AttemptStatus;
|
||||
started?: string;
|
||||
ended?: string;
|
||||
}
|
||||
|
||||
export interface Subtask {
|
||||
id: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
startTime: string;
|
||||
endTime?: string; // Undefined if timer is running
|
||||
duration?: number; // Duration in seconds (calculated when stopped)
|
||||
description?: string; // Optional note for the entry
|
||||
manual?: boolean; // True if manually entered
|
||||
}
|
||||
|
||||
export interface TimeTracking {
|
||||
entries: TimeEntry[];
|
||||
totalSeconds: number; // Total tracked time in seconds
|
||||
isRunning: boolean; // Is timer currently running
|
||||
activeEntryId?: string; // ID of the currently running entry
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string; // Sanitized filename stored on disk
|
||||
originalName: string; // Original filename from upload
|
||||
mimeType: string;
|
||||
size: number; // File size in bytes
|
||||
uploaded: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface AttachmentLimits {
|
||||
maxFileSize: number; // Max size per file in bytes
|
||||
maxFilesPerTask: number; // Max number of attachments per task
|
||||
maxTotalSize: number; // Max total size for all attachments per task
|
||||
}
|
||||
|
||||
// Default attachment limits
|
||||
export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = {
|
||||
maxFileSize: 10 * 1024 * 1024, // 10MB per file
|
||||
maxFilesPerTask: 20, // 20 files per task
|
||||
maxTotalSize: 50 * 1024 * 1024, // 50MB total per task
|
||||
};
|
||||
|
||||
// Allowed MIME types for attachments
|
||||
export const ALLOWED_MIME_TYPES = [
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'text/html',
|
||||
'text/csv',
|
||||
|
||||
// Images
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
|
||||
// Code & Config
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'application/yaml',
|
||||
'text/yaml',
|
||||
];
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
|
||||
// Code task specific
|
||||
git?: TaskGit;
|
||||
|
||||
// Current attempt
|
||||
attempt?: TaskAttempt;
|
||||
|
||||
// Attempt history
|
||||
attempts?: TaskAttempt[];
|
||||
|
||||
// Review comments (for code tasks)
|
||||
reviewComments?: ReviewComment[];
|
||||
|
||||
// Review state
|
||||
review?: ReviewState;
|
||||
|
||||
// Subtasks
|
||||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean; // Auto-complete parent when all subtasks done
|
||||
|
||||
// Dependencies
|
||||
blockedBy?: string[]; // Array of task IDs that block this task
|
||||
|
||||
// Automation task specific (for veritas sub-agent)
|
||||
automation?: {
|
||||
sessionKey?: string; // Clawdbot session key
|
||||
spawnedAt?: string; // When sub-agent was spawned
|
||||
completedAt?: string; // When sub-agent finished
|
||||
result?: string; // Result summary from sub-agent
|
||||
};
|
||||
|
||||
// Time tracking
|
||||
timeTracking?: TimeTracking;
|
||||
|
||||
// Comments
|
||||
comments?: Comment[];
|
||||
|
||||
// Attachments
|
||||
attachments?: Attachment[];
|
||||
|
||||
// Position within column (for drag-and-drop ordering)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type ReviewDecision = 'approved' | 'changes-requested' | 'rejected';
|
||||
|
||||
export interface ReviewState {
|
||||
decision?: ReviewDecision;
|
||||
decidedAt?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
// API Types
|
||||
|
||||
export interface CreateTaskInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
subtasks?: Subtask[]; // Can be provided when creating from a template
|
||||
blockedBy?: string[]; // Can be provided when creating from a blueprint
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
sprint?: string;
|
||||
git?: Partial<TaskGit>;
|
||||
attempt?: TaskAttempt;
|
||||
reviewComments?: ReviewComment[];
|
||||
review?: ReviewState;
|
||||
subtasks?: Subtask[];
|
||||
autoCompleteOnSubtasks?: boolean;
|
||||
blockedBy?: string[];
|
||||
automation?: {
|
||||
sessionKey?: string;
|
||||
spawnedAt?: string;
|
||||
completedAt?: string;
|
||||
result?: string;
|
||||
};
|
||||
timeTracking?: TimeTracking;
|
||||
comments?: Comment[];
|
||||
attachments?: Attachment[];
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
type?: TaskType | TaskType[];
|
||||
project?: string;
|
||||
search?: string;
|
||||
}
|
||||
74
shared/src/types/telemetry.types.ts
Normal file
74
shared/src/types/telemetry.types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Telemetry Types
|
||||
|
||||
import type { TaskStatus, AgentType } from './task.types.js';
|
||||
|
||||
export type TelemetryEventType =
|
||||
| 'task.created'
|
||||
| 'task.status_changed'
|
||||
| 'task.archived'
|
||||
| 'task.restored'
|
||||
| 'run.started'
|
||||
| 'run.completed'
|
||||
| 'run.error'
|
||||
| 'run.tokens';
|
||||
|
||||
/** Base telemetry event - all events extend this */
|
||||
export interface TelemetryEvent {
|
||||
id: string;
|
||||
type: TelemetryEventType;
|
||||
timestamp: string;
|
||||
taskId?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
/** Task lifecycle events */
|
||||
export interface TaskTelemetryEvent extends TelemetryEvent {
|
||||
type: 'task.created' | 'task.status_changed' | 'task.archived' | 'task.restored';
|
||||
taskId: string;
|
||||
status?: TaskStatus;
|
||||
previousStatus?: TaskStatus;
|
||||
}
|
||||
|
||||
/** Agent run events */
|
||||
export interface RunTelemetryEvent extends TelemetryEvent {
|
||||
type: 'run.started' | 'run.completed' | 'run.error';
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
agent: AgentType;
|
||||
durationMs?: number;
|
||||
exitCode?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Token usage events */
|
||||
export interface TokenTelemetryEvent extends TelemetryEvent {
|
||||
type: 'run.tokens';
|
||||
taskId: string;
|
||||
attemptId: string;
|
||||
agent: AgentType;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Union type for all telemetry events */
|
||||
export type AnyTelemetryEvent = TaskTelemetryEvent | RunTelemetryEvent | TokenTelemetryEvent;
|
||||
|
||||
/** Telemetry configuration */
|
||||
export interface TelemetryConfig {
|
||||
enabled: boolean;
|
||||
retention: number; // Days to retain events
|
||||
traces?: boolean; // Optional trace collection (future)
|
||||
}
|
||||
|
||||
/** Query options for fetching events */
|
||||
export interface TelemetryQueryOptions {
|
||||
type?: TelemetryEventType | TelemetryEventType[];
|
||||
since?: string; // ISO timestamp
|
||||
until?: string; // ISO timestamp
|
||||
taskId?: string;
|
||||
project?: string;
|
||||
limit?: number;
|
||||
}
|
||||
82
shared/src/types/template.types.ts
Normal file
82
shared/src/types/template.types.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Template Types
|
||||
|
||||
import type { TaskType, TaskPriority, AgentType } from './task.types.js';
|
||||
|
||||
/** Subtask template for pre-defined subtask lists */
|
||||
export interface SubtaskTemplate {
|
||||
title: string; // Supports variables: "Review {{project}} PR"
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Blueprint task for multi-task template creation */
|
||||
export interface BlueprintTask {
|
||||
refId: string; // Local reference for dependency wiring
|
||||
title: string; // Supports variables
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blockedByRefs?: string[]; // References to other BlueprintTask.refIds
|
||||
}
|
||||
|
||||
/** Task template with enhanced features */
|
||||
export interface TaskTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string; // Template category: "sprint", "bug", "feature", etc.
|
||||
version: number; // Schema version for migration (0 = legacy, 1 = enhanced)
|
||||
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType; // NEW in v1: preferred agent
|
||||
};
|
||||
|
||||
// NEW in v1: Pre-defined subtasks
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
|
||||
// NEW in v1: For multi-task blueprints
|
||||
blueprint?: BlueprintTask[];
|
||||
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
/** Input for creating a new template */
|
||||
export interface CreateTemplateInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
taskDefaults: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blueprint?: BlueprintTask[];
|
||||
}
|
||||
|
||||
/** Input for updating an existing template */
|
||||
export interface UpdateTemplateInput {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
taskDefaults?: {
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
descriptionTemplate?: string;
|
||||
agent?: AgentType;
|
||||
};
|
||||
subtaskTemplates?: SubtaskTemplate[];
|
||||
blueprint?: BlueprintTask[];
|
||||
}
|
||||
34
shared/src/types/websocket.types.ts
Normal file
34
shared/src/types/websocket.types.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// WebSocket Message Types
|
||||
|
||||
import type { AttemptStatus } from './task.types.js';
|
||||
|
||||
export type WSMessageType =
|
||||
| 'agent:output'
|
||||
| 'agent:status'
|
||||
| 'agent:complete'
|
||||
| 'task:updated'
|
||||
| 'error';
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AgentOutputMessage extends WSMessage {
|
||||
type: 'agent:output';
|
||||
data: {
|
||||
stream: 'stdout' | 'stderr';
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentStatusMessage extends WSMessage {
|
||||
type: 'agent:status';
|
||||
data: {
|
||||
status: AttemptStatus;
|
||||
exitCode?: number;
|
||||
};
|
||||
}
|
||||
51
shared/src/utils/api-client.ts
Normal file
51
shared/src/utils/api-client.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Shared API client for CLI and MCP
|
||||
*/
|
||||
|
||||
import type { Task } from '../types/task.types.js';
|
||||
|
||||
const DEFAULT_BASE = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Create an API client instance
|
||||
* @param baseUrl - Base URL for the API (default: http://localhost:3001)
|
||||
* @returns API client function
|
||||
*/
|
||||
export function createApiClient(baseUrl = DEFAULT_BASE) {
|
||||
return async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ error: res.statusText })) as { error?: string };
|
||||
throw new Error(error.error || `API error: ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default API client using environment variable or localhost
|
||||
*/
|
||||
export const API_BASE = process.env.VK_API_URL || DEFAULT_BASE;
|
||||
export const api = createApiClient(API_BASE);
|
||||
|
||||
/**
|
||||
* Find task by ID (supports partial matching on ID suffix)
|
||||
* @param id - Full or partial task ID
|
||||
* @param apiClient - Optional custom API client (defaults to shared api client)
|
||||
* @returns Task if found, null otherwise
|
||||
*/
|
||||
export async function findTask(id: string, apiClient = api): Promise<Task | null> {
|
||||
const tasks = await apiClient<Task[]>('/api/tasks');
|
||||
return tasks.find(t => t.id === id || t.id.endsWith(id)) || null;
|
||||
}
|
||||
37
shared/src/utils/constants.ts
Normal file
37
shared/src/utils/constants.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Shared constants used across the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Priority level labels
|
||||
*/
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
critical: 'Critical'
|
||||
};
|
||||
|
||||
/**
|
||||
* Task status labels
|
||||
*/
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
'todo': 'To Do',
|
||||
'in-progress': 'In Progress',
|
||||
'review': 'Review',
|
||||
'done': 'Done',
|
||||
'blocked': 'Blocked',
|
||||
'cancelled': 'Cancelled'
|
||||
};
|
||||
|
||||
/**
|
||||
* Task type labels
|
||||
*/
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
feature: 'Feature',
|
||||
bug: 'Bug',
|
||||
refactor: 'Refactor',
|
||||
docs: 'Documentation',
|
||||
test: 'Test',
|
||||
chore: 'Chore'
|
||||
};
|
||||
109
shared/src/utils/format.ts
Normal file
109
shared/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Formatting utilities for durations, dates, and other display values
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a duration in seconds to a human-readable string
|
||||
* @param seconds - Duration in seconds
|
||||
* @returns Formatted duration (e.g., "2h 15m", "45s")
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
if (minutes > 0 && secs > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in milliseconds to a human-readable string
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted duration (e.g., "2h 15m", "45s", "250ms")
|
||||
*/
|
||||
export function formatDurationMs(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to a localized string
|
||||
* @param date - Date string or Date object
|
||||
* @param options - Intl.DateTimeFormatOptions
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatDate(date: string | Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
};
|
||||
return d.toLocaleDateString('en-US', options || defaultOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date and time to a localized string
|
||||
* @param date - Date string or Date object
|
||||
* @param options - Intl.DateTimeFormatOptions
|
||||
* @returns Formatted date/time string
|
||||
*/
|
||||
export function formatDateTime(date: string | Date, options?: Intl.DateTimeFormatOptions): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleString('en-US', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to a relative time string (e.g., "2 hours ago")
|
||||
* @param date - Date string or Date object
|
||||
* @returns Relative time string
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
// Fall back to formatted date for older items
|
||||
return formatDate(d);
|
||||
}
|
||||
8
shared/src/utils/index.ts
Normal file
8
shared/src/utils/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Shared utilities barrel export
|
||||
*/
|
||||
|
||||
export * from './path.js';
|
||||
export * from './format.js';
|
||||
export * from './constants.js';
|
||||
export * from './api-client.js';
|
||||
16
shared/src/utils/path.ts
Normal file
16
shared/src/utils/path.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Path expansion utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Expand ~ and environment variables in a path
|
||||
* @param p - Path to expand
|
||||
* @returns Expanded path
|
||||
*/
|
||||
export function expandPath(p: string): string {
|
||||
// Replace ~ with home directory
|
||||
// Replace environment variables like $VAR
|
||||
return p
|
||||
.replace(/^~/, process.env.HOME || '')
|
||||
.replace(/\$(\w+)/g, (_, name) => process.env[name] || '');
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { KeyboardProvider } from './hooks/useKeyboard';
|
|||
import { KeyboardShortcutsDialog } from './components/layout/KeyboardShortcutsDialog';
|
||||
import { BulkActionsProvider } from './hooks/useBulkActions';
|
||||
import { useTaskSync } from './hooks/useTaskSync';
|
||||
import { TaskConfigProvider } from './contexts/TaskConfigContext';
|
||||
|
||||
function App() {
|
||||
// Connect to WebSocket for real-time task updates
|
||||
|
|
@ -13,14 +14,16 @@ function App() {
|
|||
return (
|
||||
<KeyboardProvider>
|
||||
<BulkActionsProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
<TaskConfigProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
<Toaster />
|
||||
<KeyboardShortcutsDialog />
|
||||
</div>
|
||||
</TaskConfigProvider>
|
||||
</BulkActionsProvider>
|
||||
</KeyboardProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border">
|
||||
<div className="flex items-center justify-between gap-4 mb-4 p-3 rounded-lg bg-muted/50 border" role="toolbar" aria-label="Bulk actions">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -123,6 +123,7 @@ export function BulkActionsBar({ allTaskIds }: BulkActionsBarProps) {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
aria-label={allSelected ? 'Deselect all tasks' : 'Select all tasks'}
|
||||
>
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ import { KanbanColumn } from './KanbanColumn';
|
|||
import { TaskDetailPanel } from '@/components/task/TaskDetailPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { TaskStatus, Task } from '@veritas-kanban/shared';
|
||||
import { useTaskTypes } from '@/hooks/useTaskTypes';
|
||||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { useSprints } from '@/hooks/useSprints';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -32,6 +29,7 @@ import {
|
|||
import { BulkActionsBar } from './BulkActionsBar';
|
||||
import { ArchiveSuggestionBanner } from './ArchiveSuggestionBanner';
|
||||
import { DashboardSection } from '@/components/dashboard/DashboardSection';
|
||||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
|
||||
const COLUMNS: { id: TaskStatus; title: string }[] = [
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
|
|
@ -77,9 +75,6 @@ function LoadingSkeleton() {
|
|||
|
||||
export function KanbanBoard() {
|
||||
const { data: tasks, isLoading, error } = useTasks();
|
||||
const { data: taskTypes = [] } = useTaskTypes();
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: sprints = [] } = useSprints();
|
||||
const { settings: featureSettings } = useFeatureSettings();
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
|
@ -276,14 +271,36 @@ export function KanbanBoard() {
|
|||
|
||||
{featureSettings.board.showArchiveSuggestions && <ArchiveSuggestionBanner />}
|
||||
|
||||
{featureSettings.board.enableDragAndDrop ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<FeatureErrorBoundary fallbackTitle="Board failed to render">
|
||||
{featureSettings.board.enableDragAndDrop ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{COLUMNS.map(column => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
tasks={tasksByStatus[column.id]}
|
||||
allTasks={filteredTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
selectedTaskId={selectedTaskId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<TaskCard task={activeTask} isDragging />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{COLUMNS.map(column => (
|
||||
<KanbanColumn
|
||||
|
|
@ -294,39 +311,13 @@ export function KanbanBoard() {
|
|||
allTasks={filteredTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
selectedTaskId={selectedTaskId}
|
||||
taskTypes={taskTypes}
|
||||
projects={projects}
|
||||
sprints={sprints}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<TaskCard task={activeTask} isDragging taskTypes={taskTypes} projects={projects} sprints={sprints} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{COLUMNS.map(column => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
tasks={tasksByStatus[column.id]}
|
||||
allTasks={filteredTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
selectedTaskId={selectedTaskId}
|
||||
taskTypes={taskTypes}
|
||||
projects={projects}
|
||||
sprints={sprints}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{featureSettings.board.showDashboard && <DashboardSection />}
|
||||
{featureSettings.board.showDashboard && <DashboardSection />}
|
||||
</FeatureErrorBoundary>
|
||||
|
||||
<TaskDetailPanel
|
||||
task={currentSelectedTask}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|||
import { cn } from '@/lib/utils';
|
||||
import { TaskCard } from '@/components/task/TaskCard';
|
||||
import { isTaskBlocked, getTaskBlockers } from '@/hooks/useTasks';
|
||||
import type { Task, TaskStatus, TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskStatus;
|
||||
|
|
@ -12,9 +12,6 @@ interface KanbanColumnProps {
|
|||
allTasks: Task[];
|
||||
onTaskClick?: (task: Task) => void;
|
||||
selectedTaskId?: string | null;
|
||||
taskTypes?: TaskTypeConfig[];
|
||||
projects?: ProjectConfig[];
|
||||
sprints?: SprintConfig[];
|
||||
}
|
||||
|
||||
const columnColors: Record<TaskStatus, string> = {
|
||||
|
|
@ -24,12 +21,14 @@ const columnColors: Record<TaskStatus, string> = {
|
|||
'done': 'border-t-green-500',
|
||||
};
|
||||
|
||||
export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selectedTaskId, taskTypes = [], projects = [], sprints = [] }: KanbanColumnProps) {
|
||||
export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selectedTaskId }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
role="region"
|
||||
aria-label={`${title} column, ${tasks.length} tasks`}
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg bg-muted/50 border-t-2 transition-all',
|
||||
columnColors[id],
|
||||
|
|
@ -40,7 +39,7 @@ export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selected
|
|||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full" aria-live="polite">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -63,9 +62,6 @@ export function KanbanColumn({ id, title, tasks, allTasks, onTaskClick, selected
|
|||
key={task.id}
|
||||
task={task}
|
||||
onClick={() => onTaskClick?.(task)}
|
||||
taskTypes={taskTypes}
|
||||
projects={projects}
|
||||
sprints={sprints}
|
||||
isSelected={task.id === selectedTaskId}
|
||||
isBlocked={blocked}
|
||||
blockerTitles={blockers.map(b => b.title)}
|
||||
|
|
|
|||
120
web/src/components/shared/FeatureErrorBoundary.tsx
Normal file
120
web/src/components/shared/FeatureErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { Component, ReactNode } from 'react';
|
||||
import { AlertCircle, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface FeatureErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallbackTitle?: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface FeatureErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorExpanded: boolean;
|
||||
}
|
||||
|
||||
export class FeatureErrorBoundary extends Component<
|
||||
FeatureErrorBoundaryProps,
|
||||
FeatureErrorBoundaryState
|
||||
> {
|
||||
constructor(props: FeatureErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<FeatureErrorBoundaryState> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('FeatureErrorBoundary caught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorExpanded: false,
|
||||
});
|
||||
|
||||
// Call optional reset callback
|
||||
if (this.props.onReset) {
|
||||
this.props.onReset();
|
||||
}
|
||||
};
|
||||
|
||||
toggleErrorDetails = () => {
|
||||
this.setState((prev) => ({ errorExpanded: !prev.errorExpanded }));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
const title = this.props.fallbackTitle || 'Something went wrong';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px] p-6">
|
||||
<div className="max-w-md w-full rounded-lg border border-zinc-700 bg-zinc-900 p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<h3 className="text-base font-semibold text-zinc-100">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-zinc-400">
|
||||
An unexpected error occurred. Try refreshing or contact support if the issue persists.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={this.toggleErrorDetails}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{this.state.errorExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{this.state.errorExpanded ? 'Hide' : 'Show'} error details
|
||||
</button>
|
||||
|
||||
{this.state.errorExpanded && (
|
||||
<div className="mt-2 p-3 rounded bg-zinc-950 border border-zinc-800">
|
||||
<code className="text-xs text-red-400 break-all whitespace-pre-wrap">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack && (
|
||||
<div className="mt-2 text-zinc-500 text-[10px] leading-relaxed">
|
||||
{this.state.error.stack}
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-100 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-md transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default FeatureErrorBoundary;
|
||||
|
|
@ -49,6 +49,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import type { Task, AgentType, AttemptStatus } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
|
||||
interface AgentPanelProps {
|
||||
task: Task;
|
||||
|
|
@ -151,12 +152,13 @@ export function AgentPanel({ task }: AgentPanelProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI Agent
|
||||
</Label>
|
||||
<FeatureErrorBoundary fallbackTitle="Agent panel failed to load">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
AI Agent
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
|
|
@ -391,6 +393,7 @@ export function AgentPanel({ task }: AgentPanelProps) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FeatureErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,19 +18,14 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useCreateTask } from '@/hooks/useTasks';
|
||||
import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates';
|
||||
import type { TaskPriority, Subtask } from '@veritas-kanban/shared';
|
||||
import { FileText, X, Check, AlertCircle, HelpCircle, Info } from 'lucide-react';
|
||||
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
|
||||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { useSprints } from '@/hooks/useSprints';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
interpolateVariables,
|
||||
extractCustomVariables,
|
||||
type VariableContext
|
||||
} from '@/lib/template-variables';
|
||||
import { useTemplateForm } from '@/hooks/useTemplateForm';
|
||||
import { BlueprintPreview } from './create/BlueprintPreview';
|
||||
import { TemplateVariableInputs } from './create/TemplateVariableInputs';
|
||||
import type { TaskPriority } from '@veritas-kanban/shared';
|
||||
import { FileText, X, Check, HelpCircle, Info } from 'lucide-react';
|
||||
import { getCategoryIcon } from '@/lib/template-categories';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
|
|
@ -45,20 +40,28 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
const [priority, setPriority] = useState<TaskPriority>('medium');
|
||||
const [project, setProject] = useState('');
|
||||
const [sprint, setSprint] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [customVars, setCustomVars] = useState<Record<string, string>>({});
|
||||
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
const createTask = useCreateTask();
|
||||
const { data: templates } = useTemplates();
|
||||
const { data: taskTypes = [] } = useTaskTypes();
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: sprints = [] } = useSprints();
|
||||
|
||||
const {
|
||||
selectedTemplate,
|
||||
templates,
|
||||
subtasks,
|
||||
customVars,
|
||||
requiredCustomVars,
|
||||
applyTemplate,
|
||||
clearTemplate,
|
||||
removeSubtask,
|
||||
setCustomVars,
|
||||
createTasks,
|
||||
isCreating,
|
||||
} = useTemplateForm();
|
||||
|
||||
// Filter templates by selected category
|
||||
const filteredTemplates = useMemo(() => {
|
||||
|
|
@ -67,123 +70,33 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
return templates.filter(t => (t.category || 'custom') === categoryFilter);
|
||||
}, [templates, categoryFilter]);
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
setSelectedTemplate(template.id);
|
||||
|
||||
// If this is a blueprint template, extract variables from all blueprint tasks
|
||||
if (template.blueprint && template.blueprint.length > 0) {
|
||||
const allBlueprintText = template.blueprint.flatMap(bt => [
|
||||
bt.title,
|
||||
bt.taskDefaults.descriptionTemplate || '',
|
||||
...(bt.subtaskTemplates?.map(st => st.title) || [])
|
||||
]).join(' ');
|
||||
|
||||
const customVarNames = extractCustomVariables(allBlueprintText);
|
||||
setRequiredCustomVars(customVarNames);
|
||||
|
||||
const initialCustomVars: Record<string, string> = {};
|
||||
customVarNames.forEach(name => {
|
||||
initialCustomVars[name] = '';
|
||||
});
|
||||
setCustomVars(initialCustomVars);
|
||||
|
||||
// Don't populate the form for blueprints
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
if (templateId === 'none') {
|
||||
clearTemplate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-task template
|
||||
if (template.taskDefaults.type) setType(template.taskDefaults.type);
|
||||
if (template.taskDefaults.priority) setPriority(template.taskDefaults.priority);
|
||||
if (template.taskDefaults.project) setProject(template.taskDefaults.project);
|
||||
const template = templates?.find(t => t.id === templateId);
|
||||
if (!template) return;
|
||||
|
||||
// Extract custom variables from description template and subtasks
|
||||
const allTemplateText = [
|
||||
template.taskDefaults.descriptionTemplate || '',
|
||||
...(template.subtaskTemplates?.map(st => st.title) || [])
|
||||
].join(' ');
|
||||
|
||||
const customVarNames = extractCustomVariables(allTemplateText);
|
||||
setRequiredCustomVars(customVarNames);
|
||||
|
||||
// Initialize custom vars
|
||||
const initialCustomVars: Record<string, string> = {};
|
||||
customVarNames.forEach(name => {
|
||||
initialCustomVars[name] = '';
|
||||
});
|
||||
setCustomVars(initialCustomVars);
|
||||
|
||||
// Store raw template (will be interpolated when custom vars are filled)
|
||||
if (template.taskDefaults.descriptionTemplate) {
|
||||
setDescription(template.taskDefaults.descriptionTemplate);
|
||||
}
|
||||
|
||||
// Convert subtask templates to actual subtasks (will be interpolated on submit)
|
||||
if (template.subtaskTemplates && template.subtaskTemplates.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const templateSubtasks: Subtask[] = template.subtaskTemplates
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(st => ({
|
||||
id: nanoid(),
|
||||
title: st.title, // Will be interpolated on submit
|
||||
completed: false,
|
||||
created: now,
|
||||
}));
|
||||
setSubtasks(templateSubtasks);
|
||||
} else {
|
||||
setSubtasks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setSubtasks([]);
|
||||
setCustomVars({});
|
||||
setRequiredCustomVars([]);
|
||||
};
|
||||
|
||||
const removeSubtask = (id: string) => {
|
||||
setSubtasks(prev => prev.filter(st => st.id !== id));
|
||||
const defaults = applyTemplate(template);
|
||||
if (defaults.type) setType(defaults.type);
|
||||
if (defaults.priority) setPriority(defaults.priority);
|
||||
if (defaults.project) setProject(defaults.project);
|
||||
if (defaults.description) setDescription(defaults.description);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Build variable context
|
||||
const context: VariableContext = {
|
||||
project: project.trim() || undefined,
|
||||
author: 'Brad', // TODO: Get from user config
|
||||
customVars,
|
||||
};
|
||||
|
||||
// Check if this is a blueprint template
|
||||
const template = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
|
||||
|
||||
if (template?.blueprint && template.blueprint.length > 0) {
|
||||
// Blueprint: create multiple tasks
|
||||
await handleBlueprintCreation(template, context);
|
||||
} else {
|
||||
// Single task creation
|
||||
if (!title.trim()) return;
|
||||
|
||||
// Interpolate variables in description
|
||||
const interpolatedDescription = interpolateVariables(description, context);
|
||||
|
||||
// Interpolate variables in subtask titles
|
||||
const interpolatedSubtasks = subtasks.map(st => ({
|
||||
...st,
|
||||
title: interpolateVariables(st.title, context),
|
||||
}));
|
||||
|
||||
await createTask.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: interpolatedDescription.trim(),
|
||||
type,
|
||||
priority,
|
||||
project: project.trim() || undefined,
|
||||
sprint: sprint.trim() || undefined,
|
||||
subtasks: interpolatedSubtasks.length > 0 ? interpolatedSubtasks : undefined,
|
||||
});
|
||||
// For blueprints, we don't need a title
|
||||
if (!(template?.blueprint && template.blueprint.length > 0) && !title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await createTasks(title, description, project, sprint, type, priority);
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
|
|
@ -192,61 +105,14 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
setPriority('medium');
|
||||
setProject('');
|
||||
setSprint('');
|
||||
setSelectedTemplate(null);
|
||||
setSubtasks([]);
|
||||
setCustomVars({});
|
||||
setRequiredCustomVars([]);
|
||||
clearTemplate();
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleBlueprintCreation = async (template: TaskTemplate, context: VariableContext) => {
|
||||
if (!template.blueprint) return;
|
||||
|
||||
// Map to store refId -> actual task ID
|
||||
const refIdToTaskId: Record<string, string> = {};
|
||||
|
||||
// Create tasks in order, resolving dependencies
|
||||
for (const blueprintTask of template.blueprint) {
|
||||
// Interpolate title
|
||||
const taskTitle = interpolateVariables(blueprintTask.title, context);
|
||||
|
||||
// Interpolate description
|
||||
const taskDescription = interpolateVariables(
|
||||
blueprintTask.taskDefaults.descriptionTemplate || '',
|
||||
context
|
||||
);
|
||||
|
||||
// Create subtasks
|
||||
const taskSubtasks = blueprintTask.subtaskTemplates?.map(st => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: nanoid(),
|
||||
title: interpolateVariables(st.title, context),
|
||||
completed: false,
|
||||
created: now,
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve dependencies
|
||||
const blockedBy = blueprintTask.blockedByRefs?.map(refId => refIdToTaskId[refId]).filter(Boolean);
|
||||
|
||||
// Create the task
|
||||
const createdTask = await createTask.mutateAsync({
|
||||
title: taskTitle,
|
||||
description: taskDescription,
|
||||
type: blueprintTask.taskDefaults.type,
|
||||
priority: blueprintTask.taskDefaults.priority,
|
||||
project: blueprintTask.taskDefaults.project || project.trim() || undefined,
|
||||
subtasks: taskSubtasks,
|
||||
blockedBy,
|
||||
});
|
||||
|
||||
// Store the mapping
|
||||
refIdToTaskId[blueprintTask.refId] = createdTask.id;
|
||||
}
|
||||
};
|
||||
const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
|
||||
const isBlueprint = currentTemplate?.blueprint && currentTemplate.blueprint.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -270,6 +136,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
aria-label={showHelp ? 'Hide template help' : 'Show template help'}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
|
|
@ -292,6 +159,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>
|
||||
|
|
@ -300,16 +168,10 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
<TabsTrigger value="sprint" className="text-xs">🔄</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Select
|
||||
value={selectedTemplate || 'none'}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'none') {
|
||||
clearTemplate();
|
||||
} else {
|
||||
const template = templates.find(t => t.id === value);
|
||||
if (template) applyTemplate(template);
|
||||
}
|
||||
}}
|
||||
onValueChange={handleTemplateSelect}
|
||||
>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="Select template..." />
|
||||
|
|
@ -333,268 +195,205 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
)}
|
||||
|
||||
{/* Blueprint preview or regular form */}
|
||||
{selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint ? (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Blueprint: Multiple Tasks</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
This template will create {templates.find(t => t.id === selectedTemplate)?.blueprint?.length} linked tasks.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{templates.find(t => t.id === selectedTemplate)?.blueprint?.map((bt, idx) => (
|
||||
<div key={bt.refId} className="text-sm border-l-2 border-primary/50 pl-3 py-1">
|
||||
<div className="font-medium">
|
||||
{idx + 1}. {bt.title}
|
||||
</div>
|
||||
{bt.blockedByRefs && bt.blockedByRefs.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Blocked by: {bt.blockedByRefs.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{bt.subtaskTemplates && bt.subtaskTemplates.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bt.subtaskTemplates.length} subtasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom variable inputs for blueprint */}
|
||||
{requiredCustomVars.length > 0 && (
|
||||
<div className="grid gap-3 border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Template Variables</Label>
|
||||
</div>
|
||||
{requiredCustomVars.map((varName) => (
|
||||
<div key={varName} className="grid gap-1.5">
|
||||
<Label htmlFor={`var-${varName}`} className="text-xs">
|
||||
{varName}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${varName}`}
|
||||
value={customVars[varName] || ''}
|
||||
onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))}
|
||||
placeholder={`Enter ${varName}...`}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title..."
|
||||
autoFocus
|
||||
<div className="grid gap-4 py-4">
|
||||
{isBlueprint ? (
|
||||
<>
|
||||
<BlueprintPreview template={currentTemplate!} />
|
||||
<TemplateVariableInputs
|
||||
variables={requiredCustomVars}
|
||||
values={customVars}
|
||||
onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe the task..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{taskTypes.map((taskType) => {
|
||||
const IconComponent = getTypeIcon(taskType.icon);
|
||||
return (
|
||||
<SelectItem key={taskType.id} value={taskType.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
{taskType.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project">Project (optional)</Label>
|
||||
{!showNewProject ? (
|
||||
<Select
|
||||
value={project}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
setShowNewProject(true);
|
||||
setNewProjectName('');
|
||||
} else {
|
||||
setProject(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No project</SelectItem>
|
||||
{projects.map((proj) => (
|
||||
<SelectItem key={proj.id} value={proj.id}>
|
||||
{proj.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__" className="text-primary">
|
||||
+ New Project
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name..."
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
e.preventDefault();
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newProjectName.trim()) {
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sprint */}
|
||||
<div className="grid gap-2">
|
||||
<Label>Sprint (optional)</Label>
|
||||
<Select value={sprint || '__none__'} onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No sprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No Sprint</SelectItem>
|
||||
{sprints.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Custom variable inputs */}
|
||||
{requiredCustomVars.length > 0 && (
|
||||
<div className="grid gap-3 border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Template Variables</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe the task..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
{requiredCustomVars.map((varName) => (
|
||||
<div key={varName} className="grid gap-1.5">
|
||||
<Label htmlFor={`var-${varName}`} className="text-xs">
|
||||
{varName}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${varName}`}
|
||||
value={customVars[varName] || ''}
|
||||
onChange={(e) => setCustomVars(prev => ({ ...prev, [varName]: e.target.value }))}
|
||||
placeholder={`Enter ${varName}...`}
|
||||
className="h-8"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{taskTypes.map((taskType) => {
|
||||
const IconComponent = getTypeIcon(taskType.icon);
|
||||
return (
|
||||
<SelectItem key={taskType.id} value={taskType.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
{taskType.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks from template */}
|
||||
{subtasks.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Subtasks ({subtasks.length})</Label>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project">Project (optional)</Label>
|
||||
{!showNewProject ? (
|
||||
<Select
|
||||
value={project}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
setShowNewProject(true);
|
||||
setNewProjectName('');
|
||||
} else {
|
||||
setProject(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Check className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{subtask.title}</span>
|
||||
</div>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No project</SelectItem>
|
||||
{projects.map((proj) => (
|
||||
<SelectItem key={proj.id} value={proj.id}>
|
||||
{proj.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__" className="text-primary">
|
||||
+ New Project
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
e.preventDefault();
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeSubtask(subtask.id)}
|
||||
onClick={() => {
|
||||
if (newProjectName.trim()) {
|
||||
setProject(newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Sprint (optional)</Label>
|
||||
<Select value={sprint || '__none__'} onValueChange={(v) => setSprint(v === '__none__' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No sprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No Sprint</SelectItem>
|
||||
{sprints.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TemplateVariableInputs
|
||||
variables={requiredCustomVars}
|
||||
values={customVars}
|
||||
onChange={(name, value) => setCustomVars(prev => ({ ...prev, [name]: value }))}
|
||||
/>
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<div className="grid gap-2">
|
||||
<Label>Subtasks ({subtasks.length})</Label>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Check className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{subtask.title}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeSubtask(subtask.id)}
|
||||
aria-label={`Remove subtask: ${subtask.title}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
|
|
@ -602,16 +401,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
(selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint
|
||||
? false
|
||||
: !title.trim()) || createTask.isPending
|
||||
}
|
||||
disabled={(isBlueprint ? false : !title.trim()) || isCreating}
|
||||
>
|
||||
{createTask.isPending ? 'Creating...' :
|
||||
selectedTemplate && templates?.find(t => t.id === selectedTemplate)?.blueprint
|
||||
? 'Create Tasks'
|
||||
: 'Create Task'}
|
||||
{isCreating ? 'Creating...' : isBlueprint ? 'Create Tasks' : 'Create Task'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import { useState } from 'react';
|
||||
import { useDiffSummary, useFileDiff } from '@/hooks/useDiff';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
FileCode,
|
||||
FilePlus,
|
||||
FileMinus,
|
||||
FileEdit,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useDiffSummary } from '@/hooks/useDiff';
|
||||
import { FileTree } from './diff/FileTree';
|
||||
import { FileDiffView } from './diff/FileDiffView';
|
||||
import { Loader2, AlertCircle, ChevronRight } from 'lucide-react';
|
||||
import type { Task, ReviewComment } from '@veritas-kanban/shared';
|
||||
import type { FileChange, DiffLine, DiffHunk } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
|
||||
interface DiffViewerProps {
|
||||
task: Task;
|
||||
|
|
@ -25,353 +12,6 @@ interface DiffViewerProps {
|
|||
onRemoveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
const statusIcons: Record<FileChange['status'], React.ReactNode> = {
|
||||
added: <FilePlus className="h-4 w-4 text-green-500" />,
|
||||
modified: <FileEdit className="h-4 w-4 text-amber-500" />,
|
||||
deleted: <FileMinus className="h-4 w-4 text-red-500" />,
|
||||
renamed: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
function FileTree({
|
||||
files,
|
||||
selectedFile,
|
||||
onSelectFile,
|
||||
comments,
|
||||
}: {
|
||||
files: FileChange[];
|
||||
selectedFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
comments: ReviewComment[];
|
||||
}) {
|
||||
const commentsByFile = comments.reduce((acc, c) => {
|
||||
acc[c.file] = (acc[c.file] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => onSelectFile(file.path)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left',
|
||||
'hover:bg-muted transition-colors',
|
||||
selectedFile === file.path && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{statusIcons[file.status]}
|
||||
<span className="truncate flex-1 font-mono text-xs">{file.path}</span>
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
{commentsByFile[file.path] && (
|
||||
<span className="flex items-center gap-0.5 text-amber-500">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{commentsByFile[file.path]}
|
||||
</span>
|
||||
)}
|
||||
{file.additions > 0 && (
|
||||
<span className="text-green-500">+{file.additions}</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-red-500">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentInput({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (content: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (content.trim()) {
|
||||
onSubmit(content.trim());
|
||||
setContent('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 space-y-2">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Add review comment..."
|
||||
rows={2}
|
||||
className="text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!content.trim()}>
|
||||
Add Comment
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentDisplay({
|
||||
comment,
|
||||
onRemove,
|
||||
}: {
|
||||
comment: ReviewComment;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 group">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs whitespace-pre-wrap">{comment.content}</p>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{new Date(comment.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLineView({
|
||||
line,
|
||||
comments,
|
||||
addingCommentAtLine,
|
||||
onStartAddComment,
|
||||
onSubmitComment,
|
||||
onCancelComment,
|
||||
onRemoveComment,
|
||||
}: {
|
||||
line: DiffLine;
|
||||
comments: ReviewComment[];
|
||||
addingCommentAtLine: number | null;
|
||||
onStartAddComment: (line: number) => void;
|
||||
onSubmitComment: (content: string) => void;
|
||||
onCancelComment: () => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}) {
|
||||
const lineNumber = line.newNumber || line.oldNumber;
|
||||
const lineComments = comments.filter(c => c.line === lineNumber);
|
||||
const isAddingHere = addingCommentAtLine === lineNumber;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex hover:bg-muted/30',
|
||||
line.type === 'add' && 'bg-green-500/10',
|
||||
line.type === 'delete' && 'bg-red-500/10',
|
||||
lineComments.length > 0 && 'bg-amber-500/5'
|
||||
)}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border">
|
||||
<span className="w-10 px-2 text-right border-r border-border text-[10px]">
|
||||
{line.oldNumber || ''}
|
||||
</span>
|
||||
<span className="w-10 px-2 text-right text-[10px]">
|
||||
{line.newNumber || ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add comment button */}
|
||||
{lineNumber && (
|
||||
<button
|
||||
onClick={() => onStartAddComment(lineNumber)}
|
||||
className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-amber-500"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Change indicator */}
|
||||
<div className="w-6 flex-shrink-0 flex items-center justify-center">
|
||||
{line.type === 'add' && <span className="text-green-500">+</span>}
|
||||
{line.type === 'delete' && <span className="text-red-500">-</span>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<pre className="flex-1 px-2 overflow-x-auto whitespace-pre text-xs">
|
||||
{line.content || ' '}
|
||||
</pre>
|
||||
|
||||
{/* Comment indicator */}
|
||||
{lineComments.length > 0 && (
|
||||
<div className="flex-shrink-0 px-2 flex items-center">
|
||||
<MessageSquare className="h-3 w-3 text-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline comments */}
|
||||
{lineComments.map(comment => (
|
||||
<CommentDisplay
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onRemove={() => onRemoveComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Comment input */}
|
||||
{isAddingHere && (
|
||||
<CommentInput
|
||||
onSubmit={onSubmitComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffHunkView({
|
||||
hunk,
|
||||
comments,
|
||||
addingCommentAtLine,
|
||||
onStartAddComment,
|
||||
onSubmitComment,
|
||||
onCancelComment,
|
||||
onRemoveComment,
|
||||
}: {
|
||||
hunk: DiffHunk;
|
||||
comments: ReviewComment[];
|
||||
addingCommentAtLine: number | null;
|
||||
onStartAddComment: (line: number) => void;
|
||||
onSubmitComment: (content: string) => void;
|
||||
onCancelComment: () => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-xs">
|
||||
{hunk.lines.map((line, idx) => (
|
||||
<DiffLineView
|
||||
key={idx}
|
||||
line={line}
|
||||
comments={comments}
|
||||
addingCommentAtLine={addingCommentAtLine}
|
||||
onStartAddComment={onStartAddComment}
|
||||
onSubmitComment={onSubmitComment}
|
||||
onCancelComment={onCancelComment}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffView({
|
||||
taskId,
|
||||
filePath,
|
||||
comments,
|
||||
onAddComment,
|
||||
onRemoveComment,
|
||||
}: {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
comments: ReviewComment[];
|
||||
onAddComment: (comment: ReviewComment) => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}) {
|
||||
const { data: diff, isLoading, error } = useFileDiff(taskId, filePath);
|
||||
const [addingCommentAtLine, setAddingCommentAtLine] = useState<number | null>(null);
|
||||
|
||||
const fileComments = comments.filter(c => c.file === filePath);
|
||||
|
||||
const handleSubmitComment = (content: string) => {
|
||||
if (addingCommentAtLine === null) return;
|
||||
|
||||
const comment: ReviewComment = {
|
||||
id: `comment_${nanoid(8)}`,
|
||||
file: filePath,
|
||||
line: addingCommentAtLine,
|
||||
content,
|
||||
created: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onAddComment(comment);
|
||||
setAddingCommentAtLine(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading diff...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !diff) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
{(error as Error)?.message || 'Failed to load diff'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.hunks.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No changes in this file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcons[diff.status]}
|
||||
<span className="font-mono text-sm">{diff.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{fileComments.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{fileComments.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-green-500">+{diff.additions}</span>
|
||||
<span className="text-red-500">-{diff.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{diff.hunks.map((hunk, idx) => (
|
||||
<DiffHunkView
|
||||
key={idx}
|
||||
hunk={hunk}
|
||||
comments={fileComments}
|
||||
addingCommentAtLine={addingCommentAtLine}
|
||||
onStartAddComment={setAddingCommentAtLine}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onCancelComment={() => setAddingCommentAtLine(null)}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerProps) {
|
||||
const hasWorktree = !!task.git?.worktreePath;
|
||||
const { data: summary, isLoading, error } = useDiffSummary(task.id, hasWorktree);
|
||||
|
|
@ -415,52 +55,54 @@ export function DiffViewer({ task, onAddComment, onRemoveComment }: DiffViewerPr
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[500px]">
|
||||
{/* File tree */}
|
||||
<div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card">
|
||||
<div className="px-3 py-2 border-b bg-muted/50">
|
||||
<div className="text-sm font-medium">
|
||||
Changed Files ({summary.totalFiles})
|
||||
<FeatureErrorBoundary fallbackTitle="Diff viewer failed to render">
|
||||
<div className="flex gap-4 h-[500px]">
|
||||
{/* File tree */}
|
||||
<div className="w-64 flex-shrink-0 border rounded-md overflow-hidden bg-card">
|
||||
<div className="px-3 py-2 border-b bg-muted/50">
|
||||
<div className="text-sm font-medium">
|
||||
Changed Files ({summary.totalFiles})
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="text-green-500">+{summary.totalAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-500">-{summary.totalDeletions}</span>
|
||||
{comments.length > 0 && (
|
||||
<>
|
||||
{' / '}
|
||||
<span className="text-amber-500">{comments.length} comments</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="text-green-500">+{summary.totalAdditions}</span>
|
||||
{' / '}
|
||||
<span className="text-red-500">-{summary.totalDeletions}</span>
|
||||
{comments.length > 0 && (
|
||||
<>
|
||||
{' / '}
|
||||
<span className="text-amber-500">{comments.length} comments</span>
|
||||
</>
|
||||
)}
|
||||
<div className="p-2 overflow-y-auto h-[calc(100%-60px)]">
|
||||
<FileTree
|
||||
files={summary.files}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={setSelectedFile}
|
||||
comments={comments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 overflow-y-auto h-[calc(100%-60px)]">
|
||||
<FileTree
|
||||
files={summary.files}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={setSelectedFile}
|
||||
comments={comments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff view */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<FileDiffView
|
||||
taskId={task.id}
|
||||
filePath={selectedFile}
|
||||
comments={comments}
|
||||
onAddComment={onAddComment}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground border rounded-md">
|
||||
<ChevronRight className="h-5 w-5 mr-2" />
|
||||
Select a file to view changes
|
||||
</div>
|
||||
)}
|
||||
{/* Diff view */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedFile ? (
|
||||
<FileDiffView
|
||||
taskId={task.id}
|
||||
filePath={selectedFile}
|
||||
comments={comments}
|
||||
onAddComment={onAddComment}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground border rounded-md">
|
||||
<ChevronRight className="h-5 w-5 mr-2" />
|
||||
Select a file to view changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FeatureErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,518 +1,26 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useConfig, useRepoBranches } from '@/hooks/useConfig';
|
||||
import {
|
||||
useWorktreeStatus,
|
||||
useCreateWorktree,
|
||||
useDeleteWorktree,
|
||||
useRebaseWorktree,
|
||||
useMergeWorktree
|
||||
} from '@/hooks/useWorktree';
|
||||
import { useCreatePR, useGitHubStatus } from '@/hooks/useGitHub';
|
||||
import { useConflictStatus } from '@/hooks/useConflicts';
|
||||
import { ConflictResolver } from './ConflictResolver';
|
||||
import {
|
||||
GitBranch,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Play,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
GitMerge,
|
||||
Trash2,
|
||||
FileCode,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
GitPullRequest,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import type { Task, TaskGit } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GitSelectionForm } from './git/GitSelectionForm';
|
||||
import { WorktreeStatus } from './git/WorktreeStatus';
|
||||
|
||||
interface GitSectionProps {
|
||||
task: Task;
|
||||
onGitChange: (git: Partial<TaskGit> | undefined) => void;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
function WorktreeStatus({ task }: { task: Task }) {
|
||||
const hasWorktree = !!task.git?.worktreePath;
|
||||
const hasPR = !!task.git?.prUrl;
|
||||
const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree);
|
||||
const { data: ghStatus } = useGitHubStatus();
|
||||
const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined);
|
||||
|
||||
const createWorktree = useCreateWorktree();
|
||||
const deleteWorktree = useDeleteWorktree();
|
||||
const rebaseWorktree = useRebaseWorktree();
|
||||
const mergeWorktree = useMergeWorktree();
|
||||
const createPR = useCreatePR();
|
||||
|
||||
// PR dialog state
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
const [prTitle, setPrTitle] = useState(task.title);
|
||||
const [prBody, setPrBody] = useState(task.description || '');
|
||||
const [prDraft, setPrDraft] = useState(false);
|
||||
|
||||
// Conflict resolver state
|
||||
const [conflictResolverOpen, setConflictResolverOpen] = useState(false);
|
||||
|
||||
const handleOpenInVSCode = () => {
|
||||
if (task.git?.worktreePath) {
|
||||
// Use vscode:// protocol to open
|
||||
window.open(`vscode://file/${task.git.worktreePath}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPR = () => {
|
||||
if (task.git?.prUrl) {
|
||||
window.open(task.git.prUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePR = async () => {
|
||||
try {
|
||||
const result = await createPR.mutateAsync({
|
||||
taskId: task.id,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
draft: prDraft,
|
||||
});
|
||||
setPrDialogOpen(false);
|
||||
// Open the new PR in browser
|
||||
window.open(result.url, '_blank');
|
||||
} catch (error) {
|
||||
// Error is handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
if (!task.git?.repo || !task.git?.branch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No worktree yet - show create button
|
||||
if (!hasWorktree) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => createWorktree.mutate(task.id)}
|
||||
disabled={createWorktree.isPending}
|
||||
>
|
||||
{createWorktree.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Create Worktree
|
||||
</Button>
|
||||
{createWorktree.error && (
|
||||
<p className="text-xs text-red-500 mt-2">
|
||||
{(createWorktree.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading worktree status
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading worktree status...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error loading status
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show worktree status
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t space-y-3">
|
||||
{/* Conflict warning banner */}
|
||||
{conflictStatus?.hasConflicts && (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
{conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConflictResolverOpen(true)}
|
||||
className="border-amber-500/30 hover:bg-amber-500/10"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
Resolve Conflicts
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500"
|
||||
)} />
|
||||
<span className="text-muted-foreground">
|
||||
{conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{status && (
|
||||
<>
|
||||
{status.aheadBehind.ahead > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{status.aheadBehind.ahead} ahead
|
||||
</span>
|
||||
)}
|
||||
{status.aheadBehind.behind > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{status.aheadBehind.behind} behind
|
||||
</span>
|
||||
)}
|
||||
{status.hasChanges && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{status.changedFiles} changed
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenInVSCode}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Open in VS Code
|
||||
</Button>
|
||||
|
||||
{/* PR Button - show View PR if exists, Create PR if not */}
|
||||
{hasPR ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenPR}
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3 mr-1" />
|
||||
View PR #{task.git?.prNumber}
|
||||
</Button>
|
||||
) : status && status.aheadBehind.ahead > 0 && ghStatus?.authenticated && (
|
||||
<Dialog open={prDialogOpen} onOpenChange={setPrDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<GitPullRequest className="h-3 w-3 mr-1" />
|
||||
Create PR
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Pull Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a PR from {task.git?.branch} to {task.git?.baseBranch}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-title">Title</Label>
|
||||
<Input
|
||||
id="pr-title"
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
placeholder="PR title"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
placeholder="Describe your changes..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="pr-draft"
|
||||
checked={prDraft}
|
||||
onCheckedChange={(checked) => setPrDraft(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="pr-draft" className="text-sm font-normal">
|
||||
Create as draft PR
|
||||
</Label>
|
||||
</div>
|
||||
{createPR.error && (
|
||||
<p className="text-sm text-red-500">
|
||||
{(createPR.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPrDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreatePR} disabled={createPR.isPending || !prTitle}>
|
||||
{createPR.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitPullRequest className="h-4 w-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{status && status.aheadBehind.behind > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => rebaseWorktree.mutate(task.id)}
|
||||
disabled={rebaseWorktree.isPending}
|
||||
>
|
||||
{rebaseWorktree.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Rebase
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status && status.aheadBehind.ahead > 0 && !status.hasChanges && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="default" size="sm">
|
||||
<GitMerge className="h-3 w-3 mr-1" />
|
||||
Merge
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Merge to {task.git?.baseBranch}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will merge {task.git?.branch} into {task.git?.baseBranch}, push to remote,
|
||||
delete the worktree, and mark the task as Done.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mergeWorktree.mutate(task.id)}
|
||||
disabled={mergeWorktree.isPending}
|
||||
>
|
||||
{mergeWorktree.isPending ? 'Merging...' : 'Merge & Complete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground">
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Delete Worktree
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete worktree?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{status?.hasChanges
|
||||
? 'Warning: This worktree has uncommitted changes that will be lost.'
|
||||
: 'This will remove the worktree but keep the branch.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteWorktree.mutate({
|
||||
taskId: task.id,
|
||||
force: status?.hasChanges
|
||||
})}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Worktree path */}
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{task.git.worktreePath}
|
||||
</div>
|
||||
|
||||
{/* Conflict Resolver */}
|
||||
<ConflictResolver
|
||||
task={task}
|
||||
open={conflictResolverOpen}
|
||||
onOpenChange={setConflictResolverOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitSection({ task, onGitChange }: GitSectionProps) {
|
||||
const { data: config, isLoading: configLoading } = useConfig();
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>(task.git?.repo || '');
|
||||
const [baseBranch, setBaseBranch] = useState<string>(task.git?.baseBranch || '');
|
||||
const [featureBranch, setFeatureBranch] = useState<string>(task.git?.branch || '');
|
||||
const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch);
|
||||
|
||||
const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined);
|
||||
|
||||
// Get the repo config for the selected repo
|
||||
const repoConfig = useMemo(() => {
|
||||
return config?.repos.find(r => r.name === selectedRepo);
|
||||
}, [config, selectedRepo]);
|
||||
|
||||
// Auto-generate feature branch name from task title
|
||||
useEffect(() => {
|
||||
if (autoGenerateBranch && task.title) {
|
||||
const slug = slugify(task.title);
|
||||
setFeatureBranch(`feature/${slug}`);
|
||||
}
|
||||
}, [task.title, autoGenerateBranch]);
|
||||
|
||||
// Set default base branch when repo changes
|
||||
useEffect(() => {
|
||||
if (repoConfig && !baseBranch) {
|
||||
setBaseBranch(repoConfig.defaultBranch);
|
||||
}
|
||||
}, [repoConfig, baseBranch]);
|
||||
|
||||
// Sync from task.git when it changes
|
||||
useEffect(() => {
|
||||
if (task.git) {
|
||||
setSelectedRepo(task.git.repo || '');
|
||||
setBaseBranch(task.git.baseBranch || '');
|
||||
setFeatureBranch(task.git.branch || '');
|
||||
setAutoGenerateBranch(!task.git.branch);
|
||||
}
|
||||
}, [task.id]); // Only re-sync when task changes
|
||||
|
||||
// Update parent when values change
|
||||
const handleRepoChange = (repo: string) => {
|
||||
setSelectedRepo(repo);
|
||||
const newRepoConfig = config?.repos.find(r => r.name === repo);
|
||||
const newBaseBranch = newRepoConfig?.defaultBranch || 'main';
|
||||
setBaseBranch(newBaseBranch);
|
||||
|
||||
onGitChange({
|
||||
repo,
|
||||
baseBranch: newBaseBranch,
|
||||
branch: featureBranch,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBaseBranchChange = (branch: string) => {
|
||||
setBaseBranch(branch);
|
||||
onGitChange({
|
||||
repo: selectedRepo,
|
||||
baseBranch: branch,
|
||||
branch: featureBranch,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeatureBranchChange = (branch: string) => {
|
||||
setFeatureBranch(branch);
|
||||
setAutoGenerateBranch(false);
|
||||
onGitChange({
|
||||
repo: selectedRepo,
|
||||
baseBranch,
|
||||
branch,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearGit = () => {
|
||||
setSelectedRepo('');
|
||||
setBaseBranch('');
|
||||
setFeatureBranch('');
|
||||
setAutoGenerateBranch(true);
|
||||
onGitChange(undefined);
|
||||
};
|
||||
|
||||
// Don't allow editing if worktree exists
|
||||
const isLocked = !!task.git?.worktreePath;
|
||||
const selectedRepo = task.git?.repo;
|
||||
|
||||
if (configLoading) {
|
||||
return (
|
||||
|
|
@ -563,83 +71,8 @@ export function GitSection({ task, onGitChange }: GitSectionProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 p-3 rounded-md border bg-muted/30">
|
||||
{/* Repository Selection */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Repository</Label>
|
||||
<Select
|
||||
value={selectedRepo}
|
||||
onValueChange={handleRepoChange}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<SelectTrigger className={cn(isLocked && 'opacity-60')}>
|
||||
<SelectValue placeholder="Select repository..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.repos.map((repo) => (
|
||||
<SelectItem key={repo.name} value={repo.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
{repo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRepo && (
|
||||
<>
|
||||
{/* Base Branch */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Base Branch</Label>
|
||||
{branchesLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground h-9 px-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading branches...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={baseBranch}
|
||||
onValueChange={handleBaseBranchChange}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<SelectTrigger className={cn(isLocked && 'opacity-60')}>
|
||||
<SelectValue placeholder="Select base branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches?.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Branch */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Feature Branch</Label>
|
||||
<Input
|
||||
value={featureBranch}
|
||||
onChange={(e) => handleFeatureBranchChange(e.target.value)}
|
||||
placeholder="feature/my-feature"
|
||||
disabled={isLocked}
|
||||
className={cn(isLocked && 'opacity-60')}
|
||||
/>
|
||||
{autoGenerateBranch && featureBranch && !isLocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-generated from task title
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktree Status */}
|
||||
<WorktreeStatus task={task} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<GitSelectionForm task={task} onGitChange={onGitChange} />
|
||||
<WorktreeStatus task={task} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { Task, TaskPriority, TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared';
|
||||
import type { Task, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { Check, Ban, Clock, Timer, Loader2, Paperclip, ListChecks, Zap } from 'lucide-react';
|
||||
import { useBulkActions } from '@/hooks/useBulkActions';
|
||||
import { formatDuration } from '@/hooks/useTimeTracking';
|
||||
|
|
@ -15,6 +15,7 @@ import { getTypeIcon, getTypeColor } from '@/hooks/useTaskTypes';
|
|||
import { getProjectColor, getProjectLabel } from '@/hooks/useProjects';
|
||||
import { getSprintLabel } from '@/hooks/useSprints';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { useTaskConfig } from '@/contexts/TaskConfigContext';
|
||||
|
||||
const agentNames: Record<string, string> = {
|
||||
'claude-code': 'Claude',
|
||||
|
|
@ -31,9 +32,6 @@ interface TaskCardProps {
|
|||
isSelected?: boolean;
|
||||
isBlocked?: boolean;
|
||||
blockerTitles?: string[];
|
||||
taskTypes?: TaskTypeConfig[];
|
||||
projects?: ProjectConfig[];
|
||||
sprints?: SprintConfig[];
|
||||
}
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
|
|
@ -42,7 +40,8 @@ const priorityColors: Record<TaskPriority, string> = {
|
|||
low: 'bg-slate-500/20 text-slate-400',
|
||||
};
|
||||
|
||||
export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles, taskTypes = [], projects = [], sprints = [] }: TaskCardProps) {
|
||||
export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) {
|
||||
const { taskTypes, projects, sprints } = useTaskConfig();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
|
|
@ -107,6 +106,8 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo
|
|||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={handleClick}
|
||||
role="article"
|
||||
aria-label={`Task: ${task.title}, Status: ${task.status}, Priority: ${task.priority}`}
|
||||
className={cn(
|
||||
'group bg-card border border-border rounded-md cursor-grab active:cursor-grabbing',
|
||||
isCompact ? 'p-2' : 'p-3',
|
||||
|
|
@ -119,10 +120,12 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo
|
|||
isAgentRunning && 'ring-2 ring-blue-500/50 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]'
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Status: {task.status}</span>
|
||||
<div className="flex items-start gap-2">
|
||||
{isSelecting && (
|
||||
<button
|
||||
onClick={handleCheckboxClick}
|
||||
aria-label={isChecked ? 'Deselect task' : 'Select task'}
|
||||
className={cn(
|
||||
'h-4 w-4 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 transition-colors',
|
||||
isChecked
|
||||
|
|
@ -154,6 +157,7 @@ export function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blo
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 flex items-center gap-1 animate-pulse">
|
||||
<span className="sr-only">Agent {agentNames[task.attempt?.agent || ''] || task.attempt?.agent} is actively running on this task</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{agentNames[task.attempt?.agent || ''] || 'Agent'} running
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -11,47 +11,23 @@ import {
|
|||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
|
||||
import { Trash2, Calendar, Clock, GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive, RotateCcw } from 'lucide-react';
|
||||
import type { Task, TaskStatus, TaskPriority, ReviewComment, ReviewState } from '@veritas-kanban/shared';
|
||||
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
|
||||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { useSprints } from '@/hooks/useSprints';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { useDebouncedSave } from '@/hooks/useDebouncedSave';
|
||||
import { TaskDetailsTab } from './detail/TaskDetailsTab';
|
||||
import { GitSection } from './GitSection';
|
||||
import { AgentPanel } from './AgentPanel';
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
import { ReviewPanel } from './ReviewPanel';
|
||||
import { SubtasksSection } from './SubtasksSection';
|
||||
import { DependenciesSection } from './DependenciesSection';
|
||||
import { PreviewPanel } from './PreviewPanel';
|
||||
import { TimeTrackingSection } from './TimeTrackingSection';
|
||||
import { CommentsSection } from './CommentsSection';
|
||||
import { AttachmentsSection } from './AttachmentsSection';
|
||||
import { ApplyTemplateDialog } from './ApplyTemplateDialog';
|
||||
import FeatureErrorBoundary from '@/components/shared/FeatureErrorBoundary';
|
||||
import { GitBranch, Bot, FileDiff, ClipboardCheck, Monitor, FileCode, Paperclip, Archive } from 'lucide-react';
|
||||
import type { Task, ReviewComment, ReviewState } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
task: Task | null;
|
||||
|
|
@ -61,75 +37,15 @@ interface TaskDetailPanelProps {
|
|||
onRestore?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
'todo': 'To Do',
|
||||
'in-progress': 'In Progress',
|
||||
'review': 'Review',
|
||||
'done': 'Done',
|
||||
};
|
||||
|
||||
const priorityLabels: Record<TaskPriority, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
};
|
||||
|
||||
function useDebouncedSave(task: Task | null, updateTask: ReturnType<typeof useUpdateTask>) {
|
||||
const [localTask, setLocalTask] = useState<Task | null>(task);
|
||||
const [changedFields, setChangedFields] = useState<Set<keyof Task>>(new Set());
|
||||
|
||||
// Sync from server when task prop changes (e.g., refetch)
|
||||
useEffect(() => {
|
||||
setLocalTask(task);
|
||||
setChangedFields(new Set());
|
||||
}, [task]);
|
||||
|
||||
// Debounced save - only send fields that were actually changed
|
||||
useEffect(() => {
|
||||
if (changedFields.size === 0 || !localTask) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// Build input with only changed fields
|
||||
const input: Record<string, unknown> = {};
|
||||
changedFields.forEach(field => {
|
||||
input[field] = localTask[field];
|
||||
});
|
||||
|
||||
updateTask.mutate({
|
||||
id: localTask.id,
|
||||
input,
|
||||
});
|
||||
setChangedFields(new Set());
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [localTask, changedFields, updateTask]);
|
||||
|
||||
const updateField = useCallback(<K extends keyof Task>(field: K, value: Task[K]) => {
|
||||
setLocalTask(prev => prev ? { ...prev, [field]: value } : null);
|
||||
setChangedFields(prev => new Set(prev).add(field));
|
||||
}, []);
|
||||
|
||||
const isDirty = changedFields.size > 0;
|
||||
|
||||
return { localTask, updateField, isDirty };
|
||||
}
|
||||
|
||||
export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, onRestore }: TaskDetailPanelProps) {
|
||||
const updateTask = useUpdateTask();
|
||||
const deleteTask = useDeleteTask();
|
||||
const { data: taskTypes = [] } = useTaskTypes();
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: sprints = [] } = useSprints();
|
||||
const { settings: featureSettings } = useFeatureSettings();
|
||||
const taskSettings = featureSettings.tasks;
|
||||
const agentSettings = featureSettings.agents;
|
||||
const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask);
|
||||
const { localTask, updateField, isDirty } = useDebouncedSave(task);
|
||||
const [activeTab, setActiveTab] = useState('details');
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [applyTemplateOpen, setApplyTemplateOpen] = useState(false);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -141,24 +57,8 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on
|
|||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!task) return;
|
||||
await deleteTask.mutateAsync(task.id);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!localTask) return null;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const isCodeTask = localTask.type === 'code';
|
||||
const hasWorktree = !!localTask.git?.worktreePath;
|
||||
|
||||
|
|
@ -255,346 +155,77 @@ export function TaskDetailPanel({ task, open, onOpenChange, readOnly = false, on
|
|||
|
||||
<div className="flex-1 overflow-y-auto mt-4">
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details" className="mt-0 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Description</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
|
||||
{localTask.description || 'No description'}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={localTask.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Status</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{statusLabels[localTask.status]}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={localTask.status}
|
||||
onValueChange={(v) => updateField('status', v as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Type</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{typeLabel}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={localTask.type}
|
||||
onValueChange={(v) => updateField('type', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{taskTypes.map((type) => {
|
||||
const IconComponent = getTypeIcon(type.icon);
|
||||
return (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Priority</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md capitalize">
|
||||
{localTask.priority}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={localTask.priority}
|
||||
onValueChange={(v) => updateField('priority', v as TaskPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Project</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{(projects.find(p => p.id === localTask.project)?.label) || localTask.project || 'No project'}
|
||||
</div>
|
||||
) : !showNewProject ? (
|
||||
<Select
|
||||
value={localTask.project || '__none__'}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
setShowNewProject(true);
|
||||
setNewProjectName('');
|
||||
} else if (value === '__none__') {
|
||||
updateField('project', undefined);
|
||||
} else {
|
||||
updateField('project', value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No project</SelectItem>
|
||||
{projects.map((proj) => (
|
||||
<SelectItem key={proj.id} value={proj.id}>
|
||||
{proj.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__" className="text-primary">
|
||||
+ New Project
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
e.preventDefault();
|
||||
updateField('project', newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newProjectName.trim()) {
|
||||
updateField('project', newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Sprint</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{(sprints.find(s => s.id === localTask.sprint)?.label) || localTask.sprint || 'No sprint'}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={localTask.sprint || '__none__'}
|
||||
onValueChange={(value) => updateField('sprint', value === '__none__' ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No sprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No Sprint</SelectItem>
|
||||
{sprints.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
<div className="border-t pt-4">
|
||||
<SubtasksSection
|
||||
task={localTask}
|
||||
onAutoCompleteChange={(value) => updateField('autoCompleteOnSubtasks', value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{taskSettings.enableDependencies && (
|
||||
<div className="border-t pt-4">
|
||||
<DependenciesSection
|
||||
task={localTask}
|
||||
onBlockedByChange={(blockedBy) => updateField('blockedBy', blockedBy)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Tracking */}
|
||||
{taskSettings.enableTimeTracking && (
|
||||
<div className="border-t pt-4">
|
||||
<TimeTrackingSection task={localTask} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{taskSettings.enableComments && (
|
||||
<div className="border-t pt-4">
|
||||
<CommentsSection task={localTask} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Created: {formatDate(localTask.created)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Updated: {formatDate(localTask.updated)}</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono opacity-50">ID: {localTask.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
{readOnly && onRestore ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => onRestore(localTask.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Restore to Board
|
||||
</Button>
|
||||
) : !readOnly && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{localTask.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="details" className="mt-0">
|
||||
<TaskDetailsTab
|
||||
task={localTask}
|
||||
onUpdate={updateField}
|
||||
onClose={() => onOpenChange(false)}
|
||||
readOnly={readOnly}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Attachments Tab */}
|
||||
{taskSettings.enableAttachments && (
|
||||
<TabsContent value="attachments" className="mt-0">
|
||||
<AttachmentsSection task={localTask} />
|
||||
<FeatureErrorBoundary fallbackTitle="Attachments section failed to load">
|
||||
<AttachmentsSection task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Git Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="git" className="mt-0">
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
<FeatureErrorBoundary fallbackTitle="Git section failed to load">
|
||||
<GitSection
|
||||
task={localTask}
|
||||
onGitChange={(git) => updateField('git', git as Task['git'])}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Agent Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="agent" className="mt-0">
|
||||
<AgentPanel task={localTask} />
|
||||
<FeatureErrorBoundary fallbackTitle="Agent panel failed to load">
|
||||
<AgentPanel task={localTask} />
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Changes Tab */}
|
||||
{isCodeTask && hasWorktree && (
|
||||
<TabsContent value="changes" className="mt-0">
|
||||
<DiffViewer
|
||||
task={localTask}
|
||||
onAddComment={(comment: ReviewComment) => {
|
||||
const newComments = [...(localTask.reviewComments || []), comment];
|
||||
updateField('reviewComments', newComments);
|
||||
}}
|
||||
onRemoveComment={(commentId: string) => {
|
||||
const newComments = (localTask.reviewComments || []).filter(c => c.id !== commentId);
|
||||
updateField('reviewComments', newComments.length > 0 ? newComments : undefined);
|
||||
}}
|
||||
/>
|
||||
<FeatureErrorBoundary fallbackTitle="Changes viewer failed to load">
|
||||
<DiffViewer
|
||||
task={localTask}
|
||||
onAddComment={(comment: ReviewComment) => {
|
||||
const newComments = [...(localTask.reviewComments || []), comment];
|
||||
updateField('reviewComments', newComments);
|
||||
}}
|
||||
onRemoveComment={(commentId: string) => {
|
||||
const newComments = (localTask.reviewComments || []).filter(c => c.id !== commentId);
|
||||
updateField('reviewComments', newComments.length > 0 ? newComments : undefined);
|
||||
}}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Review Tab */}
|
||||
{isCodeTask && (
|
||||
<TabsContent value="review" className="mt-0">
|
||||
<ReviewPanel
|
||||
task={localTask}
|
||||
onReview={(review: ReviewState) => {
|
||||
updateField('review', Object.keys(review).length > 0 ? review : undefined);
|
||||
}}
|
||||
onMergeComplete={() => {
|
||||
// Close the panel after successful merge
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
<FeatureErrorBoundary fallbackTitle="Review panel failed to load">
|
||||
<ReviewPanel
|
||||
task={localTask}
|
||||
onReview={(review: ReviewState) => {
|
||||
updateField('review', Object.keys(review).length > 0 ? review : undefined);
|
||||
}}
|
||||
onMergeComplete={() => onOpenChange(false)}
|
||||
/>
|
||||
</FeatureErrorBoundary>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
44
web/src/components/task/create/BlueprintPreview.tsx
Normal file
44
web/src/components/task/create/BlueprintPreview.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { TaskTemplate } from '@/hooks/useTemplates';
|
||||
|
||||
interface BlueprintPreviewProps {
|
||||
template: TaskTemplate;
|
||||
}
|
||||
|
||||
export function BlueprintPreview({ template }: BlueprintPreviewProps) {
|
||||
if (!template.blueprint || template.blueprint.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Blueprint: Multiple Tasks</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
This template will create {template.blueprint.length} linked tasks.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{template.blueprint.map((bt, idx) => (
|
||||
<div key={bt.refId} className="text-sm border-l-2 border-primary/50 pl-3 py-1">
|
||||
<div className="font-medium">
|
||||
{idx + 1}. {bt.title}
|
||||
</div>
|
||||
{bt.blockedByRefs && bt.blockedByRefs.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Blocked by: {bt.blockedByRefs.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{bt.subtaskTemplates && bt.subtaskTemplates.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bt.subtaskTemplates.length} subtasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
web/src/components/task/create/TemplateVariableInputs.tsx
Normal file
38
web/src/components/task/create/TemplateVariableInputs.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface TemplateVariableInputsProps {
|
||||
variables: string[];
|
||||
values: Record<string, string>;
|
||||
onChange: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function TemplateVariableInputs({ variables, values, onChange }: TemplateVariableInputsProps) {
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 border rounded-md p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-500" />
|
||||
<Label className="text-sm font-medium">Template Variables</Label>
|
||||
</div>
|
||||
{variables.map((varName) => (
|
||||
<div key={varName} className="grid gap-1.5">
|
||||
<Label htmlFor={`var-${varName}`} className="text-xs">
|
||||
{varName}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${varName}`}
|
||||
value={values[varName] || ''}
|
||||
onChange={(e) => onChange(varName, e.target.value)}
|
||||
placeholder={`Enter ${varName}...`}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
web/src/components/task/detail/TaskDetailsTab.tsx
Normal file
172
web/src/components/task/detail/TaskDetailsTab.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { TaskMetadataSection } from './TaskMetadataSection';
|
||||
import { SubtasksSection } from '../SubtasksSection';
|
||||
import { DependenciesSection } from '../DependenciesSection';
|
||||
import { TimeTrackingSection } from '../TimeTrackingSection';
|
||||
import { CommentsSection } from '../CommentsSection';
|
||||
import { useDeleteTask } from '@/hooks/useTasks';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { Trash2, Calendar, Clock, RotateCcw } from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskDetailsTabProps {
|
||||
task: Task;
|
||||
onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void;
|
||||
onClose: () => void;
|
||||
readOnly?: boolean;
|
||||
onRestore?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailsTab({
|
||||
task,
|
||||
onUpdate,
|
||||
onClose,
|
||||
readOnly = false,
|
||||
onRestore,
|
||||
}: TaskDetailsTabProps) {
|
||||
const deleteTask = useDeleteTask();
|
||||
const { settings: featureSettings } = useFeatureSettings();
|
||||
const taskSettings = featureSettings.tasks;
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteTask.mutateAsync(task.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Description</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm whitespace-pre-wrap text-foreground/80 bg-muted/30 rounded-md p-3 min-h-[60px]">
|
||||
{task.description || 'No description'}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={task.description}
|
||||
onChange={(e) => onUpdate('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Section */}
|
||||
<TaskMetadataSection
|
||||
task={task}
|
||||
onUpdate={onUpdate}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Subtasks */}
|
||||
<div className="border-t pt-4">
|
||||
<SubtasksSection
|
||||
task={task}
|
||||
onAutoCompleteChange={(value) => onUpdate('autoCompleteOnSubtasks', value || undefined)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{taskSettings.enableDependencies && (
|
||||
<div className="border-t pt-4">
|
||||
<DependenciesSection
|
||||
task={task}
|
||||
onBlockedByChange={(blockedBy) => onUpdate('blockedBy', blockedBy)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Tracking */}
|
||||
{taskSettings.enableTimeTracking && (
|
||||
<div className="border-t pt-4">
|
||||
<TimeTrackingSection task={task} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
{taskSettings.enableComments && (
|
||||
<div className="border-t pt-4">
|
||||
<CommentsSection task={task} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Footer */}
|
||||
<div className="border-t pt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Created: {formatDate(task.created)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Updated: {formatDate(task.updated)}</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono opacity-50">ID: {task.id}</div>
|
||||
</div>
|
||||
|
||||
{/* Delete/Restore Button */}
|
||||
<div className="border-t pt-4">
|
||||
{readOnly && onRestore ? (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={() => onRestore(task.id)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Restore to Board
|
||||
</Button>
|
||||
) : !readOnly && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this task?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{task.title}".
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
web/src/components/task/detail/TaskMetadataSection.tsx
Normal file
237
web/src/components/task/detail/TaskMetadataSection.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes';
|
||||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { useSprints } from '@/hooks/useSprints';
|
||||
import type { Task, TaskStatus, TaskPriority } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskMetadataSectionProps {
|
||||
task: Task;
|
||||
onUpdate: <K extends keyof Task>(field: K, value: Task[K]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
'todo': 'To Do',
|
||||
'in-progress': 'In Progress',
|
||||
'review': 'Review',
|
||||
'done': 'Done',
|
||||
};
|
||||
|
||||
const priorityLabels: Record<TaskPriority, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
};
|
||||
|
||||
export function TaskMetadataSection({ task, onUpdate, readOnly = false }: TaskMetadataSectionProps) {
|
||||
const { data: taskTypes = [] } = useTaskTypes();
|
||||
const { data: projects = [] } = useProjects();
|
||||
const { data: sprints = [] } = useSprints();
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
|
||||
// Get current type info
|
||||
const currentType = taskTypes.find(t => t.id === task.type);
|
||||
const typeLabel = currentType ? currentType.label : task.type;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Status, Type, Priority */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Status</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{statusLabels[task.status]}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={task.status}
|
||||
onValueChange={(v) => onUpdate('status', v as TaskStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Type</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{typeLabel}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={task.type}
|
||||
onValueChange={(v) => onUpdate('type', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{taskTypes.map((type) => {
|
||||
const IconComponent = getTypeIcon(type.icon);
|
||||
return (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Priority</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md capitalize">
|
||||
{task.priority}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={task.priority}
|
||||
onValueChange={(v) => onUpdate('priority', v as TaskPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Project</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{(projects.find(p => p.id === task.project)?.label) || task.project || 'No project'}
|
||||
</div>
|
||||
) : !showNewProject ? (
|
||||
<Select
|
||||
value={task.project || '__none__'}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__new__') {
|
||||
setShowNewProject(true);
|
||||
setNewProjectName('');
|
||||
} else if (value === '__none__') {
|
||||
onUpdate('project', undefined);
|
||||
} else {
|
||||
onUpdate('project', value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No project</SelectItem>
|
||||
{projects.map((proj) => (
|
||||
<SelectItem key={proj.id} value={proj.id}>
|
||||
{proj.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__" className="text-primary">
|
||||
+ New Project
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name..."
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newProjectName.trim()) {
|
||||
e.preventDefault();
|
||||
onUpdate('project', newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (newProjectName.trim()) {
|
||||
onUpdate('project', newProjectName.trim());
|
||||
setShowNewProject(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sprint */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Sprint</Label>
|
||||
{readOnly ? (
|
||||
<div className="text-sm font-medium px-3 py-2 bg-muted/30 rounded-md">
|
||||
{(sprints.find(s => s.id === task.sprint)?.label) || task.sprint || 'No sprint'}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={task.sprint || '__none__'}
|
||||
onValueChange={(value) => onUpdate('sprint', value === '__none__' ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No sprint" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No Sprint</SelectItem>
|
||||
{sprints.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
web/src/components/task/diff/DiffHunk.tsx
Normal file
46
web/src/components/task/diff/DiffHunk.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { DiffLineView } from './DiffLine';
|
||||
import type { DiffHunk } from '@/lib/api';
|
||||
import type { ReviewComment } from '@veritas-kanban/shared';
|
||||
|
||||
interface DiffHunkProps {
|
||||
hunk: DiffHunk;
|
||||
comments: ReviewComment[];
|
||||
addingCommentAtLine: number | null;
|
||||
onStartAddComment: (line: number) => void;
|
||||
onSubmitComment: (content: string) => void;
|
||||
onCancelComment: () => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function DiffHunkView({
|
||||
hunk,
|
||||
comments,
|
||||
addingCommentAtLine,
|
||||
onStartAddComment,
|
||||
onSubmitComment,
|
||||
onCancelComment,
|
||||
onRemoveComment,
|
||||
}: DiffHunkProps) {
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="bg-muted/50 px-4 py-1 text-xs text-muted-foreground font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-xs">
|
||||
{hunk.lines.map((line, idx) => (
|
||||
<DiffLineView
|
||||
key={idx}
|
||||
line={line}
|
||||
comments={comments}
|
||||
addingCommentAtLine={addingCommentAtLine}
|
||||
onStartAddComment={onStartAddComment}
|
||||
onSubmitComment={onSubmitComment}
|
||||
onCancelComment={onCancelComment}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
web/src/components/task/diff/DiffLine.tsx
Normal file
97
web/src/components/task/diff/DiffLine.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { Plus, MessageSquare } from 'lucide-react';
|
||||
import { CommentInput, CommentDisplay } from './ReviewComment';
|
||||
import type { DiffLine } from '@/lib/api';
|
||||
import type { ReviewComment } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiffLineProps {
|
||||
line: DiffLine;
|
||||
comments: ReviewComment[];
|
||||
addingCommentAtLine: number | null;
|
||||
onStartAddComment: (line: number) => void;
|
||||
onSubmitComment: (content: string) => void;
|
||||
onCancelComment: () => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function DiffLineView({
|
||||
line,
|
||||
comments,
|
||||
addingCommentAtLine,
|
||||
onStartAddComment,
|
||||
onSubmitComment,
|
||||
onCancelComment,
|
||||
onRemoveComment,
|
||||
}: DiffLineProps) {
|
||||
const lineNumber = line.newNumber || line.oldNumber;
|
||||
const lineComments = comments.filter(c => c.line === lineNumber);
|
||||
const isAddingHere = addingCommentAtLine === lineNumber;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex hover:bg-muted/30',
|
||||
line.type === 'add' && 'bg-green-500/10',
|
||||
line.type === 'delete' && 'bg-red-500/10',
|
||||
lineComments.length > 0 && 'bg-amber-500/5'
|
||||
)}
|
||||
>
|
||||
{/* Line numbers */}
|
||||
<div className="flex-shrink-0 w-20 flex text-muted-foreground select-none border-r border-border">
|
||||
<span className="w-10 px-2 text-right border-r border-border text-[10px]">
|
||||
{line.oldNumber || ''}
|
||||
</span>
|
||||
<span className="w-10 px-2 text-right text-[10px]">
|
||||
{line.newNumber || ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add comment button */}
|
||||
{lineNumber && (
|
||||
<button
|
||||
onClick={() => onStartAddComment(lineNumber)}
|
||||
className="w-6 flex-shrink-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-amber-500"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Change indicator */}
|
||||
<div className="w-6 flex-shrink-0 flex items-center justify-center">
|
||||
{line.type === 'add' && <span className="text-green-500">+</span>}
|
||||
{line.type === 'delete' && <span className="text-red-500">-</span>}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<pre className="flex-1 px-2 overflow-x-auto whitespace-pre text-xs">
|
||||
{line.content || ' '}
|
||||
</pre>
|
||||
|
||||
{/* Comment indicator */}
|
||||
{lineComments.length > 0 && (
|
||||
<div className="flex-shrink-0 px-2 flex items-center">
|
||||
<MessageSquare className="h-3 w-3 text-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline comments */}
|
||||
{lineComments.map(comment => (
|
||||
<CommentDisplay
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onRemove={() => onRemoveComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Comment input */}
|
||||
{isAddingHere && (
|
||||
<CommentInput
|
||||
onSubmit={onSubmitComment}
|
||||
onCancel={onCancelComment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
web/src/components/task/diff/FileDiffView.tsx
Normal file
120
web/src/components/task/diff/FileDiffView.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from 'react';
|
||||
import { useFileDiff } from '@/hooks/useDiff';
|
||||
import { DiffHunkView } from './DiffHunk';
|
||||
import {
|
||||
FileCode,
|
||||
FilePlus,
|
||||
FileMinus,
|
||||
FileEdit,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import type { ReviewComment } from '@veritas-kanban/shared';
|
||||
import type { FileChange } from '@/lib/api';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const statusIcons: Record<FileChange['status'], React.ReactNode> = {
|
||||
added: <FilePlus className="h-4 w-4 text-green-500" />,
|
||||
modified: <FileEdit className="h-4 w-4 text-amber-500" />,
|
||||
deleted: <FileMinus className="h-4 w-4 text-red-500" />,
|
||||
renamed: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
interface FileDiffViewProps {
|
||||
taskId: string;
|
||||
filePath: string;
|
||||
comments: ReviewComment[];
|
||||
onAddComment: (comment: ReviewComment) => void;
|
||||
onRemoveComment: (commentId: string) => void;
|
||||
}
|
||||
|
||||
export function FileDiffView({
|
||||
taskId,
|
||||
filePath,
|
||||
comments,
|
||||
onAddComment,
|
||||
onRemoveComment,
|
||||
}: FileDiffViewProps) {
|
||||
const { data: diff, isLoading, error } = useFileDiff(taskId, filePath);
|
||||
const [addingCommentAtLine, setAddingCommentAtLine] = useState<number | null>(null);
|
||||
|
||||
const fileComments = comments.filter(c => c.file === filePath);
|
||||
|
||||
const handleSubmitComment = (content: string) => {
|
||||
if (addingCommentAtLine === null) return;
|
||||
|
||||
const comment: ReviewComment = {
|
||||
id: `comment_${nanoid(8)}`,
|
||||
file: filePath,
|
||||
line: addingCommentAtLine,
|
||||
content,
|
||||
created: new Date().toISOString(),
|
||||
};
|
||||
|
||||
onAddComment(comment);
|
||||
setAddingCommentAtLine(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
Loading diff...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !diff) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<AlertCircle className="h-5 w-5 mr-2" />
|
||||
{(error as Error)?.message || 'Failed to load diff'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (diff.hunks.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
No changes in this file
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcons[diff.status]}
|
||||
<span className="font-mono text-sm">{diff.path}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{fileComments.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{fileComments.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-green-500">+{diff.additions}</span>
|
||||
<span className="text-red-500">-{diff.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{diff.hunks.map((hunk, idx) => (
|
||||
<DiffHunkView
|
||||
key={idx}
|
||||
hunk={hunk}
|
||||
comments={fileComments}
|
||||
addingCommentAtLine={addingCommentAtLine}
|
||||
onStartAddComment={setAddingCommentAtLine}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
onCancelComment={() => setAddingCommentAtLine(null)}
|
||||
onRemoveComment={onRemoveComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
web/src/components/task/diff/FileTree.tsx
Normal file
64
web/src/components/task/diff/FileTree.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
FileCode,
|
||||
FilePlus,
|
||||
FileMinus,
|
||||
FileEdit,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import type { FileChange } from '@/lib/api';
|
||||
import type { ReviewComment } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const statusIcons: Record<FileChange['status'], React.ReactNode> = {
|
||||
added: <FilePlus className="h-4 w-4 text-green-500" />,
|
||||
modified: <FileEdit className="h-4 w-4 text-amber-500" />,
|
||||
deleted: <FileMinus className="h-4 w-4 text-red-500" />,
|
||||
renamed: <FileCode className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
interface FileTreeProps {
|
||||
files: FileChange[];
|
||||
selectedFile: string | null;
|
||||
onSelectFile: (path: string) => void;
|
||||
comments: ReviewComment[];
|
||||
}
|
||||
|
||||
export function FileTree({ files, selectedFile, onSelectFile, comments }: FileTreeProps) {
|
||||
const commentsByFile = comments.reduce((acc, c) => {
|
||||
acc[c.file] = (acc[c.file] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => onSelectFile(file.path)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md text-left',
|
||||
'hover:bg-muted transition-colors',
|
||||
selectedFile === file.path && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{statusIcons[file.status]}
|
||||
<span className="truncate flex-1 font-mono text-xs">{file.path}</span>
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
{commentsByFile[file.path] && (
|
||||
<span className="flex items-center gap-0.5 text-amber-500">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{commentsByFile[file.path]}
|
||||
</span>
|
||||
)}
|
||||
{file.additions > 0 && (
|
||||
<span className="text-green-500">+{file.additions}</span>
|
||||
)}
|
||||
{file.deletions > 0 && (
|
||||
<span className="text-red-500">-{file.deletions}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/components/task/diff/ReviewComment.tsx
Normal file
66
web/src/components/task/diff/ReviewComment.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReviewComment } from '@veritas-kanban/shared';
|
||||
|
||||
interface CommentInputProps {
|
||||
onSubmit: (content: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CommentInput({ onSubmit, onCancel }: CommentInputProps) {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (content.trim()) {
|
||||
onSubmit(content.trim());
|
||||
setContent('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 space-y-2">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Add review comment..."
|
||||
rows={2}
|
||||
className="text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!content.trim()}>
|
||||
Add Comment
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentDisplayProps {
|
||||
comment: ReviewComment;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function CommentDisplay({ comment, onRemove }: CommentDisplayProps) {
|
||||
return (
|
||||
<div className="p-2 bg-amber-500/10 border-l-2 border-amber-500 group">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-xs whitespace-pre-wrap">{comment.content}</p>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{new Date(comment.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
web/src/components/task/git/GitSelectionForm.tsx
Normal file
180
web/src/components/task/git/GitSelectionForm.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useConfig, useRepoBranches } from '@/hooks/useConfig';
|
||||
import { Loader2, FolderGit2 } from 'lucide-react';
|
||||
import type { Task, TaskGit } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface GitSelectionFormProps {
|
||||
task: Task;
|
||||
onGitChange: (git: Partial<TaskGit> | undefined) => void;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function GitSelectionForm({ task, onGitChange }: GitSelectionFormProps) {
|
||||
const { data: config } = useConfig();
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>(task.git?.repo || '');
|
||||
const [baseBranch, setBaseBranch] = useState<string>(task.git?.baseBranch || '');
|
||||
const [featureBranch, setFeatureBranch] = useState<string>(task.git?.branch || '');
|
||||
const [autoGenerateBranch, setAutoGenerateBranch] = useState(!task.git?.branch);
|
||||
|
||||
const { data: branches, isLoading: branchesLoading } = useRepoBranches(selectedRepo || undefined);
|
||||
|
||||
// Get the repo config for the selected repo
|
||||
const repoConfig = useMemo(() => {
|
||||
return config?.repos.find(r => r.name === selectedRepo);
|
||||
}, [config, selectedRepo]);
|
||||
|
||||
// Auto-generate feature branch name from task title
|
||||
useEffect(() => {
|
||||
if (autoGenerateBranch && task.title) {
|
||||
const slug = slugify(task.title);
|
||||
setFeatureBranch(`feature/${slug}`);
|
||||
}
|
||||
}, [task.title, autoGenerateBranch]);
|
||||
|
||||
// Set default base branch when repo changes
|
||||
useEffect(() => {
|
||||
if (repoConfig && !baseBranch) {
|
||||
setBaseBranch(repoConfig.defaultBranch);
|
||||
}
|
||||
}, [repoConfig, baseBranch]);
|
||||
|
||||
// Sync from task.git when it changes
|
||||
useEffect(() => {
|
||||
if (task.git) {
|
||||
setSelectedRepo(task.git.repo || '');
|
||||
setBaseBranch(task.git.baseBranch || '');
|
||||
setFeatureBranch(task.git.branch || '');
|
||||
setAutoGenerateBranch(!task.git.branch);
|
||||
}
|
||||
}, [task.id]); // Only re-sync when task changes
|
||||
|
||||
// Update parent when values change
|
||||
const handleRepoChange = (repo: string) => {
|
||||
setSelectedRepo(repo);
|
||||
const newRepoConfig = config?.repos.find(r => r.name === repo);
|
||||
const newBaseBranch = newRepoConfig?.defaultBranch || 'main';
|
||||
setBaseBranch(newBaseBranch);
|
||||
|
||||
onGitChange({
|
||||
repo,
|
||||
baseBranch: newBaseBranch,
|
||||
branch: featureBranch,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBaseBranchChange = (branch: string) => {
|
||||
setBaseBranch(branch);
|
||||
onGitChange({
|
||||
repo: selectedRepo,
|
||||
baseBranch: branch,
|
||||
branch: featureBranch,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeatureBranchChange = (branch: string) => {
|
||||
setFeatureBranch(branch);
|
||||
setAutoGenerateBranch(false);
|
||||
onGitChange({
|
||||
repo: selectedRepo,
|
||||
baseBranch,
|
||||
branch,
|
||||
});
|
||||
};
|
||||
|
||||
// Don't allow editing if worktree exists
|
||||
const isLocked = !!task.git?.worktreePath;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 p-3 rounded-md border bg-muted/30">
|
||||
{/* Repository Selection */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Repository</Label>
|
||||
<Select
|
||||
value={selectedRepo}
|
||||
onValueChange={handleRepoChange}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<SelectTrigger className={cn(isLocked && 'opacity-60')}>
|
||||
<SelectValue placeholder="Select repository..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config?.repos.map((repo) => (
|
||||
<SelectItem key={repo.name} value={repo.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
{repo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRepo && (
|
||||
<>
|
||||
{/* Base Branch */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Base Branch</Label>
|
||||
{branchesLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground h-9 px-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Loading branches...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={baseBranch}
|
||||
onValueChange={handleBaseBranchChange}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<SelectTrigger className={cn(isLocked && 'opacity-60')}>
|
||||
<SelectValue placeholder="Select base branch..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{branches?.map((branch) => (
|
||||
<SelectItem key={branch} value={branch}>
|
||||
{branch}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Branch */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Feature Branch</Label>
|
||||
<Input
|
||||
value={featureBranch}
|
||||
onChange={(e) => handleFeatureBranchChange(e.target.value)}
|
||||
placeholder="feature/my-feature"
|
||||
disabled={isLocked}
|
||||
className={cn(isLocked && 'opacity-60')}
|
||||
/>
|
||||
{autoGenerateBranch && featureBranch && !isLocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-generated from task title
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
web/src/components/task/git/PRDialog.tsx
Normal file
114
web/src/components/task/git/PRDialog.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCreatePR } from '@/hooks/useGitHub';
|
||||
import { Loader2, GitPullRequest } from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
interface PRDialogProps {
|
||||
task: Task;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function PRDialog({ task, open, onOpenChange }: PRDialogProps) {
|
||||
const [prTitle, setPrTitle] = useState(task.title);
|
||||
const [prBody, setPrBody] = useState(task.description || '');
|
||||
const [prDraft, setPrDraft] = useState(false);
|
||||
|
||||
const createPR = useCreatePR();
|
||||
|
||||
const handleCreatePR = async () => {
|
||||
try {
|
||||
const result = await createPR.mutateAsync({
|
||||
taskId: task.id,
|
||||
title: prTitle,
|
||||
body: prBody,
|
||||
draft: prDraft,
|
||||
});
|
||||
onOpenChange(false);
|
||||
// Open the new PR in browser
|
||||
window.open(result.url, '_blank');
|
||||
} catch (error) {
|
||||
// Error is handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Pull Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a PR from {task.git?.branch} to {task.git?.baseBranch}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-title">Title</Label>
|
||||
<Input
|
||||
id="pr-title"
|
||||
value={prTitle}
|
||||
onChange={(e) => setPrTitle(e.target.value)}
|
||||
placeholder="PR title"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="pr-body">Description</Label>
|
||||
<Textarea
|
||||
id="pr-body"
|
||||
value={prBody}
|
||||
onChange={(e) => setPrBody(e.target.value)}
|
||||
placeholder="Describe your changes..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="pr-draft"
|
||||
checked={prDraft}
|
||||
onCheckedChange={(checked) => setPrDraft(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="pr-draft" className="text-sm font-normal">
|
||||
Create as draft PR
|
||||
</Label>
|
||||
</div>
|
||||
{createPR.error && (
|
||||
<p className="text-sm text-red-500">
|
||||
{(createPR.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreatePR} disabled={createPR.isPending || !prTitle}>
|
||||
{createPR.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitPullRequest className="h-4 w-4 mr-2" />
|
||||
Create PR
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
323
web/src/components/task/git/WorktreeStatus.tsx
Normal file
323
web/src/components/task/git/WorktreeStatus.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
useWorktreeStatus,
|
||||
useCreateWorktree,
|
||||
useDeleteWorktree,
|
||||
useRebaseWorktree,
|
||||
useMergeWorktree
|
||||
} from '@/hooks/useWorktree';
|
||||
import { useGitHubStatus } from '@/hooks/useGitHub';
|
||||
import { useConflictStatus } from '@/hooks/useConflicts';
|
||||
import { ConflictResolver } from '../ConflictResolver';
|
||||
import { PRDialog } from './PRDialog';
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Play,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
GitMerge,
|
||||
Trash2,
|
||||
FileCode,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
GitPullRequest,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WorktreeStatusProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export function WorktreeStatus({ task }: WorktreeStatusProps) {
|
||||
const hasWorktree = !!task.git?.worktreePath;
|
||||
const hasPR = !!task.git?.prUrl;
|
||||
const { data: status, isLoading, error } = useWorktreeStatus(task.id, hasWorktree);
|
||||
const { data: ghStatus } = useGitHubStatus();
|
||||
const { data: conflictStatus } = useConflictStatus(hasWorktree ? task.id : undefined);
|
||||
|
||||
const createWorktree = useCreateWorktree();
|
||||
const deleteWorktree = useDeleteWorktree();
|
||||
const rebaseWorktree = useRebaseWorktree();
|
||||
const mergeWorktree = useMergeWorktree();
|
||||
|
||||
// Conflict resolver state
|
||||
const [conflictResolverOpen, setConflictResolverOpen] = useState(false);
|
||||
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
||||
|
||||
const handleOpenInVSCode = () => {
|
||||
if (task.git?.worktreePath) {
|
||||
window.open(`vscode://file/${task.git.worktreePath}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPR = () => {
|
||||
if (task.git?.prUrl) {
|
||||
window.open(task.git.prUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
if (!task.git?.repo || !task.git?.branch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No worktree yet - show create button
|
||||
if (!hasWorktree) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => createWorktree.mutate(task.id)}
|
||||
disabled={createWorktree.isPending}
|
||||
>
|
||||
{createWorktree.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Create Worktree
|
||||
</Button>
|
||||
{createWorktree.error && (
|
||||
<p className="text-xs text-red-500 mt-2">
|
||||
{(createWorktree.error as Error).message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading worktree status
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading worktree status...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error loading status
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(error as Error).message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show worktree status
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t space-y-3">
|
||||
{/* Conflict warning banner */}
|
||||
{conflictStatus?.hasConflicts && (
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
{conflictStatus.conflictingFiles.length} conflict{conflictStatus.conflictingFiles.length !== 1 ? 's' : ''} detected
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
{conflictStatus.rebaseInProgress ? 'Rebase' : 'Merge'} requires manual resolution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConflictResolverOpen(true)}
|
||||
className="border-amber-500/30 hover:bg-amber-500/10"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
Resolve Conflicts
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicators */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
conflictStatus?.hasConflicts ? "bg-amber-500" : "bg-green-500"
|
||||
)}>
|
||||
<span className="sr-only">
|
||||
{conflictStatus?.hasConflicts ? 'Warning: conflicts detected' : 'Status: active and healthy'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{conflictStatus?.hasConflicts ? 'Conflicts detected' : 'Worktree active'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{status && (
|
||||
<>
|
||||
{status.aheadBehind.ahead > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{status.aheadBehind.ahead} ahead
|
||||
</span>
|
||||
)}
|
||||
{status.aheadBehind.behind > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-500">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
{status.aheadBehind.behind} behind
|
||||
</span>
|
||||
)}
|
||||
{status.hasChanges && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{status.changedFiles} changed
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenInVSCode}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Open in VS Code
|
||||
</Button>
|
||||
|
||||
{/* PR Button - show View PR if exists, Create PR if not */}
|
||||
{hasPR ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleOpenPR}
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3 mr-1" />
|
||||
View PR #{task.git?.prNumber}
|
||||
</Button>
|
||||
) : status && status.aheadBehind.ahead > 0 && ghStatus?.authenticated && (
|
||||
<Button variant="outline" size="sm" onClick={() => setPrDialogOpen(true)}>
|
||||
<GitPullRequest className="h-3 w-3 mr-1" />
|
||||
Create PR
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status && status.aheadBehind.behind > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => rebaseWorktree.mutate(task.id)}
|
||||
disabled={rebaseWorktree.isPending}
|
||||
>
|
||||
{rebaseWorktree.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Rebase
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status && status.aheadBehind.ahead > 0 && !status.hasChanges && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="default" size="sm">
|
||||
<GitMerge className="h-3 w-3 mr-1" />
|
||||
Merge
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Merge to {task.git?.baseBranch}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will merge {task.git?.branch} into {task.git?.baseBranch}, push to remote,
|
||||
delete the worktree, and mark the task as Done.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => mergeWorktree.mutate(task.id)}
|
||||
disabled={mergeWorktree.isPending}
|
||||
>
|
||||
{mergeWorktree.isPending ? 'Merging...' : 'Merge & Complete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground">
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Delete Worktree
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete worktree?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{status?.hasChanges
|
||||
? 'Warning: This worktree has uncommitted changes that will be lost.'
|
||||
: 'This will remove the worktree but keep the branch.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteWorktree.mutate({
|
||||
taskId: task.id,
|
||||
force: status?.hasChanges
|
||||
})}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Worktree path */}
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{task.git.worktreePath}
|
||||
</div>
|
||||
|
||||
{/* Conflict Resolver */}
|
||||
<ConflictResolver
|
||||
task={task}
|
||||
open={conflictResolverOpen}
|
||||
onOpenChange={setConflictResolverOpen}
|
||||
/>
|
||||
|
||||
{/* PR Dialog */}
|
||||
<PRDialog
|
||||
task={task}
|
||||
open={prDialogOpen}
|
||||
onOpenChange={setPrDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
web/src/contexts/TaskConfigContext.tsx
Normal file
36
web/src/contexts/TaskConfigContext.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { useTaskTypes } from '@/hooks/useTaskTypes';
|
||||
import { useProjects } from '@/hooks/useProjects';
|
||||
import { useSprints } from '@/hooks/useSprints';
|
||||
import type { TaskTypeConfig, ProjectConfig, SprintConfig } from '@veritas-kanban/shared';
|
||||
|
||||
interface TaskConfigContextValue {
|
||||
taskTypes: TaskTypeConfig[];
|
||||
projects: ProjectConfig[];
|
||||
sprints: SprintConfig[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const TaskConfigContext = createContext<TaskConfigContextValue | null>(null);
|
||||
|
||||
export function TaskConfigProvider({ children }: { children: ReactNode }) {
|
||||
const { data: taskTypes = [], isLoading: taskTypesLoading } = useTaskTypes();
|
||||
const { data: projects = [], isLoading: projectsLoading } = useProjects();
|
||||
const { data: sprints = [], isLoading: sprintsLoading } = useSprints();
|
||||
|
||||
const isLoading = taskTypesLoading || projectsLoading || sprintsLoading;
|
||||
|
||||
return (
|
||||
<TaskConfigContext.Provider value={{ taskTypes, projects, sprints, isLoading }}>
|
||||
{children}
|
||||
</TaskConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskConfig() {
|
||||
const context = useContext(TaskConfigContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskConfig must be used within TaskConfigProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,43 +1,12 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type Activity, type ActivityType } from '../lib/api';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export type ActivityType =
|
||||
| 'task_created'
|
||||
| 'task_updated'
|
||||
| 'status_changed'
|
||||
| 'agent_started'
|
||||
| 'agent_stopped'
|
||||
| 'agent_completed'
|
||||
| 'task_archived'
|
||||
| 'task_deleted'
|
||||
| 'worktree_created'
|
||||
| 'worktree_merged';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
details?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
async function fetchActivities(limit: number = 50): Promise<Activity[]> {
|
||||
const response = await fetch(`${API_BASE}/activity?limit=${limit}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch activities');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function clearActivities(): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/activity`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Failed to clear activities');
|
||||
}
|
||||
export type { Activity, ActivityType };
|
||||
|
||||
export function useActivities(limit: number = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['activities', limit],
|
||||
queryFn: () => fetchActivities(limit),
|
||||
queryFn: () => api.activity.list(limit),
|
||||
refetchInterval: 30000, // Refresh every 30s
|
||||
});
|
||||
}
|
||||
|
|
@ -45,7 +14,7 @@ export function useActivities(limit: number = 50) {
|
|||
export function useClearActivities() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: clearActivities,
|
||||
mutationFn: () => api.activity.clear(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activities'] });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,31 +1,6 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Attachment } from '@veritas-kanban/shared';
|
||||
|
||||
const API_BASE = 'http://localhost:3001/api';
|
||||
|
||||
interface UploadResponse {
|
||||
success: boolean;
|
||||
attachments: Attachment[];
|
||||
task: unknown;
|
||||
}
|
||||
|
||||
interface TaskContext {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
attachments: {
|
||||
count: number;
|
||||
documents: { filename: string; text: string }[];
|
||||
images: string[];
|
||||
};
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
import { api, type TaskContext } from '../lib/api';
|
||||
|
||||
/**
|
||||
* Fetch attachments for a task
|
||||
|
|
@ -33,13 +8,7 @@ interface TaskContext {
|
|||
export function useAttachments(taskId: string) {
|
||||
return useQuery<Attachment[]>({
|
||||
queryKey: ['attachments', taskId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch attachments');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
queryFn: () => api.attachments.list(taskId),
|
||||
enabled: !!taskId,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,17 +21,7 @@ export function useUploadAttachment() {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: async ({ taskId, formData }: { taskId: string; formData: FormData }) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to upload attachment');
|
||||
}
|
||||
|
||||
return response.json() as Promise<UploadResponse>;
|
||||
return api.attachments.upload(taskId, formData);
|
||||
},
|
||||
onSuccess: (_, { taskId }) => {
|
||||
// Invalidate both attachments and task queries
|
||||
|
|
@ -81,16 +40,7 @@ export function useDeleteAttachment() {
|
|||
|
||||
return useMutation({
|
||||
mutationFn: async ({ taskId, attachmentId }: { taskId: string; attachmentId: string }) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete attachment');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return api.attachments.delete(taskId, attachmentId);
|
||||
},
|
||||
onSuccess: (_, { taskId }) => {
|
||||
// Invalidate both attachments and task queries
|
||||
|
|
@ -107,13 +57,7 @@ export function useDeleteAttachment() {
|
|||
export function useTaskContext(taskId: string) {
|
||||
return useQuery<TaskContext>({
|
||||
queryKey: ['task-context', taskId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/context`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch task context');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
queryFn: () => api.attachments.getTaskContext(taskId),
|
||||
enabled: !!taskId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,13 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
api,
|
||||
type ConflictStatus,
|
||||
type ConflictFile,
|
||||
type ResolveResult,
|
||||
type ConflictMarker
|
||||
} from '../lib/api';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface ConflictStatus {
|
||||
hasConflicts: boolean;
|
||||
conflictingFiles: string[];
|
||||
rebaseInProgress: boolean;
|
||||
mergeInProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ConflictMarker {
|
||||
startLine: number;
|
||||
separatorLine: number;
|
||||
endLine: number;
|
||||
oursLines: string[];
|
||||
theirsLines: string[];
|
||||
}
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string;
|
||||
content: string;
|
||||
oursContent: string;
|
||||
theirsContent: string;
|
||||
baseContent: string;
|
||||
markers: ConflictMarker[];
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
success: boolean;
|
||||
remainingConflicts: string[];
|
||||
}
|
||||
export type { ConflictStatus, ConflictFile, ResolveResult, ConflictMarker };
|
||||
|
||||
/**
|
||||
* Get conflict status for a task
|
||||
|
|
@ -39,11 +17,7 @@ export function useConflictStatus(taskId: string | undefined) {
|
|||
queryKey: ['conflicts', taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return { hasConflicts: false, conflictingFiles: [], rebaseInProgress: false, mergeInProgress: false };
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get conflict status');
|
||||
}
|
||||
return response.json();
|
||||
return api.conflicts.getStatus(taskId);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
|
|
@ -64,11 +38,7 @@ export function useFileConflict(taskId: string | undefined, filePath: string | u
|
|||
queryKey: ['conflicts', taskId, 'file', filePath],
|
||||
queryFn: async () => {
|
||||
if (!taskId || !filePath) throw new Error('Task ID and file path required');
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get file conflict');
|
||||
}
|
||||
return response.json();
|
||||
return api.conflicts.getFile(taskId, filePath);
|
||||
},
|
||||
enabled: !!taskId && !!filePath,
|
||||
});
|
||||
|
|
@ -87,18 +57,7 @@ export function useResolveConflict() {
|
|||
manualContent?: string;
|
||||
}>({
|
||||
mutationFn: async ({ taskId, filePath, resolution, manualContent }) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolution, manualContent }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to resolve conflict');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return api.conflicts.resolve(taskId, filePath, resolution, manualContent);
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
|
||||
|
|
@ -114,18 +73,7 @@ export function useAbortConflict() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean }, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to abort');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: (taskId) => api.conflicts.abort(taskId),
|
||||
onSuccess: (_data, taskId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['worktree', taskId] });
|
||||
|
|
@ -140,20 +88,7 @@ export function useContinueConflict() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ success: boolean; error?: string }, Error, { taskId: string; message?: string }>({
|
||||
mutationFn: async ({ taskId, message }) => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to continue');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: ({ taskId, message }) => api.conflicts.continue(taskId, message),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['conflicts', variables.taskId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['worktree', variables.taskId] });
|
||||
|
|
|
|||
45
web/src/hooks/useDebouncedSave.ts
Normal file
45
web/src/hooks/useDebouncedSave.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useUpdateTask } from './useTasks';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
export function useDebouncedSave(task: Task | null) {
|
||||
const updateTask = useUpdateTask();
|
||||
const [localTask, setLocalTask] = useState<Task | null>(task);
|
||||
const [changedFields, setChangedFields] = useState<Set<keyof Task>>(new Set());
|
||||
|
||||
// Sync from server when task prop changes (e.g., refetch)
|
||||
useEffect(() => {
|
||||
setLocalTask(task);
|
||||
setChangedFields(new Set());
|
||||
}, [task]);
|
||||
|
||||
// Debounced save - only send fields that were actually changed
|
||||
useEffect(() => {
|
||||
if (changedFields.size === 0 || !localTask) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// Build input with only changed fields
|
||||
const input: Record<string, unknown> = {};
|
||||
changedFields.forEach(field => {
|
||||
input[field] = localTask[field];
|
||||
});
|
||||
|
||||
updateTask.mutate({
|
||||
id: localTask.id,
|
||||
input,
|
||||
});
|
||||
setChangedFields(new Set());
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [localTask, changedFields, updateTask]);
|
||||
|
||||
const updateField = useCallback(<K extends keyof Task>(field: K, value: Task[K]) => {
|
||||
setLocalTask(prev => prev ? { ...prev, [field]: value } : null);
|
||||
setChangedFields(prev => new Set(prev).add(field));
|
||||
}, []);
|
||||
|
||||
const isDirty = changedFields.size > 0;
|
||||
|
||||
return { localTask, updateField, isDirty };
|
||||
}
|
||||
|
|
@ -1,30 +1,7 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type GitHubStatus, type PRInfo, type CreatePRInput } from '../lib/api';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface GitHubStatus {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
draft: boolean;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
}
|
||||
|
||||
export interface CreatePRInput {
|
||||
taskId: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
targetBranch?: string;
|
||||
draft?: boolean;
|
||||
}
|
||||
export type { GitHubStatus, PRInfo, CreatePRInput };
|
||||
|
||||
/**
|
||||
* Check GitHub CLI status
|
||||
|
|
@ -32,13 +9,7 @@ export interface CreatePRInput {
|
|||
export function useGitHubStatus() {
|
||||
return useQuery<GitHubStatus>({
|
||||
queryKey: ['github', 'status'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE}/github/status`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check GitHub status');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
queryFn: () => api.github.getStatus(),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
|
@ -50,20 +21,7 @@ export function useCreatePR() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<PRInfo, Error, CreatePRInput>({
|
||||
mutationFn: async (input) => {
|
||||
const response = await fetch(`${API_BASE}/github/pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to create PR');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: (input) => api.github.createPR(input),
|
||||
onSuccess: (_data, variables) => {
|
||||
// Invalidate the task to refresh its PR info
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
|
|
@ -77,15 +35,6 @@ export function useCreatePR() {
|
|||
*/
|
||||
export function useOpenPR() {
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to open PR');
|
||||
}
|
||||
},
|
||||
mutationFn: (taskId) => api.github.openPR(taskId),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,7 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type PreviewServer } from '../lib/api';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface PreviewServer {
|
||||
taskId: string;
|
||||
repoName: string;
|
||||
pid: number;
|
||||
port: number;
|
||||
url: string;
|
||||
status: 'starting' | 'running' | 'stopped' | 'error';
|
||||
startedAt: string;
|
||||
output: string[];
|
||||
error?: string;
|
||||
}
|
||||
export type { PreviewServer };
|
||||
|
||||
/**
|
||||
* Get preview status for a task
|
||||
|
|
@ -22,11 +11,7 @@ export function usePreviewStatus(taskId: string | undefined) {
|
|||
queryKey: ['preview', taskId],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return { status: 'stopped' as const };
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get preview status');
|
||||
}
|
||||
return response.json();
|
||||
return api.preview.getStatus(taskId);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (data) => {
|
||||
|
|
@ -51,11 +36,7 @@ export function usePreviewOutput(taskId: string | undefined, lines: number = 50)
|
|||
queryKey: ['preview', taskId, 'output', lines],
|
||||
queryFn: async () => {
|
||||
if (!taskId) return { output: [] };
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get preview output');
|
||||
}
|
||||
return response.json();
|
||||
return api.preview.getOutput(taskId, lines);
|
||||
},
|
||||
enabled: !!taskId,
|
||||
refetchInterval: 2000, // Poll every 2s for output
|
||||
|
|
@ -69,18 +50,7 @@ export function useStartPreview() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<PreviewServer, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to start preview');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: (taskId) => api.preview.start(taskId),
|
||||
onSuccess: (_data, taskId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['preview', taskId] });
|
||||
},
|
||||
|
|
@ -94,16 +64,7 @@ export function useStopPreview() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to stop preview');
|
||||
}
|
||||
},
|
||||
mutationFn: (taskId) => api.preview.stop(taskId),
|
||||
onSuccess: (_data, taskId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['preview', taskId] });
|
||||
},
|
||||
|
|
|
|||
209
web/src/hooks/useTemplateForm.ts
Normal file
209
web/src/hooks/useTemplateForm.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { useState } from 'react';
|
||||
import { useTemplates, type TaskTemplate } from './useTemplates';
|
||||
import { useCreateTask } from './useTasks';
|
||||
import {
|
||||
interpolateVariables,
|
||||
extractCustomVariables,
|
||||
type VariableContext
|
||||
} from '@/lib/template-variables';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { Subtask } from '@veritas-kanban/shared';
|
||||
|
||||
export function useTemplateForm() {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||
const [customVars, setCustomVars] = useState<Record<string, string>>({});
|
||||
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
|
||||
|
||||
const { data: templates } = useTemplates();
|
||||
const createTask = useCreateTask();
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
setSelectedTemplate(template.id);
|
||||
|
||||
// If this is a blueprint template, extract variables from all blueprint tasks
|
||||
if (template.blueprint && template.blueprint.length > 0) {
|
||||
const allBlueprintText = template.blueprint.flatMap(bt => [
|
||||
bt.title,
|
||||
bt.taskDefaults.descriptionTemplate || '',
|
||||
...(bt.subtaskTemplates?.map(st => st.title) || [])
|
||||
]).join(' ');
|
||||
|
||||
const customVarNames = extractCustomVariables(allBlueprintText);
|
||||
setRequiredCustomVars(customVarNames);
|
||||
|
||||
const initialCustomVars: Record<string, string> = {};
|
||||
customVarNames.forEach(name => {
|
||||
initialCustomVars[name] = '';
|
||||
});
|
||||
setCustomVars(initialCustomVars);
|
||||
|
||||
return {
|
||||
type: template.taskDefaults.type,
|
||||
priority: template.taskDefaults.priority,
|
||||
project: template.taskDefaults.project,
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Single-task template
|
||||
// Extract custom variables from description template and subtasks
|
||||
const allTemplateText = [
|
||||
template.taskDefaults.descriptionTemplate || '',
|
||||
...(template.subtaskTemplates?.map(st => st.title) || [])
|
||||
].join(' ');
|
||||
|
||||
const customVarNames = extractCustomVariables(allTemplateText);
|
||||
setRequiredCustomVars(customVarNames);
|
||||
|
||||
// Initialize custom vars
|
||||
const initialCustomVars: Record<string, string> = {};
|
||||
customVarNames.forEach(name => {
|
||||
initialCustomVars[name] = '';
|
||||
});
|
||||
setCustomVars(initialCustomVars);
|
||||
|
||||
// Convert subtask templates to actual subtasks
|
||||
if (template.subtaskTemplates && template.subtaskTemplates.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
const templateSubtasks: Subtask[] = template.subtaskTemplates
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(st => ({
|
||||
id: nanoid(),
|
||||
title: st.title,
|
||||
completed: false,
|
||||
created: now,
|
||||
}));
|
||||
setSubtasks(templateSubtasks);
|
||||
} else {
|
||||
setSubtasks([]);
|
||||
}
|
||||
|
||||
return {
|
||||
type: template.taskDefaults.type,
|
||||
priority: template.taskDefaults.priority,
|
||||
project: template.taskDefaults.project,
|
||||
description: template.taskDefaults.descriptionTemplate || '',
|
||||
};
|
||||
};
|
||||
|
||||
const clearTemplate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setSubtasks([]);
|
||||
setCustomVars({});
|
||||
setRequiredCustomVars([]);
|
||||
};
|
||||
|
||||
const removeSubtask = (id: string) => {
|
||||
setSubtasks(prev => prev.filter(st => st.id !== id));
|
||||
};
|
||||
|
||||
const createTasks = async (
|
||||
title: string,
|
||||
description: string,
|
||||
project: string,
|
||||
sprint: string,
|
||||
type: string,
|
||||
priority: string,
|
||||
) => {
|
||||
// Build variable context
|
||||
const context: VariableContext = {
|
||||
project: project.trim() || undefined,
|
||||
author: 'Brad', // TODO: Get from user config
|
||||
customVars,
|
||||
};
|
||||
|
||||
// Check if this is a blueprint template
|
||||
const template = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null;
|
||||
|
||||
if (template?.blueprint && template.blueprint.length > 0) {
|
||||
// Blueprint: create multiple tasks
|
||||
await handleBlueprintCreation(template, context, project);
|
||||
} else {
|
||||
// Single task creation
|
||||
// Interpolate variables in description
|
||||
const interpolatedDescription = interpolateVariables(description, context);
|
||||
|
||||
// Interpolate variables in subtask titles
|
||||
const interpolatedSubtasks = subtasks.map(st => ({
|
||||
...st,
|
||||
title: interpolateVariables(st.title, context),
|
||||
}));
|
||||
|
||||
await createTask.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: interpolatedDescription.trim(),
|
||||
type,
|
||||
priority: priority as any,
|
||||
project: project.trim() || undefined,
|
||||
sprint: sprint.trim() || undefined,
|
||||
subtasks: interpolatedSubtasks.length > 0 ? interpolatedSubtasks : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlueprintCreation = async (
|
||||
template: TaskTemplate,
|
||||
context: VariableContext,
|
||||
project: string,
|
||||
) => {
|
||||
if (!template.blueprint) return;
|
||||
|
||||
// Map to store refId -> actual task ID
|
||||
const refIdToTaskId: Record<string, string> = {};
|
||||
|
||||
// Create tasks in order, resolving dependencies
|
||||
for (const blueprintTask of template.blueprint) {
|
||||
// Interpolate title
|
||||
const taskTitle = interpolateVariables(blueprintTask.title, context);
|
||||
|
||||
// Interpolate description
|
||||
const taskDescription = interpolateVariables(
|
||||
blueprintTask.taskDefaults.descriptionTemplate || '',
|
||||
context
|
||||
);
|
||||
|
||||
// Create subtasks
|
||||
const taskSubtasks = blueprintTask.subtaskTemplates?.map(st => {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: nanoid(),
|
||||
title: interpolateVariables(st.title, context),
|
||||
completed: false,
|
||||
created: now,
|
||||
};
|
||||
});
|
||||
|
||||
// Resolve dependencies
|
||||
const blockedBy = blueprintTask.blockedByRefs?.map(refId => refIdToTaskId[refId]).filter(Boolean);
|
||||
|
||||
// Create the task
|
||||
const createdTask = await createTask.mutateAsync({
|
||||
title: taskTitle,
|
||||
description: taskDescription,
|
||||
type: blueprintTask.taskDefaults.type,
|
||||
priority: blueprintTask.taskDefaults.priority,
|
||||
project: blueprintTask.taskDefaults.project || project.trim() || undefined,
|
||||
subtasks: taskSubtasks,
|
||||
blockedBy,
|
||||
});
|
||||
|
||||
// Store the mapping
|
||||
refIdToTaskId[blueprintTask.refId] = createdTask.id;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedTemplate,
|
||||
templates,
|
||||
subtasks,
|
||||
customVars,
|
||||
requiredCustomVars,
|
||||
applyTemplate,
|
||||
clearTemplate,
|
||||
removeSubtask,
|
||||
setCustomVars,
|
||||
createTasks,
|
||||
isCreating: createTask.isPending,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
import { api, type TimeSummary } from '../lib/api';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface TimeSummary {
|
||||
byProject: { project: string; totalSeconds: number; taskCount: number }[];
|
||||
total: number;
|
||||
}
|
||||
export type { TimeSummary };
|
||||
|
||||
/**
|
||||
* Get time summary by project
|
||||
|
|
@ -14,13 +10,7 @@ export interface TimeSummary {
|
|||
export function useTimeSummary() {
|
||||
return useQuery<TimeSummary>({
|
||||
queryKey: ['time', 'summary'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${API_BASE}/tasks/time/summary`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get time summary');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
queryFn: () => api.time.getSummary(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -31,18 +21,7 @@ export function useStartTimer() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Task, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to start timer');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: (taskId) => api.time.start(taskId),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
|
|
@ -58,18 +37,7 @@ export function useStopTimer() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Task, Error, string>({
|
||||
mutationFn: async (taskId) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to stop timer');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: (taskId) => api.time.stop(taskId),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
|
|
@ -85,20 +53,7 @@ export function useAddTimeEntry() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Task, Error, { taskId: string; duration: number; description?: string }>({
|
||||
mutationFn: async ({ taskId, duration, description }) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ duration, description }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to add time entry');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: ({ taskId, duration, description }) => api.time.addEntry(taskId, duration, description),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
|
|
@ -114,18 +69,7 @@ export function useDeleteTimeEntry() {
|
|||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<Task, Error, { taskId: string; entryId: string }>({
|
||||
mutationFn: async ({ taskId, entryId }) => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete time entry');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
mutationFn: ({ taskId, entryId }) => api.time.deleteEntry(taskId, entryId),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import type {
|
|||
TaskTypeConfig,
|
||||
SprintConfig,
|
||||
FeatureSettings,
|
||||
Attachment,
|
||||
} from '@veritas-kanban/shared';
|
||||
|
||||
const API_BASE = '/api';
|
||||
import { API_BASE } from './config';
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
|
|
@ -517,6 +517,175 @@ export const api = {
|
|||
return handleResponse<SprintConfig[]>(response);
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
list: async (limit: number = 50): Promise<Activity[]> => {
|
||||
const response = await fetch(`${API_BASE}/activity?limit=${limit}`);
|
||||
return handleResponse<Activity[]>(response);
|
||||
},
|
||||
|
||||
clear: async (): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/activity`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
|
||||
attachments: {
|
||||
list: async (taskId: string): Promise<Attachment[]> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`);
|
||||
return handleResponse<Attachment[]>(response);
|
||||
},
|
||||
|
||||
upload: async (taskId: string, formData: FormData): Promise<AttachmentUploadResponse> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
return handleResponse<AttachmentUploadResponse>(response);
|
||||
},
|
||||
|
||||
delete: async (taskId: string, attachmentId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/attachments/${attachmentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
|
||||
getTaskContext: async (taskId: string): Promise<TaskContext> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/context`);
|
||||
return handleResponse<TaskContext>(response);
|
||||
},
|
||||
},
|
||||
|
||||
conflicts: {
|
||||
getStatus: async (taskId: string): Promise<ConflictStatus> => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}`);
|
||||
return handleResponse<ConflictStatus>(response);
|
||||
},
|
||||
|
||||
getFile: async (taskId: string, filePath: string): Promise<ConflictFile> => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/file?path=${encodeURIComponent(filePath)}`);
|
||||
return handleResponse<ConflictFile>(response);
|
||||
},
|
||||
|
||||
resolve: async (
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
resolution: 'ours' | 'theirs' | 'manual',
|
||||
manualContent?: string
|
||||
): Promise<ResolveResult> => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/resolve?path=${encodeURIComponent(filePath)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolution, manualContent }),
|
||||
});
|
||||
return handleResponse<ResolveResult>(response);
|
||||
},
|
||||
|
||||
abort: async (taskId: string): Promise<{ success: boolean }> => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/abort`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<{ success: boolean }>(response);
|
||||
},
|
||||
|
||||
continue: async (taskId: string, message?: string): Promise<{ success: boolean; error?: string }> => {
|
||||
const response = await fetch(`${API_BASE}/conflicts/${taskId}/continue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
return handleResponse<{ success: boolean; error?: string }>(response);
|
||||
},
|
||||
},
|
||||
|
||||
github: {
|
||||
getStatus: async (): Promise<GitHubStatus> => {
|
||||
const response = await fetch(`${API_BASE}/github/status`);
|
||||
return handleResponse<GitHubStatus>(response);
|
||||
},
|
||||
|
||||
createPR: async (input: CreatePRInput): Promise<PRInfo> => {
|
||||
const response = await fetch(`${API_BASE}/github/pr`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<PRInfo>(response);
|
||||
},
|
||||
|
||||
openPR: async (taskId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/github/pr/${taskId}/open`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
|
||||
preview: {
|
||||
getStatus: async (taskId: string): Promise<PreviewServer | { status: 'stopped' }> => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}`);
|
||||
return handleResponse<PreviewServer | { status: 'stopped' }>(response);
|
||||
},
|
||||
|
||||
getOutput: async (taskId: string, lines: number = 50): Promise<{ output: string[] }> => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/output?lines=${lines}`);
|
||||
return handleResponse<{ output: string[] }>(response);
|
||||
},
|
||||
|
||||
start: async (taskId: string): Promise<PreviewServer> => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<PreviewServer>(response);
|
||||
},
|
||||
|
||||
stop: async (taskId: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
|
||||
time: {
|
||||
getSummary: async (): Promise<TimeSummary> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/time/summary`);
|
||||
return handleResponse<TimeSummary>(response);
|
||||
},
|
||||
|
||||
start: async (taskId: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
stop: async (taskId: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/stop`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
addEntry: async (taskId: string, duration: number, description?: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ duration, description }),
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
deleteEntry: async (taskId: string, entryId: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${taskId}/time/entry/${entryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Types for archive suggestions
|
||||
|
|
@ -604,6 +773,127 @@ export interface WorktreeInfo {
|
|||
changedFiles: number;
|
||||
}
|
||||
|
||||
// Types for activity
|
||||
export type ActivityType =
|
||||
| 'task_created'
|
||||
| 'task_updated'
|
||||
| 'status_changed'
|
||||
| 'agent_started'
|
||||
| 'agent_stopped'
|
||||
| 'agent_completed'
|
||||
| 'task_archived'
|
||||
| 'task_deleted'
|
||||
| 'worktree_created'
|
||||
| 'worktree_merged';
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
details?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Types for attachments
|
||||
export interface AttachmentUploadResponse {
|
||||
success: boolean;
|
||||
attachments: Attachment[];
|
||||
task: unknown;
|
||||
}
|
||||
|
||||
export interface TaskContext {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
attachments: {
|
||||
count: number;
|
||||
documents: { filename: string; text: string }[];
|
||||
images: string[];
|
||||
};
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
// Types for conflicts
|
||||
export interface ConflictStatus {
|
||||
hasConflicts: boolean;
|
||||
conflictingFiles: string[];
|
||||
rebaseInProgress: boolean;
|
||||
mergeInProgress: boolean;
|
||||
}
|
||||
|
||||
export interface ConflictMarker {
|
||||
startLine: number;
|
||||
separatorLine: number;
|
||||
endLine: number;
|
||||
oursLines: string[];
|
||||
theirsLines: string[];
|
||||
}
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string;
|
||||
content: string;
|
||||
oursContent: string;
|
||||
theirsContent: string;
|
||||
baseContent: string;
|
||||
markers: ConflictMarker[];
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
success: boolean;
|
||||
remainingConflicts: string[];
|
||||
}
|
||||
|
||||
// Types for GitHub
|
||||
export interface GitHubStatus {
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
url: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
draft: boolean;
|
||||
headBranch: string;
|
||||
baseBranch: string;
|
||||
}
|
||||
|
||||
export interface CreatePRInput {
|
||||
taskId: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
targetBranch?: string;
|
||||
draft?: boolean;
|
||||
}
|
||||
|
||||
// Types for preview
|
||||
export interface PreviewServer {
|
||||
taskId: string;
|
||||
repoName: string;
|
||||
pid: number;
|
||||
port: number;
|
||||
url: string;
|
||||
status: 'starting' | 'running' | 'stopped' | 'error';
|
||||
startedAt: string;
|
||||
output: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Types for time tracking
|
||||
export interface TimeSummary {
|
||||
byProject: { project: string; totalSeconds: number; taskCount: number }[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Managed List API helpers
|
||||
export const managedList = {
|
||||
/**
|
||||
|
|
|
|||
4
web/src/lib/config.ts
Normal file
4
web/src/lib/config.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Shared configuration for the application
|
||||
*/
|
||||
export const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
741
web/src/lib/template-schema.d.ts
vendored
741
web/src/lib/template-schema.d.ts
vendored
|
|
@ -1,741 +0,0 @@
|
|||
/**
|
||||
* Zod validation schema for task templates
|
||||
* Enforces strict validation, size limits, and security checks
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
/**
|
||||
* Task template schema with strict validation
|
||||
*/
|
||||
export declare const TaskTemplateSchema: z.ZodEffects<z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodEffects<z.ZodString, string, string>;
|
||||
description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
version: z.ZodOptional<z.ZodNumber>;
|
||||
taskDefaults: z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
refId: z.ZodEffects<z.ZodString, string, string>;
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
taskDefaults: z.ZodOptional<z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}>, "many">>;
|
||||
created: z.ZodOptional<z.ZodString>;
|
||||
updated: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>;
|
||||
/**
|
||||
* Schema for importing templates (single or array)
|
||||
*/
|
||||
export declare const TemplateImportSchema: z.ZodUnion<[z.ZodEffects<z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodEffects<z.ZodString, string, string>;
|
||||
description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
version: z.ZodOptional<z.ZodNumber>;
|
||||
taskDefaults: z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
refId: z.ZodEffects<z.ZodString, string, string>;
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
taskDefaults: z.ZodOptional<z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}>, "many">>;
|
||||
created: z.ZodOptional<z.ZodString>;
|
||||
updated: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>, z.ZodArray<z.ZodEffects<z.ZodObject<{
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
name: z.ZodEffects<z.ZodString, string, string>;
|
||||
description: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
category: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, string | undefined>;
|
||||
version: z.ZodOptional<z.ZodNumber>;
|
||||
taskDefaults: z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blueprint: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
refId: z.ZodEffects<z.ZodString, string, string>;
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
taskDefaults: z.ZodOptional<z.ZodObject<{
|
||||
type: z.ZodOptional<z.ZodString>;
|
||||
priority: z.ZodOptional<z.ZodString>;
|
||||
project: z.ZodOptional<z.ZodString>;
|
||||
descriptionTemplate: z.ZodOptional<z.ZodString>;
|
||||
agent: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}, {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
}>>;
|
||||
subtaskTemplates: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
title: z.ZodEffects<z.ZodString, string, string>;
|
||||
order: z.ZodOptional<z.ZodNumber>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}>, "many">>;
|
||||
blockedByRefs: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}, {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}>, "many">>;
|
||||
created: z.ZodOptional<z.ZodString>;
|
||||
updated: z.ZodOptional<z.ZodString>;
|
||||
}, "strict", z.ZodTypeAny, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}, {
|
||||
name: string;
|
||||
taskDefaults: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
};
|
||||
id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
created?: string | undefined;
|
||||
updated?: string | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
category?: string | undefined;
|
||||
blueprint?: {
|
||||
title: string;
|
||||
refId: string;
|
||||
taskDefaults?: {
|
||||
type?: string | undefined;
|
||||
project?: string | undefined;
|
||||
priority?: string | undefined;
|
||||
agent?: string | undefined;
|
||||
descriptionTemplate?: string | undefined;
|
||||
} | undefined;
|
||||
subtaskTemplates?: {
|
||||
title: string;
|
||||
order?: number | undefined;
|
||||
}[] | undefined;
|
||||
blockedByRefs?: string[] | undefined;
|
||||
}[] | undefined;
|
||||
version?: number | undefined;
|
||||
}>, "many">]>;
|
||||
/**
|
||||
* Type exports
|
||||
*/
|
||||
export type ValidatedTemplate = z.infer<typeof TaskTemplateSchema>;
|
||||
export type TemplateImport = z.infer<typeof TemplateImportSchema>;
|
||||
9
web/src/vite-env.d.ts
vendored
Normal file
9
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue