@critical-path/core 0.15.0 → 0.15.2
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/CHANGELOG.md +12 -0
- package/dist/domain/events.d.ts +8 -1
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +80 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +56 -1
- package/dist/index.test.js.map +1 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +12 -2
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +13 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/task-state.test.d.ts +2 -0
- package/dist/task-state.test.d.ts.map +1 -0
- package/dist/task-state.test.js +374 -0
- package/dist/task-state.test.js.map +1 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/workflow.d.ts +1 -0
- package/dist/utils/workflow.d.ts.map +1 -1
- package/dist/utils/workflow.js +50 -3
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/events.ts +9 -0
- package/src/engine/index.ts +93 -0
- package/src/index.test.ts +69 -1
- package/src/store/firebase.test.ts +16 -0
- package/src/store/firebase.ts +12 -2
- package/src/task-state.test.ts +447 -0
- package/src/types/index.ts +1 -0
- package/src/utils/workflow.ts +60 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { CriticalPathEngine } from './engine/index.js';
|
|
3
|
+
import { deriveTaskLifecycleState } from './utils/status.js';
|
|
4
|
+
import type { Task, StatusDefinition, TaskUnblockedEvent } from './index.js';
|
|
5
|
+
|
|
6
|
+
describe('Task State Behaviour & Dependency Validation', () => {
|
|
7
|
+
const baseTask: Task = {
|
|
8
|
+
id: 'task_target',
|
|
9
|
+
projectId: 'proj_1',
|
|
10
|
+
title: 'Implement Authentication UI',
|
|
11
|
+
status: 'todo',
|
|
12
|
+
priority: 'high',
|
|
13
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
14
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('deriveTaskLifecycleState (Pure Function)', () => {
|
|
18
|
+
it('marks a task as blocked when an upstream dependency is not complete', () => {
|
|
19
|
+
const upstreamIncomplete: Task = {
|
|
20
|
+
id: 'task_upstream',
|
|
21
|
+
projectId: 'proj_1',
|
|
22
|
+
title: 'Backend Auth API',
|
|
23
|
+
status: 'in_progress',
|
|
24
|
+
priority: 'high',
|
|
25
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
26
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const state = deriveTaskLifecycleState(baseTask, {
|
|
30
|
+
upstreamTasks: [upstreamIncomplete]
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(state.isBlocked).toBe(true);
|
|
34
|
+
expect(state.isReady).toBe(false);
|
|
35
|
+
expect(state.blockingTaskIds).toEqual(['task_upstream']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('marks a task as unblocked and ready when an upstream dependency is complete (vice versa)', () => {
|
|
39
|
+
const upstreamComplete: Task = {
|
|
40
|
+
id: 'task_upstream',
|
|
41
|
+
projectId: 'proj_1',
|
|
42
|
+
title: 'Backend Auth API',
|
|
43
|
+
status: 'done',
|
|
44
|
+
priority: 'high',
|
|
45
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
46
|
+
updatedAt: '2026-09-02T00:00:00.000Z'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const state = deriveTaskLifecycleState(baseTask, {
|
|
50
|
+
upstreamTasks: [upstreamComplete]
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(state.isBlocked).toBe(false);
|
|
54
|
+
expect(state.isReady).toBe(true);
|
|
55
|
+
expect(state.blockingTaskIds).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('marks a task as blocked again if upstream dependency transitions from complete back to incomplete', () => {
|
|
59
|
+
const upstreamTask: Task = {
|
|
60
|
+
id: 'task_upstream',
|
|
61
|
+
projectId: 'proj_1',
|
|
62
|
+
title: 'Backend Auth API',
|
|
63
|
+
status: 'done',
|
|
64
|
+
priority: 'high',
|
|
65
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
66
|
+
updatedAt: '2026-09-02T00:00:00.000Z'
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Initially complete: target is ready
|
|
70
|
+
const initial = deriveTaskLifecycleState(baseTask, {
|
|
71
|
+
upstreamTasks: [upstreamTask]
|
|
72
|
+
});
|
|
73
|
+
expect(initial.isBlocked).toBe(false);
|
|
74
|
+
expect(initial.isReady).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Upstream reopened to in_progress
|
|
77
|
+
const reopenedUpstream: Task = {
|
|
78
|
+
...upstreamTask,
|
|
79
|
+
status: 'in_progress',
|
|
80
|
+
updatedAt: '2026-09-03T00:00:00.000Z'
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const afterReopen = deriveTaskLifecycleState(baseTask, {
|
|
84
|
+
upstreamTasks: [reopenedUpstream]
|
|
85
|
+
});
|
|
86
|
+
expect(afterReopen.isBlocked).toBe(true);
|
|
87
|
+
expect(afterReopen.isReady).toBe(false);
|
|
88
|
+
expect(afterReopen.blockingTaskIds).toEqual(['task_upstream']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('handles multiple upstream dependencies (fan-in): blocked until ALL are complete', () => {
|
|
92
|
+
const upstreamA: Task = {
|
|
93
|
+
id: 'up_a',
|
|
94
|
+
projectId: 'proj_1',
|
|
95
|
+
title: 'API Endpoints',
|
|
96
|
+
status: 'done',
|
|
97
|
+
priority: 'high',
|
|
98
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
99
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
100
|
+
};
|
|
101
|
+
const upstreamB: Task = {
|
|
102
|
+
id: 'up_b',
|
|
103
|
+
projectId: 'proj_1',
|
|
104
|
+
title: 'Database Schema Migration',
|
|
105
|
+
status: 'todo',
|
|
106
|
+
priority: 'urgent',
|
|
107
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
108
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// 1. One done, one todo -> still blocked
|
|
112
|
+
const partialState = deriveTaskLifecycleState(baseTask, {
|
|
113
|
+
upstreamTasks: [upstreamA, upstreamB]
|
|
114
|
+
});
|
|
115
|
+
expect(partialState.isBlocked).toBe(true);
|
|
116
|
+
expect(partialState.isReady).toBe(false);
|
|
117
|
+
expect(partialState.blockingTaskIds).toEqual(['up_b']);
|
|
118
|
+
|
|
119
|
+
// 2. Both done -> now unblocked and ready
|
|
120
|
+
const allCompleteState = deriveTaskLifecycleState(baseTask, {
|
|
121
|
+
upstreamTasks: [upstreamA, { ...upstreamB, status: 'done' }]
|
|
122
|
+
});
|
|
123
|
+
expect(allCompleteState.isBlocked).toBe(false);
|
|
124
|
+
expect(allCompleteState.isReady).toBe(true);
|
|
125
|
+
expect(allCompleteState.blockingTaskIds).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('does not mark a completed or canceled task as blocked even if upstream is incomplete', () => {
|
|
129
|
+
const upstreamIncomplete: Task = {
|
|
130
|
+
id: 'task_upstream',
|
|
131
|
+
projectId: 'proj_1',
|
|
132
|
+
title: 'Database Setup',
|
|
133
|
+
status: 'todo',
|
|
134
|
+
priority: 'high',
|
|
135
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
136
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Target task is already done
|
|
140
|
+
const completedTask: Task = {
|
|
141
|
+
...baseTask,
|
|
142
|
+
status: 'done'
|
|
143
|
+
};
|
|
144
|
+
const doneState = deriveTaskLifecycleState(completedTask, {
|
|
145
|
+
upstreamTasks: [upstreamIncomplete]
|
|
146
|
+
});
|
|
147
|
+
expect(doneState.isDone).toBe(true);
|
|
148
|
+
expect(doneState.isBlocked).toBe(false);
|
|
149
|
+
|
|
150
|
+
// Target task was canceled
|
|
151
|
+
const canceledTask: Task = {
|
|
152
|
+
...baseTask,
|
|
153
|
+
status: 'canceled'
|
|
154
|
+
};
|
|
155
|
+
const canceledState = deriveTaskLifecycleState(canceledTask, {
|
|
156
|
+
upstreamTasks: [upstreamIncomplete]
|
|
157
|
+
});
|
|
158
|
+
expect(canceledState.isCancelled).toBe(true);
|
|
159
|
+
expect(canceledState.isBlocked).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('correctly uses custom status definitions to determine blocking completion', () => {
|
|
163
|
+
const customDefinitions: StatusDefinition[] = [
|
|
164
|
+
{ key: 'backlog', label: 'Backlog', category: 'not_started' },
|
|
165
|
+
{ key: 'qa_review', label: 'QA Review', category: 'in_progress' },
|
|
166
|
+
{ key: 'shipped_prod', label: 'Shipped to Production', category: 'completed' },
|
|
167
|
+
{ key: 'abandoned', label: 'Abandoned', category: 'canceled' }
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const upstreamInQa: Task = {
|
|
171
|
+
id: 'up_qa',
|
|
172
|
+
projectId: 'proj_1',
|
|
173
|
+
title: 'Core Engine Feature',
|
|
174
|
+
status: 'qa_review',
|
|
175
|
+
priority: 'high',
|
|
176
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
177
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// qa_review is categorized as in_progress -> blocks target
|
|
181
|
+
const blockedState = deriveTaskLifecycleState(baseTask, {
|
|
182
|
+
customDefinitions,
|
|
183
|
+
upstreamTasks: [upstreamInQa]
|
|
184
|
+
});
|
|
185
|
+
expect(blockedState.isBlocked).toBe(true);
|
|
186
|
+
expect(blockedState.blockingTaskIds).toEqual(['up_qa']);
|
|
187
|
+
|
|
188
|
+
// shipped_prod is categorized as completed -> unblocks target
|
|
189
|
+
const unblockedState = deriveTaskLifecycleState(baseTask, {
|
|
190
|
+
customDefinitions,
|
|
191
|
+
upstreamTasks: [{ ...upstreamInQa, status: 'shipped_prod' }]
|
|
192
|
+
});
|
|
193
|
+
expect(unblockedState.isBlocked).toBe(false);
|
|
194
|
+
expect(unblockedState.isReady).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('CriticalPathEngine Lifecycle & Dependency Integration', () => {
|
|
199
|
+
it('validates upstream blocking behavior and directional asymmetry', async () => {
|
|
200
|
+
const engine = new CriticalPathEngine();
|
|
201
|
+
const project = await engine.createProject({ key: 'ST', name: 'State Test Project' });
|
|
202
|
+
|
|
203
|
+
// Task A: Upstream
|
|
204
|
+
const taskA = await engine.createTask({
|
|
205
|
+
projectId: project.id,
|
|
206
|
+
title: 'Task A (Upstream)',
|
|
207
|
+
status: 'todo',
|
|
208
|
+
priority: 'high'
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Task B: Downstream dependent
|
|
212
|
+
const taskB = await engine.createTask({
|
|
213
|
+
projectId: project.id,
|
|
214
|
+
title: 'Task B (Downstream)',
|
|
215
|
+
status: 'todo',
|
|
216
|
+
priority: 'medium'
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Before dependency: both are ready, neither is blocked
|
|
220
|
+
let stateA = await engine.getTaskLifecycleState(taskA.id);
|
|
221
|
+
let stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
222
|
+
expect(stateA?.isBlocked).toBe(false);
|
|
223
|
+
expect(stateA?.isReady).toBe(true);
|
|
224
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
225
|
+
expect(stateB?.isReady).toBe(true);
|
|
226
|
+
|
|
227
|
+
// Add blocking dependency: B depends on A
|
|
228
|
+
await engine.addDependency({
|
|
229
|
+
taskId: taskB.id,
|
|
230
|
+
dependsOnTaskId: taskA.id,
|
|
231
|
+
type: 'blocking'
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Verify B is blocked by A, but A is NOT blocked by B (directional check)
|
|
235
|
+
stateA = await engine.getTaskLifecycleState(taskA.id);
|
|
236
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
237
|
+
|
|
238
|
+
expect(stateA?.isBlocked).toBe(false);
|
|
239
|
+
expect(stateA?.isReady).toBe(true);
|
|
240
|
+
expect(stateA?.blockingTaskIds).toHaveLength(0);
|
|
241
|
+
|
|
242
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
243
|
+
expect(stateB?.isReady).toBe(false);
|
|
244
|
+
expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
|
|
245
|
+
|
|
246
|
+
// Complete Upstream Task A -> Task B is unblocked
|
|
247
|
+
await engine.updateTask(taskA.id, { status: 'done' });
|
|
248
|
+
|
|
249
|
+
stateA = await engine.getTaskLifecycleState(taskA.id);
|
|
250
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
251
|
+
|
|
252
|
+
expect(stateA?.isDone).toBe(true);
|
|
253
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
254
|
+
expect(stateB?.isReady).toBe(true);
|
|
255
|
+
expect(stateB?.blockingTaskIds).toHaveLength(0);
|
|
256
|
+
|
|
257
|
+
// Reopen Task A from done back to todo -> Task B becomes blocked again
|
|
258
|
+
await engine.updateTask(taskA.id, { status: 'todo' });
|
|
259
|
+
|
|
260
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
261
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
262
|
+
expect(stateB?.isReady).toBe(false);
|
|
263
|
+
expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('unblocks task when blocking upstream task is deleted', async () => {
|
|
267
|
+
const engine = new CriticalPathEngine();
|
|
268
|
+
const project = await engine.createProject({ key: 'DEL', name: 'Delete Dep Project' });
|
|
269
|
+
|
|
270
|
+
const taskA = await engine.createTask({ projectId: project.id, title: 'Task A', status: 'todo' });
|
|
271
|
+
const taskB = await engine.createTask({ projectId: project.id, title: 'Task B', status: 'todo' });
|
|
272
|
+
|
|
273
|
+
await engine.addDependency({
|
|
274
|
+
taskId: taskB.id,
|
|
275
|
+
dependsOnTaskId: taskA.id,
|
|
276
|
+
type: 'blocking'
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
let stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
280
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
281
|
+
|
|
282
|
+
// Delete upstream task
|
|
283
|
+
await engine.deleteTask(taskA.id);
|
|
284
|
+
|
|
285
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
286
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
287
|
+
expect(stateB?.isReady).toBe(true);
|
|
288
|
+
expect(stateB?.blockingTaskIds).toHaveLength(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('validates multi-level transitive dependency chain (A -> B -> C)', async () => {
|
|
292
|
+
const engine = new CriticalPathEngine();
|
|
293
|
+
const project = await engine.createProject({ key: 'CHAIN', name: 'Chain Project' });
|
|
294
|
+
|
|
295
|
+
const taskA = await engine.createTask({ projectId: project.id, title: 'Step 1: DB Schema', status: 'todo' });
|
|
296
|
+
const taskB = await engine.createTask({ projectId: project.id, title: 'Step 2: API Endpoints', status: 'todo' });
|
|
297
|
+
const taskC = await engine.createTask({ projectId: project.id, title: 'Step 3: Frontend View', status: 'todo' });
|
|
298
|
+
|
|
299
|
+
// B depends on A
|
|
300
|
+
await engine.addDependency({
|
|
301
|
+
taskId: taskB.id,
|
|
302
|
+
dependsOnTaskId: taskA.id,
|
|
303
|
+
type: 'blocking'
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// C depends on B
|
|
307
|
+
await engine.addDependency({
|
|
308
|
+
taskId: taskC.id,
|
|
309
|
+
dependsOnTaskId: taskB.id,
|
|
310
|
+
type: 'blocking'
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Initial state: A is ready; B is blocked by A; C is blocked by B
|
|
314
|
+
let stateA = await engine.getTaskLifecycleState(taskA.id);
|
|
315
|
+
let stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
316
|
+
let stateC = await engine.getTaskLifecycleState(taskC.id);
|
|
317
|
+
|
|
318
|
+
expect(stateA?.isReady).toBe(true);
|
|
319
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
320
|
+
expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
|
|
321
|
+
expect(stateC?.isBlocked).toBe(true);
|
|
322
|
+
expect(stateC?.blockingTaskIds).toEqual([taskB.id]);
|
|
323
|
+
|
|
324
|
+
// Complete A: B becomes ready; C is STILL blocked by B
|
|
325
|
+
await engine.updateTask(taskA.id, { status: 'done' });
|
|
326
|
+
|
|
327
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
328
|
+
stateC = await engine.getTaskLifecycleState(taskC.id);
|
|
329
|
+
|
|
330
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
331
|
+
expect(stateB?.isReady).toBe(true);
|
|
332
|
+
|
|
333
|
+
expect(stateC?.isBlocked).toBe(true);
|
|
334
|
+
expect(stateC?.isReady).toBe(false);
|
|
335
|
+
expect(stateC?.blockingTaskIds).toEqual([taskB.id]);
|
|
336
|
+
|
|
337
|
+
// Complete B: C is finally unblocked and ready
|
|
338
|
+
await engine.updateTask(taskB.id, { status: 'done' });
|
|
339
|
+
|
|
340
|
+
stateC = await engine.getTaskLifecycleState(taskC.id);
|
|
341
|
+
expect(stateC?.isBlocked).toBe(false);
|
|
342
|
+
expect(stateC?.isReady).toBe(true);
|
|
343
|
+
expect(stateC?.blockingTaskIds).toHaveLength(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('emits task.unblocked domain event and auto-transitions status from blocked to todo when dependencies complete', async () => {
|
|
347
|
+
const engine = new CriticalPathEngine();
|
|
348
|
+
const project = await engine.createProject({ key: 'UNBLK', name: 'Auto Unblock Project' });
|
|
349
|
+
|
|
350
|
+
const taskUpstream = await engine.createTask({
|
|
351
|
+
projectId: project.id,
|
|
352
|
+
title: 'Compile Library',
|
|
353
|
+
status: 'todo'
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Create downstream task with status: 'blocked' and customFields.isBlocked = true
|
|
357
|
+
const taskDownstream = await engine.createTask({
|
|
358
|
+
projectId: project.id,
|
|
359
|
+
title: 'Run Integration Tests',
|
|
360
|
+
status: 'blocked',
|
|
361
|
+
customFields: { isBlocked: true, blockedReason: 'Waiting on compilation' }
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await engine.addDependency({
|
|
365
|
+
taskId: taskDownstream.id,
|
|
366
|
+
dependsOnTaskId: taskUpstream.id,
|
|
367
|
+
type: 'blocking'
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Subscribe to domain event
|
|
371
|
+
const unblockedEvents: TaskUnblockedEvent[] = [];
|
|
372
|
+
engine.events.subscribe<TaskUnblockedEvent>('task.unblocked', (event) => {
|
|
373
|
+
unblockedEvents.push(event);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Complete upstream task
|
|
377
|
+
await engine.updateTask(taskUpstream.id, { status: 'done' });
|
|
378
|
+
|
|
379
|
+
// Verify domain event was published
|
|
380
|
+
expect(unblockedEvents).toHaveLength(1);
|
|
381
|
+
expect(unblockedEvents[0].name).toBe('task.unblocked');
|
|
382
|
+
expect(unblockedEvents[0].aggregateId).toBe(taskDownstream.id);
|
|
383
|
+
expect(unblockedEvents[0].payload.upstreamTaskId).toBe(taskUpstream.id);
|
|
384
|
+
|
|
385
|
+
// Verify downstream task was automatically transitioned from 'blocked' to 'todo'
|
|
386
|
+
const updatedDownstream = await engine.getTask(taskDownstream.id);
|
|
387
|
+
expect(updatedDownstream?.status).toBe('todo');
|
|
388
|
+
expect(updatedDownstream?.customFields?.isBlocked).toBe(false);
|
|
389
|
+
expect(updatedDownstream?.customFields?.blockedReason).toBeNull();
|
|
390
|
+
|
|
391
|
+
// Verify lifecycle state is ready and unblocked
|
|
392
|
+
const lifecycle = await engine.getTaskLifecycleState(taskDownstream.id);
|
|
393
|
+
expect(lifecycle?.isBlocked).toBe(false);
|
|
394
|
+
expect(lifecycle?.isReady).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('does not auto-unblock downstream task if other upstream dependencies remain incomplete', async () => {
|
|
398
|
+
const engine = new CriticalPathEngine();
|
|
399
|
+
const project = await engine.createProject({ key: 'FANIN', name: 'Fan In Project' });
|
|
400
|
+
|
|
401
|
+
const up1 = await engine.createTask({ projectId: project.id, title: 'Dependency 1', status: 'todo' });
|
|
402
|
+
const up2 = await engine.createTask({ projectId: project.id, title: 'Dependency 2', status: 'todo' });
|
|
403
|
+
|
|
404
|
+
const downstream = await engine.createTask({
|
|
405
|
+
projectId: project.id,
|
|
406
|
+
title: 'Final Assembly',
|
|
407
|
+
status: 'blocked',
|
|
408
|
+
customFields: { isBlocked: true }
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await engine.addDependency({ taskId: downstream.id, dependsOnTaskId: up1.id, type: 'blocking' });
|
|
412
|
+
await engine.addDependency({ taskId: downstream.id, dependsOnTaskId: up2.id, type: 'blocking' });
|
|
413
|
+
|
|
414
|
+
const unblockedEvents: TaskUnblockedEvent[] = [];
|
|
415
|
+
engine.events.subscribe<TaskUnblockedEvent>('task.unblocked', (evt) => {
|
|
416
|
+
unblockedEvents.push(evt);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// Complete up1 only
|
|
420
|
+
await engine.updateTask(up1.id, { status: 'done' });
|
|
421
|
+
|
|
422
|
+
// Downstream should NOT be unblocked because up2 is still incomplete
|
|
423
|
+
expect(unblockedEvents).toHaveLength(0);
|
|
424
|
+
|
|
425
|
+
let taskState = await engine.getTask(downstream.id);
|
|
426
|
+
expect(taskState?.status).toBe('blocked');
|
|
427
|
+
expect(taskState?.customFields?.isBlocked).toBe(true);
|
|
428
|
+
|
|
429
|
+
let lifecycle = await engine.getTaskLifecycleState(downstream.id);
|
|
430
|
+
expect(lifecycle?.isBlocked).toBe(true);
|
|
431
|
+
expect(lifecycle?.blockingTaskIds).toEqual([up2.id]);
|
|
432
|
+
|
|
433
|
+
// Complete up2
|
|
434
|
+
await engine.updateTask(up2.id, { status: 'done' });
|
|
435
|
+
|
|
436
|
+
// Now it should be unblocked
|
|
437
|
+
expect(unblockedEvents).toHaveLength(1);
|
|
438
|
+
taskState = await engine.getTask(downstream.id);
|
|
439
|
+
expect(taskState?.status).toBe('todo');
|
|
440
|
+
expect(taskState?.customFields?.isBlocked).toBe(false);
|
|
441
|
+
|
|
442
|
+
lifecycle = await engine.getTaskLifecycleState(downstream.id);
|
|
443
|
+
expect(lifecycle?.isBlocked).toBe(false);
|
|
444
|
+
expect(lifecycle?.isReady).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|
package/src/types/index.ts
CHANGED
package/src/utils/workflow.ts
CHANGED
|
@@ -101,14 +101,27 @@ export const DEFAULT_VFX_WORKFLOW: Workflow = {
|
|
|
101
101
|
],
|
|
102
102
|
transitions: [
|
|
103
103
|
{ name: 'Award Bid', fromStatusKey: 'bidding', toStatusKey: 'awarded' },
|
|
104
|
+
{ name: 'Start Directly from Bid', fromStatusKey: 'bidding', toStatusKey: 'in_production' },
|
|
104
105
|
{ name: 'Start Work', fromStatusKey: 'awarded', toStatusKey: 'in_production' },
|
|
106
|
+
{ name: 'Reopen Bid', fromStatusKey: 'awarded', toStatusKey: 'bidding' },
|
|
105
107
|
{ name: 'Submit to Lead Review', fromStatusKey: 'in_production', toStatusKey: 'internal_review' },
|
|
108
|
+
{ name: 'Fast-Track Client Review', fromStatusKey: 'in_production', toStatusKey: 'client_review' },
|
|
109
|
+
{ name: 'Direct Approval', fromStatusKey: 'in_production', toStatusKey: 'approved' },
|
|
106
110
|
{ name: 'Submit to Client Review', fromStatusKey: 'internal_review', toStatusKey: 'client_review' },
|
|
107
111
|
{ name: 'Request Internal Revisions', fromStatusKey: 'internal_review', toStatusKey: 'revision_requested' },
|
|
112
|
+
{ name: 'Return to Production', fromStatusKey: 'internal_review', toStatusKey: 'in_production' },
|
|
113
|
+
{ name: 'Approve from Lead Review', fromStatusKey: 'internal_review', toStatusKey: 'approved' },
|
|
108
114
|
{ name: 'Request Client Revisions', fromStatusKey: 'client_review', toStatusKey: 'revision_requested' },
|
|
109
|
-
{ name: '
|
|
115
|
+
{ name: 'Return to Lead Review', fromStatusKey: 'client_review', toStatusKey: 'internal_review' },
|
|
110
116
|
{ name: 'Approve Final Shot', fromStatusKey: 'client_review', toStatusKey: 'approved' },
|
|
111
|
-
{ name: '
|
|
117
|
+
{ name: 'Address Revisions in Production', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
|
|
118
|
+
{ name: 'Resubmit to Lead Review', fromStatusKey: 'revision_requested', toStatusKey: 'internal_review' },
|
|
119
|
+
{ name: 'Resubmit to Client Review', fromStatusKey: 'revision_requested', toStatusKey: 'client_review' },
|
|
120
|
+
{ name: 'Reopen Shot', fromStatusKey: 'approved', toStatusKey: 'in_production' },
|
|
121
|
+
{ name: 'Reopen Lead Review', fromStatusKey: 'approved', toStatusKey: 'internal_review' },
|
|
122
|
+
{ name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
|
|
123
|
+
{ name: 'Restore to Bidding', fromStatusKey: 'canceled', toStatusKey: 'bidding' },
|
|
124
|
+
{ name: 'Restore to Production', fromStatusKey: 'canceled', toStatusKey: 'in_production' }
|
|
112
125
|
],
|
|
113
126
|
taskTypes: [
|
|
114
127
|
{ key: 'vfx_task', label: 'VFX Task', description: 'Department task (e.g. Matchmove, Animation, FX, Comp)', icon: 'film' },
|
|
@@ -139,14 +152,23 @@ export const DEFAULT_CREATIVE_WORKFLOW: Workflow = {
|
|
|
139
152
|
transitions: [
|
|
140
153
|
{ name: 'Start Concepts', fromStatusKey: 'briefing', toStatusKey: 'concept' },
|
|
141
154
|
{ name: 'Approve Concept for Production', fromStatusKey: 'concept', toStatusKey: 'in_production' },
|
|
155
|
+
{ name: 'Return to Briefing', fromStatusKey: 'concept', toStatusKey: 'briefing' },
|
|
142
156
|
{ name: 'Submit for Internal Review', fromStatusKey: 'in_production', toStatusKey: 'internal_review' },
|
|
157
|
+
{ name: 'Submit for Client Review', fromStatusKey: 'in_production', toStatusKey: 'client_review' },
|
|
143
158
|
{ name: 'Submit for Client Review', fromStatusKey: 'internal_review', toStatusKey: 'client_review' },
|
|
144
159
|
{ name: 'Request Revisions (Internal)', fromStatusKey: 'internal_review', toStatusKey: 'revision_requested' },
|
|
160
|
+
{ name: 'Return to Production', fromStatusKey: 'internal_review', toStatusKey: 'in_production' },
|
|
161
|
+
{ name: 'Approve from Lead Review', fromStatusKey: 'internal_review', toStatusKey: 'approved' },
|
|
145
162
|
{ name: 'Request Revisions (Client)', fromStatusKey: 'client_review', toStatusKey: 'revision_requested' },
|
|
163
|
+
{ name: 'Return to Internal Review', fromStatusKey: 'client_review', toStatusKey: 'internal_review' },
|
|
146
164
|
{ name: 'Address Revisions', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
|
|
165
|
+
{ name: 'Resubmit to Lead Review', fromStatusKey: 'revision_requested', toStatusKey: 'internal_review' },
|
|
166
|
+
{ name: 'Resubmit to Client Review', fromStatusKey: 'revision_requested', toStatusKey: 'client_review' },
|
|
147
167
|
{ name: 'Approve Work', fromStatusKey: 'client_review', toStatusKey: 'approved' },
|
|
148
168
|
{ name: 'Deliver Final Assets', fromStatusKey: 'approved', toStatusKey: 'delivered' },
|
|
149
|
-
{ name: '
|
|
169
|
+
{ name: 'Reopen Deliverable', fromStatusKey: 'delivered', toStatusKey: 'in_production' },
|
|
170
|
+
{ name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
|
|
171
|
+
{ name: 'Restore', fromStatusKey: 'canceled', toStatusKey: 'briefing' }
|
|
150
172
|
],
|
|
151
173
|
taskTypes: [
|
|
152
174
|
{ key: 'deliverable', label: 'Deliverable', description: 'Finished asset, cutdown, or packaged export', icon: 'package' },
|
|
@@ -211,6 +233,41 @@ export function getAllowedNextStatuses(
|
|
|
211
233
|
return [allStatusKeys[currentIndex + 1]];
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
export function getAllowedTransitions(
|
|
237
|
+
workflow: Workflow | undefined,
|
|
238
|
+
currentStatus: string
|
|
239
|
+
): string[] {
|
|
240
|
+
if (!workflow || !workflow.statuses || workflow.statuses.length === 0) {
|
|
241
|
+
return Object.keys(DEFAULT_STATUS_DEFINITIONS);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const allStatusKeys = workflow.statuses.map((s) => s.key);
|
|
245
|
+
if (!allStatusKeys.includes(currentStatus)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (workflow.transitions && workflow.transitions.length > 0) {
|
|
250
|
+
const matches = workflow.transitions.filter(
|
|
251
|
+
(t) => t.fromStatusKey === '*' || t.fromStatusKey === currentStatus
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const allowed = new Set<string>();
|
|
255
|
+
for (const t of matches) {
|
|
256
|
+
if (t.toStatusKey === '*') {
|
|
257
|
+
allStatusKeys.filter((k) => k !== currentStatus).forEach((k) => allowed.add(k));
|
|
258
|
+
} else if (t.toStatusKey !== currentStatus && allStatusKeys.includes(t.toStatusKey)) {
|
|
259
|
+
allowed.add(t.toStatusKey);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Array.from(allowed);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const next = getAllowedNextStatuses(workflow, currentStatus);
|
|
267
|
+
const prev = getAllowedPreviousStatuses(workflow, currentStatus);
|
|
268
|
+
return Array.from(new Set([...next, ...prev]));
|
|
269
|
+
}
|
|
270
|
+
|
|
214
271
|
export function getAllowedPreviousStatuses(
|
|
215
272
|
workflow: Workflow | undefined,
|
|
216
273
|
currentStatus: string
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/use-case-validation.test.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/deliverables.test.ts","./src/index.test.ts","./src/index.ts","./src/status.test.ts","./src/task-state.test.ts","./src/use-case-validation.test.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/storage/file-storage.ts","./src/storage/firebase-storage.ts","./src/storage/index.ts","./src/storage/s3.ts","./src/storage/storage.test.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/mentions.test.ts","./src/utils/mentions.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|