@critical-path/core 0.19.0 → 0.20.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/CHANGELOG.md +19 -0
- package/dist/domain/calendar.d.ts +13 -0
- package/dist/domain/calendar.d.ts.map +1 -1
- package/dist/domain/calendar.js +29 -0
- package/dist/domain/calendar.js.map +1 -1
- package/dist/domain/calendar.test.js +46 -1
- package/dist/domain/calendar.test.js.map +1 -1
- package/dist/domain/entities.d.ts +2 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +55 -7
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/events.d.ts +28 -0
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +14 -2
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/task-duration.test.d.ts +2 -0
- package/dist/domain/task-duration.test.d.ts.map +1 -0
- package/dist/domain/task-duration.test.js +146 -0
- package/dist/domain/task-duration.test.js.map +1 -0
- package/dist/engine/index.d.ts +19 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +106 -22
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +41 -0
- package/dist/index.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +9 -5
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/calendar.test.ts +53 -1
- package/src/domain/calendar.ts +45 -0
- package/src/domain/entities.ts +57 -7
- package/src/domain/events.ts +16 -1
- package/src/domain/metrics.ts +14 -2
- package/src/domain/task-duration.test.ts +170 -0
- package/src/engine/index.ts +134 -22
- package/src/index.test.ts +48 -0
- package/src/store/sqlite.ts +11 -3
- package/src/types/index.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { TaskEntity } from './entities.js';
|
|
3
|
+
import { CriticalPathEngine } from '../engine/index.js';
|
|
4
|
+
import { InMemoryStore } from '../store/index.js';
|
|
5
|
+
import { DEFAULT_SOFTWARE_WORKFLOW } from '../utils/workflow.js';
|
|
6
|
+
|
|
7
|
+
describe('Task Execution Timestamps & Cumulative In-Progress Duration', () => {
|
|
8
|
+
it('stamps actualStartDate and inProgressSince when moving to in_progress', () => {
|
|
9
|
+
const task = TaskEntity.create({
|
|
10
|
+
projectId: 'proj_1',
|
|
11
|
+
title: 'Implement Task Timestamps',
|
|
12
|
+
status: 'todo'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(task.actualStartDate).toBeUndefined();
|
|
16
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
17
|
+
expect(task.inProgressSince).toBeUndefined();
|
|
18
|
+
expect(task.actualDurationSeconds).toBeUndefined();
|
|
19
|
+
|
|
20
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
21
|
+
|
|
22
|
+
expect(task.actualStartDate).toBeDefined();
|
|
23
|
+
expect(task.inProgressSince).toBeDefined();
|
|
24
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accumulates in-progress duration across pause/resume cycles and retains earliest actualStartDate', async () => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
try {
|
|
30
|
+
const now = new Date('2026-09-23T10:00:00Z');
|
|
31
|
+
vi.setSystemTime(now);
|
|
32
|
+
|
|
33
|
+
const task = TaskEntity.create({
|
|
34
|
+
projectId: 'proj_1',
|
|
35
|
+
title: 'Work Tracking',
|
|
36
|
+
status: 'todo'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 1. Start working at 10:00
|
|
40
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
41
|
+
const initialStartDate = task.actualStartDate;
|
|
42
|
+
expect(initialStartDate).toBe(now.toISOString());
|
|
43
|
+
expect(task.inProgressSince).toBe(now.toISOString());
|
|
44
|
+
|
|
45
|
+
// Fast-forward 30 minutes (1800s)
|
|
46
|
+
vi.setSystemTime(new Date('2026-09-23T10:30:00Z'));
|
|
47
|
+
|
|
48
|
+
// 2. Pause: move to todo
|
|
49
|
+
task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
|
|
50
|
+
expect(task.inProgressSince).toBeNull();
|
|
51
|
+
expect(task.actualDurationSeconds).toBe(1800);
|
|
52
|
+
expect(task.actualHours).toBe(0.5);
|
|
53
|
+
expect(task.actualDurationMinutes).toBe(30);
|
|
54
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
55
|
+
|
|
56
|
+
// Paused for 1 hour
|
|
57
|
+
vi.setSystemTime(new Date('2026-09-23T11:30:00Z'));
|
|
58
|
+
|
|
59
|
+
// 3. Resume: move back to in_progress
|
|
60
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
61
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Still retains earliest
|
|
62
|
+
expect(task.inProgressSince).toBe(new Date('2026-09-23T11:30:00Z').toISOString());
|
|
63
|
+
expect(task.actualDurationSeconds).toBe(1800); // Unchanged until leaving
|
|
64
|
+
|
|
65
|
+
// Fast-forward another 45 minutes (2700s)
|
|
66
|
+
vi.setSystemTime(new Date('2026-09-23T12:15:00Z'));
|
|
67
|
+
|
|
68
|
+
// 4. Complete: move to done
|
|
69
|
+
task.transitionTo('done', DEFAULT_SOFTWARE_WORKFLOW);
|
|
70
|
+
expect(task.inProgressSince).toBeNull();
|
|
71
|
+
// Total duration = 1800s + 2700s = 4500s = 1.25h
|
|
72
|
+
expect(task.actualDurationSeconds).toBe(4500);
|
|
73
|
+
expect(task.actualHours).toBe(1.25);
|
|
74
|
+
expect(task.actualDurationMinutes).toBe(75);
|
|
75
|
+
expect(task.actualStartDate).toBe(initialStartDate);
|
|
76
|
+
expect(task.actualEndDate).toBe(new Date('2026-09-23T12:15:00Z').toISOString());
|
|
77
|
+
expect(task.progress).toBe(100);
|
|
78
|
+
|
|
79
|
+
// 5. Reopen: move from done to todo, then to in_progress
|
|
80
|
+
vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
|
|
81
|
+
task.transitionTo('todo', DEFAULT_SOFTWARE_WORKFLOW);
|
|
82
|
+
expect(task.actualEndDate).toBeUndefined(); // Reset upon reopen
|
|
83
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
84
|
+
|
|
85
|
+
vi.setSystemTime(new Date('2026-09-23T14:15:00Z'));
|
|
86
|
+
task.transitionTo('in_progress', DEFAULT_SOFTWARE_WORKFLOW);
|
|
87
|
+
expect(task.actualEndDate).toBeUndefined();
|
|
88
|
+
expect(task.actualStartDate).toBe(initialStartDate); // Retains earliest
|
|
89
|
+
expect(task.inProgressSince).toBe(new Date('2026-09-23T14:15:00Z').toISOString());
|
|
90
|
+
} finally {
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps actualDurationSeconds and actualHours synchronized on manual updates', () => {
|
|
96
|
+
const task = TaskEntity.create({
|
|
97
|
+
projectId: 'proj_1',
|
|
98
|
+
title: 'Manual Adjustments',
|
|
99
|
+
status: 'todo'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Update actualDurationSeconds directly
|
|
103
|
+
task.update({ actualDurationSeconds: 7200 });
|
|
104
|
+
expect(task.actualHours).toBe(2);
|
|
105
|
+
expect(task.actualDurationMinutes).toBe(120);
|
|
106
|
+
|
|
107
|
+
// Update actualHours directly
|
|
108
|
+
task.update({ actualHours: 1.5 });
|
|
109
|
+
expect(task.actualDurationSeconds).toBe(5400);
|
|
110
|
+
expect(task.actualDurationMinutes).toBe(90);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('integrates with CriticalPathEngine updateTask and metrics calculation', async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
try {
|
|
116
|
+
const store = new InMemoryStore();
|
|
117
|
+
const engine = new CriticalPathEngine({ store });
|
|
118
|
+
|
|
119
|
+
const project = await engine.createProject({ name: 'Engine Duration Test' });
|
|
120
|
+
|
|
121
|
+
vi.setSystemTime(new Date('2026-09-23T09:00:00Z'));
|
|
122
|
+
const task = await engine.createTask({
|
|
123
|
+
projectId: project.id,
|
|
124
|
+
title: 'Build Feature',
|
|
125
|
+
estimatedHours: 8
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 1. Move to in_progress
|
|
129
|
+
const started = await engine.updateTask(task.id, { status: 'in_progress' });
|
|
130
|
+
expect(started?.actualStartDate).toBe('2026-09-23T09:00:00.000Z');
|
|
131
|
+
expect(started?.inProgressSince).toBe('2026-09-23T09:00:00.000Z');
|
|
132
|
+
expect(started?.actualEndDate).toBeUndefined();
|
|
133
|
+
|
|
134
|
+
// 2. Work for 2 hours (7200s), then move to todo (pause)
|
|
135
|
+
vi.setSystemTime(new Date('2026-09-23T11:00:00Z'));
|
|
136
|
+
const paused = await engine.updateTask(task.id, { status: 'todo' });
|
|
137
|
+
expect(paused?.inProgressSince).toBeNull();
|
|
138
|
+
expect(paused?.actualDurationSeconds).toBe(7200);
|
|
139
|
+
expect(paused?.actualHours).toBe(2);
|
|
140
|
+
|
|
141
|
+
// 3. Complete task at 14:00 (move from todo to done)
|
|
142
|
+
vi.setSystemTime(new Date('2026-09-23T14:00:00Z'));
|
|
143
|
+
const completed = await engine.updateTask(task.id, { status: 'done' });
|
|
144
|
+
expect(completed?.actualEndDate).toBe('2026-09-23T14:00:00.000Z');
|
|
145
|
+
// in-progress duration should still be 7200s (since time in todo was paused)
|
|
146
|
+
expect(completed?.actualDurationSeconds).toBe(7200);
|
|
147
|
+
|
|
148
|
+
// 4. Verify metrics calculation
|
|
149
|
+
const metrics = await engine.getTaskMetrics(task.id);
|
|
150
|
+
expect(metrics?.inferredActuals.actualDurationSeconds).toBe(7200);
|
|
151
|
+
expect(metrics?.inferredActuals.activeWorkingHours).toBe(2);
|
|
152
|
+
// Calendar duration: 09:00 to 14:00 = 5.0 hours
|
|
153
|
+
expect(metrics?.inferredActuals.calendarDurationHours).toBe(5);
|
|
154
|
+
|
|
155
|
+
// 5. Reopen task (done -> todo -> in_progress): actualEndDate must be cleared (undefined)
|
|
156
|
+
vi.setSystemTime(new Date('2026-09-23T15:00:00Z'));
|
|
157
|
+
const reopenedTodo = await engine.updateTask(task.id, { status: 'todo' });
|
|
158
|
+
expect(reopenedTodo?.actualEndDate).toBeUndefined();
|
|
159
|
+
|
|
160
|
+
vi.setSystemTime(new Date('2026-09-23T15:30:00Z'));
|
|
161
|
+
const reopenedProgress = await engine.updateTask(task.id, { status: 'in_progress' });
|
|
162
|
+
expect(reopenedProgress?.actualEndDate).toBeUndefined();
|
|
163
|
+
expect(reopenedProgress?.actualStartDate).toBe('2026-09-23T09:00:00.000Z'); // Earliest preserved
|
|
164
|
+
expect(reopenedProgress?.inProgressSince).toBe('2026-09-23T15:30:00.000Z');
|
|
165
|
+
expect(reopenedProgress?.actualDurationSeconds).toBe(7200);
|
|
166
|
+
} finally {
|
|
167
|
+
vi.useRealTimers();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -371,6 +371,7 @@ export class CriticalPathEngine {
|
|
|
371
371
|
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
372
372
|
const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
|
|
373
373
|
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
|
+
const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
|
|
374
375
|
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
|
|
375
376
|
|
|
376
377
|
const created = await this.store.createTask({
|
|
@@ -400,6 +401,8 @@ export class CriticalPathEngine {
|
|
|
400
401
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
401
402
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
402
403
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
404
|
+
actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
|
|
405
|
+
inProgressSince,
|
|
403
406
|
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
|
|
404
407
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
405
408
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
@@ -431,30 +434,76 @@ export class CriticalPathEngine {
|
|
|
431
434
|
return created;
|
|
432
435
|
}
|
|
433
436
|
|
|
434
|
-
async updateTask(
|
|
437
|
+
async updateTask(
|
|
438
|
+
id: string,
|
|
439
|
+
updates: Partial<Task> & {
|
|
440
|
+
actorId?: string;
|
|
441
|
+
actorName?: string;
|
|
442
|
+
actorType?: string;
|
|
443
|
+
actor?: { userId: string; username?: string; actorType?: string };
|
|
444
|
+
},
|
|
445
|
+
options?: {
|
|
446
|
+
actorId?: string;
|
|
447
|
+
actorName?: string;
|
|
448
|
+
actorType?: string;
|
|
449
|
+
actor?: { userId: string; username?: string; actorType?: string };
|
|
450
|
+
}
|
|
451
|
+
): Promise<Task | null> {
|
|
435
452
|
const existing = await this.store.getTask(id);
|
|
436
453
|
if (!existing) return null;
|
|
437
454
|
|
|
455
|
+
const actorId =
|
|
456
|
+
options?.actorId ||
|
|
457
|
+
updates.actorId ||
|
|
458
|
+
options?.actor?.userId ||
|
|
459
|
+
updates.actor?.userId ||
|
|
460
|
+
'system';
|
|
461
|
+
const actorName =
|
|
462
|
+
options?.actorName ||
|
|
463
|
+
updates.actorName ||
|
|
464
|
+
options?.actor?.username ||
|
|
465
|
+
updates.actor?.username ||
|
|
466
|
+
(actorId === 'system' ? 'System' : undefined);
|
|
467
|
+
const actorType =
|
|
468
|
+
options?.actorType ||
|
|
469
|
+
updates.actorType ||
|
|
470
|
+
options?.actor?.actorType ||
|
|
471
|
+
updates.actor?.actorType ||
|
|
472
|
+
(actorId === 'system' ? 'system' : 'user');
|
|
473
|
+
const actorObj = options?.actor || updates.actor || {
|
|
474
|
+
userId: actorId,
|
|
475
|
+
username: actorName,
|
|
476
|
+
actorType
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const {
|
|
480
|
+
actorId: _aId,
|
|
481
|
+
actorName: _aName,
|
|
482
|
+
actorType: _aType,
|
|
483
|
+
actor: _aObj,
|
|
484
|
+
...taskUpdates
|
|
485
|
+
} = updates;
|
|
486
|
+
|
|
438
487
|
const project = await this.store.getProject(existing.projectId);
|
|
439
488
|
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
440
489
|
|
|
441
490
|
// Validate workflow transition invariant
|
|
442
|
-
if (
|
|
443
|
-
const isValid = validateTransition(workflow || undefined, existing.status,
|
|
491
|
+
if (taskUpdates.status && taskUpdates.status !== existing.status) {
|
|
492
|
+
const isValid = validateTransition(workflow || undefined, existing.status, taskUpdates.status);
|
|
444
493
|
if (!isValid) {
|
|
445
|
-
throw new WorkflowValidationError(existing.status,
|
|
494
|
+
throw new WorkflowValidationError(existing.status, taskUpdates.status, workflow?.id);
|
|
446
495
|
}
|
|
447
496
|
}
|
|
448
497
|
|
|
449
498
|
// Validate custom field invariants
|
|
450
|
-
if (project?.customFieldDefinitions &&
|
|
499
|
+
if (project?.customFieldDefinitions && taskUpdates.customFields) {
|
|
451
500
|
validateCustomFieldValues(project.customFieldDefinitions, {
|
|
452
501
|
...existing.customFields,
|
|
453
|
-
...
|
|
502
|
+
...taskUpdates.customFields
|
|
454
503
|
});
|
|
455
504
|
}
|
|
456
505
|
|
|
457
|
-
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id,
|
|
506
|
+
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
|
|
458
507
|
|
|
459
508
|
// Auto-update execution/completion timestamps if status changes
|
|
460
509
|
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
@@ -469,17 +518,51 @@ export class CriticalPathEngine {
|
|
|
469
518
|
existing.status,
|
|
470
519
|
project?.statusDefinitions || workflow?.statuses
|
|
471
520
|
);
|
|
472
|
-
|
|
521
|
+
|
|
522
|
+
// 1. Leaving in_progress: finalize active session duration
|
|
523
|
+
if (existingStatusDef.category === 'in_progress' && statusDef.category !== 'in_progress') {
|
|
524
|
+
const inProgressSince = existing.inProgressSince;
|
|
525
|
+
if (inProgressSince) {
|
|
526
|
+
const startMs = new Date(inProgressSince).getTime();
|
|
527
|
+
const endMs = new Date(now).getTime();
|
|
528
|
+
if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
|
|
529
|
+
const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
|
|
530
|
+
processedUpdates.actualDurationSeconds =
|
|
531
|
+
(processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
|
|
532
|
+
if (processedUpdates.actualHours === undefined) {
|
|
533
|
+
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
534
|
+
}
|
|
535
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
536
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
processedUpdates.inProgressSince = null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 2. Entering in_progress: retain earliest start date & start session timer
|
|
544
|
+
if (statusDef.category === 'in_progress') {
|
|
545
|
+
if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
546
|
+
processedUpdates.actualStartDate = now;
|
|
547
|
+
}
|
|
548
|
+
if (!processedUpdates.inProgressSince) {
|
|
549
|
+
processedUpdates.inProgressSince = now;
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
553
|
+
processedUpdates.actualEndDate === undefined
|
|
554
|
+
) {
|
|
555
|
+
processedUpdates.actualEndDate = undefined;
|
|
556
|
+
}
|
|
557
|
+
} else if (
|
|
473
558
|
(existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
|
|
474
|
-
|
|
559
|
+
statusDef.category === 'not_started' &&
|
|
475
560
|
processedUpdates.actualEndDate === undefined
|
|
476
561
|
) {
|
|
477
562
|
processedUpdates.actualEndDate = undefined;
|
|
478
563
|
}
|
|
479
564
|
|
|
480
|
-
|
|
481
|
-
processedUpdates.actualStartDate = now;
|
|
482
|
-
}
|
|
565
|
+
// 3. Entering completed or canceled
|
|
483
566
|
if ((statusDef.category === 'completed' || statusDef.category === 'canceled') && !processedUpdates.actualEndDate) {
|
|
484
567
|
processedUpdates.actualEndDate = now;
|
|
485
568
|
if (statusDef.category === 'completed' && processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
@@ -488,6 +571,19 @@ export class CriticalPathEngine {
|
|
|
488
571
|
}
|
|
489
572
|
}
|
|
490
573
|
|
|
574
|
+
// Mutual sync for manual duration/effort edits
|
|
575
|
+
if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
|
|
576
|
+
processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
|
|
577
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
578
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
|
|
579
|
+
}
|
|
580
|
+
} else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
|
|
581
|
+
processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
|
|
582
|
+
if (processedUpdates.actualDurationMinutes === undefined) {
|
|
583
|
+
processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
491
587
|
const updated = await this.store.updateTask(id, processedUpdates);
|
|
492
588
|
if (!updated) return null;
|
|
493
589
|
|
|
@@ -507,7 +603,10 @@ export class CriticalPathEngine {
|
|
|
507
603
|
payload: {
|
|
508
604
|
task: updated,
|
|
509
605
|
previousStatus: existing.status,
|
|
510
|
-
newStatus: updated.status
|
|
606
|
+
newStatus: updated.status,
|
|
607
|
+
actorId,
|
|
608
|
+
...(actorName ? { actorName } : {}),
|
|
609
|
+
actor: actorObj
|
|
511
610
|
}
|
|
512
611
|
};
|
|
513
612
|
await this.events.publish(statusEvent);
|
|
@@ -528,7 +627,10 @@ export class CriticalPathEngine {
|
|
|
528
627
|
occurredAt: now,
|
|
529
628
|
payload: {
|
|
530
629
|
task: updated,
|
|
531
|
-
previous: existing
|
|
630
|
+
previous: existing,
|
|
631
|
+
actorId,
|
|
632
|
+
...(actorName ? { actorName } : {}),
|
|
633
|
+
actor: actorObj
|
|
532
634
|
}
|
|
533
635
|
};
|
|
534
636
|
await this.events.publish(updateEvent);
|
|
@@ -547,16 +649,19 @@ export class CriticalPathEngine {
|
|
|
547
649
|
occurredAt: now,
|
|
548
650
|
payload: {
|
|
549
651
|
task: updated,
|
|
550
|
-
reason: blockedReason
|
|
652
|
+
reason: blockedReason,
|
|
653
|
+
actorId,
|
|
654
|
+
...(actorName ? { actorName } : {}),
|
|
655
|
+
actor: actorObj
|
|
551
656
|
}
|
|
552
657
|
};
|
|
553
658
|
await this.events.publish(blockedEvent);
|
|
554
659
|
await this.store.logActivity({
|
|
555
660
|
projectId: updated.projectId,
|
|
556
661
|
taskId: updated.id,
|
|
557
|
-
actorId
|
|
662
|
+
actorId,
|
|
558
663
|
action: 'task.blocked',
|
|
559
|
-
details: { reason: blockedReason }
|
|
664
|
+
details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
|
|
560
665
|
});
|
|
561
666
|
this.dispatchWebhook('task.blocked', {
|
|
562
667
|
task: updated,
|
|
@@ -570,16 +675,19 @@ export class CriticalPathEngine {
|
|
|
570
675
|
aggregateType: 'Task',
|
|
571
676
|
occurredAt: now,
|
|
572
677
|
payload: {
|
|
573
|
-
task: updated
|
|
678
|
+
task: updated,
|
|
679
|
+
actorId,
|
|
680
|
+
...(actorName ? { actorName } : {}),
|
|
681
|
+
actor: actorObj
|
|
574
682
|
}
|
|
575
683
|
};
|
|
576
684
|
await this.events.publish(unblockedEvent);
|
|
577
685
|
await this.store.logActivity({
|
|
578
686
|
projectId: updated.projectId,
|
|
579
687
|
taskId: updated.id,
|
|
580
|
-
actorId
|
|
688
|
+
actorId,
|
|
581
689
|
action: 'task.unblocked',
|
|
582
|
-
details: {}
|
|
690
|
+
details: { ...(actorName ? { actorName } : {}) }
|
|
583
691
|
});
|
|
584
692
|
this.dispatchWebhook('task.unblocked', {
|
|
585
693
|
task: updated
|
|
@@ -589,9 +697,13 @@ export class CriticalPathEngine {
|
|
|
589
697
|
await this.store.logActivity({
|
|
590
698
|
projectId: updated.projectId,
|
|
591
699
|
taskId: updated.id,
|
|
592
|
-
actorId
|
|
700
|
+
actorId,
|
|
593
701
|
action: isStatusChange ? 'task.status_changed' : 'task.updated',
|
|
594
|
-
details: {
|
|
702
|
+
details: {
|
|
703
|
+
fromStatus: existing.status,
|
|
704
|
+
toStatus: updated.status,
|
|
705
|
+
...(actorName ? { actorName } : {})
|
|
706
|
+
}
|
|
595
707
|
});
|
|
596
708
|
|
|
597
709
|
this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
|
package/src/index.test.ts
CHANGED
|
@@ -440,6 +440,54 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
440
440
|
const updated = await engine.updateTask(task.id, { status: 'in_production' });
|
|
441
441
|
expect(updated?.status).toBe('in_production');
|
|
442
442
|
});
|
|
443
|
+
|
|
444
|
+
it('attributes activities and domain events to the real actor rather than the assignee', async () => {
|
|
445
|
+
const engine = new CriticalPathEngine();
|
|
446
|
+
const proj = await engine.createProject({ key: 'ACT', name: 'Actor Attribution Project' });
|
|
447
|
+
const task = await engine.createTask({
|
|
448
|
+
projectId: proj.id,
|
|
449
|
+
title: 'Assigned Task',
|
|
450
|
+
status: 'todo',
|
|
451
|
+
assigneeId: 'agent_worker_1'
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const receivedEvents: any[] = [];
|
|
455
|
+
engine.events.subscribe('task.status_changed', async (evt) => {
|
|
456
|
+
receivedEvents.push(evt);
|
|
457
|
+
});
|
|
458
|
+
engine.events.subscribe('task.updated', async (evt) => {
|
|
459
|
+
receivedEvents.push(evt);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Update with explicit actor (e.g. human user updating an assigned agent task)
|
|
463
|
+
await engine.updateTask(task.id, {
|
|
464
|
+
status: 'in_progress',
|
|
465
|
+
actorId: 'user_human_42',
|
|
466
|
+
actorName: 'Jack James'
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const activities = await engine.store.getActivities({ taskId: task.id });
|
|
470
|
+
const statusActivity = activities.find((a) => a.action === 'task.status_changed');
|
|
471
|
+
expect(statusActivity).toBeDefined();
|
|
472
|
+
expect(statusActivity?.actorId).toBe('user_human_42');
|
|
473
|
+
expect((statusActivity?.details as any)?.actorName).toBe('Jack James');
|
|
474
|
+
expect(statusActivity?.actorId).not.toBe('agent_worker_1');
|
|
475
|
+
|
|
476
|
+
expect(receivedEvents).toHaveLength(1);
|
|
477
|
+
expect(receivedEvents[0].payload.actorId).toBe('user_human_42');
|
|
478
|
+
expect(receivedEvents[0].payload.actorName).toBe('Jack James');
|
|
479
|
+
|
|
480
|
+
// Update without explicit actor - should fall back to 'system', NEVER task.assigneeId
|
|
481
|
+
await engine.updateTask(task.id, {
|
|
482
|
+
title: 'Updated title'
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const updatedActivities = await engine.store.getActivities({ taskId: task.id });
|
|
486
|
+
const updateActivity = updatedActivities.find((a) => a.action === 'task.updated');
|
|
487
|
+
expect(updateActivity).toBeDefined();
|
|
488
|
+
expect(updateActivity?.actorId).toBe('system');
|
|
489
|
+
expect(updateActivity?.actorId).not.toBe('agent_worker_1');
|
|
490
|
+
});
|
|
443
491
|
});
|
|
444
492
|
|
|
445
493
|
|
package/src/store/sqlite.ts
CHANGED
|
@@ -131,6 +131,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
131
131
|
estimatedDurationMinutes REAL,
|
|
132
132
|
actualDurationMinutes REAL,
|
|
133
133
|
billableDurationMinutes REAL,
|
|
134
|
+
actualDurationSeconds REAL,
|
|
135
|
+
inProgressSince TEXT,
|
|
134
136
|
progress REAL,
|
|
135
137
|
isBlocked INTEGER,
|
|
136
138
|
blockedReason TEXT,
|
|
@@ -660,10 +662,11 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
660
662
|
id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
|
|
661
663
|
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
662
664
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
663
|
-
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
665
|
+
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
666
|
+
actualDurationSeconds, inProgressSince, progress,
|
|
664
667
|
isBlocked, blockedReason,
|
|
665
668
|
tags, customFields, parentId, createdAt, updatedAt
|
|
666
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
669
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
667
670
|
`);
|
|
668
671
|
stmt.run(
|
|
669
672
|
newTask.id,
|
|
@@ -692,6 +695,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
692
695
|
newTask.estimatedDurationMinutes ?? null,
|
|
693
696
|
newTask.actualDurationMinutes ?? null,
|
|
694
697
|
newTask.billableDurationMinutes ?? null,
|
|
698
|
+
newTask.actualDurationSeconds ?? null,
|
|
699
|
+
newTask.inProgressSince ?? null,
|
|
695
700
|
newTask.progress ?? null,
|
|
696
701
|
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
697
702
|
newTask.blockedReason ?? null,
|
|
@@ -720,7 +725,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
720
725
|
assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
|
|
721
726
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
722
727
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
723
|
-
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
728
|
+
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
729
|
+
actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
|
|
724
730
|
isBlocked = ?, blockedReason = ?,
|
|
725
731
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
726
732
|
WHERE id = ?
|
|
@@ -751,6 +757,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
751
757
|
updated.estimatedDurationMinutes ?? null,
|
|
752
758
|
updated.actualDurationMinutes ?? null,
|
|
753
759
|
updated.billableDurationMinutes ?? null,
|
|
760
|
+
updated.actualDurationSeconds ?? null,
|
|
761
|
+
updated.inProgressSince ?? null,
|
|
754
762
|
updated.progress ?? null,
|
|
755
763
|
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
756
764
|
updated.blockedReason ?? null,
|
package/src/types/index.ts
CHANGED
|
@@ -214,6 +214,10 @@ export interface Task {
|
|
|
214
214
|
estimatedDurationMinutes?: number;
|
|
215
215
|
actualDurationMinutes?: number;
|
|
216
216
|
billableDurationMinutes?: number;
|
|
217
|
+
/** Cumulative active execution time spent in 'in_progress' status (in seconds) */
|
|
218
|
+
actualDurationSeconds?: number;
|
|
219
|
+
/** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
|
|
220
|
+
inProgressSince?: string | null;
|
|
217
221
|
// Progress (0 to 100 percentage)
|
|
218
222
|
progress?: number;
|
|
219
223
|
isBlocked?: boolean;
|
|
@@ -626,6 +630,10 @@ export interface TaskInferredActuals {
|
|
|
626
630
|
isStartDateInferred: boolean;
|
|
627
631
|
isEndDateInferred: boolean;
|
|
628
632
|
activeWorkingHours?: number;
|
|
633
|
+
/** Calendar elapsed hours between actualStartDate and actualEndDate */
|
|
634
|
+
calendarDurationHours?: number;
|
|
635
|
+
/** Cumulative active execution duration in seconds */
|
|
636
|
+
actualDurationSeconds?: number;
|
|
629
637
|
}
|
|
630
638
|
|
|
631
639
|
export interface TaskMetrics {
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
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/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.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/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/workload.test.ts","./src/domain/workload.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/calendar.test.ts","./src/domain/calendar.ts","./src/domain/cpm.test.ts","./src/domain/cpm.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/domain/ladder.test.ts","./src/domain/ladder.ts","./src/domain/metrics.test.ts","./src/domain/metrics.ts","./src/domain/task-duration.test.ts","./src/domain/workload.test.ts","./src/domain/workload.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"}
|