@allpepper/task-orchestrator 1.1.3 → 1.2.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/package.json +1 -1
- package/src/db/migrate.ts +1 -1
- package/src/db/migrations/002_generalize_dependencies.sql +27 -0
- package/src/domain/types.ts +8 -2
- package/src/repos/dependencies.ts +275 -147
- package/src/repos/features.ts +4 -1
- package/src/repos/projects.ts +3 -2
- package/src/repos/tasks.ts +2 -2
- package/src/server.ts +6 -0
- package/src/services/status-validator.ts +37 -35
- package/src/services/workflow.ts +35 -8
- package/src/tools/get-blocked-features.ts +63 -0
- package/src/tools/get-blocked-tasks.ts +4 -2
- package/src/tools/get-next-feature.ts +75 -0
- package/src/tools/get-next-status.ts +1 -47
- package/src/tools/get-next-task.ts +4 -2
- package/src/tools/index.ts +2 -0
- package/src/tools/manage-dependency.ts +33 -13
- package/src/tools/query-dependencies.ts +7 -3
- package/src/tools/query-workflow-state.ts +54 -129
- package/src/tools/registry.ts +3 -0
package/package.json
CHANGED
package/src/db/migrate.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface Migration {
|
|
|
19
19
|
|
|
20
20
|
function loadMigrations(): Migration[] {
|
|
21
21
|
const migrationsDir = join(dirname(import.meta.path), 'migrations');
|
|
22
|
-
const files = ['001_initial_schema.sql'];
|
|
22
|
+
const files = ['001_initial_schema.sql', '002_generalize_dependencies.sql'];
|
|
23
23
|
|
|
24
24
|
return files.map((file, i) => ({
|
|
25
25
|
version: i + 1,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Migration 002: Generalize dependencies table for polymorphic entity references
|
|
2
|
+
-- Renames task-specific columns to entity-generic ones and adds entity_type discriminator
|
|
3
|
+
|
|
4
|
+
-- Step 1: Create new table with generalized schema
|
|
5
|
+
CREATE TABLE IF NOT EXISTS dependencies_new (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
from_entity_id TEXT NOT NULL,
|
|
8
|
+
to_entity_id TEXT NOT NULL,
|
|
9
|
+
entity_type TEXT NOT NULL DEFAULT 'task' CHECK (entity_type IN ('task', 'feature')),
|
|
10
|
+
type VARCHAR(20) NOT NULL CHECK (type IN ('BLOCKS', 'IS_BLOCKED_BY', 'RELATES_TO')),
|
|
11
|
+
created_at TEXT NOT NULL
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Step 2: Copy existing data, mapping old columns to new and defaulting entity_type to 'task'
|
|
15
|
+
INSERT INTO dependencies_new (id, from_entity_id, to_entity_id, entity_type, type, created_at)
|
|
16
|
+
SELECT id, from_task_id, to_task_id, 'task', type, created_at
|
|
17
|
+
FROM dependencies;
|
|
18
|
+
|
|
19
|
+
-- Step 3: Drop old table and rename new one
|
|
20
|
+
DROP TABLE IF EXISTS dependencies;
|
|
21
|
+
ALTER TABLE dependencies_new RENAME TO dependencies;
|
|
22
|
+
|
|
23
|
+
-- Step 4: Recreate indexes with new column names
|
|
24
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_dependencies_unique ON dependencies(from_entity_id, to_entity_id, type, entity_type);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_dependencies_from_entity_id ON dependencies(from_entity_id);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_dependencies_to_entity_id ON dependencies(to_entity_id);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_dependencies_entity_type ON dependencies(entity_type);
|
package/src/domain/types.ts
CHANGED
|
@@ -76,6 +76,11 @@ export enum DependencyType {
|
|
|
76
76
|
RELATES_TO = 'RELATES_TO'
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export enum DependencyEntityType {
|
|
80
|
+
TASK = 'task',
|
|
81
|
+
FEATURE = 'feature'
|
|
82
|
+
}
|
|
83
|
+
|
|
79
84
|
export enum LockStatus {
|
|
80
85
|
UNLOCKED = 'UNLOCKED',
|
|
81
86
|
LOCKED_EXCLUSIVE = 'LOCKED_EXCLUSIVE',
|
|
@@ -177,8 +182,9 @@ export interface TemplateSection {
|
|
|
177
182
|
|
|
178
183
|
export interface Dependency {
|
|
179
184
|
id: string;
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
fromEntityId: string;
|
|
186
|
+
toEntityId: string;
|
|
187
|
+
entityType: DependencyEntityType;
|
|
182
188
|
type: DependencyType;
|
|
183
189
|
createdAt: Date;
|
|
184
190
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
err,
|
|
10
10
|
toDate
|
|
11
11
|
} from './base';
|
|
12
|
-
import type { Result, Dependency, DependencyType, Task } from '../domain/types';
|
|
12
|
+
import type { Result, Dependency, DependencyType, DependencyEntityType, Task, Feature } from '../domain/types';
|
|
13
13
|
import { ValidationError, NotFoundError, ConflictError } from '../domain/types';
|
|
14
14
|
|
|
15
15
|
// ============================================================================
|
|
@@ -18,17 +18,17 @@ import { ValidationError, NotFoundError, ConflictError } from '../domain/types';
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Check if adding a dependency would create a circular dependency.
|
|
21
|
-
* Uses BFS to traverse the dependency graph from
|
|
22
|
-
* If we reach
|
|
21
|
+
* Uses BFS to traverse the dependency graph from toEntityId following BLOCKS dependencies.
|
|
22
|
+
* If we reach fromEntityId, it means adding fromEntityId -> toEntityId would create a cycle.
|
|
23
23
|
*/
|
|
24
|
-
function hasCircularDependency(
|
|
24
|
+
function hasCircularDependency(fromEntityId: string, toEntityId: string, entityType: DependencyEntityType): boolean {
|
|
25
25
|
const visited = new Set<string>();
|
|
26
|
-
const queue = [
|
|
26
|
+
const queue = [toEntityId];
|
|
27
27
|
|
|
28
28
|
while (queue.length > 0) {
|
|
29
29
|
const current = queue.shift()!;
|
|
30
30
|
|
|
31
|
-
if (current ===
|
|
31
|
+
if (current === fromEntityId) {
|
|
32
32
|
return true;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -38,14 +38,13 @@ function hasCircularDependency(fromTaskId: string, toTaskId: string): boolean {
|
|
|
38
38
|
|
|
39
39
|
visited.add(current);
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
[current]
|
|
41
|
+
const deps = queryAll<{ to_entity_id: string }>(
|
|
42
|
+
"SELECT to_entity_id FROM dependencies WHERE from_entity_id = ? AND type = 'BLOCKS' AND entity_type = ?",
|
|
43
|
+
[current, entityType]
|
|
45
44
|
);
|
|
46
45
|
|
|
47
46
|
for (const dep of deps) {
|
|
48
|
-
queue.push(dep.
|
|
47
|
+
queue.push(dep.to_entity_id);
|
|
49
48
|
}
|
|
50
49
|
}
|
|
51
50
|
|
|
@@ -60,8 +59,9 @@ function hasCircularDependency(fromTaskId: string, toTaskId: string): boolean {
|
|
|
60
59
|
function mapRowToDependency(row: any): Dependency {
|
|
61
60
|
return {
|
|
62
61
|
id: row.id,
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
fromEntityId: row.from_entity_id,
|
|
63
|
+
toEntityId: row.to_entity_id,
|
|
64
|
+
entityType: row.entity_type as DependencyEntityType,
|
|
65
65
|
type: row.type as DependencyType,
|
|
66
66
|
createdAt: toDate(row.created_at)
|
|
67
67
|
};
|
|
@@ -87,52 +87,83 @@ function mapRowToTask(row: any): Task {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Map database row to Feature domain object */
|
|
91
|
+
function mapRowToFeature(row: any): Feature {
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
projectId: row.project_id ?? undefined,
|
|
95
|
+
name: row.name,
|
|
96
|
+
summary: row.summary,
|
|
97
|
+
description: row.description ?? undefined,
|
|
98
|
+
status: row.status,
|
|
99
|
+
priority: row.priority,
|
|
100
|
+
version: row.version,
|
|
101
|
+
createdAt: toDate(row.created_at),
|
|
102
|
+
modifiedAt: toDate(row.modified_at)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
// ============================================================================
|
|
91
107
|
// Repository Functions
|
|
92
108
|
// ============================================================================
|
|
93
109
|
|
|
94
110
|
/**
|
|
95
|
-
* Create a new dependency between two
|
|
111
|
+
* Create a new dependency between two entities of the same type.
|
|
96
112
|
*
|
|
97
113
|
* Validates:
|
|
98
|
-
* -
|
|
99
|
-
* - Both
|
|
114
|
+
* - fromEntityId != toEntityId (no self-dependency)
|
|
115
|
+
* - Both entities exist
|
|
100
116
|
* - No circular dependencies (if A blocks B, B cannot block A)
|
|
101
117
|
* - No duplicate dependencies
|
|
102
118
|
*/
|
|
103
119
|
export function createDependency(params: {
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
fromEntityId: string;
|
|
121
|
+
toEntityId: string;
|
|
106
122
|
type: DependencyType;
|
|
123
|
+
entityType: DependencyEntityType;
|
|
107
124
|
}): Result<Dependency> {
|
|
108
|
-
|
|
125
|
+
let { fromEntityId, toEntityId, type, entityType } = params;
|
|
126
|
+
|
|
127
|
+
// Normalize IS_BLOCKED_BY → BLOCKS by swapping from/to.
|
|
128
|
+
// This keeps a single canonical form in the DB so all downstream
|
|
129
|
+
// queries only need to handle BLOCKS direction.
|
|
130
|
+
if (type === 'IS_BLOCKED_BY') {
|
|
131
|
+
[fromEntityId, toEntityId] = [toEntityId, fromEntityId];
|
|
132
|
+
type = 'BLOCKS' as DependencyType;
|
|
133
|
+
}
|
|
109
134
|
|
|
110
135
|
// Validate: no self-dependency
|
|
111
|
-
if (
|
|
112
|
-
return err('Cannot create a dependency from
|
|
136
|
+
if (fromEntityId === toEntityId) {
|
|
137
|
+
return err('Cannot create a dependency from an entity to itself', 'SELF_DEPENDENCY');
|
|
113
138
|
}
|
|
114
139
|
|
|
115
|
-
// Validate: both
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
140
|
+
// Validate: both entities exist
|
|
141
|
+
const table = entityType === 'task' ? 'tasks' : 'features';
|
|
142
|
+
const fromEntity = queryOne<{ id: string }>(
|
|
143
|
+
`SELECT id FROM ${table} WHERE id = ?`,
|
|
144
|
+
[fromEntityId]
|
|
119
145
|
);
|
|
120
146
|
|
|
121
|
-
if (!
|
|
122
|
-
return err(
|
|
147
|
+
if (!fromEntity) {
|
|
148
|
+
return err(`${entityType} not found: ${fromEntityId}`, 'NOT_FOUND');
|
|
123
149
|
}
|
|
124
150
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
[
|
|
151
|
+
const toEntity = queryOne<{ id: string }>(
|
|
152
|
+
`SELECT id FROM ${table} WHERE id = ?`,
|
|
153
|
+
[toEntityId]
|
|
128
154
|
);
|
|
129
155
|
|
|
130
|
-
if (!
|
|
131
|
-
return err(
|
|
156
|
+
if (!toEntity) {
|
|
157
|
+
return err(`${entityType} not found: ${toEntityId}`, 'NOT_FOUND');
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
// Validate: no circular dependencies for BLOCKS type
|
|
135
|
-
|
|
161
|
+
// (IS_BLOCKED_BY is already normalized to BLOCKS above, RELATES_TO has no direction)
|
|
162
|
+
const isCircular = type === 'BLOCKS'
|
|
163
|
+
? hasCircularDependency(fromEntityId, toEntityId, entityType)
|
|
164
|
+
: false;
|
|
165
|
+
|
|
166
|
+
if (isCircular) {
|
|
136
167
|
return err(
|
|
137
168
|
'Cannot create dependency: would create a circular dependency',
|
|
138
169
|
'CIRCULAR_DEPENDENCY'
|
|
@@ -141,13 +172,13 @@ export function createDependency(params: {
|
|
|
141
172
|
|
|
142
173
|
// Check for duplicate
|
|
143
174
|
const existing = queryOne<{ id: string }>(
|
|
144
|
-
'SELECT id FROM dependencies WHERE
|
|
145
|
-
[
|
|
175
|
+
'SELECT id FROM dependencies WHERE from_entity_id = ? AND to_entity_id = ? AND type = ? AND entity_type = ?',
|
|
176
|
+
[fromEntityId, toEntityId, type, entityType]
|
|
146
177
|
);
|
|
147
178
|
|
|
148
179
|
if (existing) {
|
|
149
180
|
return err(
|
|
150
|
-
'Dependency already exists between these
|
|
181
|
+
'Dependency already exists between these entities with this type',
|
|
151
182
|
'DUPLICATE_DEPENDENCY'
|
|
152
183
|
);
|
|
153
184
|
}
|
|
@@ -158,14 +189,15 @@ export function createDependency(params: {
|
|
|
158
189
|
|
|
159
190
|
try {
|
|
160
191
|
execute(
|
|
161
|
-
'INSERT INTO dependencies (id,
|
|
162
|
-
[id,
|
|
192
|
+
'INSERT INTO dependencies (id, from_entity_id, to_entity_id, entity_type, type, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
193
|
+
[id, fromEntityId, toEntityId, entityType, type, createdAt]
|
|
163
194
|
);
|
|
164
195
|
|
|
165
196
|
const dependency: Dependency = {
|
|
166
197
|
id,
|
|
167
|
-
|
|
168
|
-
|
|
198
|
+
fromEntityId,
|
|
199
|
+
toEntityId,
|
|
200
|
+
entityType,
|
|
169
201
|
type,
|
|
170
202
|
createdAt: toDate(createdAt)
|
|
171
203
|
};
|
|
@@ -177,33 +209,37 @@ export function createDependency(params: {
|
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
/**
|
|
180
|
-
* Get dependencies for
|
|
212
|
+
* Get dependencies for an entity.
|
|
181
213
|
*
|
|
182
|
-
* @param
|
|
214
|
+
* @param entityId - The entity ID to query
|
|
183
215
|
* @param direction - Filter by direction:
|
|
184
|
-
* - 'dependencies':
|
|
185
|
-
* - 'dependents':
|
|
216
|
+
* - 'dependencies': entities that this entity depends on (from_entity_id = entityId)
|
|
217
|
+
* - 'dependents': entities that depend on this entity (to_entity_id = entityId)
|
|
186
218
|
* - 'both': union of above (default)
|
|
219
|
+
* @param entityType - Optional filter by entity type
|
|
187
220
|
*/
|
|
188
221
|
export function getDependencies(
|
|
189
|
-
|
|
190
|
-
direction: 'dependencies' | 'dependents' | 'both' = 'both'
|
|
222
|
+
entityId: string,
|
|
223
|
+
direction: 'dependencies' | 'dependents' | 'both' = 'both',
|
|
224
|
+
entityType?: DependencyEntityType
|
|
191
225
|
): Result<Dependency[]> {
|
|
192
226
|
try {
|
|
193
227
|
let dependencies: Dependency[] = [];
|
|
228
|
+
const typeFilter = entityType ? ' AND entity_type = ?' : '';
|
|
229
|
+
const typeParam = entityType ? [entityType] : [];
|
|
194
230
|
|
|
195
231
|
if (direction === 'dependencies' || direction === 'both') {
|
|
196
232
|
const rows = queryAll<any>(
|
|
197
|
-
|
|
198
|
-
[
|
|
233
|
+
`SELECT * FROM dependencies WHERE from_entity_id = ?${typeFilter} ORDER BY created_at`,
|
|
234
|
+
[entityId, ...typeParam]
|
|
199
235
|
);
|
|
200
236
|
dependencies.push(...rows.map(mapRowToDependency));
|
|
201
237
|
}
|
|
202
238
|
|
|
203
239
|
if (direction === 'dependents' || direction === 'both') {
|
|
204
240
|
const rows = queryAll<any>(
|
|
205
|
-
|
|
206
|
-
[
|
|
241
|
+
`SELECT * FROM dependencies WHERE to_entity_id = ?${typeFilter} ORDER BY created_at`,
|
|
242
|
+
[entityId, ...typeParam]
|
|
207
243
|
);
|
|
208
244
|
dependencies.push(...rows.map(mapRowToDependency));
|
|
209
245
|
}
|
|
@@ -232,125 +268,217 @@ export function deleteDependency(id: string): Result<boolean> {
|
|
|
232
268
|
}
|
|
233
269
|
|
|
234
270
|
/**
|
|
235
|
-
* Get all blocked
|
|
271
|
+
* Get all blocked entities of a given type.
|
|
236
272
|
*
|
|
237
|
-
* Returns
|
|
273
|
+
* Returns entities that either:
|
|
238
274
|
* - Have status = 'BLOCKED', OR
|
|
239
|
-
* - Have incomplete blocking dependencies (
|
|
275
|
+
* - Have incomplete blocking dependencies (blockers that are not completed/resolved)
|
|
240
276
|
*
|
|
241
|
-
* @param params -
|
|
277
|
+
* @param params - Entity type and optional filters
|
|
242
278
|
*/
|
|
243
|
-
export function
|
|
279
|
+
export function getBlocked(params: {
|
|
280
|
+
entityType: DependencyEntityType;
|
|
244
281
|
projectId?: string;
|
|
245
282
|
featureId?: string;
|
|
246
|
-
}): Result<Task[]> {
|
|
283
|
+
}): Result<(Task | Feature)[]> {
|
|
247
284
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
285
|
+
const { entityType } = params;
|
|
286
|
+
|
|
287
|
+
if (entityType === 'task') {
|
|
288
|
+
let sql = `
|
|
289
|
+
SELECT DISTINCT t.*
|
|
290
|
+
FROM tasks t
|
|
291
|
+
WHERE (
|
|
292
|
+
t.status = 'BLOCKED'
|
|
293
|
+
OR EXISTS (
|
|
294
|
+
SELECT 1
|
|
295
|
+
FROM dependencies d
|
|
296
|
+
JOIN tasks blocker ON blocker.id = d.from_entity_id
|
|
297
|
+
WHERE d.to_entity_id = t.id
|
|
298
|
+
AND d.type = 'BLOCKS'
|
|
299
|
+
AND d.entity_type = 'task'
|
|
300
|
+
AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
|
|
301
|
+
)
|
|
260
302
|
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
const sqlParams: string[] = [];
|
|
306
|
+
|
|
307
|
+
if (params.projectId) {
|
|
308
|
+
sql += ' AND t.project_id = ?';
|
|
309
|
+
sqlParams.push(params.projectId);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (params.featureId) {
|
|
313
|
+
sql += ' AND t.feature_id = ?';
|
|
314
|
+
sqlParams.push(params.featureId);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
sql += `
|
|
318
|
+
ORDER BY
|
|
319
|
+
CASE t.priority
|
|
320
|
+
WHEN 'HIGH' THEN 1
|
|
321
|
+
WHEN 'MEDIUM' THEN 2
|
|
322
|
+
WHEN 'LOW' THEN 3
|
|
323
|
+
END,
|
|
324
|
+
t.created_at ASC
|
|
325
|
+
`;
|
|
326
|
+
|
|
327
|
+
const rows = queryAll<any>(sql, sqlParams);
|
|
328
|
+
return ok(rows.map(mapRowToTask));
|
|
329
|
+
} else {
|
|
330
|
+
let sql = `
|
|
331
|
+
SELECT DISTINCT f.*
|
|
332
|
+
FROM features f
|
|
333
|
+
WHERE (
|
|
334
|
+
f.status = 'BLOCKED'
|
|
335
|
+
OR EXISTS (
|
|
336
|
+
SELECT 1
|
|
337
|
+
FROM dependencies d
|
|
338
|
+
JOIN features blocker ON blocker.id = d.from_entity_id
|
|
339
|
+
WHERE d.to_entity_id = f.id
|
|
340
|
+
AND d.type = 'BLOCKS'
|
|
341
|
+
AND d.entity_type = 'feature'
|
|
342
|
+
AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
`;
|
|
346
|
+
|
|
347
|
+
const sqlParams: string[] = [];
|
|
348
|
+
|
|
349
|
+
if (params.projectId) {
|
|
350
|
+
sql += ' AND f.project_id = ?';
|
|
351
|
+
sqlParams.push(params.projectId);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
sql += `
|
|
355
|
+
ORDER BY
|
|
356
|
+
CASE f.priority
|
|
357
|
+
WHEN 'HIGH' THEN 1
|
|
358
|
+
WHEN 'MEDIUM' THEN 2
|
|
359
|
+
WHEN 'LOW' THEN 3
|
|
360
|
+
END,
|
|
361
|
+
f.created_at ASC
|
|
362
|
+
`;
|
|
363
|
+
|
|
364
|
+
const rows = queryAll<any>(sql, sqlParams);
|
|
365
|
+
return ok(rows.map(mapRowToFeature));
|
|
274
366
|
}
|
|
275
|
-
|
|
276
|
-
sql += ' ORDER BY t.priority DESC, t.created_at ASC';
|
|
277
|
-
|
|
278
|
-
const rows = queryAll<any>(sql, sqlParams);
|
|
279
|
-
const tasks = rows.map(mapRowToTask);
|
|
280
|
-
|
|
281
|
-
return ok(tasks);
|
|
282
367
|
} catch (error: any) {
|
|
283
|
-
return err(`Failed to get blocked
|
|
368
|
+
return err(`Failed to get blocked entities: ${error.message}`, 'QUERY_FAILED');
|
|
284
369
|
}
|
|
285
370
|
}
|
|
286
371
|
|
|
287
372
|
/**
|
|
288
|
-
* Get the next
|
|
373
|
+
* Get the next entity to work on.
|
|
374
|
+
*
|
|
375
|
+
* For tasks: returns the highest priority PENDING task with no incomplete blockers.
|
|
376
|
+
* Ordered by priority, complexity (simpler first), then creation time.
|
|
289
377
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
378
|
+
* For features: returns the highest priority DRAFT/PLANNING feature with no incomplete blockers.
|
|
379
|
+
* Ordered by priority, then creation time.
|
|
292
380
|
*
|
|
293
|
-
* @param params -
|
|
381
|
+
* @param params - Entity type, optional filters, and priority preference
|
|
294
382
|
*/
|
|
295
|
-
export function
|
|
383
|
+
export function getNext(params: {
|
|
384
|
+
entityType: DependencyEntityType;
|
|
296
385
|
projectId?: string;
|
|
297
386
|
featureId?: string;
|
|
298
387
|
priority?: string;
|
|
299
|
-
}): Result<Task | null> {
|
|
388
|
+
}): Result<Task | Feature | null> {
|
|
300
389
|
try {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
sqlParams
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
390
|
+
const { entityType } = params;
|
|
391
|
+
|
|
392
|
+
if (entityType === 'task') {
|
|
393
|
+
let sql = `
|
|
394
|
+
SELECT t.*
|
|
395
|
+
FROM tasks t
|
|
396
|
+
WHERE t.status = 'PENDING'
|
|
397
|
+
AND NOT EXISTS (
|
|
398
|
+
SELECT 1
|
|
399
|
+
FROM dependencies d
|
|
400
|
+
JOIN tasks blocker ON blocker.id = d.from_entity_id
|
|
401
|
+
WHERE d.to_entity_id = t.id
|
|
402
|
+
AND d.type = 'BLOCKS'
|
|
403
|
+
AND d.entity_type = 'task'
|
|
404
|
+
AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
|
|
405
|
+
)
|
|
406
|
+
`;
|
|
407
|
+
|
|
408
|
+
const sqlParams: string[] = [];
|
|
409
|
+
|
|
410
|
+
if (params.projectId) {
|
|
411
|
+
sql += ' AND t.project_id = ?';
|
|
412
|
+
sqlParams.push(params.projectId);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (params.featureId) {
|
|
416
|
+
sql += ' AND t.feature_id = ?';
|
|
417
|
+
sqlParams.push(params.featureId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (params.priority) {
|
|
421
|
+
sql += ' AND t.priority = ?';
|
|
422
|
+
sqlParams.push(params.priority);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
sql += `
|
|
426
|
+
ORDER BY
|
|
427
|
+
CASE t.priority
|
|
428
|
+
WHEN 'HIGH' THEN 1
|
|
429
|
+
WHEN 'MEDIUM' THEN 2
|
|
430
|
+
WHEN 'LOW' THEN 3
|
|
431
|
+
END,
|
|
432
|
+
t.complexity ASC,
|
|
433
|
+
t.created_at ASC
|
|
434
|
+
LIMIT 1
|
|
435
|
+
`;
|
|
436
|
+
|
|
437
|
+
const row = queryOne<any>(sql, sqlParams);
|
|
438
|
+
return ok(row ? mapRowToTask(row) : null);
|
|
439
|
+
} else {
|
|
440
|
+
let sql = `
|
|
441
|
+
SELECT f.*
|
|
442
|
+
FROM features f
|
|
443
|
+
WHERE f.status IN ('DRAFT', 'PLANNING')
|
|
444
|
+
AND NOT EXISTS (
|
|
445
|
+
SELECT 1
|
|
446
|
+
FROM dependencies d
|
|
447
|
+
JOIN features blocker ON blocker.id = d.from_entity_id
|
|
448
|
+
WHERE d.to_entity_id = f.id
|
|
449
|
+
AND d.type = 'BLOCKS'
|
|
450
|
+
AND d.entity_type = 'feature'
|
|
451
|
+
AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
|
|
452
|
+
)
|
|
453
|
+
`;
|
|
454
|
+
|
|
455
|
+
const sqlParams: string[] = [];
|
|
456
|
+
|
|
457
|
+
if (params.projectId) {
|
|
458
|
+
sql += ' AND f.project_id = ?';
|
|
459
|
+
sqlParams.push(params.projectId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (params.priority) {
|
|
463
|
+
sql += ' AND f.priority = ?';
|
|
464
|
+
sqlParams.push(params.priority);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
sql += `
|
|
468
|
+
ORDER BY
|
|
469
|
+
CASE f.priority
|
|
470
|
+
WHEN 'HIGH' THEN 1
|
|
471
|
+
WHEN 'MEDIUM' THEN 2
|
|
472
|
+
WHEN 'LOW' THEN 3
|
|
473
|
+
END,
|
|
474
|
+
f.created_at ASC
|
|
475
|
+
LIMIT 1
|
|
476
|
+
`;
|
|
477
|
+
|
|
478
|
+
const row = queryOne<any>(sql, sqlParams);
|
|
479
|
+
return ok(row ? mapRowToFeature(row) : null);
|
|
349
480
|
}
|
|
350
|
-
|
|
351
|
-
const task = mapRowToTask(row);
|
|
352
|
-
return ok(task);
|
|
353
481
|
} catch (error: any) {
|
|
354
|
-
return err(`Failed to get next
|
|
482
|
+
return err(`Failed to get next entity: ${error.message}`, 'QUERY_FAILED');
|
|
355
483
|
}
|
|
356
484
|
}
|
package/src/repos/features.ts
CHANGED
|
@@ -376,7 +376,7 @@ export function deleteFeature(id: string, options?: { cascade?: boolean }): Resu
|
|
|
376
376
|
|
|
377
377
|
// Delete each task's dependencies, sections, and tags
|
|
378
378
|
for (const task of taskIds) {
|
|
379
|
-
execute('DELETE FROM dependencies WHERE
|
|
379
|
+
execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [task.id, task.id, 'task']);
|
|
380
380
|
execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.TASK, task.id]);
|
|
381
381
|
deleteTags(task.id, EntityType.TASK);
|
|
382
382
|
}
|
|
@@ -385,6 +385,9 @@ export function deleteFeature(id: string, options?: { cascade?: boolean }): Resu
|
|
|
385
385
|
execute('DELETE FROM tasks WHERE feature_id = ?', [id]);
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
+
// Delete feature-level dependencies
|
|
389
|
+
execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [id, id, 'feature']);
|
|
390
|
+
|
|
388
391
|
// Delete feature sections
|
|
389
392
|
execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.FEATURE, id]);
|
|
390
393
|
|