@allpepper/task-orchestrator 1.1.2 → 1.2.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.
- 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 +269 -148
- 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 +8 -4
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,76 @@ 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
|
-
const {
|
|
125
|
+
const { fromEntityId, toEntityId, type, entityType } = params;
|
|
109
126
|
|
|
110
127
|
// Validate: no self-dependency
|
|
111
|
-
if (
|
|
112
|
-
return err('Cannot create a dependency from
|
|
128
|
+
if (fromEntityId === toEntityId) {
|
|
129
|
+
return err('Cannot create a dependency from an entity to itself', 'SELF_DEPENDENCY');
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
// Validate: both
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
// Validate: both entities exist
|
|
133
|
+
const table = entityType === 'task' ? 'tasks' : 'features';
|
|
134
|
+
const fromEntity = queryOne<{ id: string }>(
|
|
135
|
+
`SELECT id FROM ${table} WHERE id = ?`,
|
|
136
|
+
[fromEntityId]
|
|
119
137
|
);
|
|
120
138
|
|
|
121
|
-
if (!
|
|
122
|
-
return err(
|
|
139
|
+
if (!fromEntity) {
|
|
140
|
+
return err(`${entityType} not found: ${fromEntityId}`, 'NOT_FOUND');
|
|
123
141
|
}
|
|
124
142
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
[
|
|
143
|
+
const toEntity = queryOne<{ id: string }>(
|
|
144
|
+
`SELECT id FROM ${table} WHERE id = ?`,
|
|
145
|
+
[toEntityId]
|
|
128
146
|
);
|
|
129
147
|
|
|
130
|
-
if (!
|
|
131
|
-
return err(
|
|
148
|
+
if (!toEntity) {
|
|
149
|
+
return err(`${entityType} not found: ${toEntityId}`, 'NOT_FOUND');
|
|
132
150
|
}
|
|
133
151
|
|
|
134
|
-
// Validate: no circular dependencies for BLOCKS
|
|
135
|
-
|
|
152
|
+
// Validate: no circular dependencies for BLOCKS and IS_BLOCKED_BY types
|
|
153
|
+
const isCircular = type === 'BLOCKS'
|
|
154
|
+
? hasCircularDependency(fromEntityId, toEntityId, entityType)
|
|
155
|
+
: type === 'IS_BLOCKED_BY'
|
|
156
|
+
? hasCircularDependency(toEntityId, fromEntityId, entityType)
|
|
157
|
+
: false;
|
|
158
|
+
|
|
159
|
+
if (isCircular) {
|
|
136
160
|
return err(
|
|
137
161
|
'Cannot create dependency: would create a circular dependency',
|
|
138
162
|
'CIRCULAR_DEPENDENCY'
|
|
@@ -141,13 +165,13 @@ export function createDependency(params: {
|
|
|
141
165
|
|
|
142
166
|
// Check for duplicate
|
|
143
167
|
const existing = queryOne<{ id: string }>(
|
|
144
|
-
'SELECT id FROM dependencies WHERE
|
|
145
|
-
[
|
|
168
|
+
'SELECT id FROM dependencies WHERE from_entity_id = ? AND to_entity_id = ? AND type = ? AND entity_type = ?',
|
|
169
|
+
[fromEntityId, toEntityId, type, entityType]
|
|
146
170
|
);
|
|
147
171
|
|
|
148
172
|
if (existing) {
|
|
149
173
|
return err(
|
|
150
|
-
'Dependency already exists between these
|
|
174
|
+
'Dependency already exists between these entities with this type',
|
|
151
175
|
'DUPLICATE_DEPENDENCY'
|
|
152
176
|
);
|
|
153
177
|
}
|
|
@@ -158,14 +182,15 @@ export function createDependency(params: {
|
|
|
158
182
|
|
|
159
183
|
try {
|
|
160
184
|
execute(
|
|
161
|
-
'INSERT INTO dependencies (id,
|
|
162
|
-
[id,
|
|
185
|
+
'INSERT INTO dependencies (id, from_entity_id, to_entity_id, entity_type, type, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
186
|
+
[id, fromEntityId, toEntityId, entityType, type, createdAt]
|
|
163
187
|
);
|
|
164
188
|
|
|
165
189
|
const dependency: Dependency = {
|
|
166
190
|
id,
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
fromEntityId,
|
|
192
|
+
toEntityId,
|
|
193
|
+
entityType,
|
|
169
194
|
type,
|
|
170
195
|
createdAt: toDate(createdAt)
|
|
171
196
|
};
|
|
@@ -177,33 +202,37 @@ export function createDependency(params: {
|
|
|
177
202
|
}
|
|
178
203
|
|
|
179
204
|
/**
|
|
180
|
-
* Get dependencies for
|
|
205
|
+
* Get dependencies for an entity.
|
|
181
206
|
*
|
|
182
|
-
* @param
|
|
207
|
+
* @param entityId - The entity ID to query
|
|
183
208
|
* @param direction - Filter by direction:
|
|
184
|
-
* - 'dependencies':
|
|
185
|
-
* - 'dependents':
|
|
209
|
+
* - 'dependencies': entities that this entity depends on (from_entity_id = entityId)
|
|
210
|
+
* - 'dependents': entities that depend on this entity (to_entity_id = entityId)
|
|
186
211
|
* - 'both': union of above (default)
|
|
212
|
+
* @param entityType - Optional filter by entity type
|
|
187
213
|
*/
|
|
188
214
|
export function getDependencies(
|
|
189
|
-
|
|
190
|
-
direction: 'dependencies' | 'dependents' | 'both' = 'both'
|
|
215
|
+
entityId: string,
|
|
216
|
+
direction: 'dependencies' | 'dependents' | 'both' = 'both',
|
|
217
|
+
entityType?: DependencyEntityType
|
|
191
218
|
): Result<Dependency[]> {
|
|
192
219
|
try {
|
|
193
220
|
let dependencies: Dependency[] = [];
|
|
221
|
+
const typeFilter = entityType ? ' AND entity_type = ?' : '';
|
|
222
|
+
const typeParam = entityType ? [entityType] : [];
|
|
194
223
|
|
|
195
224
|
if (direction === 'dependencies' || direction === 'both') {
|
|
196
225
|
const rows = queryAll<any>(
|
|
197
|
-
|
|
198
|
-
[
|
|
226
|
+
`SELECT * FROM dependencies WHERE from_entity_id = ?${typeFilter} ORDER BY created_at`,
|
|
227
|
+
[entityId, ...typeParam]
|
|
199
228
|
);
|
|
200
229
|
dependencies.push(...rows.map(mapRowToDependency));
|
|
201
230
|
}
|
|
202
231
|
|
|
203
232
|
if (direction === 'dependents' || direction === 'both') {
|
|
204
233
|
const rows = queryAll<any>(
|
|
205
|
-
|
|
206
|
-
[
|
|
234
|
+
`SELECT * FROM dependencies WHERE to_entity_id = ?${typeFilter} ORDER BY created_at`,
|
|
235
|
+
[entityId, ...typeParam]
|
|
207
236
|
);
|
|
208
237
|
dependencies.push(...rows.map(mapRowToDependency));
|
|
209
238
|
}
|
|
@@ -232,125 +261,217 @@ export function deleteDependency(id: string): Result<boolean> {
|
|
|
232
261
|
}
|
|
233
262
|
|
|
234
263
|
/**
|
|
235
|
-
* Get all blocked
|
|
264
|
+
* Get all blocked entities of a given type.
|
|
236
265
|
*
|
|
237
|
-
* Returns
|
|
266
|
+
* Returns entities that either:
|
|
238
267
|
* - Have status = 'BLOCKED', OR
|
|
239
|
-
* - Have incomplete blocking dependencies (
|
|
268
|
+
* - Have incomplete blocking dependencies (blockers that are not completed/resolved)
|
|
240
269
|
*
|
|
241
|
-
* @param params -
|
|
270
|
+
* @param params - Entity type and optional filters
|
|
242
271
|
*/
|
|
243
|
-
export function
|
|
272
|
+
export function getBlocked(params: {
|
|
273
|
+
entityType: DependencyEntityType;
|
|
244
274
|
projectId?: string;
|
|
245
275
|
featureId?: string;
|
|
246
|
-
}): Result<Task[]> {
|
|
276
|
+
}): Result<(Task | Feature)[]> {
|
|
247
277
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
278
|
+
const { entityType } = params;
|
|
279
|
+
|
|
280
|
+
if (entityType === 'task') {
|
|
281
|
+
let sql = `
|
|
282
|
+
SELECT DISTINCT t.*
|
|
283
|
+
FROM tasks t
|
|
284
|
+
WHERE (
|
|
285
|
+
t.status = 'BLOCKED'
|
|
286
|
+
OR EXISTS (
|
|
287
|
+
SELECT 1
|
|
288
|
+
FROM dependencies d
|
|
289
|
+
JOIN tasks blocker ON blocker.id = d.from_entity_id
|
|
290
|
+
WHERE d.to_entity_id = t.id
|
|
291
|
+
AND d.type = 'BLOCKS'
|
|
292
|
+
AND d.entity_type = 'task'
|
|
293
|
+
AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
|
|
294
|
+
)
|
|
260
295
|
)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
const sqlParams: string[] = [];
|
|
299
|
+
|
|
300
|
+
if (params.projectId) {
|
|
301
|
+
sql += ' AND t.project_id = ?';
|
|
302
|
+
sqlParams.push(params.projectId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (params.featureId) {
|
|
306
|
+
sql += ' AND t.feature_id = ?';
|
|
307
|
+
sqlParams.push(params.featureId);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
sql += `
|
|
311
|
+
ORDER BY
|
|
312
|
+
CASE t.priority
|
|
313
|
+
WHEN 'HIGH' THEN 1
|
|
314
|
+
WHEN 'MEDIUM' THEN 2
|
|
315
|
+
WHEN 'LOW' THEN 3
|
|
316
|
+
END,
|
|
317
|
+
t.created_at ASC
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
const rows = queryAll<any>(sql, sqlParams);
|
|
321
|
+
return ok(rows.map(mapRowToTask));
|
|
322
|
+
} else {
|
|
323
|
+
let sql = `
|
|
324
|
+
SELECT DISTINCT f.*
|
|
325
|
+
FROM features f
|
|
326
|
+
WHERE (
|
|
327
|
+
f.status = 'BLOCKED'
|
|
328
|
+
OR EXISTS (
|
|
329
|
+
SELECT 1
|
|
330
|
+
FROM dependencies d
|
|
331
|
+
JOIN features blocker ON blocker.id = d.from_entity_id
|
|
332
|
+
WHERE d.to_entity_id = f.id
|
|
333
|
+
AND d.type = 'BLOCKS'
|
|
334
|
+
AND d.entity_type = 'feature'
|
|
335
|
+
AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
`;
|
|
339
|
+
|
|
340
|
+
const sqlParams: string[] = [];
|
|
341
|
+
|
|
342
|
+
if (params.projectId) {
|
|
343
|
+
sql += ' AND f.project_id = ?';
|
|
344
|
+
sqlParams.push(params.projectId);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
sql += `
|
|
348
|
+
ORDER BY
|
|
349
|
+
CASE f.priority
|
|
350
|
+
WHEN 'HIGH' THEN 1
|
|
351
|
+
WHEN 'MEDIUM' THEN 2
|
|
352
|
+
WHEN 'LOW' THEN 3
|
|
353
|
+
END,
|
|
354
|
+
f.created_at ASC
|
|
355
|
+
`;
|
|
356
|
+
|
|
357
|
+
const rows = queryAll<any>(sql, sqlParams);
|
|
358
|
+
return ok(rows.map(mapRowToFeature));
|
|
274
359
|
}
|
|
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
360
|
} catch (error: any) {
|
|
283
|
-
return err(`Failed to get blocked
|
|
361
|
+
return err(`Failed to get blocked entities: ${error.message}`, 'QUERY_FAILED');
|
|
284
362
|
}
|
|
285
363
|
}
|
|
286
364
|
|
|
287
365
|
/**
|
|
288
|
-
* Get the next
|
|
366
|
+
* Get the next entity to work on.
|
|
367
|
+
*
|
|
368
|
+
* For tasks: returns the highest priority PENDING task with no incomplete blockers.
|
|
369
|
+
* Ordered by priority, complexity (simpler first), then creation time.
|
|
289
370
|
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
371
|
+
* For features: returns the highest priority DRAFT/PLANNING feature with no incomplete blockers.
|
|
372
|
+
* Ordered by priority, then creation time.
|
|
292
373
|
*
|
|
293
|
-
* @param params -
|
|
374
|
+
* @param params - Entity type, optional filters, and priority preference
|
|
294
375
|
*/
|
|
295
|
-
export function
|
|
376
|
+
export function getNext(params: {
|
|
377
|
+
entityType: DependencyEntityType;
|
|
296
378
|
projectId?: string;
|
|
297
379
|
featureId?: string;
|
|
298
380
|
priority?: string;
|
|
299
|
-
}): Result<Task | null> {
|
|
381
|
+
}): Result<Task | Feature | null> {
|
|
300
382
|
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
|
-
|
|
383
|
+
const { entityType } = params;
|
|
384
|
+
|
|
385
|
+
if (entityType === 'task') {
|
|
386
|
+
let sql = `
|
|
387
|
+
SELECT t.*
|
|
388
|
+
FROM tasks t
|
|
389
|
+
WHERE t.status = 'PENDING'
|
|
390
|
+
AND NOT EXISTS (
|
|
391
|
+
SELECT 1
|
|
392
|
+
FROM dependencies d
|
|
393
|
+
JOIN tasks blocker ON blocker.id = d.from_entity_id
|
|
394
|
+
WHERE d.to_entity_id = t.id
|
|
395
|
+
AND d.type = 'BLOCKS'
|
|
396
|
+
AND d.entity_type = 'task'
|
|
397
|
+
AND blocker.status NOT IN ('COMPLETED', 'CANCELLED')
|
|
398
|
+
)
|
|
399
|
+
`;
|
|
400
|
+
|
|
401
|
+
const sqlParams: string[] = [];
|
|
402
|
+
|
|
403
|
+
if (params.projectId) {
|
|
404
|
+
sql += ' AND t.project_id = ?';
|
|
405
|
+
sqlParams.push(params.projectId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (params.featureId) {
|
|
409
|
+
sql += ' AND t.feature_id = ?';
|
|
410
|
+
sqlParams.push(params.featureId);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (params.priority) {
|
|
414
|
+
sql += ' AND t.priority = ?';
|
|
415
|
+
sqlParams.push(params.priority);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
sql += `
|
|
419
|
+
ORDER BY
|
|
420
|
+
CASE t.priority
|
|
421
|
+
WHEN 'HIGH' THEN 1
|
|
422
|
+
WHEN 'MEDIUM' THEN 2
|
|
423
|
+
WHEN 'LOW' THEN 3
|
|
424
|
+
END,
|
|
425
|
+
t.complexity ASC,
|
|
426
|
+
t.created_at ASC
|
|
427
|
+
LIMIT 1
|
|
428
|
+
`;
|
|
429
|
+
|
|
430
|
+
const row = queryOne<any>(sql, sqlParams);
|
|
431
|
+
return ok(row ? mapRowToTask(row) : null);
|
|
432
|
+
} else {
|
|
433
|
+
let sql = `
|
|
434
|
+
SELECT f.*
|
|
435
|
+
FROM features f
|
|
436
|
+
WHERE f.status IN ('DRAFT', 'PLANNING')
|
|
437
|
+
AND NOT EXISTS (
|
|
438
|
+
SELECT 1
|
|
439
|
+
FROM dependencies d
|
|
440
|
+
JOIN features blocker ON blocker.id = d.from_entity_id
|
|
441
|
+
WHERE d.to_entity_id = f.id
|
|
442
|
+
AND d.type = 'BLOCKS'
|
|
443
|
+
AND d.entity_type = 'feature'
|
|
444
|
+
AND blocker.status NOT IN ('COMPLETED', 'ARCHIVED')
|
|
445
|
+
)
|
|
446
|
+
`;
|
|
447
|
+
|
|
448
|
+
const sqlParams: string[] = [];
|
|
449
|
+
|
|
450
|
+
if (params.projectId) {
|
|
451
|
+
sql += ' AND f.project_id = ?';
|
|
452
|
+
sqlParams.push(params.projectId);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (params.priority) {
|
|
456
|
+
sql += ' AND f.priority = ?';
|
|
457
|
+
sqlParams.push(params.priority);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
sql += `
|
|
461
|
+
ORDER BY
|
|
462
|
+
CASE f.priority
|
|
463
|
+
WHEN 'HIGH' THEN 1
|
|
464
|
+
WHEN 'MEDIUM' THEN 2
|
|
465
|
+
WHEN 'LOW' THEN 3
|
|
466
|
+
END,
|
|
467
|
+
f.created_at ASC
|
|
468
|
+
LIMIT 1
|
|
469
|
+
`;
|
|
470
|
+
|
|
471
|
+
const row = queryOne<any>(sql, sqlParams);
|
|
472
|
+
return ok(row ? mapRowToFeature(row) : null);
|
|
349
473
|
}
|
|
350
|
-
|
|
351
|
-
const task = mapRowToTask(row);
|
|
352
|
-
return ok(task);
|
|
353
474
|
} catch (error: any) {
|
|
354
|
-
return err(`Failed to get next
|
|
475
|
+
return err(`Failed to get next entity: ${error.message}`, 'QUERY_FAILED');
|
|
355
476
|
}
|
|
356
477
|
}
|
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
|
|
package/src/repos/projects.ts
CHANGED
|
@@ -268,7 +268,7 @@ export function deleteProject(id: string, options?: { cascade?: boolean }): Resu
|
|
|
268
268
|
|
|
269
269
|
// Delete each task's dependencies, sections, and tags
|
|
270
270
|
for (const task of taskIds) {
|
|
271
|
-
execute('DELETE FROM dependencies WHERE
|
|
271
|
+
execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [task.id, task.id, 'task']);
|
|
272
272
|
execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.TASK, task.id]);
|
|
273
273
|
deleteTags(task.id, EntityType.TASK);
|
|
274
274
|
}
|
|
@@ -280,8 +280,9 @@ export function deleteProject(id: string, options?: { cascade?: boolean }): Resu
|
|
|
280
280
|
[id, id]
|
|
281
281
|
);
|
|
282
282
|
|
|
283
|
-
// Delete each feature's sections and tags
|
|
283
|
+
// Delete each feature's dependencies, sections, and tags
|
|
284
284
|
for (const feature of featureIds) {
|
|
285
|
+
execute('DELETE FROM dependencies WHERE (from_entity_id = ? OR to_entity_id = ?) AND entity_type = ?', [feature.id, feature.id, 'feature']);
|
|
285
286
|
execute('DELETE FROM sections WHERE entity_type = ? AND entity_id = ?', [EntityType.FEATURE, feature.id]);
|
|
286
287
|
deleteTags(feature.id, EntityType.FEATURE);
|
|
287
288
|
}
|