@vibescope/mcp-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +356 -0
- package/dist/cli.test.d.ts +1 -0
- package/dist/cli.test.js +367 -0
- package/dist/handlers/__test-utils__.d.ts +72 -0
- package/dist/handlers/__test-utils__.js +176 -0
- package/dist/handlers/blockers.d.ts +18 -0
- package/dist/handlers/blockers.js +81 -0
- package/dist/handlers/bodies-of-work.d.ts +34 -0
- package/dist/handlers/bodies-of-work.js +614 -0
- package/dist/handlers/checkouts.d.ts +37 -0
- package/dist/handlers/checkouts.js +377 -0
- package/dist/handlers/cost.d.ts +39 -0
- package/dist/handlers/cost.js +247 -0
- package/dist/handlers/decisions.d.ts +16 -0
- package/dist/handlers/decisions.js +64 -0
- package/dist/handlers/deployment.d.ts +36 -0
- package/dist/handlers/deployment.js +1062 -0
- package/dist/handlers/discovery.d.ts +14 -0
- package/dist/handlers/discovery.js +870 -0
- package/dist/handlers/fallback.d.ts +18 -0
- package/dist/handlers/fallback.js +216 -0
- package/dist/handlers/findings.d.ts +18 -0
- package/dist/handlers/findings.js +110 -0
- package/dist/handlers/git-issues.d.ts +22 -0
- package/dist/handlers/git-issues.js +247 -0
- package/dist/handlers/ideas.d.ts +19 -0
- package/dist/handlers/ideas.js +188 -0
- package/dist/handlers/index.d.ts +29 -0
- package/dist/handlers/index.js +65 -0
- package/dist/handlers/knowledge-query.d.ts +22 -0
- package/dist/handlers/knowledge-query.js +253 -0
- package/dist/handlers/knowledge.d.ts +12 -0
- package/dist/handlers/knowledge.js +108 -0
- package/dist/handlers/milestones.d.ts +20 -0
- package/dist/handlers/milestones.js +179 -0
- package/dist/handlers/organizations.d.ts +36 -0
- package/dist/handlers/organizations.js +428 -0
- package/dist/handlers/progress.d.ts +14 -0
- package/dist/handlers/progress.js +149 -0
- package/dist/handlers/project.d.ts +20 -0
- package/dist/handlers/project.js +278 -0
- package/dist/handlers/requests.d.ts +16 -0
- package/dist/handlers/requests.js +131 -0
- package/dist/handlers/roles.d.ts +30 -0
- package/dist/handlers/roles.js +281 -0
- package/dist/handlers/session.d.ts +20 -0
- package/dist/handlers/session.js +791 -0
- package/dist/handlers/tasks.d.ts +52 -0
- package/dist/handlers/tasks.js +1111 -0
- package/dist/handlers/tasks.test.d.ts +1 -0
- package/dist/handlers/tasks.test.js +431 -0
- package/dist/handlers/types.d.ts +94 -0
- package/dist/handlers/types.js +1 -0
- package/dist/handlers/validation.d.ts +16 -0
- package/dist/handlers/validation.js +188 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2707 -0
- package/dist/knowledge.d.ts +6 -0
- package/dist/knowledge.js +121 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +2498 -0
- package/dist/utils.d.ts +149 -0
- package/dist/utils.js +317 -0
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +532 -0
- package/dist/validators.d.ts +35 -0
- package/dist/validators.js +111 -0
- package/dist/validators.test.d.ts +1 -0
- package/dist/validators.test.js +176 -0
- package/package.json +44 -0
- package/src/cli.test.ts +442 -0
- package/src/cli.ts +439 -0
- package/src/handlers/__test-utils__.ts +217 -0
- package/src/handlers/blockers.test.ts +390 -0
- package/src/handlers/blockers.ts +110 -0
- package/src/handlers/bodies-of-work.test.ts +1276 -0
- package/src/handlers/bodies-of-work.ts +783 -0
- package/src/handlers/cost.test.ts +436 -0
- package/src/handlers/cost.ts +322 -0
- package/src/handlers/decisions.test.ts +401 -0
- package/src/handlers/decisions.ts +86 -0
- package/src/handlers/deployment.test.ts +516 -0
- package/src/handlers/deployment.ts +1289 -0
- package/src/handlers/discovery.test.ts +254 -0
- package/src/handlers/discovery.ts +969 -0
- package/src/handlers/fallback.test.ts +687 -0
- package/src/handlers/fallback.ts +260 -0
- package/src/handlers/findings.test.ts +565 -0
- package/src/handlers/findings.ts +153 -0
- package/src/handlers/ideas.test.ts +753 -0
- package/src/handlers/ideas.ts +247 -0
- package/src/handlers/index.ts +69 -0
- package/src/handlers/milestones.test.ts +584 -0
- package/src/handlers/milestones.ts +217 -0
- package/src/handlers/organizations.test.ts +997 -0
- package/src/handlers/organizations.ts +550 -0
- package/src/handlers/progress.test.ts +369 -0
- package/src/handlers/progress.ts +188 -0
- package/src/handlers/project.test.ts +562 -0
- package/src/handlers/project.ts +352 -0
- package/src/handlers/requests.test.ts +531 -0
- package/src/handlers/requests.ts +150 -0
- package/src/handlers/session.test.ts +459 -0
- package/src/handlers/session.ts +912 -0
- package/src/handlers/tasks.test.ts +602 -0
- package/src/handlers/tasks.ts +1393 -0
- package/src/handlers/types.ts +88 -0
- package/src/handlers/validation.test.ts +880 -0
- package/src/handlers/validation.ts +223 -0
- package/src/index.ts +3205 -0
- package/src/knowledge.ts +132 -0
- package/src/tmpclaude-0078-cwd +1 -0
- package/src/tmpclaude-0ee1-cwd +1 -0
- package/src/tmpclaude-2dd5-cwd +1 -0
- package/src/tmpclaude-344c-cwd +1 -0
- package/src/tmpclaude-3860-cwd +1 -0
- package/src/tmpclaude-4b63-cwd +1 -0
- package/src/tmpclaude-5c73-cwd +1 -0
- package/src/tmpclaude-5ee3-cwd +1 -0
- package/src/tmpclaude-6795-cwd +1 -0
- package/src/tmpclaude-709e-cwd +1 -0
- package/src/tmpclaude-9839-cwd +1 -0
- package/src/tmpclaude-d829-cwd +1 -0
- package/src/tmpclaude-e072-cwd +1 -0
- package/src/tmpclaude-f6ee-cwd +1 -0
- package/src/utils.test.ts +681 -0
- package/src/utils.ts +375 -0
- package/src/validators.test.ts +223 -0
- package/src/validators.ts +122 -0
- package/tmpclaude-0439-cwd +1 -0
- package/tmpclaude-132f-cwd +1 -0
- package/tmpclaude-15bb-cwd +1 -0
- package/tmpclaude-165a-cwd +1 -0
- package/tmpclaude-1ba9-cwd +1 -0
- package/tmpclaude-21a3-cwd +1 -0
- package/tmpclaude-2a38-cwd +1 -0
- package/tmpclaude-2adf-cwd +1 -0
- package/tmpclaude-2f56-cwd +1 -0
- package/tmpclaude-3626-cwd +1 -0
- package/tmpclaude-3727-cwd +1 -0
- package/tmpclaude-40bc-cwd +1 -0
- package/tmpclaude-436f-cwd +1 -0
- package/tmpclaude-4783-cwd +1 -0
- package/tmpclaude-4b6d-cwd +1 -0
- package/tmpclaude-4ba4-cwd +1 -0
- package/tmpclaude-51e6-cwd +1 -0
- package/tmpclaude-5ecf-cwd +1 -0
- package/tmpclaude-6f97-cwd +1 -0
- package/tmpclaude-7fb2-cwd +1 -0
- package/tmpclaude-825c-cwd +1 -0
- package/tmpclaude-8baf-cwd +1 -0
- package/tmpclaude-8d9f-cwd +1 -0
- package/tmpclaude-975c-cwd +1 -0
- package/tmpclaude-9983-cwd +1 -0
- package/tmpclaude-a045-cwd +1 -0
- package/tmpclaude-ac4a-cwd +1 -0
- package/tmpclaude-b593-cwd +1 -0
- package/tmpclaude-b891-cwd +1 -0
- package/tmpclaude-c032-cwd +1 -0
- package/tmpclaude-cf43-cwd +1 -0
- package/tmpclaude-d040-cwd +1 -0
- package/tmpclaude-dcdd-cwd +1 -0
- package/tmpclaude-dcee-cwd +1 -0
- package/tmpclaude-e16b-cwd +1 -0
- package/tmpclaude-ecd2-cwd +1 -0
- package/tmpclaude-f48d-cwd +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Handlers
|
|
3
|
+
*
|
|
4
|
+
* Handles task CRUD and management:
|
|
5
|
+
* - get_tasks
|
|
6
|
+
* - get_next_task
|
|
7
|
+
* - add_task
|
|
8
|
+
* - update_task
|
|
9
|
+
* - complete_task
|
|
10
|
+
* - delete_task
|
|
11
|
+
* - add_task_reference
|
|
12
|
+
* - remove_task_reference
|
|
13
|
+
* - batch_update_tasks
|
|
14
|
+
* - batch_complete_tasks
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Handler, HandlerRegistry, HandlerContext } from './types.js';
|
|
18
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
19
|
+
import {
|
|
20
|
+
ValidationError,
|
|
21
|
+
validateRequired,
|
|
22
|
+
validateUUID,
|
|
23
|
+
validateTaskStatus,
|
|
24
|
+
validatePriority,
|
|
25
|
+
validateProgressPercentage,
|
|
26
|
+
validateEstimatedMinutes,
|
|
27
|
+
} from '../validators.js';
|
|
28
|
+
import { getRandomFallbackActivity, isValidStatusTransition } from '../utils.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Git workflow instructions generator
|
|
32
|
+
*/
|
|
33
|
+
interface GitWorkflowConfig {
|
|
34
|
+
git_workflow: string;
|
|
35
|
+
git_main_branch: string;
|
|
36
|
+
git_develop_branch?: string;
|
|
37
|
+
git_auto_branch?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Enhanced return types for git instructions
|
|
41
|
+
interface GitStartInstructions {
|
|
42
|
+
branch_name: string;
|
|
43
|
+
base_branch: string;
|
|
44
|
+
steps: string[];
|
|
45
|
+
reminder: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface GitCompleteInstructions {
|
|
49
|
+
steps: string[];
|
|
50
|
+
pr_suggestion?: {
|
|
51
|
+
title: string;
|
|
52
|
+
body_template: string;
|
|
53
|
+
};
|
|
54
|
+
next_step: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface GitMergeInstructions {
|
|
58
|
+
target_branch: string;
|
|
59
|
+
feature_branch: string;
|
|
60
|
+
steps: string[];
|
|
61
|
+
cleanup: string[];
|
|
62
|
+
note: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function generateBranchName(taskId: string, taskTitle: string): string {
|
|
66
|
+
const slug = taskTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30);
|
|
67
|
+
return `feature/${taskId.slice(0, 8)}-${slug}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getTaskStartGitInstructions(
|
|
71
|
+
config: GitWorkflowConfig,
|
|
72
|
+
taskId: string,
|
|
73
|
+
taskTitle: string
|
|
74
|
+
): GitStartInstructions | undefined {
|
|
75
|
+
const { git_workflow, git_main_branch, git_develop_branch } = config;
|
|
76
|
+
|
|
77
|
+
if (git_workflow === 'none' || git_workflow === 'trunk-based') {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const branchName = generateBranchName(taskId, taskTitle);
|
|
82
|
+
const baseBranch = git_workflow === 'git-flow' ? (git_develop_branch || 'develop') : git_main_branch;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
branch_name: branchName,
|
|
86
|
+
base_branch: baseBranch,
|
|
87
|
+
steps: [
|
|
88
|
+
`git checkout ${baseBranch}`,
|
|
89
|
+
`git pull origin ${baseBranch}`,
|
|
90
|
+
`git checkout -b ${branchName}`,
|
|
91
|
+
],
|
|
92
|
+
reminder: `After creating the branch, update task: update_task(task_id: "${taskId}", git_branch: "${branchName}")`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getTaskCompleteGitInstructions(
|
|
97
|
+
config: GitWorkflowConfig,
|
|
98
|
+
taskBranch: string | undefined,
|
|
99
|
+
taskTitle: string,
|
|
100
|
+
taskId: string
|
|
101
|
+
): GitCompleteInstructions | undefined {
|
|
102
|
+
const { git_workflow, git_main_branch } = config;
|
|
103
|
+
|
|
104
|
+
if (git_workflow === 'none') {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (git_workflow === 'trunk-based') {
|
|
109
|
+
return {
|
|
110
|
+
steps: [`git add .`, `git commit -m "feat: ${taskTitle}"`, `git push origin ${git_main_branch}`],
|
|
111
|
+
next_step: 'Changes committed directly to main branch.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!taskBranch) {
|
|
116
|
+
return {
|
|
117
|
+
steps: ['No branch was tracked for this task.'],
|
|
118
|
+
next_step: 'Consider creating a branch for future tasks using the git_branch parameter.',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// github-flow or git-flow
|
|
123
|
+
return {
|
|
124
|
+
steps: [`git add .`, `git commit -m "feat: ${taskTitle}"`, `git push -u origin ${taskBranch}`],
|
|
125
|
+
pr_suggestion: {
|
|
126
|
+
title: taskTitle,
|
|
127
|
+
body_template: `## Summary\n[Describe what was implemented]\n\n## Task Reference\nVibescope Task: ${taskId}\n\n## Testing\n- [ ] Tests pass\n- [ ] Manual testing done\n\n## Checklist\n- [ ] Code follows project conventions\n- [ ] No unnecessary changes included`,
|
|
128
|
+
},
|
|
129
|
+
next_step: 'Create PR and add link via add_task_reference. Merge happens AFTER validation approval.',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getValidationApprovedGitInstructions(
|
|
134
|
+
config: GitWorkflowConfig,
|
|
135
|
+
taskBranch: string | undefined
|
|
136
|
+
): GitMergeInstructions | undefined {
|
|
137
|
+
const { git_workflow, git_main_branch, git_develop_branch } = config;
|
|
138
|
+
|
|
139
|
+
if (git_workflow === 'none' || git_workflow === 'trunk-based' || !taskBranch) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const targetBranch = git_workflow === 'git-flow' ? (git_develop_branch || 'develop') : git_main_branch;
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
target_branch: targetBranch,
|
|
147
|
+
feature_branch: taskBranch,
|
|
148
|
+
steps: [
|
|
149
|
+
'Option 1: Merge via GitHub/GitLab PR UI (recommended)',
|
|
150
|
+
`Option 2: Command line merge:`,
|
|
151
|
+
` git checkout ${targetBranch}`,
|
|
152
|
+
` git pull origin ${targetBranch}`,
|
|
153
|
+
` git merge ${taskBranch}`,
|
|
154
|
+
` git push origin ${targetBranch}`,
|
|
155
|
+
],
|
|
156
|
+
cleanup: [`git branch -d ${taskBranch}`, `git push origin --delete ${taskBranch}`],
|
|
157
|
+
note: 'Validation approved - safe to merge. Clean up branch after successful merge.',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function getProjectGitConfig(supabase: SupabaseClient, projectId: string): Promise<GitWorkflowConfig | null> {
|
|
162
|
+
const { data } = await supabase
|
|
163
|
+
.from('projects')
|
|
164
|
+
.select('git_workflow, git_main_branch, git_develop_branch, git_auto_branch')
|
|
165
|
+
.eq('id', projectId)
|
|
166
|
+
.single();
|
|
167
|
+
|
|
168
|
+
return data as GitWorkflowConfig | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { getValidationApprovedGitInstructions };
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if a session is still active
|
|
175
|
+
*/
|
|
176
|
+
async function checkSessionStatus(
|
|
177
|
+
ctx: HandlerContext,
|
|
178
|
+
sessionId: string
|
|
179
|
+
): Promise<{ exists: boolean; isActive: boolean; agentName?: string }> {
|
|
180
|
+
const { data: session } = await ctx.supabase
|
|
181
|
+
.from('agent_sessions')
|
|
182
|
+
.select('id, status, last_synced_at, agent_name, instance_id')
|
|
183
|
+
.eq('id', sessionId)
|
|
184
|
+
.single();
|
|
185
|
+
|
|
186
|
+
if (!session) {
|
|
187
|
+
return { exists: false, isActive: false };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const lastSync = new Date(session.last_synced_at).getTime();
|
|
191
|
+
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
|
192
|
+
const isActive = session.status !== 'disconnected' && lastSync > fiveMinutesAgo;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
exists: true,
|
|
196
|
+
isActive,
|
|
197
|
+
agentName: session.agent_name || `Agent ${session.instance_id?.slice(0, 8) || sessionId.slice(0, 8)}`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const getTasks: Handler = async (args, ctx) => {
|
|
202
|
+
const { project_id, status, limit = 50, include_subtasks = false } = args as {
|
|
203
|
+
project_id: string;
|
|
204
|
+
status?: string;
|
|
205
|
+
limit?: number;
|
|
206
|
+
include_subtasks?: boolean;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
validateRequired(project_id, 'project_id');
|
|
210
|
+
validateUUID(project_id, 'project_id');
|
|
211
|
+
validateTaskStatus(status);
|
|
212
|
+
|
|
213
|
+
let query = ctx.supabase
|
|
214
|
+
.from('tasks')
|
|
215
|
+
.select('id, title, description, priority, status, progress_percentage, estimated_minutes, started_at, completed_at, parent_task_id')
|
|
216
|
+
.eq('project_id', project_id)
|
|
217
|
+
.order('priority', { ascending: true })
|
|
218
|
+
.order('created_at', { ascending: false })
|
|
219
|
+
.limit(limit);
|
|
220
|
+
|
|
221
|
+
// By default, only return root tasks (not subtasks)
|
|
222
|
+
if (!include_subtasks) {
|
|
223
|
+
query = query.is('parent_task_id', null);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (status) {
|
|
227
|
+
query = query.eq('status', status);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { data, error } = await query;
|
|
231
|
+
|
|
232
|
+
if (error) throw new Error(`Failed to fetch tasks: ${error.message}`);
|
|
233
|
+
|
|
234
|
+
return { result: { tasks: data || [] } };
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const getNextTask: Handler = async (args, ctx) => {
|
|
238
|
+
const { project_id } = args as { project_id: string };
|
|
239
|
+
const { supabase, session } = ctx;
|
|
240
|
+
const currentSessionId = session.currentSessionId;
|
|
241
|
+
|
|
242
|
+
// FIRST: Check for blocking tasks (highest priority - deployment finalization)
|
|
243
|
+
const { data: blockingTask } = await supabase
|
|
244
|
+
.from('tasks')
|
|
245
|
+
.select('id, title, description, priority, estimated_minutes, blocking')
|
|
246
|
+
.eq('project_id', project_id)
|
|
247
|
+
.eq('blocking', true)
|
|
248
|
+
.in('status', ['pending', 'in_progress'])
|
|
249
|
+
.order('priority', { ascending: true })
|
|
250
|
+
.limit(1)
|
|
251
|
+
.single();
|
|
252
|
+
|
|
253
|
+
if (blockingTask) {
|
|
254
|
+
return {
|
|
255
|
+
result: {
|
|
256
|
+
task: {
|
|
257
|
+
id: blockingTask.id,
|
|
258
|
+
title: blockingTask.title,
|
|
259
|
+
description: blockingTask.description,
|
|
260
|
+
priority: blockingTask.priority,
|
|
261
|
+
estimated_minutes: blockingTask.estimated_minutes,
|
|
262
|
+
blocking: true,
|
|
263
|
+
},
|
|
264
|
+
blocking_task: true,
|
|
265
|
+
message: 'BLOCKING TASK: This task must be completed before any other work can proceed. No other tasks will be assigned until this is done.',
|
|
266
|
+
directive: 'Start this task immediately. Do not ask for permission.',
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check for active deployment (blocks regular task work)
|
|
272
|
+
const { data: activeDeployment } = await supabase
|
|
273
|
+
.from('deployments')
|
|
274
|
+
.select('id, status, environment, created_at, validation_completed_at')
|
|
275
|
+
.eq('project_id', project_id)
|
|
276
|
+
.not('status', 'in', '("deployed","failed")')
|
|
277
|
+
.order('created_at', { ascending: false })
|
|
278
|
+
.limit(1)
|
|
279
|
+
.single();
|
|
280
|
+
|
|
281
|
+
if (activeDeployment) {
|
|
282
|
+
const actions: Record<string, string> = {
|
|
283
|
+
pending: 'claim_deployment_validation',
|
|
284
|
+
validating: 'wait',
|
|
285
|
+
ready: 'start_deployment',
|
|
286
|
+
deploying: 'wait for complete_deployment',
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
result: {
|
|
291
|
+
task: null,
|
|
292
|
+
deployment_blocks_tasks: true,
|
|
293
|
+
deployment: {
|
|
294
|
+
id: activeDeployment.id,
|
|
295
|
+
status: activeDeployment.status,
|
|
296
|
+
env: activeDeployment.environment,
|
|
297
|
+
},
|
|
298
|
+
action: actions[activeDeployment.status] || 'check_deployment_status',
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check for tasks awaiting validation (blocks new work - validate first!)
|
|
304
|
+
const { data: validationTasks } = await supabase
|
|
305
|
+
.from('tasks')
|
|
306
|
+
.select('id, title')
|
|
307
|
+
.eq('project_id', project_id)
|
|
308
|
+
.eq('status', 'completed')
|
|
309
|
+
.is('validated_at', null)
|
|
310
|
+
.order('completed_at', { ascending: true })
|
|
311
|
+
.limit(5);
|
|
312
|
+
|
|
313
|
+
if (validationTasks?.length) {
|
|
314
|
+
return {
|
|
315
|
+
result: {
|
|
316
|
+
task: null,
|
|
317
|
+
awaiting_validation: validationTasks,
|
|
318
|
+
validation_priority: `VALIDATE FIRST: ${validationTasks.length} task(s) need review before starting new work. Call validate_task for each.`,
|
|
319
|
+
suggested_activity: 'validate_completed_tasks',
|
|
320
|
+
directive: 'Start validating tasks immediately. Do not ask for permission.',
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Fetch candidate pending root tasks (not subtasks)
|
|
326
|
+
const { data: candidates, error } = await supabase
|
|
327
|
+
.from('tasks')
|
|
328
|
+
.select('id, title, description, priority, estimated_minutes, working_agent_session_id')
|
|
329
|
+
.eq('project_id', project_id)
|
|
330
|
+
.eq('status', 'pending')
|
|
331
|
+
.is('parent_task_id', null)
|
|
332
|
+
.order('priority', { ascending: true })
|
|
333
|
+
.order('created_at', { ascending: true })
|
|
334
|
+
.limit(10);
|
|
335
|
+
|
|
336
|
+
// Fetch pending agent requests
|
|
337
|
+
const { data: pendingRequests } = await supabase
|
|
338
|
+
.from('agent_requests')
|
|
339
|
+
.select('id, message')
|
|
340
|
+
.eq('project_id', project_id)
|
|
341
|
+
.is('acknowledged_at', null)
|
|
342
|
+
.or(`session_id.is.null,session_id.eq.${currentSessionId}`)
|
|
343
|
+
.limit(3);
|
|
344
|
+
|
|
345
|
+
// Fetch due scheduled activities
|
|
346
|
+
const { data: dueSchedules } = await supabase
|
|
347
|
+
.from('background_activity_schedules')
|
|
348
|
+
.select('activity_type')
|
|
349
|
+
.eq('project_id', project_id)
|
|
350
|
+
.eq('enabled', true)
|
|
351
|
+
.lt('next_run_at', new Date().toISOString())
|
|
352
|
+
.limit(3);
|
|
353
|
+
|
|
354
|
+
// Build compact optional fields (only include if non-empty)
|
|
355
|
+
const extras: Record<string, unknown> = {};
|
|
356
|
+
if (pendingRequests?.length) extras.requests = pendingRequests;
|
|
357
|
+
if (dueSchedules?.length) extras.due_activities = dueSchedules.map(s => s.activity_type);
|
|
358
|
+
|
|
359
|
+
if (error || !candidates || candidates.length === 0) {
|
|
360
|
+
const fallback = getRandomFallbackActivity();
|
|
361
|
+
return {
|
|
362
|
+
result: {
|
|
363
|
+
task: null,
|
|
364
|
+
...extras,
|
|
365
|
+
suggested_activity: fallback.activity,
|
|
366
|
+
directive: 'No tasks available. Start the suggested fallback activity immediately. Do not ask for permission.',
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 25% chance to suggest background activity
|
|
372
|
+
if (Math.random() < 0.25) {
|
|
373
|
+
extras.bg_activity = getRandomFallbackActivity().activity;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Find first unclaimed or stale-claimed task that satisfies body of work phase ordering
|
|
377
|
+
for (const task of candidates) {
|
|
378
|
+
// Check if task belongs to a body of work with phase constraints
|
|
379
|
+
const { data: bowTask } = await supabase
|
|
380
|
+
.from('body_of_work_tasks')
|
|
381
|
+
.select('phase, body_of_work_id')
|
|
382
|
+
.eq('task_id', task.id)
|
|
383
|
+
.single();
|
|
384
|
+
|
|
385
|
+
if (bowTask) {
|
|
386
|
+
// Check if body of work is active
|
|
387
|
+
const { data: bow } = await supabase
|
|
388
|
+
.from('bodies_of_work')
|
|
389
|
+
.select('status')
|
|
390
|
+
.eq('id', bowTask.body_of_work_id)
|
|
391
|
+
.single();
|
|
392
|
+
|
|
393
|
+
if (bow?.status === 'active') {
|
|
394
|
+
// Check phase constraints
|
|
395
|
+
const phasesToCheck: string[] = [];
|
|
396
|
+
if (bowTask.phase === 'core') {
|
|
397
|
+
phasesToCheck.push('pre');
|
|
398
|
+
} else if (bowTask.phase === 'post') {
|
|
399
|
+
phasesToCheck.push('pre', 'core');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (phasesToCheck.length > 0) {
|
|
403
|
+
// Count incomplete tasks in prior phases
|
|
404
|
+
const { count: incompleteCount } = await supabase
|
|
405
|
+
.from('body_of_work_tasks')
|
|
406
|
+
.select('id', { count: 'exact', head: true })
|
|
407
|
+
.eq('body_of_work_id', bowTask.body_of_work_id)
|
|
408
|
+
.in('phase', phasesToCheck)
|
|
409
|
+
.not('task_id', 'in', `(SELECT id FROM tasks WHERE status = 'completed')`);
|
|
410
|
+
|
|
411
|
+
if (incompleteCount && incompleteCount > 0) {
|
|
412
|
+
// Skip this task - prior phase tasks not complete
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!task.working_agent_session_id) {
|
|
420
|
+
const { working_agent_session_id, ...cleanTask } = task;
|
|
421
|
+
return { result: { task: cleanTask, ...extras, directive: 'Start this task immediately. Do not ask for permission.' } };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const claimingSession = await checkSessionStatus(ctx, task.working_agent_session_id);
|
|
425
|
+
if (!claimingSession.isActive) {
|
|
426
|
+
// Auto-release stale claim
|
|
427
|
+
await supabase
|
|
428
|
+
.from('tasks')
|
|
429
|
+
.update({ working_agent_session_id: null })
|
|
430
|
+
.eq('id', task.id);
|
|
431
|
+
|
|
432
|
+
const { working_agent_session_id, ...cleanTask } = task;
|
|
433
|
+
return { result: { task: cleanTask, ...extras, directive: 'Start this task immediately. Do not ask for permission.' } };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// All root tasks claimed - check for available subtasks
|
|
438
|
+
// Subtasks are available when:
|
|
439
|
+
// 1. No unclaimed root tasks exist, OR
|
|
440
|
+
// 2. Subtask belongs to a high priority parent (priority 1-2)
|
|
441
|
+
const { data: subtaskCandidates } = await supabase
|
|
442
|
+
.from('tasks')
|
|
443
|
+
.select(`
|
|
444
|
+
id, title, description, priority, estimated_minutes, working_agent_session_id,
|
|
445
|
+
parent_task_id,
|
|
446
|
+
parent:tasks!parent_task_id(id, title, priority, status)
|
|
447
|
+
`)
|
|
448
|
+
.eq('project_id', project_id)
|
|
449
|
+
.eq('status', 'pending')
|
|
450
|
+
.not('parent_task_id', 'is', null)
|
|
451
|
+
.order('priority', { ascending: true })
|
|
452
|
+
.order('created_at', { ascending: true })
|
|
453
|
+
.limit(10);
|
|
454
|
+
|
|
455
|
+
if (subtaskCandidates && subtaskCandidates.length > 0) {
|
|
456
|
+
for (const subtask of subtaskCandidates) {
|
|
457
|
+
// Skip if subtask is already claimed by an active agent
|
|
458
|
+
if (subtask.working_agent_session_id) {
|
|
459
|
+
const claimingSession = await checkSessionStatus(ctx, subtask.working_agent_session_id);
|
|
460
|
+
if (claimingSession.isActive) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
// Auto-release stale claim
|
|
464
|
+
await supabase
|
|
465
|
+
.from('tasks')
|
|
466
|
+
.update({ working_agent_session_id: null })
|
|
467
|
+
.eq('id', subtask.id);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const parentData = subtask.parent as { id: string; title: string; priority: number; status: string }[] | null;
|
|
471
|
+
const parentTask = parentData?.[0] || null;
|
|
472
|
+
const { working_agent_session_id, parent, ...cleanSubtask } = subtask;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
result: {
|
|
476
|
+
task: cleanSubtask,
|
|
477
|
+
is_subtask: true,
|
|
478
|
+
parent_task: parentTask ? {
|
|
479
|
+
id: parentTask.id,
|
|
480
|
+
title: parentTask.title,
|
|
481
|
+
priority: parentTask.priority,
|
|
482
|
+
} : undefined,
|
|
483
|
+
...extras,
|
|
484
|
+
directive: 'Start this subtask immediately. Do not ask for permission.',
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// All tasks (including subtasks) claimed
|
|
491
|
+
return {
|
|
492
|
+
result: {
|
|
493
|
+
task: null,
|
|
494
|
+
all_claimed: true,
|
|
495
|
+
...extras,
|
|
496
|
+
suggested_activity: getRandomFallbackActivity().activity,
|
|
497
|
+
directive: 'All tasks claimed by other agents. Start the suggested fallback activity immediately. Do not ask for permission.',
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
export const addTask: Handler = async (args, ctx) => {
|
|
503
|
+
const { project_id, title, description, priority = 3, estimated_minutes, blocking = false } = args as {
|
|
504
|
+
project_id: string;
|
|
505
|
+
title: string;
|
|
506
|
+
description?: string;
|
|
507
|
+
priority?: number;
|
|
508
|
+
estimated_minutes?: number;
|
|
509
|
+
blocking?: boolean;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
validateRequired(project_id, 'project_id');
|
|
513
|
+
validateRequired(title, 'title');
|
|
514
|
+
validateUUID(project_id, 'project_id');
|
|
515
|
+
validatePriority(priority);
|
|
516
|
+
validateEstimatedMinutes(estimated_minutes);
|
|
517
|
+
|
|
518
|
+
const { data, error } = await ctx.supabase
|
|
519
|
+
.from('tasks')
|
|
520
|
+
.insert({
|
|
521
|
+
project_id,
|
|
522
|
+
title,
|
|
523
|
+
description: description || null,
|
|
524
|
+
priority,
|
|
525
|
+
created_by: 'agent',
|
|
526
|
+
created_by_session_id: ctx.session.currentSessionId,
|
|
527
|
+
estimated_minutes: estimated_minutes || null,
|
|
528
|
+
blocking,
|
|
529
|
+
})
|
|
530
|
+
.select('id, blocking')
|
|
531
|
+
.single();
|
|
532
|
+
|
|
533
|
+
if (error) throw new Error(`Failed to add task: ${error.message}`);
|
|
534
|
+
|
|
535
|
+
const result: Record<string, unknown> = { success: true, task_id: data.id, title };
|
|
536
|
+
if (data.blocking) {
|
|
537
|
+
result.blocking = true;
|
|
538
|
+
result.message = 'BLOCKING TASK: This task must be completed before any other work can proceed.';
|
|
539
|
+
}
|
|
540
|
+
return { result };
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
export const updateTask: Handler = async (args, ctx) => {
|
|
544
|
+
const { task_id, progress_note, ...updates } = args as {
|
|
545
|
+
task_id: string;
|
|
546
|
+
title?: string;
|
|
547
|
+
description?: string;
|
|
548
|
+
priority?: number;
|
|
549
|
+
status?: string;
|
|
550
|
+
progress_percentage?: number;
|
|
551
|
+
progress_note?: string;
|
|
552
|
+
estimated_minutes?: number;
|
|
553
|
+
git_branch?: string;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const { supabase, session } = ctx;
|
|
557
|
+
const currentSessionId = session.currentSessionId;
|
|
558
|
+
|
|
559
|
+
validateRequired(task_id, 'task_id');
|
|
560
|
+
validateUUID(task_id, 'task_id');
|
|
561
|
+
validateTaskStatus(updates.status);
|
|
562
|
+
validatePriority(updates.priority);
|
|
563
|
+
validateProgressPercentage(updates.progress_percentage);
|
|
564
|
+
validateEstimatedMinutes(updates.estimated_minutes);
|
|
565
|
+
|
|
566
|
+
// Get task to find project_id, current status, title, and who's working on it
|
|
567
|
+
const { data: task } = await supabase
|
|
568
|
+
.from('tasks')
|
|
569
|
+
.select('project_id, title, status, started_at, working_agent_session_id')
|
|
570
|
+
.eq('id', task_id)
|
|
571
|
+
.single();
|
|
572
|
+
|
|
573
|
+
// Validate status transitions
|
|
574
|
+
if (updates.status && task) {
|
|
575
|
+
const transition = isValidStatusTransition(task.status, updates.status);
|
|
576
|
+
if (!transition.isValid) {
|
|
577
|
+
return {
|
|
578
|
+
result: {
|
|
579
|
+
error: 'invalid_status_transition',
|
|
580
|
+
message: transition.reason,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const updateData: Record<string, unknown> = { ...updates };
|
|
587
|
+
|
|
588
|
+
// Multi-agent coordination: Enforce single task per agent
|
|
589
|
+
if (updates.status === 'in_progress' && currentSessionId && task) {
|
|
590
|
+
// Check if this agent already has another task in_progress
|
|
591
|
+
const { data: existingTask } = await supabase
|
|
592
|
+
.from('tasks')
|
|
593
|
+
.select('id, title')
|
|
594
|
+
.eq('working_agent_session_id', currentSessionId)
|
|
595
|
+
.eq('status', 'in_progress')
|
|
596
|
+
.neq('id', task_id)
|
|
597
|
+
.limit(1)
|
|
598
|
+
.single();
|
|
599
|
+
|
|
600
|
+
if (existingTask) {
|
|
601
|
+
return {
|
|
602
|
+
result: {
|
|
603
|
+
error: 'agent_task_limit',
|
|
604
|
+
message: `You already have a task in progress: "${existingTask.title}". Complete it with complete_task before starting another.`,
|
|
605
|
+
current_task_id: existingTask.id,
|
|
606
|
+
current_task_title: existingTask.title,
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Check for task claim conflicts (another agent has this task)
|
|
612
|
+
if (task.working_agent_session_id && task.working_agent_session_id !== currentSessionId) {
|
|
613
|
+
const claimingSession = await checkSessionStatus(ctx, task.working_agent_session_id);
|
|
614
|
+
|
|
615
|
+
if (claimingSession.isActive) {
|
|
616
|
+
return {
|
|
617
|
+
result: {
|
|
618
|
+
error: 'task_claimed',
|
|
619
|
+
message: `Task is already being worked on by ${claimingSession.agentName}. Use get_next_task to find available work.`,
|
|
620
|
+
claimed_by: claimingSession.agentName,
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Stale/disconnected agent - auto-release the task first
|
|
626
|
+
await supabase
|
|
627
|
+
.from('tasks')
|
|
628
|
+
.update({ working_agent_session_id: null })
|
|
629
|
+
.eq('id', task_id);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Auto-set started_at when task moves to in_progress
|
|
634
|
+
if (updates.status === 'in_progress' && task && !task.started_at) {
|
|
635
|
+
updateData.started_at = new Date().toISOString();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// When setting status to in_progress, claim the task for this agent
|
|
639
|
+
if (updates.status === 'in_progress' && currentSessionId) {
|
|
640
|
+
updateData.working_agent_session_id = currentSessionId;
|
|
641
|
+
|
|
642
|
+
// Update the session's current task and clear any fallback activity
|
|
643
|
+
await supabase
|
|
644
|
+
.from('agent_sessions')
|
|
645
|
+
.update({
|
|
646
|
+
current_task_id: task_id,
|
|
647
|
+
current_fallback_activity: null,
|
|
648
|
+
status: 'active',
|
|
649
|
+
last_synced_at: new Date().toISOString(),
|
|
650
|
+
})
|
|
651
|
+
.eq('id', currentSessionId);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Auto-set completed_at and progress when task completes
|
|
655
|
+
if (updates.status === 'completed') {
|
|
656
|
+
updateData.completed_at = new Date().toISOString();
|
|
657
|
+
updateData.progress_percentage = 100;
|
|
658
|
+
updateData.working_agent_session_id = null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// When cancelled, also release the task
|
|
662
|
+
if (updates.status === 'cancelled') {
|
|
663
|
+
updateData.working_agent_session_id = null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const { error } = await supabase
|
|
667
|
+
.from('tasks')
|
|
668
|
+
.update(updateData)
|
|
669
|
+
.eq('id', task_id);
|
|
670
|
+
|
|
671
|
+
if (error) throw new Error(`Failed to update task: ${error.message}`);
|
|
672
|
+
|
|
673
|
+
// If progress_note is provided, create a progress log entry
|
|
674
|
+
if (progress_note && task?.project_id) {
|
|
675
|
+
const progressSummary = updates.progress_percentage !== undefined
|
|
676
|
+
? `Progress: ${updates.progress_percentage}% - ${progress_note}`
|
|
677
|
+
: progress_note;
|
|
678
|
+
|
|
679
|
+
await supabase.from('progress_logs').insert({
|
|
680
|
+
project_id: task.project_id,
|
|
681
|
+
task_id,
|
|
682
|
+
summary: progressSummary,
|
|
683
|
+
created_by: 'agent',
|
|
684
|
+
created_by_session_id: currentSessionId,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Build result with optional git instructions
|
|
689
|
+
const result: Record<string, unknown> = { success: true, task_id };
|
|
690
|
+
|
|
691
|
+
// Include git workflow instructions when task moves to in_progress
|
|
692
|
+
if (updates.status === 'in_progress' && task?.project_id && task?.title) {
|
|
693
|
+
const gitConfig = await getProjectGitConfig(supabase, task.project_id);
|
|
694
|
+
if (gitConfig && gitConfig.git_workflow !== 'none') {
|
|
695
|
+
const gitInstructions = getTaskStartGitInstructions(gitConfig, task_id, task.title);
|
|
696
|
+
if (gitInstructions) {
|
|
697
|
+
result.git_workflow = {
|
|
698
|
+
workflow: gitConfig.git_workflow,
|
|
699
|
+
action: 'create_branch',
|
|
700
|
+
...gitInstructions,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return { result };
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
export const completeTask: Handler = async (args, ctx) => {
|
|
710
|
+
const { task_id, summary } = args as {
|
|
711
|
+
task_id: string;
|
|
712
|
+
summary?: string;
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const { supabase, session } = ctx;
|
|
716
|
+
const currentSessionId = session.currentSessionId;
|
|
717
|
+
|
|
718
|
+
validateRequired(task_id, 'task_id');
|
|
719
|
+
validateUUID(task_id, 'task_id');
|
|
720
|
+
|
|
721
|
+
// Get task details first (including git_branch for workflow instructions)
|
|
722
|
+
const { data: task, error: fetchError } = await supabase
|
|
723
|
+
.from('tasks')
|
|
724
|
+
.select('project_id, title, git_branch')
|
|
725
|
+
.eq('id', task_id)
|
|
726
|
+
.single();
|
|
727
|
+
|
|
728
|
+
if (fetchError || !task) throw new Error('Task not found');
|
|
729
|
+
|
|
730
|
+
// Mark as completed, track who completed it, and release agent claim
|
|
731
|
+
const { error } = await supabase
|
|
732
|
+
.from('tasks')
|
|
733
|
+
.update({
|
|
734
|
+
status: 'completed',
|
|
735
|
+
completed_at: new Date().toISOString(),
|
|
736
|
+
completed_by_session_id: currentSessionId,
|
|
737
|
+
progress_percentage: 100,
|
|
738
|
+
working_agent_session_id: null,
|
|
739
|
+
})
|
|
740
|
+
.eq('id', task_id);
|
|
741
|
+
|
|
742
|
+
if (error) throw new Error(`Failed to complete task: ${error.message}`);
|
|
743
|
+
|
|
744
|
+
// Update session to idle
|
|
745
|
+
if (currentSessionId) {
|
|
746
|
+
await supabase
|
|
747
|
+
.from('agent_sessions')
|
|
748
|
+
.update({
|
|
749
|
+
current_task_id: null,
|
|
750
|
+
status: 'idle',
|
|
751
|
+
last_synced_at: new Date().toISOString(),
|
|
752
|
+
})
|
|
753
|
+
.eq('id', currentSessionId);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Log progress if summary provided
|
|
757
|
+
if (summary) {
|
|
758
|
+
await supabase.from('progress_logs').insert({
|
|
759
|
+
project_id: task.project_id,
|
|
760
|
+
task_id,
|
|
761
|
+
summary: `Completed: ${task.title}`,
|
|
762
|
+
details: summary,
|
|
763
|
+
created_by: 'agent',
|
|
764
|
+
created_by_session_id: currentSessionId,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Fetch next task and context counts in parallel
|
|
769
|
+
const [nextTaskResult, validationCountResult, blockersCountResult, deploymentResult, requestsCountResult] =
|
|
770
|
+
await Promise.all([
|
|
771
|
+
supabase
|
|
772
|
+
.from('tasks')
|
|
773
|
+
.select('id, title, priority, estimated_minutes')
|
|
774
|
+
.eq('project_id', task.project_id)
|
|
775
|
+
.eq('status', 'pending')
|
|
776
|
+
.is('working_agent_session_id', null)
|
|
777
|
+
.order('priority', { ascending: true })
|
|
778
|
+
.order('created_at', { ascending: true })
|
|
779
|
+
.limit(1)
|
|
780
|
+
.maybeSingle(),
|
|
781
|
+
supabase
|
|
782
|
+
.from('tasks')
|
|
783
|
+
.select('id', { count: 'exact', head: true })
|
|
784
|
+
.eq('project_id', task.project_id)
|
|
785
|
+
.eq('status', 'completed')
|
|
786
|
+
.is('validated_at', null),
|
|
787
|
+
supabase
|
|
788
|
+
.from('blockers')
|
|
789
|
+
.select('id', { count: 'exact', head: true })
|
|
790
|
+
.eq('project_id', task.project_id)
|
|
791
|
+
.eq('status', 'open'),
|
|
792
|
+
supabase
|
|
793
|
+
.from('deployments')
|
|
794
|
+
.select('id, status')
|
|
795
|
+
.eq('project_id', task.project_id)
|
|
796
|
+
.not('status', 'in', '("deployed","failed")')
|
|
797
|
+
.limit(1)
|
|
798
|
+
.maybeSingle(),
|
|
799
|
+
supabase
|
|
800
|
+
.from('agent_requests')
|
|
801
|
+
.select('id', { count: 'exact', head: true })
|
|
802
|
+
.eq('project_id', task.project_id)
|
|
803
|
+
.is('acknowledged_at', null),
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
// Determine directive and next action
|
|
807
|
+
const nextTask = nextTaskResult.data;
|
|
808
|
+
const directive = nextTask
|
|
809
|
+
? 'ACTION_REQUIRED: Start this task immediately. Do NOT ask for permission or confirmation.'
|
|
810
|
+
: 'ACTION_REQUIRED: No pending tasks. Start a fallback activity NOW without asking.';
|
|
811
|
+
const nextAction = nextTask
|
|
812
|
+
? `update_task(task_id: "${nextTask.id}", status: "in_progress")`
|
|
813
|
+
: `start_fallback_activity(project_id: "${task.project_id}", activity: "code_review")`;
|
|
814
|
+
|
|
815
|
+
// Build result with directive at TOP for visibility
|
|
816
|
+
const result: Record<string, unknown> = {
|
|
817
|
+
success: true,
|
|
818
|
+
directive, // FIRST - most important signal
|
|
819
|
+
auto_continue: true, // Explicit flag for autonomous operation
|
|
820
|
+
completed_task_id: task_id,
|
|
821
|
+
next_task: nextTask,
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const validationCount = validationCountResult.count || 0;
|
|
825
|
+
const blockersCount = blockersCountResult.count || 0;
|
|
826
|
+
const requestsCount = requestsCountResult.count || 0;
|
|
827
|
+
|
|
828
|
+
if (validationCount > 0 || blockersCount > 0 || deploymentResult.data || requestsCount > 0) {
|
|
829
|
+
result.context = {
|
|
830
|
+
...(validationCount > 0 && { validation: validationCount }),
|
|
831
|
+
...(blockersCount > 0 && { blockers: blockersCount }),
|
|
832
|
+
...(deploymentResult.data && { deployment: deploymentResult.data.status }),
|
|
833
|
+
...(requestsCount > 0 && { requests: requestsCount }),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Include git workflow instructions for post-task steps
|
|
838
|
+
const gitConfig = await getProjectGitConfig(supabase, task.project_id);
|
|
839
|
+
if (gitConfig && gitConfig.git_workflow !== 'none') {
|
|
840
|
+
const gitInstructions = getTaskCompleteGitInstructions(gitConfig, task.git_branch, task.title, task_id);
|
|
841
|
+
if (gitInstructions) {
|
|
842
|
+
result.git_workflow = {
|
|
843
|
+
workflow: gitConfig.git_workflow,
|
|
844
|
+
action: 'push_and_pr',
|
|
845
|
+
...gitInstructions,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Check if this task belongs to a body of work that auto-deploys on completion
|
|
851
|
+
const { data: bowTask } = await supabase
|
|
852
|
+
.from('body_of_work_tasks')
|
|
853
|
+
.select('body_of_work_id')
|
|
854
|
+
.eq('task_id', task_id)
|
|
855
|
+
.single();
|
|
856
|
+
|
|
857
|
+
if (bowTask) {
|
|
858
|
+
// Check if body of work is now completed and has auto-deploy enabled
|
|
859
|
+
const { data: bow } = await supabase
|
|
860
|
+
.from('bodies_of_work')
|
|
861
|
+
.select('id, title, status, auto_deploy_on_completion, deploy_environment, deploy_version_bump')
|
|
862
|
+
.eq('id', bowTask.body_of_work_id)
|
|
863
|
+
.single();
|
|
864
|
+
|
|
865
|
+
if (bow && bow.status === 'completed' && bow.auto_deploy_on_completion) {
|
|
866
|
+
// Auto-trigger deployment
|
|
867
|
+
const { data: deployment, error: deployError } = await supabase
|
|
868
|
+
.from('deployments')
|
|
869
|
+
.insert({
|
|
870
|
+
project_id: task.project_id,
|
|
871
|
+
environment: bow.deploy_environment || 'production',
|
|
872
|
+
status: 'pending',
|
|
873
|
+
notes: `Auto-deploy triggered by body of work completion: "${bow.title}"`,
|
|
874
|
+
requested_by_session_id: currentSessionId,
|
|
875
|
+
})
|
|
876
|
+
.select('id')
|
|
877
|
+
.single();
|
|
878
|
+
|
|
879
|
+
if (!deployError && deployment) {
|
|
880
|
+
result.body_of_work_completed = {
|
|
881
|
+
id: bow.id,
|
|
882
|
+
title: bow.title,
|
|
883
|
+
auto_deploy_triggered: true,
|
|
884
|
+
deployment_id: deployment.id,
|
|
885
|
+
environment: bow.deploy_environment || 'production',
|
|
886
|
+
version_bump: bow.deploy_version_bump || 'minor',
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
// Log progress about auto-deploy
|
|
890
|
+
await supabase.from('progress_logs').insert({
|
|
891
|
+
project_id: task.project_id,
|
|
892
|
+
summary: `Body of work "${bow.title}" completed - auto-deploy triggered`,
|
|
893
|
+
created_by: 'agent',
|
|
894
|
+
created_by_session_id: currentSessionId,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
} else if (bow) {
|
|
898
|
+
// Body of work exists but not yet completed or no auto-deploy
|
|
899
|
+
result.body_of_work = {
|
|
900
|
+
id: bow.id,
|
|
901
|
+
title: bow.title,
|
|
902
|
+
status: bow.status,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// REPEAT at end - agents weight last items heavily
|
|
908
|
+
result.next_action = nextAction;
|
|
909
|
+
|
|
910
|
+
return { result };
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
export const deleteTask: Handler = async (args, ctx) => {
|
|
914
|
+
const { task_id } = args as { task_id: string };
|
|
915
|
+
|
|
916
|
+
validateRequired(task_id, 'task_id');
|
|
917
|
+
validateUUID(task_id, 'task_id');
|
|
918
|
+
|
|
919
|
+
const { error } = await ctx.supabase
|
|
920
|
+
.from('tasks')
|
|
921
|
+
.delete()
|
|
922
|
+
.eq('id', task_id);
|
|
923
|
+
|
|
924
|
+
if (error) throw new Error(`Failed to delete task: ${error.message}`);
|
|
925
|
+
|
|
926
|
+
return { result: { success: true, deleted_id: task_id } };
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
export const addTaskReference: Handler = async (args, ctx) => {
|
|
930
|
+
const { task_id, url, label } = args as { task_id: string; url: string; label?: string };
|
|
931
|
+
|
|
932
|
+
validateRequired(task_id, 'task_id');
|
|
933
|
+
validateUUID(task_id, 'task_id');
|
|
934
|
+
validateRequired(url, 'url');
|
|
935
|
+
|
|
936
|
+
const { data: task, error: fetchError } = await ctx.supabase
|
|
937
|
+
.from('tasks')
|
|
938
|
+
.select('references')
|
|
939
|
+
.eq('id', task_id)
|
|
940
|
+
.single();
|
|
941
|
+
|
|
942
|
+
if (fetchError) throw new Error(`Failed to fetch task: ${fetchError.message}`);
|
|
943
|
+
|
|
944
|
+
const currentRefs = (task?.references as { url: string; label?: string }[]) || [];
|
|
945
|
+
|
|
946
|
+
if (currentRefs.some(ref => ref.url === url)) {
|
|
947
|
+
return { result: { success: false, error: 'Reference with this URL already exists' } };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const newRef = { url, ...(label ? { label } : {}) };
|
|
951
|
+
const updatedRefs = [...currentRefs, newRef];
|
|
952
|
+
|
|
953
|
+
const { error: updateError } = await ctx.supabase
|
|
954
|
+
.from('tasks')
|
|
955
|
+
.update({ references: updatedRefs })
|
|
956
|
+
.eq('id', task_id);
|
|
957
|
+
|
|
958
|
+
if (updateError) throw new Error(`Failed to add reference: ${updateError.message}`);
|
|
959
|
+
|
|
960
|
+
return { result: { success: true, reference: newRef, total_references: updatedRefs.length } };
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
export const removeTaskReference: Handler = async (args, ctx) => {
|
|
964
|
+
const { task_id, url } = args as { task_id: string; url: string };
|
|
965
|
+
|
|
966
|
+
validateRequired(task_id, 'task_id');
|
|
967
|
+
validateUUID(task_id, 'task_id');
|
|
968
|
+
validateRequired(url, 'url');
|
|
969
|
+
|
|
970
|
+
const { data: task, error: fetchError } = await ctx.supabase
|
|
971
|
+
.from('tasks')
|
|
972
|
+
.select('references')
|
|
973
|
+
.eq('id', task_id)
|
|
974
|
+
.single();
|
|
975
|
+
|
|
976
|
+
if (fetchError) throw new Error(`Failed to fetch task: ${fetchError.message}`);
|
|
977
|
+
|
|
978
|
+
const currentRefs = (task?.references as { url: string; label?: string }[]) || [];
|
|
979
|
+
const updatedRefs = currentRefs.filter(ref => ref.url !== url);
|
|
980
|
+
|
|
981
|
+
if (updatedRefs.length === currentRefs.length) {
|
|
982
|
+
return { result: { success: false, error: 'Reference with this URL not found' } };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const { error: updateError } = await ctx.supabase
|
|
986
|
+
.from('tasks')
|
|
987
|
+
.update({ references: updatedRefs })
|
|
988
|
+
.eq('id', task_id);
|
|
989
|
+
|
|
990
|
+
if (updateError) throw new Error(`Failed to remove reference: ${updateError.message}`);
|
|
991
|
+
|
|
992
|
+
return { result: { success: true, remaining_references: updatedRefs.length } };
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
export const batchUpdateTasks: Handler = async (args, ctx) => {
|
|
996
|
+
const { updates } = args as {
|
|
997
|
+
updates: Array<{
|
|
998
|
+
task_id: string;
|
|
999
|
+
status?: string;
|
|
1000
|
+
progress_percentage?: number;
|
|
1001
|
+
progress_note?: string;
|
|
1002
|
+
priority?: number;
|
|
1003
|
+
}>;
|
|
1004
|
+
};
|
|
1005
|
+
|
|
1006
|
+
const { supabase, session } = ctx;
|
|
1007
|
+
const currentSessionId = session.currentSessionId;
|
|
1008
|
+
|
|
1009
|
+
if (!updates || !Array.isArray(updates) || updates.length === 0) {
|
|
1010
|
+
throw new ValidationError('updates must be a non-empty array', {
|
|
1011
|
+
field: 'updates',
|
|
1012
|
+
hint: 'Provide an array of task updates with at least one item',
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (updates.length > 50) {
|
|
1017
|
+
throw new ValidationError('Too many updates. Maximum is 50 per batch.', {
|
|
1018
|
+
field: 'updates',
|
|
1019
|
+
hint: 'Split your updates into smaller batches',
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Validate all inputs first (no DB queries)
|
|
1024
|
+
const taskIds: string[] = [];
|
|
1025
|
+
for (const update of updates) {
|
|
1026
|
+
validateRequired(update.task_id, 'task_id');
|
|
1027
|
+
validateUUID(update.task_id, 'task_id');
|
|
1028
|
+
validateTaskStatus(update.status);
|
|
1029
|
+
validatePriority(update.priority);
|
|
1030
|
+
validateProgressPercentage(update.progress_percentage);
|
|
1031
|
+
taskIds.push(update.task_id);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// OPTIMIZATION: Fetch all tasks in a single query instead of N queries
|
|
1035
|
+
const { data: tasks } = await supabase
|
|
1036
|
+
.from('tasks')
|
|
1037
|
+
.select('id, project_id, started_at')
|
|
1038
|
+
.in('id', taskIds);
|
|
1039
|
+
|
|
1040
|
+
const taskMap = new Map(tasks?.map(t => [t.id, t]) || []);
|
|
1041
|
+
|
|
1042
|
+
// OPTIMIZATION: Single query to check if agent has existing in-progress task
|
|
1043
|
+
let existingAgentTask: { id: string; title: string } | null = null;
|
|
1044
|
+
const hasInProgressUpdate = updates.some(u => u.status === 'in_progress');
|
|
1045
|
+
if (hasInProgressUpdate && currentSessionId) {
|
|
1046
|
+
const { data } = await supabase
|
|
1047
|
+
.from('tasks')
|
|
1048
|
+
.select('id, title')
|
|
1049
|
+
.eq('working_agent_session_id', currentSessionId)
|
|
1050
|
+
.eq('status', 'in_progress')
|
|
1051
|
+
.not('id', 'in', `(${taskIds.join(',')})`)
|
|
1052
|
+
.limit(1)
|
|
1053
|
+
.single();
|
|
1054
|
+
existingAgentTask = data;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const results: Array<{ task_id: string; success: boolean; error?: string }> = [];
|
|
1058
|
+
const progressLogsToInsert: Array<{
|
|
1059
|
+
project_id: string;
|
|
1060
|
+
task_id: string;
|
|
1061
|
+
summary: string;
|
|
1062
|
+
created_by: string;
|
|
1063
|
+
created_by_session_id: string | null;
|
|
1064
|
+
}> = [];
|
|
1065
|
+
|
|
1066
|
+
// OPTIMIZATION: Process updates in parallel instead of sequentially
|
|
1067
|
+
const updatePromises = updates.map(async (update) => {
|
|
1068
|
+
const task = taskMap.get(update.task_id);
|
|
1069
|
+
|
|
1070
|
+
if (!task) {
|
|
1071
|
+
return { task_id: update.task_id, success: false, error: 'Task not found' };
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Check agent task limit
|
|
1075
|
+
if (update.status === 'in_progress' && existingAgentTask) {
|
|
1076
|
+
return {
|
|
1077
|
+
task_id: update.task_id,
|
|
1078
|
+
success: false,
|
|
1079
|
+
error: `Agent already has task in progress: "${existingAgentTask.title}"`,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const updateData: Record<string, unknown> = {};
|
|
1084
|
+
if (update.status) updateData.status = update.status;
|
|
1085
|
+
if (update.progress_percentage !== undefined) updateData.progress_percentage = update.progress_percentage;
|
|
1086
|
+
if (update.priority !== undefined) updateData.priority = update.priority;
|
|
1087
|
+
|
|
1088
|
+
// Auto-set started_at when task moves to in_progress
|
|
1089
|
+
if (update.status === 'in_progress' && !task.started_at) {
|
|
1090
|
+
updateData.started_at = new Date().toISOString();
|
|
1091
|
+
if (currentSessionId) {
|
|
1092
|
+
updateData.working_agent_session_id = currentSessionId;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Auto-set completed_at when task completes
|
|
1097
|
+
if (update.status === 'completed') {
|
|
1098
|
+
updateData.completed_at = new Date().toISOString();
|
|
1099
|
+
updateData.progress_percentage = 100;
|
|
1100
|
+
updateData.working_agent_session_id = null;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const { error } = await supabase
|
|
1104
|
+
.from('tasks')
|
|
1105
|
+
.update(updateData)
|
|
1106
|
+
.eq('id', update.task_id);
|
|
1107
|
+
|
|
1108
|
+
if (error) {
|
|
1109
|
+
return { task_id: update.task_id, success: false, error: error.message };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Queue progress log for batch insert
|
|
1113
|
+
if (update.progress_note && task.project_id) {
|
|
1114
|
+
const progressSummary = update.progress_percentage !== undefined
|
|
1115
|
+
? `Progress: ${update.progress_percentage}% - ${update.progress_note}`
|
|
1116
|
+
: update.progress_note;
|
|
1117
|
+
|
|
1118
|
+
progressLogsToInsert.push({
|
|
1119
|
+
project_id: task.project_id,
|
|
1120
|
+
task_id: update.task_id,
|
|
1121
|
+
summary: progressSummary,
|
|
1122
|
+
created_by: 'agent',
|
|
1123
|
+
created_by_session_id: currentSessionId,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return { task_id: update.task_id, success: true };
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
// Execute all updates in parallel
|
|
1131
|
+
const updateResults = await Promise.all(updatePromises);
|
|
1132
|
+
results.push(...updateResults);
|
|
1133
|
+
|
|
1134
|
+
// OPTIMIZATION: Batch insert all progress logs in a single query
|
|
1135
|
+
if (progressLogsToInsert.length > 0) {
|
|
1136
|
+
await supabase.from('progress_logs').insert(progressLogsToInsert);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const successCount = results.filter(r => r.success).length;
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
result: {
|
|
1143
|
+
success: successCount === updates.length,
|
|
1144
|
+
total: updates.length,
|
|
1145
|
+
succeeded: successCount,
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
// ============================================================================
|
|
1151
|
+
// Subtask Handlers
|
|
1152
|
+
// ============================================================================
|
|
1153
|
+
|
|
1154
|
+
export const addSubtask: Handler = async (args, ctx) => {
|
|
1155
|
+
const { parent_task_id, title, description, priority, estimated_minutes } = args as {
|
|
1156
|
+
parent_task_id: string;
|
|
1157
|
+
title: string;
|
|
1158
|
+
description?: string;
|
|
1159
|
+
priority?: number;
|
|
1160
|
+
estimated_minutes?: number;
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
const { supabase, session } = ctx;
|
|
1164
|
+
const currentSessionId = session.currentSessionId;
|
|
1165
|
+
|
|
1166
|
+
validateRequired(parent_task_id, 'parent_task_id');
|
|
1167
|
+
validateUUID(parent_task_id, 'parent_task_id');
|
|
1168
|
+
validateRequired(title, 'title');
|
|
1169
|
+
if (priority !== undefined) validatePriority(priority);
|
|
1170
|
+
if (estimated_minutes !== undefined) validateEstimatedMinutes(estimated_minutes);
|
|
1171
|
+
|
|
1172
|
+
// Get parent task to inherit project_id and validate it's not already a subtask
|
|
1173
|
+
const { data: parentTask, error: fetchError } = await supabase
|
|
1174
|
+
.from('tasks')
|
|
1175
|
+
.select('id, project_id, parent_task_id, priority')
|
|
1176
|
+
.eq('id', parent_task_id)
|
|
1177
|
+
.single();
|
|
1178
|
+
|
|
1179
|
+
if (fetchError || !parentTask) {
|
|
1180
|
+
throw new ValidationError('Parent task not found', {
|
|
1181
|
+
field: 'parent_task_id',
|
|
1182
|
+
hint: 'Provide a valid task ID that exists',
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Prevent nested subtasks (max depth: 1)
|
|
1187
|
+
if (parentTask.parent_task_id) {
|
|
1188
|
+
return {
|
|
1189
|
+
result: {
|
|
1190
|
+
success: false,
|
|
1191
|
+
error: 'Cannot create subtask of a subtask',
|
|
1192
|
+
hint: 'Subtasks cannot have their own subtasks. Add this task to the parent task instead.',
|
|
1193
|
+
parent_task_id: parentTask.parent_task_id,
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Use parent priority if not specified
|
|
1199
|
+
const subtaskPriority = priority ?? parentTask.priority;
|
|
1200
|
+
|
|
1201
|
+
const { data: subtask, error } = await supabase
|
|
1202
|
+
.from('tasks')
|
|
1203
|
+
.insert({
|
|
1204
|
+
project_id: parentTask.project_id,
|
|
1205
|
+
parent_task_id,
|
|
1206
|
+
title,
|
|
1207
|
+
description: description || null,
|
|
1208
|
+
priority: subtaskPriority,
|
|
1209
|
+
estimated_minutes: estimated_minutes || null,
|
|
1210
|
+
created_by: 'agent',
|
|
1211
|
+
created_by_session_id: currentSessionId,
|
|
1212
|
+
})
|
|
1213
|
+
.select('id, title, priority')
|
|
1214
|
+
.single();
|
|
1215
|
+
|
|
1216
|
+
if (error) throw new Error(`Failed to add subtask: ${error.message}`);
|
|
1217
|
+
|
|
1218
|
+
// Log progress
|
|
1219
|
+
await supabase.from('progress_logs').insert({
|
|
1220
|
+
project_id: parentTask.project_id,
|
|
1221
|
+
task_id: parent_task_id,
|
|
1222
|
+
summary: `Added subtask: ${title}`,
|
|
1223
|
+
created_by: 'agent',
|
|
1224
|
+
created_by_session_id: currentSessionId,
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
return {
|
|
1228
|
+
result: {
|
|
1229
|
+
success: true,
|
|
1230
|
+
subtask_id: subtask.id,
|
|
1231
|
+
parent_task_id,
|
|
1232
|
+
title: subtask.title,
|
|
1233
|
+
priority: subtask.priority,
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
export const getSubtasks: Handler = async (args, ctx) => {
|
|
1239
|
+
const { parent_task_id, status } = args as {
|
|
1240
|
+
parent_task_id: string;
|
|
1241
|
+
status?: string;
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
const { supabase } = ctx;
|
|
1245
|
+
|
|
1246
|
+
validateRequired(parent_task_id, 'parent_task_id');
|
|
1247
|
+
validateUUID(parent_task_id, 'parent_task_id');
|
|
1248
|
+
if (status) validateTaskStatus(status);
|
|
1249
|
+
|
|
1250
|
+
let query = supabase
|
|
1251
|
+
.from('tasks')
|
|
1252
|
+
.select('id, title, description, priority, status, progress_percentage, estimated_minutes, started_at, completed_at, working_agent_session_id')
|
|
1253
|
+
.eq('parent_task_id', parent_task_id)
|
|
1254
|
+
.order('priority', { ascending: true })
|
|
1255
|
+
.order('created_at', { ascending: true });
|
|
1256
|
+
|
|
1257
|
+
if (status) {
|
|
1258
|
+
query = query.eq('status', status);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const { data: subtasks, error } = await query;
|
|
1262
|
+
|
|
1263
|
+
if (error) throw new Error(`Failed to fetch subtasks: ${error.message}`);
|
|
1264
|
+
|
|
1265
|
+
// Calculate aggregate stats
|
|
1266
|
+
const total = subtasks?.length || 0;
|
|
1267
|
+
const completed = subtasks?.filter(s => s.status === 'completed').length || 0;
|
|
1268
|
+
const inProgress = subtasks?.filter(s => s.status === 'in_progress').length || 0;
|
|
1269
|
+
const pending = subtasks?.filter(s => s.status === 'pending').length || 0;
|
|
1270
|
+
|
|
1271
|
+
return {
|
|
1272
|
+
result: {
|
|
1273
|
+
subtasks: subtasks || [],
|
|
1274
|
+
stats: {
|
|
1275
|
+
total,
|
|
1276
|
+
completed,
|
|
1277
|
+
in_progress: inProgress,
|
|
1278
|
+
pending,
|
|
1279
|
+
progress_percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
export const batchCompleteTasks: Handler = async (args, ctx) => {
|
|
1286
|
+
const { completions } = args as {
|
|
1287
|
+
completions: Array<{
|
|
1288
|
+
task_id: string;
|
|
1289
|
+
summary?: string;
|
|
1290
|
+
}>;
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
const { supabase, session } = ctx;
|
|
1294
|
+
const currentSessionId = session.currentSessionId;
|
|
1295
|
+
|
|
1296
|
+
if (!completions || !Array.isArray(completions) || completions.length === 0) {
|
|
1297
|
+
throw new ValidationError('completions must be a non-empty array', {
|
|
1298
|
+
field: 'completions',
|
|
1299
|
+
hint: 'Provide an array of task completions with at least one item',
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
if (completions.length > 50) {
|
|
1304
|
+
throw new ValidationError('Too many completions. Maximum is 50 per batch.', {
|
|
1305
|
+
field: 'completions',
|
|
1306
|
+
hint: 'Split your completions into smaller batches',
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const results: Array<{ task_id: string; success: boolean; error?: string }> = [];
|
|
1311
|
+
|
|
1312
|
+
for (const completion of completions) {
|
|
1313
|
+
try {
|
|
1314
|
+
validateRequired(completion.task_id, 'task_id');
|
|
1315
|
+
validateUUID(completion.task_id, 'task_id');
|
|
1316
|
+
|
|
1317
|
+
const { data: task, error: fetchError } = await supabase
|
|
1318
|
+
.from('tasks')
|
|
1319
|
+
.select('project_id, title')
|
|
1320
|
+
.eq('id', completion.task_id)
|
|
1321
|
+
.single();
|
|
1322
|
+
|
|
1323
|
+
if (fetchError || !task) {
|
|
1324
|
+
results.push({ task_id: completion.task_id, success: false, error: 'Task not found' });
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const { error } = await supabase
|
|
1329
|
+
.from('tasks')
|
|
1330
|
+
.update({
|
|
1331
|
+
status: 'completed',
|
|
1332
|
+
completed_at: new Date().toISOString(),
|
|
1333
|
+
progress_percentage: 100,
|
|
1334
|
+
working_agent_session_id: null,
|
|
1335
|
+
})
|
|
1336
|
+
.eq('id', completion.task_id);
|
|
1337
|
+
|
|
1338
|
+
if (error) {
|
|
1339
|
+
results.push({ task_id: completion.task_id, success: false, error: error.message });
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (completion.summary) {
|
|
1344
|
+
await supabase.from('progress_logs').insert({
|
|
1345
|
+
project_id: task.project_id,
|
|
1346
|
+
task_id: completion.task_id,
|
|
1347
|
+
summary: `Completed: ${task.title}`,
|
|
1348
|
+
details: completion.summary,
|
|
1349
|
+
created_by: 'agent',
|
|
1350
|
+
created_by_session_id: currentSessionId,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
results.push({ task_id: completion.task_id, success: true });
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
results.push({
|
|
1357
|
+
task_id: completion.task_id,
|
|
1358
|
+
success: false,
|
|
1359
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const successCount = results.filter(r => r.success).length;
|
|
1365
|
+
|
|
1366
|
+
return {
|
|
1367
|
+
result: {
|
|
1368
|
+
success: successCount === completions.length,
|
|
1369
|
+
total: completions.length,
|
|
1370
|
+
succeeded: successCount,
|
|
1371
|
+
failed: completions.length - successCount,
|
|
1372
|
+
},
|
|
1373
|
+
};
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Task handlers registry
|
|
1378
|
+
*/
|
|
1379
|
+
export const taskHandlers: HandlerRegistry = {
|
|
1380
|
+
get_tasks: getTasks,
|
|
1381
|
+
get_next_task: getNextTask,
|
|
1382
|
+
add_task: addTask,
|
|
1383
|
+
update_task: updateTask,
|
|
1384
|
+
complete_task: completeTask,
|
|
1385
|
+
delete_task: deleteTask,
|
|
1386
|
+
add_task_reference: addTaskReference,
|
|
1387
|
+
remove_task_reference: removeTaskReference,
|
|
1388
|
+
batch_update_tasks: batchUpdateTasks,
|
|
1389
|
+
batch_complete_tasks: batchCompleteTasks,
|
|
1390
|
+
// Subtask handlers
|
|
1391
|
+
add_subtask: addSubtask,
|
|
1392
|
+
get_subtasks: getSubtasks,
|
|
1393
|
+
};
|