@critical-path/core 0.14.0 → 0.15.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/CHANGELOG.md +24 -0
- package/README.md +22 -0
- package/dist/deliverables.test.js +2 -2
- package/dist/deliverables.test.js.map +1 -1
- package/dist/domain/entities.d.ts +2 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +11 -4
- package/dist/domain/entities.js.map +1 -1
- 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 +7 -3
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +102 -13
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +64 -9
- package/dist/index.test.js.map +1 -1
- package/dist/status.test.d.ts +2 -0
- package/dist/status.test.d.ts.map +1 -0
- package/dist/status.test.js +199 -0
- package/dist/status.test.js.map +1 -0
- 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 +14 -1
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/sqlite.test.js +1 -1
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +21 -6
- package/dist/types/index.d.ts.map +1 -1
- package/dist/use-case-validation.test.js +1 -1
- package/dist/use-case-validation.test.js.map +1 -1
- package/dist/utils/status.d.ts +7 -10
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +104 -21
- package/dist/utils/status.js.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 +65 -18
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/deliverables.test.ts +2 -2
- package/src/domain/entities.ts +15 -5
- package/src/domain/events.ts +9 -0
- package/src/engine/index.ts +123 -14
- package/src/index.test.ts +77 -9
- package/src/status.test.ts +226 -0
- package/src/store/firebase.test.ts +17 -1
- package/src/store/firebase.ts +12 -2
- package/src/store/sqlite.test.ts +1 -1
- package/src/types/index.ts +23 -5
- package/src/use-case-validation.test.ts +1 -1
- package/src/utils/status.ts +119 -33
- package/src/utils/workflow.ts +75 -18
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -20,15 +20,17 @@ import type {
|
|
|
20
20
|
CreateProjectInput,
|
|
21
21
|
Deliverable,
|
|
22
22
|
DeliverableSummary,
|
|
23
|
-
CreateDeliverableInput
|
|
23
|
+
CreateDeliverableInput,
|
|
24
|
+
TaskLifecycleState
|
|
24
25
|
} from '../types/index.js';
|
|
25
26
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
26
27
|
import { PluginRegistry } from '../plugins/index.js';
|
|
27
|
-
import { deriveTaskLifecycleState,
|
|
28
|
+
import { deriveTaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
|
|
28
29
|
import {
|
|
29
30
|
validateTransition,
|
|
30
31
|
getAllowedNextStatuses,
|
|
31
32
|
getAllowedPreviousStatuses,
|
|
33
|
+
getAllowedTransitions,
|
|
32
34
|
WorkflowValidationError,
|
|
33
35
|
DEFAULT_SOFTWARE_WORKFLOW
|
|
34
36
|
} from '../utils/workflow.js';
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
TaskCreatedEvent,
|
|
39
41
|
TaskUpdatedEvent,
|
|
40
42
|
TaskStatusChangedEvent,
|
|
43
|
+
TaskUnblockedEvent,
|
|
41
44
|
TaskDeletedEvent,
|
|
42
45
|
ProjectCreatedEvent,
|
|
43
46
|
ProjectUpdatedEvent,
|
|
@@ -234,6 +237,13 @@ export class CriticalPathEngine {
|
|
|
234
237
|
}
|
|
235
238
|
|
|
236
239
|
async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
|
|
240
|
+
const task = await this.store.getTask(taskId);
|
|
241
|
+
if (!task) return [];
|
|
242
|
+
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
243
|
+
return getAllowedTransitions(workflow || undefined, task.status);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
|
|
237
247
|
const task = await this.store.getTask(taskId);
|
|
238
248
|
if (!task) return [];
|
|
239
249
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
@@ -335,16 +345,18 @@ export class CriticalPathEngine {
|
|
|
335
345
|
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
336
346
|
const now = new Date().toISOString();
|
|
337
347
|
|
|
338
|
-
// Derive initial lifecycle timestamps
|
|
348
|
+
// Derive initial lifecycle timestamps and semantic status
|
|
339
349
|
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
340
|
-
const
|
|
341
|
-
const
|
|
350
|
+
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
351
|
+
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
352
|
+
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
342
353
|
|
|
343
354
|
const created = await this.store.createTask({
|
|
344
355
|
projectId,
|
|
345
356
|
title: processedInput.title || taskInput.title,
|
|
346
357
|
description: processedInput.description ?? taskInput.description,
|
|
347
358
|
status: initialStatus,
|
|
359
|
+
semanticStatus,
|
|
348
360
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
349
361
|
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
350
362
|
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
@@ -366,7 +378,7 @@ export class CriticalPathEngine {
|
|
|
366
378
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
367
379
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
368
380
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
369
|
-
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.
|
|
381
|
+
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
370
382
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
371
383
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
372
384
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -428,13 +440,14 @@ export class CriticalPathEngine {
|
|
|
428
440
|
processedUpdates.status,
|
|
429
441
|
project?.statusDefinitions || workflow?.statuses
|
|
430
442
|
);
|
|
443
|
+
processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? statusDef.category;
|
|
431
444
|
const now = new Date().toISOString();
|
|
432
|
-
if (statusDef.
|
|
445
|
+
if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
433
446
|
processedUpdates.actualStartDate = now;
|
|
434
447
|
}
|
|
435
|
-
if (statusDef.
|
|
448
|
+
if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
|
|
436
449
|
processedUpdates.actualEndDate = now;
|
|
437
|
-
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
450
|
+
if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
438
451
|
processedUpdates.progress = 100;
|
|
439
452
|
}
|
|
440
453
|
}
|
|
@@ -463,6 +476,14 @@ export class CriticalPathEngine {
|
|
|
463
476
|
}
|
|
464
477
|
};
|
|
465
478
|
await this.events.publish(statusEvent);
|
|
479
|
+
|
|
480
|
+
const statusDef = resolveStatusDefinition(
|
|
481
|
+
updated.status,
|
|
482
|
+
project?.statusDefinitions || workflow?.statuses
|
|
483
|
+
);
|
|
484
|
+
if (statusDef.category === 'completed') {
|
|
485
|
+
await this.checkAndUnblockDownstreamTasks(updated);
|
|
486
|
+
}
|
|
466
487
|
} else {
|
|
467
488
|
const updateEvent: TaskUpdatedEvent = {
|
|
468
489
|
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
@@ -494,6 +515,82 @@ export class CriticalPathEngine {
|
|
|
494
515
|
return updated;
|
|
495
516
|
}
|
|
496
517
|
|
|
518
|
+
private async checkAndUnblockDownstreamTasks(completedTask: Task): Promise<void> {
|
|
519
|
+
try {
|
|
520
|
+
const graph = await this.getTaskDependencyGraph(completedTask.id);
|
|
521
|
+
if (!graph.downstreamTasks || graph.downstreamTasks.length === 0) return;
|
|
522
|
+
|
|
523
|
+
for (const downstream of graph.downstreamTasks) {
|
|
524
|
+
const downstreamProject = await this.store.getProject(downstream.projectId);
|
|
525
|
+
const downstreamWorkflow = await this.resolveProjectWorkflow(downstream.projectId);
|
|
526
|
+
const downstreamStatusDefs = downstreamProject?.statusDefinitions || downstreamWorkflow?.statuses;
|
|
527
|
+
const downstreamStatusDef = resolveStatusDefinition(downstream.status, downstreamStatusDefs);
|
|
528
|
+
|
|
529
|
+
if (downstreamStatusDef.category === 'completed' || downstreamStatusDef.category === 'canceled') {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const downstreamGraph = await this.getTaskDependencyGraph(downstream.id);
|
|
534
|
+
const allUpstreamsCompleted = downstreamGraph.upstreamTasks.every((up) => {
|
|
535
|
+
const upDef = resolveStatusDefinition(up.status, downstreamStatusDefs);
|
|
536
|
+
return upDef.category === 'completed';
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (allUpstreamsCompleted) {
|
|
540
|
+
let updatedDownstream = downstream;
|
|
541
|
+
const updates: Partial<Task> = {};
|
|
542
|
+
|
|
543
|
+
if (downstream.status === 'blocked') {
|
|
544
|
+
const defaultStatus = downstreamWorkflow?.defaultStatusKey || 'todo';
|
|
545
|
+
updates.status = defaultStatus;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (downstream.customFields?.isBlocked || downstream.customFields?.blockedReason) {
|
|
549
|
+
updates.customFields = {
|
|
550
|
+
...downstream.customFields,
|
|
551
|
+
isBlocked: false,
|
|
552
|
+
blockedReason: null
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (Object.keys(updates).length > 0) {
|
|
557
|
+
const res = await this.store.updateTask(downstream.id, updates);
|
|
558
|
+
if (res) updatedDownstream = res;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const now = new Date().toISOString();
|
|
562
|
+
const unblockedEvent: TaskUnblockedEvent = {
|
|
563
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
564
|
+
name: 'task.unblocked',
|
|
565
|
+
aggregateId: downstream.id,
|
|
566
|
+
aggregateType: 'Task',
|
|
567
|
+
occurredAt: now,
|
|
568
|
+
payload: {
|
|
569
|
+
task: updatedDownstream,
|
|
570
|
+
upstreamTaskId: completedTask.id
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
await this.events.publish(unblockedEvent);
|
|
574
|
+
|
|
575
|
+
await this.store.logActivity({
|
|
576
|
+
projectId: downstream.projectId,
|
|
577
|
+
taskId: downstream.id,
|
|
578
|
+
actorId: 'system',
|
|
579
|
+
action: 'task.unblocked',
|
|
580
|
+
details: { unblockedByTaskId: completedTask.id }
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
this.dispatchWebhook('task.unblocked', {
|
|
584
|
+
task: updatedDownstream,
|
|
585
|
+
upstreamTaskId: completedTask.id
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
} catch (err) {
|
|
590
|
+
console.error(`[CriticalPathEngine] Error during auto-unblocking for task ${completedTask.id}:`, err);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
497
594
|
async deleteTask(id: string): Promise<boolean> {
|
|
498
595
|
const existing = await this.store.getTask(id);
|
|
499
596
|
if (!existing) return false;
|
|
@@ -590,13 +687,25 @@ export class CriticalPathEngine {
|
|
|
590
687
|
};
|
|
591
688
|
}
|
|
592
689
|
|
|
593
|
-
async getTaskLifecycleState(
|
|
690
|
+
async getTaskLifecycleState(
|
|
691
|
+
taskId: string,
|
|
692
|
+
options?: { referenceDate?: Date; stalledThresholdDays?: number }
|
|
693
|
+
): Promise<TaskLifecycleState | null> {
|
|
594
694
|
const task = await this.getTask(taskId);
|
|
595
695
|
if (!task) return null;
|
|
596
696
|
const project = await this.getProject(task.projectId);
|
|
597
697
|
const workflow = await this.resolveProjectWorkflow(task.projectId);
|
|
598
698
|
const statusDefs = project?.statusDefinitions || workflow?.statuses;
|
|
599
|
-
|
|
699
|
+
|
|
700
|
+
const graph = await this.getTaskDependencyGraph(taskId);
|
|
701
|
+
const upstreamTasks = graph.upstreamTasks;
|
|
702
|
+
|
|
703
|
+
return deriveTaskLifecycleState(task, {
|
|
704
|
+
customDefinitions: statusDefs,
|
|
705
|
+
referenceDate: options?.referenceDate,
|
|
706
|
+
stalledThresholdDays: options?.stalledThresholdDays,
|
|
707
|
+
upstreamTasks
|
|
708
|
+
});
|
|
600
709
|
}
|
|
601
710
|
|
|
602
711
|
// --- Time Tracking ---
|
|
@@ -1142,9 +1251,9 @@ export class CriticalPathEngine {
|
|
|
1142
1251
|
|
|
1143
1252
|
for (const task of tasks) {
|
|
1144
1253
|
const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
|
|
1145
|
-
if (statusDef.
|
|
1254
|
+
if (statusDef.category === 'completed') {
|
|
1146
1255
|
completedTasks++;
|
|
1147
|
-
} else if (statusDef.
|
|
1256
|
+
} else if (statusDef.category === 'in_progress') {
|
|
1148
1257
|
activeTasks++;
|
|
1149
1258
|
}
|
|
1150
1259
|
|
|
@@ -1154,7 +1263,7 @@ export class CriticalPathEngine {
|
|
|
1154
1263
|
if (typeof task.progress === 'number') {
|
|
1155
1264
|
totalProgress += task.progress;
|
|
1156
1265
|
} else {
|
|
1157
|
-
totalProgress += statusDef.
|
|
1266
|
+
totalProgress += statusDef.category === 'completed' ? 100 : 0;
|
|
1158
1267
|
}
|
|
1159
1268
|
}
|
|
1160
1269
|
|
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CriticalPathEngine, deriveTaskLifecycleState } from './index.js';
|
|
2
|
+
import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } from './index.js';
|
|
3
3
|
import type { CriticalPathPlugin } from './types/index.js';
|
|
4
4
|
|
|
5
5
|
describe('CriticalPathEngine Core Tests', () => {
|
|
@@ -128,9 +128,9 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
128
128
|
key: 'CUST',
|
|
129
129
|
name: 'Custom Workflow Project',
|
|
130
130
|
statusDefinitions: [
|
|
131
|
-
{ key: 'draft', label: 'Draft',
|
|
132
|
-
{ key: 'active_work', label: 'Active Work',
|
|
133
|
-
{ key: 'finished', label: 'Finished',
|
|
131
|
+
{ key: 'draft', label: 'Draft', category: 'not_started' },
|
|
132
|
+
{ key: 'active_work', label: 'Active Work', category: 'in_progress' },
|
|
133
|
+
{ key: 'finished', label: 'Finished', category: 'completed' }
|
|
134
134
|
]
|
|
135
135
|
});
|
|
136
136
|
|
|
@@ -145,10 +145,10 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
145
145
|
|
|
146
146
|
const state = await engine.getTaskLifecycleState(task.id);
|
|
147
147
|
expect(state).not.toBeNull();
|
|
148
|
-
expect(state?.
|
|
149
|
-
expect(state?.executionState).toBe('active');
|
|
148
|
+
expect(state?.semanticStatus).toBe('in_progress');
|
|
150
149
|
expect(state?.isActive).toBe(true);
|
|
151
150
|
expect(state?.isDone).toBe(false);
|
|
151
|
+
expect(state?.isBlocked).toBe(false);
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it('builds task dependency graph with upstream and downstream tasks', async () => {
|
|
@@ -181,9 +181,9 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
181
181
|
name: 'Strict Software Workflow',
|
|
182
182
|
defaultStatusKey: 'backlog',
|
|
183
183
|
statuses: [
|
|
184
|
-
{ key: 'backlog', label: 'Backlog',
|
|
185
|
-
{ key: 'in_progress', label: 'In Progress',
|
|
186
|
-
{ key: 'done', label: 'Done',
|
|
184
|
+
{ key: 'backlog', label: 'Backlog', category: 'not_started' },
|
|
185
|
+
{ key: 'in_progress', label: 'In Progress', category: 'in_progress' },
|
|
186
|
+
{ key: 'done', label: 'Done', category: 'completed' }
|
|
187
187
|
],
|
|
188
188
|
transitions: [
|
|
189
189
|
{ id: 't1', fromStatusKey: 'backlog', toStatusKey: 'in_progress', name: 'Start Work' },
|
|
@@ -372,6 +372,74 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
372
372
|
const backlogPrev = await engine.getAllowedPreviousTaskTransitions(backlogTask.id);
|
|
373
373
|
expect(backlogPrev).toEqual([]);
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
it('automatically unblocks downstream tasks and publishes task.unblocked event when upstream task completes', async () => {
|
|
377
|
+
const engine = new CriticalPathEngine();
|
|
378
|
+
const proj = await engine.createProject({ key: 'UNBLK', name: 'Auto-Unblock Project' });
|
|
379
|
+
|
|
380
|
+
const upstream = await engine.createTask({
|
|
381
|
+
projectId: proj.id,
|
|
382
|
+
title: 'Upstream Task',
|
|
383
|
+
status: 'in_progress'
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const downstream = await engine.createTask({
|
|
387
|
+
projectId: proj.id,
|
|
388
|
+
title: 'Downstream Task',
|
|
389
|
+
status: 'blocked',
|
|
390
|
+
customFields: { isBlocked: true, blockedReason: 'Waiting on upstream' }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await engine.addDependency({
|
|
394
|
+
taskId: downstream.id,
|
|
395
|
+
dependsOnTaskId: upstream.id,
|
|
396
|
+
type: 'blocking'
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const unblockedEvents: any[] = [];
|
|
400
|
+
engine.events.subscribe('task.unblocked', async (evt) => {
|
|
401
|
+
unblockedEvents.push(evt);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Complete upstream task
|
|
405
|
+
const completedUpstream = await engine.updateTask(upstream.id, { status: 'done' });
|
|
406
|
+
expect(completedUpstream?.status).toBe('done');
|
|
407
|
+
|
|
408
|
+
// Downstream task should now be unblocked
|
|
409
|
+
expect(unblockedEvents).toHaveLength(1);
|
|
410
|
+
expect(unblockedEvents[0].payload.task.id).toBe(downstream.id);
|
|
411
|
+
expect(unblockedEvents[0].payload.upstreamTaskId).toBe(upstream.id);
|
|
412
|
+
|
|
413
|
+
const refreshedDownstream = await engine.getTask(downstream.id);
|
|
414
|
+
expect(refreshedDownstream?.status).toBe('todo');
|
|
415
|
+
expect(refreshedDownstream?.customFields?.isBlocked).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('allows VFX workflow transitions from revision_requested to in_production', async () => {
|
|
419
|
+
const engine = new CriticalPathEngine();
|
|
420
|
+
const vfxWorkflow = await engine.createWorkflow(DEFAULT_VFX_WORKFLOW);
|
|
421
|
+
const proj = await engine.createProject({
|
|
422
|
+
key: 'VFX',
|
|
423
|
+
name: 'VFX Film Project',
|
|
424
|
+
workflowId: vfxWorkflow.id
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const task = await engine.createTask({
|
|
428
|
+
projectId: proj.id,
|
|
429
|
+
title: 'Compositing Shot 010',
|
|
430
|
+
status: 'revision_requested'
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const allowed = await engine.getAllowedTaskTransitions(task.id);
|
|
434
|
+
expect(allowed).toContain('in_production');
|
|
435
|
+
expect(allowed).toContain('internal_review');
|
|
436
|
+
|
|
437
|
+
const allowedPrevious = await engine.getAllowedPreviousTaskTransitions(task.id);
|
|
438
|
+
expect(allowedPrevious).toContain('in_production');
|
|
439
|
+
|
|
440
|
+
const updated = await engine.updateTask(task.id, { status: 'in_production' });
|
|
441
|
+
expect(updated?.status).toBe('in_production');
|
|
442
|
+
});
|
|
375
443
|
});
|
|
376
444
|
|
|
377
445
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CriticalPathEngine } from './engine/index.js';
|
|
3
|
+
import { resolveStatusDefinition, deriveTaskLifecycleState } from './utils/status.js';
|
|
4
|
+
import type { Task, StatusDefinition } from './types/index.js';
|
|
5
|
+
|
|
6
|
+
describe('Universal Semantic Statuses & Implied Statuses', () => {
|
|
7
|
+
describe('resolveStatusDefinition', () => {
|
|
8
|
+
it('resolves core standard statuses to their semantic category', () => {
|
|
9
|
+
expect(resolveStatusDefinition('todo').category).toBe('not_started');
|
|
10
|
+
expect(resolveStatusDefinition('backlog').category).toBe('not_started');
|
|
11
|
+
expect(resolveStatusDefinition('in_progress').category).toBe('in_progress');
|
|
12
|
+
expect(resolveStatusDefinition('in_review').category).toBe('in_progress');
|
|
13
|
+
expect(resolveStatusDefinition('done').category).toBe('completed');
|
|
14
|
+
expect(resolveStatusDefinition('canceled').category).toBe('canceled');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('resolves custom status definitions when provided', () => {
|
|
18
|
+
const customDefs: StatusDefinition[] = [
|
|
19
|
+
{ key: 'concept', label: 'Concept & Brainstorming', category: 'not_started' },
|
|
20
|
+
{ key: 'client_review', label: 'Client Feedback Session', category: 'in_progress' },
|
|
21
|
+
{ key: 'shipped', label: 'Shipped to Customer', category: 'completed' },
|
|
22
|
+
{ key: 'abandoned', label: 'Abandoned Initiative', category: 'canceled' }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
expect(resolveStatusDefinition('concept', customDefs).category).toBe('not_started');
|
|
26
|
+
expect(resolveStatusDefinition('client_review', customDefs).category).toBe('in_progress');
|
|
27
|
+
expect(resolveStatusDefinition('shipped', customDefs).category).toBe('completed');
|
|
28
|
+
expect(resolveStatusDefinition('abandoned', customDefs).category).toBe('canceled');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('infers category heuristically when unknown status key is provided', () => {
|
|
32
|
+
expect(resolveStatusDefinition('draft_v2').category).toBe('not_started');
|
|
33
|
+
expect(resolveStatusDefinition('final_approved').category).toBe('completed');
|
|
34
|
+
expect(resolveStatusDefinition('active_development').category).toBe('in_progress');
|
|
35
|
+
expect(resolveStatusDefinition('rejected_proposal').category).toBe('canceled');
|
|
36
|
+
expect(resolveStatusDefinition('custom_arbitrary_key').category).toBe('not_started');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('deriveTaskLifecycleState', () => {
|
|
41
|
+
const baseTask: Task = {
|
|
42
|
+
id: 'task_1',
|
|
43
|
+
projectId: 'proj_1',
|
|
44
|
+
title: 'Design Wireframes',
|
|
45
|
+
status: 'todo',
|
|
46
|
+
priority: 'high',
|
|
47
|
+
createdAt: '2026-09-01T00:00:00.000Z',
|
|
48
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
it('derives basic state for not_started task', () => {
|
|
52
|
+
const derived = deriveTaskLifecycleState(baseTask);
|
|
53
|
+
expect(derived.semanticStatus).toBe('not_started');
|
|
54
|
+
expect(derived.isActive).toBe(false);
|
|
55
|
+
expect(derived.isDone).toBe(false);
|
|
56
|
+
expect(derived.isCancelled).toBe(false);
|
|
57
|
+
expect(derived.isReady).toBe(true);
|
|
58
|
+
expect(derived.isBlocked).toBe(false);
|
|
59
|
+
expect(derived.isUnassigned).toBe(true);
|
|
60
|
+
expect(derived.isUnplanned).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('derives blocked and ready states based on upstream tasks', () => {
|
|
64
|
+
const upstream1: Task = {
|
|
65
|
+
...baseTask,
|
|
66
|
+
id: 'task_up1',
|
|
67
|
+
status: 'in_progress',
|
|
68
|
+
semanticStatus: 'in_progress'
|
|
69
|
+
};
|
|
70
|
+
const upstream2: Task = {
|
|
71
|
+
...baseTask,
|
|
72
|
+
id: 'task_up2',
|
|
73
|
+
status: 'done',
|
|
74
|
+
semanticStatus: 'completed'
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// When upstream1 is in progress, task_1 is blocked
|
|
78
|
+
const blockedState = deriveTaskLifecycleState(baseTask, {
|
|
79
|
+
upstreamTasks: [upstream1, upstream2]
|
|
80
|
+
});
|
|
81
|
+
expect(blockedState.isBlocked).toBe(true);
|
|
82
|
+
expect(blockedState.isReady).toBe(false);
|
|
83
|
+
expect(blockedState.blockingTaskIds).toEqual(['task_up1']);
|
|
84
|
+
|
|
85
|
+
// When all upstream tasks are completed, task_1 becomes ready
|
|
86
|
+
const allDoneState = deriveTaskLifecycleState(baseTask, {
|
|
87
|
+
upstreamTasks: [
|
|
88
|
+
{ ...upstream1, status: 'done', semanticStatus: 'completed' },
|
|
89
|
+
upstream2
|
|
90
|
+
]
|
|
91
|
+
});
|
|
92
|
+
expect(allDoneState.isBlocked).toBe(false);
|
|
93
|
+
expect(allDoneState.isReady).toBe(true);
|
|
94
|
+
expect(allDoneState.blockingTaskIds).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('derives schedule indicators: overdue, upcoming, unplanned', () => {
|
|
98
|
+
const refDate = new Date('2026-09-10T12:00:00.000Z');
|
|
99
|
+
|
|
100
|
+
// Overdue task
|
|
101
|
+
const overdueTask: Task = {
|
|
102
|
+
...baseTask,
|
|
103
|
+
dueDate: '2026-09-08T00:00:00.000Z'
|
|
104
|
+
};
|
|
105
|
+
const overdueDerived = deriveTaskLifecycleState(overdueTask, { referenceDate: refDate });
|
|
106
|
+
expect(overdueDerived.isOverdue).toBe(true);
|
|
107
|
+
expect(overdueDerived.isUnplanned).toBe(false);
|
|
108
|
+
|
|
109
|
+
// Completed task is not overdue even if dueDate is past
|
|
110
|
+
const completedTask: Task = {
|
|
111
|
+
...overdueTask,
|
|
112
|
+
status: 'done'
|
|
113
|
+
};
|
|
114
|
+
const completedDerived = deriveTaskLifecycleState(completedTask, { referenceDate: refDate });
|
|
115
|
+
expect(completedDerived.isOverdue).toBe(false);
|
|
116
|
+
expect(completedDerived.isDone).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Upcoming task
|
|
119
|
+
const upcomingTask: Task = {
|
|
120
|
+
...baseTask,
|
|
121
|
+
plannedStartDate: '2026-09-12T00:00:00.000Z' // within 3 days of Sept 10
|
|
122
|
+
};
|
|
123
|
+
const upcomingDerived = deriveTaskLifecycleState(upcomingTask, { referenceDate: refDate });
|
|
124
|
+
expect(upcomingDerived.isUpcoming).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('derives resource indicators: unassigned and stalled', () => {
|
|
128
|
+
const refDate = new Date('2026-09-15T12:00:00.000Z');
|
|
129
|
+
|
|
130
|
+
// Assigned via assigneeId
|
|
131
|
+
const assignedTask1: Task = {
|
|
132
|
+
...baseTask,
|
|
133
|
+
assigneeId: 'user_alice'
|
|
134
|
+
};
|
|
135
|
+
expect(deriveTaskLifecycleState(assignedTask1).isUnassigned).toBe(false);
|
|
136
|
+
|
|
137
|
+
// Assigned via assignees array
|
|
138
|
+
const assignedTask2: Task = {
|
|
139
|
+
...baseTask,
|
|
140
|
+
assignees: [{ id: 'user_bob' }]
|
|
141
|
+
};
|
|
142
|
+
expect(deriveTaskLifecycleState(assignedTask2).isUnassigned).toBe(false);
|
|
143
|
+
|
|
144
|
+
// Stalled task: in_progress with no update in > 7 days
|
|
145
|
+
const stalledTask: Task = {
|
|
146
|
+
...baseTask,
|
|
147
|
+
status: 'in_progress',
|
|
148
|
+
updatedAt: '2026-09-01T00:00:00.000Z'
|
|
149
|
+
};
|
|
150
|
+
const stalledDerived = deriveTaskLifecycleState(stalledTask, { referenceDate: refDate, stalledThresholdDays: 7 });
|
|
151
|
+
expect(stalledDerived.isStalled).toBe(true);
|
|
152
|
+
|
|
153
|
+
// Not stalled if updated recently
|
|
154
|
+
const activeTask: Task = {
|
|
155
|
+
...stalledTask,
|
|
156
|
+
updatedAt: '2026-09-14T00:00:00.000Z'
|
|
157
|
+
};
|
|
158
|
+
expect(deriveTaskLifecycleState(activeTask, { referenceDate: refDate, stalledThresholdDays: 7 }).isStalled).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('derives estimate indicators: isOverEstimate and isPaceWarning', () => {
|
|
162
|
+
// Over estimate by logged hours
|
|
163
|
+
const overEstimateTask: Task = {
|
|
164
|
+
...baseTask,
|
|
165
|
+
status: 'in_progress',
|
|
166
|
+
estimatedHours: 10,
|
|
167
|
+
loggedHours: 12
|
|
168
|
+
};
|
|
169
|
+
const overEstDerived = deriveTaskLifecycleState(overEstimateTask);
|
|
170
|
+
expect(overEstDerived.isOverEstimate).toBe(true);
|
|
171
|
+
|
|
172
|
+
// Pace warning: in_progress task that has been active longer than estimated duration
|
|
173
|
+
const refDate = new Date('2026-09-10T00:00:00.000Z');
|
|
174
|
+
const paceWarningTask: Task = {
|
|
175
|
+
...baseTask,
|
|
176
|
+
status: 'in_progress',
|
|
177
|
+
actualStartDate: '2026-09-01T00:00:00.000Z', // 9 days = 216 hours elapsed
|
|
178
|
+
estimatedHours: 40 // only 40 hours estimated
|
|
179
|
+
};
|
|
180
|
+
const paceDerived = deriveTaskLifecycleState(paceWarningTask, { referenceDate: refDate });
|
|
181
|
+
expect(paceDerived.isPaceWarning).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('Engine Integration', () => {
|
|
186
|
+
it('computes full lifecycle state with dependency graph integration', async () => {
|
|
187
|
+
const engine = new CriticalPathEngine();
|
|
188
|
+
const proj = await engine.createProject({ key: 'LIFECYCLE', name: 'Lifecycle Test' });
|
|
189
|
+
|
|
190
|
+
const taskA = await engine.createTask({
|
|
191
|
+
projectId: proj.id,
|
|
192
|
+
title: 'Task A (Dependency)',
|
|
193
|
+
status: 'todo',
|
|
194
|
+
priority: 'high'
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const taskB = await engine.createTask({
|
|
198
|
+
projectId: proj.id,
|
|
199
|
+
title: 'Task B (Dependent)',
|
|
200
|
+
status: 'todo',
|
|
201
|
+
priority: 'medium'
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await engine.store.addDependency({
|
|
205
|
+
taskId: taskB.id,
|
|
206
|
+
dependsOnTaskId: taskA.id,
|
|
207
|
+
type: 'blocking'
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Task B should be blocked by Task A
|
|
211
|
+
let stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
212
|
+
expect(stateB?.isBlocked).toBe(true);
|
|
213
|
+
expect(stateB?.isReady).toBe(false);
|
|
214
|
+
expect(stateB?.blockingTaskIds).toEqual([taskA.id]);
|
|
215
|
+
|
|
216
|
+
// Complete Task A
|
|
217
|
+
await engine.updateTask(taskA.id, { status: 'done' });
|
|
218
|
+
|
|
219
|
+
// Task B should now be ready
|
|
220
|
+
stateB = await engine.getTaskLifecycleState(taskB.id);
|
|
221
|
+
expect(stateB?.isBlocked).toBe(false);
|
|
222
|
+
expect(stateB?.isReady).toBe(true);
|
|
223
|
+
expect(stateB?.blockingTaskIds).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -62,7 +62,7 @@ describe('FirebaseStore', () => {
|
|
|
62
62
|
name: 'Firebase Workflow',
|
|
63
63
|
isDefault: false,
|
|
64
64
|
defaultStatusKey: 'backlog',
|
|
65
|
-
statuses: [{ key: 'backlog', label: 'Backlog',
|
|
65
|
+
statuses: [{ key: 'backlog', label: 'Backlog', category: 'not_started' }],
|
|
66
66
|
transitions: []
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -91,5 +91,21 @@ describe('FirebaseStore', () => {
|
|
|
91
91
|
expect(doc?.name).toBe('Undefined Test');
|
|
92
92
|
expect('description' in (doc as any)).toBe(false);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it('should retrieve dependencies when queried as taskId or dependsOnTaskId', async () => {
|
|
96
|
+
const dep = await store.addDependency({
|
|
97
|
+
taskId: 'task-downstream',
|
|
98
|
+
dependsOnTaskId: 'task-upstream',
|
|
99
|
+
type: 'blocking'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const byDownstream = await store.getDependencies('task-downstream');
|
|
103
|
+
expect(byDownstream).toHaveLength(1);
|
|
104
|
+
expect(byDownstream[0].id).toBe(dep.id);
|
|
105
|
+
|
|
106
|
+
const byUpstream = await store.getDependencies('task-upstream');
|
|
107
|
+
expect(byUpstream).toHaveLength(1);
|
|
108
|
+
expect(byUpstream[0].id).toBe(dep.id);
|
|
109
|
+
});
|
|
94
110
|
});
|
|
95
111
|
|
package/src/store/firebase.ts
CHANGED
|
@@ -546,8 +546,18 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
546
546
|
|
|
547
547
|
// --- Dependencies ---
|
|
548
548
|
async getDependencies(taskId: string): Promise<TaskDependency[]> {
|
|
549
|
-
const
|
|
550
|
-
|
|
549
|
+
const [snap1, snap2] = await Promise.all([
|
|
550
|
+
this.db.collection('dependencies').where('taskId', '==', taskId).get(),
|
|
551
|
+
this.db.collection('dependencies').where('dependsOnTaskId', '==', taskId).get()
|
|
552
|
+
]);
|
|
553
|
+
const depMap = new Map<string, TaskDependency>();
|
|
554
|
+
for (const doc of snap1.docs) {
|
|
555
|
+
depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
|
|
556
|
+
}
|
|
557
|
+
for (const doc of snap2.docs) {
|
|
558
|
+
depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
|
|
559
|
+
}
|
|
560
|
+
return Array.from(depMap.values());
|
|
551
561
|
}
|
|
552
562
|
|
|
553
563
|
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -155,7 +155,7 @@ describe('SQLiteStore', () => {
|
|
|
155
155
|
name: 'SQLite Workflow',
|
|
156
156
|
isDefault: true,
|
|
157
157
|
defaultStatusKey: 'todo',
|
|
158
|
-
statuses: [{ key: 'todo', label: 'To Do',
|
|
158
|
+
statuses: [{ key: 'todo', label: 'To Do', category: 'not_started' }],
|
|
159
159
|
transitions: []
|
|
160
160
|
});
|
|
161
161
|
|