@allpepper/task-orchestrator 0.1.0

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.
Files changed (42) hide show
  1. package/README.md +15 -0
  2. package/package.json +51 -0
  3. package/src/db/client.ts +34 -0
  4. package/src/db/index.ts +1 -0
  5. package/src/db/migrate.ts +51 -0
  6. package/src/db/migrations/001_initial_schema.sql +160 -0
  7. package/src/domain/index.ts +1 -0
  8. package/src/domain/types.ts +225 -0
  9. package/src/index.ts +7 -0
  10. package/src/repos/base.ts +151 -0
  11. package/src/repos/dependencies.ts +356 -0
  12. package/src/repos/features.ts +507 -0
  13. package/src/repos/index.ts +4 -0
  14. package/src/repos/projects.ts +350 -0
  15. package/src/repos/sections.ts +505 -0
  16. package/src/repos/tags.example.ts +125 -0
  17. package/src/repos/tags.ts +175 -0
  18. package/src/repos/tasks.ts +581 -0
  19. package/src/repos/templates.ts +649 -0
  20. package/src/server.ts +121 -0
  21. package/src/services/index.ts +2 -0
  22. package/src/services/status-validator.ts +100 -0
  23. package/src/services/workflow.ts +104 -0
  24. package/src/tools/apply-template.ts +129 -0
  25. package/src/tools/get-blocked-tasks.ts +63 -0
  26. package/src/tools/get-next-status.ts +183 -0
  27. package/src/tools/get-next-task.ts +75 -0
  28. package/src/tools/get-tag-usage.ts +54 -0
  29. package/src/tools/index.ts +30 -0
  30. package/src/tools/list-tags.ts +56 -0
  31. package/src/tools/manage-container.ts +333 -0
  32. package/src/tools/manage-dependency.ts +198 -0
  33. package/src/tools/manage-sections.ts +388 -0
  34. package/src/tools/manage-template.ts +313 -0
  35. package/src/tools/query-container.ts +296 -0
  36. package/src/tools/query-dependencies.ts +68 -0
  37. package/src/tools/query-sections.ts +70 -0
  38. package/src/tools/query-templates.ts +137 -0
  39. package/src/tools/query-workflow-state.ts +198 -0
  40. package/src/tools/registry.ts +180 -0
  41. package/src/tools/rename-tag.ts +64 -0
  42. package/src/tools/setup-project.ts +189 -0
@@ -0,0 +1,581 @@
1
+ import {
2
+ db,
3
+ generateId,
4
+ now,
5
+ queryOne,
6
+ queryAll,
7
+ execute,
8
+ ok,
9
+ err,
10
+ buildSearchVector,
11
+ loadTags,
12
+ saveTags,
13
+ deleteTags,
14
+ buildPaginationClause,
15
+ } from './base';
16
+ import type { Result, Task } from '../domain/types';
17
+ import { TaskStatus, Priority, LockStatus, EntityType, ValidationError } from '../domain/types';
18
+ import { isValidTransition, getAllowedTransitions, isTerminalStatus } from '../services/status-validator';
19
+
20
+ // ============================================================================
21
+ // Row Mapping
22
+ // ============================================================================
23
+
24
+ interface TaskRow {
25
+ id: string;
26
+ project_id: string | null;
27
+ feature_id: string | null;
28
+ title: string;
29
+ summary: string;
30
+ description: string | null;
31
+ status: string;
32
+ priority: string;
33
+ complexity: number;
34
+ version: number;
35
+ last_modified_by: string | null;
36
+ lock_status: string;
37
+ created_at: string;
38
+ modified_at: string;
39
+ search_vector: string | null;
40
+ }
41
+
42
+ function rowToTask(row: TaskRow): Task {
43
+ return {
44
+ id: row.id,
45
+ projectId: row.project_id ?? undefined,
46
+ featureId: row.feature_id ?? undefined,
47
+ title: row.title,
48
+ summary: row.summary,
49
+ description: row.description ?? undefined,
50
+ status: row.status as TaskStatus,
51
+ priority: row.priority as Priority,
52
+ complexity: row.complexity,
53
+ version: row.version,
54
+ lastModifiedBy: row.last_modified_by ?? undefined,
55
+ lockStatus: row.lock_status as LockStatus,
56
+ createdAt: new Date(row.created_at),
57
+ modifiedAt: new Date(row.modified_at),
58
+ searchVector: row.search_vector ?? undefined,
59
+ tags: loadTags(row.id, EntityType.TASK),
60
+ };
61
+ }
62
+
63
+ // ============================================================================
64
+ // Validation
65
+ // ============================================================================
66
+
67
+ function validateComplexity(complexity: number): boolean {
68
+ return Number.isInteger(complexity) && complexity >= 1 && complexity <= 10;
69
+ }
70
+
71
+ // ============================================================================
72
+ // Repository Functions
73
+ // ============================================================================
74
+
75
+ export function createTask(params: {
76
+ projectId?: string;
77
+ featureId?: string;
78
+ title: string;
79
+ summary: string;
80
+ description?: string;
81
+ status?: TaskStatus;
82
+ priority: Priority;
83
+ complexity: number;
84
+ tags?: string[];
85
+ }): Result<Task> {
86
+ try {
87
+ // Validate complexity
88
+ if (!validateComplexity(params.complexity)) {
89
+ return err('Complexity must be an integer between 1 and 10', 'VALIDATION_ERROR');
90
+ }
91
+
92
+ // Validate required fields
93
+ if (!params.title?.trim()) {
94
+ return err('Title is required', 'VALIDATION_ERROR');
95
+ }
96
+ if (!params.summary?.trim()) {
97
+ return err('Summary is required', 'VALIDATION_ERROR');
98
+ }
99
+
100
+ const id = generateId();
101
+ const timestamp = now();
102
+ const status = params.status ?? TaskStatus.PENDING;
103
+ const searchVector = buildSearchVector(params.title, params.summary, params.description);
104
+
105
+ db.run('BEGIN TRANSACTION');
106
+
107
+ try {
108
+ execute(
109
+ `INSERT INTO tasks (
110
+ id, project_id, feature_id, title, summary, description,
111
+ status, priority, complexity, version, last_modified_by,
112
+ lock_status, created_at, modified_at, search_vector
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
114
+ [
115
+ id,
116
+ params.projectId ?? null,
117
+ params.featureId ?? null,
118
+ params.title.trim(),
119
+ params.summary.trim(),
120
+ params.description?.trim() ?? null,
121
+ status,
122
+ params.priority,
123
+ params.complexity,
124
+ 1, // version
125
+ null, // last_modified_by
126
+ LockStatus.UNLOCKED,
127
+ timestamp,
128
+ timestamp,
129
+ searchVector,
130
+ ]
131
+ );
132
+
133
+ // Save tags if provided
134
+ if (params.tags && params.tags.length > 0) {
135
+ saveTags(id, EntityType.TASK, params.tags);
136
+ }
137
+
138
+ db.run('COMMIT');
139
+
140
+ const row = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
141
+ if (!row) {
142
+ return err('Failed to create task', 'INTERNAL_ERROR');
143
+ }
144
+
145
+ return ok(rowToTask(row));
146
+ } catch (error) {
147
+ db.run('ROLLBACK');
148
+ throw error;
149
+ }
150
+ } catch (error) {
151
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
152
+ }
153
+ }
154
+
155
+ export function getTask(id: string): Result<Task> {
156
+ try {
157
+ const row = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
158
+
159
+ if (!row) {
160
+ return err(`Task not found: ${id}`, 'NOT_FOUND');
161
+ }
162
+
163
+ return ok(rowToTask(row));
164
+ } catch (error) {
165
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
166
+ }
167
+ }
168
+
169
+ export function updateTask(
170
+ id: string,
171
+ params: {
172
+ title?: string;
173
+ summary?: string;
174
+ description?: string;
175
+ status?: TaskStatus;
176
+ priority?: Priority;
177
+ complexity?: number;
178
+ projectId?: string;
179
+ featureId?: string;
180
+ lastModifiedBy?: string;
181
+ tags?: string[];
182
+ version: number;
183
+ }
184
+ ): Result<Task> {
185
+ try {
186
+ // Validate complexity if provided
187
+ if (params.complexity !== undefined && !validateComplexity(params.complexity)) {
188
+ return err('Complexity must be an integer between 1 and 10', 'VALIDATION_ERROR');
189
+ }
190
+
191
+ // Check if task exists and version matches (optimistic locking)
192
+ const existing = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
193
+ if (!existing) {
194
+ return err(`Task not found: ${id}`, 'NOT_FOUND');
195
+ }
196
+
197
+ if (existing.version !== params.version) {
198
+ return err(
199
+ `Version conflict: expected ${params.version}, got ${existing.version}`,
200
+ 'CONFLICT'
201
+ );
202
+ }
203
+
204
+ db.run('BEGIN TRANSACTION');
205
+
206
+ try {
207
+ // Validate status transition if status is being changed
208
+ if (params.status !== undefined && params.status !== existing.status) {
209
+ const currentStatus = existing.status;
210
+
211
+ // Check if current status is terminal
212
+ if (isTerminalStatus('task', currentStatus)) {
213
+ db.run('ROLLBACK');
214
+ return err(
215
+ `Invalid status transition: no transitions are allowed from terminal status '${currentStatus}'`,
216
+ 'VALIDATION_ERROR'
217
+ );
218
+ }
219
+
220
+ // Check if the transition is valid
221
+ if (!isValidTransition('task', currentStatus, params.status)) {
222
+ const allowed = getAllowedTransitions('task', currentStatus);
223
+ db.run('ROLLBACK');
224
+ return err(
225
+ `Invalid status transition from '${currentStatus}' to '${params.status}'. Allowed transitions: ${allowed.join(', ')}`,
226
+ 'VALIDATION_ERROR'
227
+ );
228
+ }
229
+ }
230
+
231
+ const updates: string[] = [];
232
+ const values: any[] = [];
233
+
234
+ if (params.title !== undefined) {
235
+ if (!params.title.trim()) {
236
+ db.run('ROLLBACK');
237
+ return err('Title cannot be empty', 'VALIDATION_ERROR');
238
+ }
239
+ updates.push('title = ?');
240
+ values.push(params.title.trim());
241
+ }
242
+
243
+ if (params.summary !== undefined) {
244
+ if (!params.summary.trim()) {
245
+ db.run('ROLLBACK');
246
+ return err('Summary cannot be empty', 'VALIDATION_ERROR');
247
+ }
248
+ updates.push('summary = ?');
249
+ values.push(params.summary.trim());
250
+ }
251
+
252
+ if (params.description !== undefined) {
253
+ updates.push('description = ?');
254
+ values.push(params.description?.trim() ?? null);
255
+ }
256
+
257
+ if (params.status !== undefined) {
258
+ updates.push('status = ?');
259
+ values.push(params.status);
260
+ }
261
+
262
+ if (params.priority !== undefined) {
263
+ updates.push('priority = ?');
264
+ values.push(params.priority);
265
+ }
266
+
267
+ if (params.complexity !== undefined) {
268
+ updates.push('complexity = ?');
269
+ values.push(params.complexity);
270
+ }
271
+
272
+ if (params.projectId !== undefined) {
273
+ updates.push('project_id = ?');
274
+ values.push(params.projectId ?? null);
275
+ }
276
+
277
+ if (params.featureId !== undefined) {
278
+ updates.push('feature_id = ?');
279
+ values.push(params.featureId ?? null);
280
+ }
281
+
282
+ if (params.lastModifiedBy !== undefined) {
283
+ updates.push('last_modified_by = ?');
284
+ values.push(params.lastModifiedBy ?? null);
285
+ }
286
+
287
+ // Update search vector if any searchable field changed
288
+ if (params.title !== undefined || params.summary !== undefined || params.description !== undefined) {
289
+ const title = params.title ?? existing.title;
290
+ const summary = params.summary ?? existing.summary;
291
+ const description = params.description !== undefined ? params.description : existing.description;
292
+ updates.push('search_vector = ?');
293
+ values.push(buildSearchVector(title, summary, description));
294
+ }
295
+
296
+ // Always update version and modified_at
297
+ updates.push('version = version + 1');
298
+ updates.push('modified_at = ?');
299
+ values.push(now());
300
+
301
+ // Add id to WHERE clause
302
+ values.push(id);
303
+ values.push(params.version);
304
+
305
+ const sql = `UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND version = ?`;
306
+ const changes = execute(sql, values);
307
+
308
+ if (changes === 0) {
309
+ db.run('ROLLBACK');
310
+ return err('Update failed: version conflict', 'CONFLICT');
311
+ }
312
+
313
+ // Update tags if provided
314
+ if (params.tags !== undefined) {
315
+ saveTags(id, EntityType.TASK, params.tags);
316
+ }
317
+
318
+ db.run('COMMIT');
319
+
320
+ const updated = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
321
+ if (!updated) {
322
+ return err('Failed to retrieve updated task', 'INTERNAL_ERROR');
323
+ }
324
+
325
+ return ok(rowToTask(updated));
326
+ } catch (error) {
327
+ db.run('ROLLBACK');
328
+ throw error;
329
+ }
330
+ } catch (error) {
331
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
332
+ }
333
+ }
334
+
335
+ export function deleteTask(id: string): Result<boolean> {
336
+ try {
337
+ // Check if task exists
338
+ const existing = queryOne<TaskRow>('SELECT id FROM tasks WHERE id = ?', [id]);
339
+ if (!existing) {
340
+ return err(`Task not found: ${id}`, 'NOT_FOUND');
341
+ }
342
+
343
+ db.run('BEGIN TRANSACTION');
344
+
345
+ try {
346
+ // Delete related dependencies
347
+ execute('DELETE FROM dependencies WHERE from_task_id = ? OR to_task_id = ?', [id, id]);
348
+
349
+ // Delete related sections
350
+ execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.TASK, id]);
351
+
352
+ // Delete related tags
353
+ deleteTags(id, EntityType.TASK);
354
+
355
+ // Delete the task
356
+ execute('DELETE FROM tasks WHERE id = ?', [id]);
357
+
358
+ db.run('COMMIT');
359
+
360
+ return ok(true);
361
+ } catch (error) {
362
+ db.run('ROLLBACK');
363
+ throw error;
364
+ }
365
+ } catch (error) {
366
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
367
+ }
368
+ }
369
+
370
+ export function searchTasks(params: {
371
+ query?: string;
372
+ status?: string;
373
+ priority?: string;
374
+ projectId?: string;
375
+ featureId?: string;
376
+ tags?: string;
377
+ limit?: number;
378
+ offset?: number;
379
+ }): Result<Task[]> {
380
+ try {
381
+ const conditions: string[] = [];
382
+ const values: any[] = [];
383
+
384
+ // Text search
385
+ if (params.query?.trim()) {
386
+ conditions.push('search_vector LIKE ?');
387
+ values.push(`%${params.query.toLowerCase()}%`);
388
+ }
389
+
390
+ // Status filter (supports multi-value and negation)
391
+ if (params.status) {
392
+ const statusFilters = params.status.split(',').map(s => s.trim());
393
+ const negated = statusFilters.filter(s => s.startsWith('!'));
394
+ const positive = statusFilters.filter(s => !s.startsWith('!'));
395
+
396
+ if (positive.length > 0) {
397
+ conditions.push(`status IN (${positive.map(() => '?').join(', ')})`);
398
+ values.push(...positive);
399
+ }
400
+
401
+ if (negated.length > 0) {
402
+ const negatedValues = negated.map(s => s.substring(1));
403
+ conditions.push(`status NOT IN (${negatedValues.map(() => '?').join(', ')})`);
404
+ values.push(...negatedValues);
405
+ }
406
+ }
407
+
408
+ // Priority filter (supports multi-value and negation)
409
+ if (params.priority) {
410
+ const priorityFilters = params.priority.split(',').map(p => p.trim());
411
+ const negated = priorityFilters.filter(p => p.startsWith('!'));
412
+ const positive = priorityFilters.filter(p => !p.startsWith('!'));
413
+
414
+ if (positive.length > 0) {
415
+ conditions.push(`priority IN (${positive.map(() => '?').join(', ')})`);
416
+ values.push(...positive);
417
+ }
418
+
419
+ if (negated.length > 0) {
420
+ const negatedValues = negated.map(p => p.substring(1));
421
+ conditions.push(`priority NOT IN (${negatedValues.map(() => '?').join(', ')})`);
422
+ values.push(...negatedValues);
423
+ }
424
+ }
425
+
426
+ // Project filter
427
+ if (params.projectId) {
428
+ conditions.push('project_id = ?');
429
+ values.push(params.projectId);
430
+ }
431
+
432
+ // Feature filter
433
+ if (params.featureId) {
434
+ conditions.push('feature_id = ?');
435
+ values.push(params.featureId);
436
+ }
437
+
438
+ // Tags filter (supports multi-value and negation)
439
+ if (params.tags) {
440
+ const tagFilters = params.tags.split(',').map(t => t.trim().toLowerCase());
441
+ const negated = tagFilters.filter(t => t.startsWith('!'));
442
+ const positive = tagFilters.filter(t => !t.startsWith('!'));
443
+
444
+ if (positive.length > 0) {
445
+ conditions.push(
446
+ `id IN (SELECT entity_id FROM entity_tags WHERE entity_type = '${EntityType.TASK}' AND tag IN (${positive.map(() => '?').join(', ')}))`
447
+ );
448
+ values.push(...positive);
449
+ }
450
+
451
+ if (negated.length > 0) {
452
+ const negatedValues = negated.map(t => t.substring(1));
453
+ conditions.push(
454
+ `id NOT IN (SELECT entity_id FROM entity_tags WHERE entity_type = '${EntityType.TASK}' AND tag IN (${negatedValues.map(() => '?').join(', ')}))`
455
+ );
456
+ values.push(...negatedValues);
457
+ }
458
+ }
459
+
460
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
461
+ const paginationClause = buildPaginationClause({ limit: params.limit, offset: params.offset });
462
+
463
+ const sql = `SELECT * FROM tasks ${whereClause} ORDER BY created_at DESC${paginationClause}`;
464
+ const rows = queryAll<TaskRow>(sql, values);
465
+
466
+ return ok(rows.map(rowToTask));
467
+ } catch (error) {
468
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
469
+ }
470
+ }
471
+
472
+ export function setTaskStatus(id: string, status: TaskStatus, version: number): Result<Task> {
473
+ try {
474
+ // Check if task exists and version matches
475
+ const existing = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
476
+ if (!existing) {
477
+ return err(`Task not found: ${id}`, 'NOT_FOUND');
478
+ }
479
+
480
+ if (existing.version !== version) {
481
+ return err(
482
+ `Version conflict: expected ${version}, got ${existing.version}`,
483
+ 'CONFLICT'
484
+ );
485
+ }
486
+
487
+ // Validate status transition if status is being changed
488
+ if (status !== existing.status) {
489
+ // Check if current status is terminal
490
+ if (isTerminalStatus('task', existing.status)) {
491
+ return err(
492
+ `Cannot transition from terminal status ${existing.status}`,
493
+ 'VALIDATION_ERROR'
494
+ );
495
+ }
496
+
497
+ // Check if the transition is valid
498
+ if (!isValidTransition('task', existing.status, status)) {
499
+ const allowed = getAllowedTransitions('task', existing.status);
500
+ return err(
501
+ `Invalid status transition from ${existing.status} to ${status}. Allowed transitions: ${allowed.join(', ')}`,
502
+ 'VALIDATION_ERROR'
503
+ );
504
+ }
505
+ }
506
+
507
+ const changes = execute(
508
+ 'UPDATE tasks SET status = ?, version = version + 1, modified_at = ? WHERE id = ? AND version = ?',
509
+ [status, now(), id, version]
510
+ );
511
+
512
+ if (changes === 0) {
513
+ return err('Update failed: version conflict', 'CONFLICT');
514
+ }
515
+
516
+ const updated = queryOne<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]);
517
+ if (!updated) {
518
+ return err('Failed to retrieve updated task', 'INTERNAL_ERROR');
519
+ }
520
+
521
+ return ok(rowToTask(updated));
522
+ } catch (error) {
523
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
524
+ }
525
+ }
526
+
527
+ export function bulkUpdateTasks(
528
+ ids: string[],
529
+ updates: {
530
+ status?: TaskStatus;
531
+ priority?: Priority;
532
+ }
533
+ ): Result<number> {
534
+ try {
535
+ if (ids.length === 0) {
536
+ return ok(0);
537
+ }
538
+
539
+ if (!updates.status && !updates.priority) {
540
+ return err('At least one update field (status or priority) must be provided', 'VALIDATION_ERROR');
541
+ }
542
+
543
+ db.run('BEGIN TRANSACTION');
544
+
545
+ try {
546
+ const updateFields: string[] = [];
547
+ const values: any[] = [];
548
+
549
+ if (updates.status !== undefined) {
550
+ updateFields.push('status = ?');
551
+ values.push(updates.status);
552
+ }
553
+
554
+ if (updates.priority !== undefined) {
555
+ updateFields.push('priority = ?');
556
+ values.push(updates.priority);
557
+ }
558
+
559
+ // Always update version and modified_at
560
+ updateFields.push('version = version + 1');
561
+ updateFields.push('modified_at = ?');
562
+ values.push(now());
563
+
564
+ // Add ids to WHERE clause
565
+ const placeholders = ids.map(() => '?').join(', ');
566
+ values.push(...ids);
567
+
568
+ const sql = `UPDATE tasks SET ${updateFields.join(', ')} WHERE id IN (${placeholders})`;
569
+ const changes = execute(sql, values);
570
+
571
+ db.run('COMMIT');
572
+
573
+ return ok(changes);
574
+ } catch (error) {
575
+ db.run('ROLLBACK');
576
+ throw error;
577
+ }
578
+ } catch (error) {
579
+ return err(error instanceof Error ? error.message : 'Unknown error', 'INTERNAL_ERROR');
580
+ }
581
+ }