@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/domain/calendar.d.ts +13 -0
  3. package/dist/domain/calendar.d.ts.map +1 -1
  4. package/dist/domain/calendar.js +29 -0
  5. package/dist/domain/calendar.js.map +1 -1
  6. package/dist/domain/calendar.test.js +46 -1
  7. package/dist/domain/calendar.test.js.map +1 -1
  8. package/dist/domain/entities.d.ts +2 -0
  9. package/dist/domain/entities.d.ts.map +1 -1
  10. package/dist/domain/entities.js +55 -7
  11. package/dist/domain/entities.js.map +1 -1
  12. package/dist/domain/events.d.ts +28 -0
  13. package/dist/domain/events.d.ts.map +1 -1
  14. package/dist/domain/events.js.map +1 -1
  15. package/dist/domain/metrics.d.ts.map +1 -1
  16. package/dist/domain/metrics.js +14 -2
  17. package/dist/domain/metrics.js.map +1 -1
  18. package/dist/domain/task-duration.test.d.ts +2 -0
  19. package/dist/domain/task-duration.test.d.ts.map +1 -0
  20. package/dist/domain/task-duration.test.js +146 -0
  21. package/dist/domain/task-duration.test.js.map +1 -0
  22. package/dist/engine/index.d.ts +19 -1
  23. package/dist/engine/index.d.ts.map +1 -1
  24. package/dist/engine/index.js +106 -22
  25. package/dist/engine/index.js.map +1 -1
  26. package/dist/index.test.js +41 -0
  27. package/dist/index.test.js.map +1 -1
  28. package/dist/store/sqlite.d.ts.map +1 -1
  29. package/dist/store/sqlite.js +9 -5
  30. package/dist/store/sqlite.js.map +1 -1
  31. package/dist/types/index.d.ts +8 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/domain/calendar.test.ts +53 -1
  35. package/src/domain/calendar.ts +45 -0
  36. package/src/domain/entities.ts +57 -7
  37. package/src/domain/events.ts +16 -1
  38. package/src/domain/metrics.ts +14 -2
  39. package/src/domain/task-duration.test.ts +170 -0
  40. package/src/engine/index.ts +134 -22
  41. package/src/index.test.ts +48 -0
  42. package/src/store/sqlite.ts +11 -3
  43. package/src/types/index.ts +8 -0
  44. 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
+ });
@@ -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(id: string, updates: Partial<Task>): Promise<Task | null> {
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 (updates.status && updates.status !== existing.status) {
443
- const isValid = validateTransition(workflow || undefined, existing.status, updates.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, updates.status, workflow?.id);
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 && updates.customFields) {
499
+ if (project?.customFieldDefinitions && taskUpdates.customFields) {
451
500
  validateCustomFieldValues(project.customFieldDefinitions, {
452
501
  ...existing.customFields,
453
- ...updates.customFields
502
+ ...taskUpdates.customFields
454
503
  });
455
504
  }
456
505
 
457
- const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, updates);
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
- if (
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
- (statusDef.category === 'in_progress' || statusDef.category === 'not_started') &&
559
+ statusDef.category === 'not_started' &&
475
560
  processedUpdates.actualEndDate === undefined
476
561
  ) {
477
562
  processedUpdates.actualEndDate = undefined;
478
563
  }
479
564
 
480
- if (statusDef.category === 'in_progress' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
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: updated.assigneeId || 'system',
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: updated.assigneeId || 'system',
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: updated.assigneeId || 'system',
700
+ actorId,
593
701
  action: isStatusChange ? 'task.status_changed' : 'task.updated',
594
- details: { fromStatus: existing.status, toStatus: updated.status }
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
 
@@ -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, progress,
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 = ?, progress = ?,
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,
@@ -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 {
@@ -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"}