@critical-path/core 0.52.0 → 0.54.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.
@@ -6,8 +6,8 @@ import { extractMentions } from '../utils/mentions.js';
6
6
  import { DomainEventBus } from '../domain/events.js';
7
7
  import { validateAttachmentUrl } from '../domain/entities.js';
8
8
  import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
9
- import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
10
- import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
9
+ import { detectDependencyCycle, CircularDependencyError, precedenceOf } from '../domain/graph.js';
10
+ import { ValidationError, NotFoundError, ForbiddenError, ConflictError } from '../domain/errors.js';
11
11
  import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
12
12
  import { buildStorageKey, normalizeUploadData } from '../storage/file-storage.js';
13
13
  import { generateWebhookSecret } from '../webhooks/signature.js';
@@ -361,12 +361,28 @@ export class CriticalPathEngine {
361
361
  });
362
362
  return created;
363
363
  }
364
+ /**
365
+ * Updates a workflow. Removing a status that tasks in a project using this workflow are still in
366
+ * is refused (`ConflictError`): move those tasks first, or switch the project with `statusMapping`.
367
+ */
364
368
  async updateWorkflow(id, updates) {
365
369
  const existing = await this.getWorkflow(id);
366
370
  if (!existing)
367
371
  return null;
368
372
  await this.requireWorkspaceAccess('workspace.manage');
369
373
  updates = this.withoutTenant(updates);
374
+ if (updates.statuses) {
375
+ const kept = new Set(updates.statuses.map((status) => status.key));
376
+ const removed = existing.statuses.map((status) => status.key).filter((key) => !kept.has(key));
377
+ if (removed.length) {
378
+ for (const project of await this.projectsUsingWorkflow(existing)) {
379
+ const stranded = [...new Set((await this.store.getTasks(project.id)).map((t) => t.status))].filter((key) => removed.includes(key));
380
+ if (stranded.length) {
381
+ throw new ConflictError(`Tasks in project "${project.id}" are still in ${stranded.map((k) => `"${k}"`).join(', ')}, which this update removes. Move them to another status first.`);
382
+ }
383
+ }
384
+ }
385
+ }
370
386
  const updated = await this.store.updateWorkflow(id, updates);
371
387
  if (updated) {
372
388
  const now = new Date().toISOString();
@@ -387,11 +403,16 @@ export class CriticalPathEngine {
387
403
  }
388
404
  return updated;
389
405
  }
406
+ /** Deletes a workflow. Refused (`ConflictError`) while any project uses it, even as its default. */
390
407
  async deleteWorkflow(id) {
391
408
  const existing = await this.getWorkflow(id);
392
409
  if (!existing)
393
410
  return false;
394
411
  await this.requireWorkspaceAccess('workspace.manage');
412
+ const users = await this.projectsUsingWorkflow(existing);
413
+ if (users.length) {
414
+ throw new ConflictError(`Workflow "${id}" is used by ${users.length} project(s) (${users.map((p) => p.id).join(', ')}). Switch them to another workflow first.`);
415
+ }
395
416
  const deleted = await this.store.deleteWorkflow(id);
396
417
  if (deleted) {
397
418
  const now = new Date().toISOString();
@@ -413,7 +434,10 @@ export class CriticalPathEngine {
413
434
  return deleted;
414
435
  }
415
436
  async resolveProjectWorkflow(projectId) {
416
- const project = await this.store.getProject(projectId);
437
+ return this.workflowFor(await this.store.getProject(projectId));
438
+ }
439
+ /** The workflow a project (or a project as it would be after an update) uses. */
440
+ async workflowFor(project) {
417
441
  if (project?.workflow)
418
442
  return project.workflow;
419
443
  if (project?.workflowId) {
@@ -421,14 +445,33 @@ export class CriticalPathEngine {
421
445
  if (wf && (wf.tenantId ?? undefined) === (project.tenantId ?? undefined))
422
446
  return wf;
423
447
  }
448
+ return (await this.defaultWorkflowFor(project?.tenantId)) ?? DEFAULT_SOFTWARE_WORKFLOW;
449
+ }
450
+ /** The tenant's stored default workflow: the one marked `isDefault`, else the first. */
451
+ async defaultWorkflowFor(tenantId) {
424
452
  // Only fall back to workflows from the project's own tenant.
425
- const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
426
- const defaultWf = workflows.find((w) => w.isDefault);
427
- if (defaultWf)
428
- return defaultWf;
429
- if (workflows.length > 0)
430
- return workflows[0];
431
- return DEFAULT_SOFTWARE_WORKFLOW;
453
+ const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === tenantId);
454
+ return workflows.find((w) => w.isDefault) ?? workflows[0];
455
+ }
456
+ /** The statuses tasks in a project may have: its own definitions, else its workflow's. */
457
+ async statusDefinitionsFor(project) {
458
+ return project?.statusDefinitions?.length ? project.statusDefinitions : (await this.workflowFor(project)).statuses;
459
+ }
460
+ /** Projects whose tasks follow `workflow`, explicitly or as their tenant's default. */
461
+ async projectsUsingWorkflow(workflow) {
462
+ const projects = await this.store.getProjects(workflow.tenantId ? { tenantId: workflow.tenantId } : undefined);
463
+ const using = [];
464
+ for (const project of projects) {
465
+ if ((project.tenantId ?? undefined) === (workflow.tenantId ?? undefined) && (await this.workflowFor(project)).id === workflow.id)
466
+ using.push(project);
467
+ }
468
+ return using;
469
+ }
470
+ /** Throws unless `status` is one of the project's statuses. */
471
+ assertKnownStatus(status, definitions) {
472
+ if (definitions.length && !definitions.some((d) => d.key === status)) {
473
+ throw new ValidationError(`Status "${status}" is not in this project's workflow (${definitions.map((d) => d.key).join(', ')}).`);
474
+ }
432
475
  }
433
476
  async getAllowedTaskTransitions(taskId) {
434
477
  const task = await this.getTask(taskId);
@@ -469,6 +512,13 @@ export class CriticalPathEngine {
469
512
  // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
470
513
  const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
471
514
  await this.assertUsableWorkflow(input.workflowId, input.tenantId);
515
+ // Pin the workflow the project starts with, so adding a new default workflow later does not
516
+ // silently change it.
517
+ if (!input.workflowId && !input.workflow) {
518
+ const workflow = await this.defaultWorkflowFor(input.tenantId);
519
+ if (workflow)
520
+ input.workflowId = workflow.id;
521
+ }
472
522
  // The creator administers the project they create.
473
523
  if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor.userId)) {
474
524
  input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
@@ -492,7 +542,16 @@ export class CriticalPathEngine {
492
542
  });
493
543
  return created;
494
544
  }
495
- async updateProject(id, updates) {
545
+ /**
546
+ * Updates a project. When the update changes which statuses the project has (`workflowId`,
547
+ * `workflow` or `statusDefinitions`), every task in a status the project no longer has must be
548
+ * moved: pass `statusMapping` (old status key -> new status key). Without a mapping for each such
549
+ * status the update is refused with a `ConflictError`, so no task is left in an unreachable status.
550
+ */
551
+ async updateProject(id, updates, options = {}) {
552
+ return this.inTransaction((engine) => engine.updateProjectNow(id, updates, options));
553
+ }
554
+ async updateProjectNow(id, updates, options) {
496
555
  const existing = await this.getProject(id);
497
556
  if (!existing)
498
557
  return null;
@@ -512,7 +571,24 @@ export class CriticalPathEngine {
512
571
  if (updates.workflowId && updates.workflowId !== existing.workflowId) {
513
572
  await this.assertUsableWorkflow(updates.workflowId, existing.tenantId);
514
573
  }
574
+ // Tasks whose status the project will no longer have, and where they go
575
+ const moves = [];
576
+ if ('workflowId' in updates || 'workflow' in updates || 'statusDefinitions' in updates) {
577
+ const next = new Set((await this.statusDefinitionsFor({ ...existing, ...updates })).map((d) => d.key));
578
+ const mapping = options.statusMapping ?? {};
579
+ const stranded = (await this.store.getTasks(id)).filter((task) => !next.has(task.status));
580
+ const unmapped = [...new Set(stranded.map((task) => task.status))].filter((key) => !next.has(mapping[key]));
581
+ if (unmapped.length) {
582
+ throw new ConflictError(`Tasks are in ${unmapped.map((k) => `"${k}"`).join(', ')}, which the project will no longer have. ` +
583
+ `Pass statusMapping with a new status for each (available: ${[...next].join(', ')}).`);
584
+ }
585
+ moves.push(...stranded.map((task) => ({ taskId: task.id, status: mapping[task.status] })));
586
+ }
515
587
  const updated = await this.store.updateProject(id, updates);
588
+ // The old status is not in the new workflow, so there is no transition to check
589
+ for (const move of moves) {
590
+ await this.elevated().updateTask(move.taskId, { status: move.status }, { ...(this.actor ? { actor: this.actor } : {}), ignoreTransitions: true });
591
+ }
516
592
  if (updated) {
517
593
  const now = new Date().toISOString();
518
594
  const event = {
@@ -648,9 +724,11 @@ export class CriticalPathEngine {
648
724
  await this.assertValidParent(input.parentId, projectId);
649
725
  const defaultStatus = workflow?.defaultStatusKey || 'todo';
650
726
  const initialStatus = processedInput.status || taskInput.status || defaultStatus;
727
+ const statusDefinitions = await this.statusDefinitionsFor(project);
728
+ this.assertKnownStatus(initialStatus, statusDefinitions);
651
729
  const now = new Date().toISOString();
652
730
  // Derive initial lifecycle timestamps and semantic status
653
- const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
731
+ const statusDef = resolveStatusDefinition(initialStatus, statusDefinitions);
654
732
  const inProgress = statusDef.category === 'in_progress';
655
733
  const completed = statusDef.category === 'completed';
656
734
  const isBlocked = input.isBlocked ?? false;
@@ -659,15 +737,16 @@ export class CriticalPathEngine {
659
737
  projectId,
660
738
  title: processedInput.title || taskInput.title,
661
739
  status: initialStatus,
662
- semanticStatus: input.semanticStatus ?? statusDef.category,
740
+ // Always derived from the status, so every module agrees on it
741
+ semanticStatus: statusDef.category,
663
742
  priority: processedInput.priority || taskInput.priority || 'medium',
664
743
  taskType: processedInput.taskType || taskInput.taskType || 'task',
665
744
  actualStartDate: input.actualStartDate ?? (inProgress ? now : undefined),
666
745
  actualEndDate: input.actualEndDate ?? (completed || statusDef.category === 'canceled' ? now : undefined),
667
746
  completedAt: input.completedAt ?? (completed ? now : undefined),
668
- loggedHours: input.loggedHours ?? 0,
669
- inProgressSince: input.inProgressSince ?? (inProgress ? now : undefined),
670
- blockedSince: input.blockedSince ?? (isBlocked && inProgress ? now : undefined),
747
+ loggedHours: 0,
748
+ inProgressSince: inProgress ? now : undefined,
749
+ blockedSince: isBlocked && inProgress ? now : undefined,
671
750
  progress: input.progress ?? (completed ? 100 : 0),
672
751
  isBlocked,
673
752
  blockedReason: input.blockedReason ?? null,
@@ -711,8 +790,13 @@ export class CriticalPathEngine {
711
790
  // Plugins run first; their output is then validated like caller input. Identity and
712
791
  // ownership fields are never changed by hooks.
713
792
  const { id: _id, projectId: _projectId, createdAt: _createdAt, ...processedUpdates } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates);
793
+ // Derived from the status below; never taken from callers or hooks
794
+ delete processedUpdates.semanticStatus;
714
795
  // Validate workflow transition invariant
715
796
  if (processedUpdates.status && processedUpdates.status !== existing.status) {
797
+ this.assertKnownStatus(processedUpdates.status, await this.statusDefinitionsFor(project));
798
+ }
799
+ if (processedUpdates.status && processedUpdates.status !== existing.status && !options?.ignoreTransitions) {
716
800
  const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
717
801
  if (!isValid) {
718
802
  throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
@@ -733,7 +817,7 @@ export class CriticalPathEngine {
733
817
  const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
734
818
  // Auto-update execution/completion timestamps if status changes
735
819
  if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
736
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
820
+ processedUpdates.semanticStatus = newStatusDef.category;
737
821
  // 1. Leaving in_progress: finalize active session duration and blocked duration
738
822
  if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
739
823
  const inProgressSince = existing.inProgressSince;
@@ -744,9 +828,6 @@ export class CriticalPathEngine {
744
828
  const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
745
829
  processedUpdates.actualDurationSeconds =
746
830
  (processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
747
- if (processedUpdates.actualHours === undefined) {
748
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
749
- }
750
831
  if (processedUpdates.actualDurationMinutes === undefined) {
751
832
  processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
752
833
  }
@@ -854,18 +935,10 @@ export class CriticalPathEngine {
854
935
  else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
855
936
  processedUpdates.blockedSince = null;
856
937
  }
857
- // Mutual sync for manual duration/effort edits
858
- if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
859
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
860
- if (processedUpdates.actualDurationMinutes === undefined) {
861
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
862
- }
863
- }
864
- else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
865
- processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
866
- if (processedUpdates.actualDurationMinutes === undefined) {
867
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
868
- }
938
+ // Elapsed time (actualDuration*) and effort (actualHours, loggedHours) are separate quantities:
939
+ // keep the elapsed minutes in step with the seconds, and never derive effort from the clock.
940
+ if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualDurationMinutes === undefined) {
941
+ processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
869
942
  }
870
943
  const updated = await this.store.updateTask(id, processedUpdates);
871
944
  if (!updated)
@@ -976,9 +1049,12 @@ export class CriticalPathEngine {
976
1049
  return updated;
977
1050
  }
978
1051
  /**
979
- * After a task completes or is canceled, unblocks downstream tasks whose upstream tasks are all
980
- * completed or canceled. Runs on an elevated view, so upstream tasks the caller cannot read still
981
- * count, and resolves each task's status against its own project's definitions.
1052
+ * After a task completes or is canceled, publishes `task.unblocked` for each downstream task whose
1053
+ * upstream tasks are now all completed or canceled, so it is free to start. Dependency blocking is
1054
+ * derived (see `getTaskLifecycleState`), so nothing is written to the task: a manual block
1055
+ * (`isBlocked`, `blockedReason`) stays until someone clears it. Runs on an elevated view, so
1056
+ * upstream tasks the caller cannot read still count, and resolves each task's status against its
1057
+ * own project's definitions.
982
1058
  */
983
1059
  async checkAndUnblockDownstreamTasks(completedTask) {
984
1060
  try {
@@ -995,39 +1071,28 @@ export class CriticalPathEngine {
995
1071
  }
996
1072
  return resolveStatusDefinition(task.status, statusDefsByProject.get(task.projectId)).category;
997
1073
  };
1074
+ const isFinished = async (task) => {
1075
+ const category = await categoryOf(task);
1076
+ return category === 'completed' || category === 'canceled';
1077
+ };
998
1078
  for (const downstream of graph.downstreamTasks) {
999
- const category = await categoryOf(downstream);
1000
- if (category === 'completed' || category === 'canceled')
1001
- continue;
1002
- // Nothing to unblock; don't publish task.unblocked for tasks that were never blocked.
1003
- if (!downstream.isBlocked && !downstream.blockedReason && !downstream.blockedSince)
1079
+ if (await isFinished(downstream))
1004
1080
  continue;
1005
1081
  const downstreamGraph = await engine.getTaskDependencyGraph(downstream.id);
1006
1082
  let allSatisfied = true;
1007
1083
  for (const up of downstreamGraph.upstreamTasks) {
1008
- const upCategory = await categoryOf(up);
1009
- if (upCategory !== 'completed' && upCategory !== 'canceled') {
1084
+ if (!(await isFinished(up))) {
1010
1085
  allSatisfied = false;
1011
1086
  break;
1012
1087
  }
1013
1088
  }
1014
1089
  if (!allSatisfied)
1015
1090
  continue;
1016
- const updates = { isBlocked: false, blockedReason: null };
1017
- if (downstream.blockedSince) {
1018
- const startMs = new Date(downstream.blockedSince).getTime();
1019
- const endMs = Date.now();
1020
- if (!isNaN(startMs) && endMs >= startMs) {
1021
- updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + (endMs - startMs) / 1000;
1022
- }
1023
- updates.blockedSince = null;
1024
- }
1025
- const updatedDownstream = (await this.store.updateTask(downstream.id, updates)) ?? downstream;
1026
1091
  await this.publishEvent({
1027
1092
  name: 'task.unblocked',
1028
1093
  aggregateId: downstream.id,
1029
1094
  aggregateType: 'Task',
1030
- payload: { task: updatedDownstream, upstreamTaskId: completedTask.id }
1095
+ payload: { task: downstream, upstreamTaskId: completedTask.id }
1031
1096
  });
1032
1097
  await this.store.logActivity({
1033
1098
  projectId: downstream.projectId,
@@ -1179,6 +1244,9 @@ export class CriticalPathEngine {
1179
1244
  // --- Task Dependencies & Graph ---
1180
1245
  /** Adds a dependency. Adding one that already exists (same tasks and type) returns the existing one. */
1181
1246
  async addDependency(dep) {
1247
+ if (dep.type !== 'blocking' && dep.type !== 'relates_to') {
1248
+ throw new ValidationError(`Dependency type must be 'blocking' or 'relates_to', got "${String(dep.type)}".`);
1249
+ }
1182
1250
  const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
1183
1251
  if (!task)
1184
1252
  throw new NotFoundError(`Task "${dep.taskId}" not found.`);
@@ -1188,11 +1256,13 @@ export class CriticalPathEngine {
1188
1256
  const duplicate = (await this.store.getDependencies(dep.taskId)).find((d) => d.taskId === dep.taskId && d.dependsOnTaskId === dep.dependsOnTaskId && d.type === dep.type);
1189
1257
  if (duplicate)
1190
1258
  return duplicate;
1191
- // Enforce Directed Acyclic Graph (DAG) Invariant
1192
- const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
1193
- const cycleCheck = detectDependencyCycle(reachableDeps, dep);
1194
- if (cycleCheck.hasCycle) {
1195
- throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
1259
+ // Enforce the Directed Acyclic Graph (DAG) invariant. Informational links never form cycles.
1260
+ if (dep.type === 'blocking') {
1261
+ const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
1262
+ const cycleCheck = detectDependencyCycle(reachableDeps, dep);
1263
+ if (cycleCheck.hasCycle) {
1264
+ throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
1265
+ }
1196
1266
  }
1197
1267
  const created = await this.store.addDependency(dep);
1198
1268
  const now = new Date().toISOString();
@@ -1222,12 +1292,14 @@ export class CriticalPathEngine {
1222
1292
  frontier.forEach((id) => visited.add(id));
1223
1293
  for (const deps of results) {
1224
1294
  for (const d of deps) {
1225
- // getDependencies returns edges in both directions; follow only outgoing ones.
1226
- if (!visited.has(d.taskId))
1295
+ // getDependencies returns edges touching the task in both directions; follow the ones
1296
+ // where a visited task waits for another.
1297
+ const edge = precedenceOf(d);
1298
+ if (!edge || !visited.has(edge.after))
1227
1299
  continue;
1228
1300
  edges.set(d.id, d);
1229
- if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
1230
- next.push(d.dependsOnTaskId);
1301
+ if (!visited.has(edge.before) && !next.includes(edge.before)) {
1302
+ next.push(edge.before);
1231
1303
  }
1232
1304
  }
1233
1305
  }
@@ -1242,12 +1314,14 @@ export class CriticalPathEngine {
1242
1314
  const dependencies = await this.store.getDependencies(taskId);
1243
1315
  const upstreamTaskIds = new Set();
1244
1316
  const downstreamTaskIds = new Set();
1317
+ // Upstream and downstream follow scheduling edges only; `relates_to` links stay in `dependencies`.
1245
1318
  for (const dep of dependencies) {
1246
- if (dep.taskId === taskId) {
1247
- upstreamTaskIds.add(dep.dependsOnTaskId);
1319
+ const edge = precedenceOf(dep);
1320
+ if (edge?.after === taskId) {
1321
+ upstreamTaskIds.add(edge.before);
1248
1322
  }
1249
- else if (dep.dependsOnTaskId === taskId) {
1250
- downstreamTaskIds.add(dep.taskId);
1323
+ else if (edge?.before === taskId) {
1324
+ downstreamTaskIds.add(edge.after);
1251
1325
  }
1252
1326
  }
1253
1327
  const upstreamTasks = (await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))).filter((t) => t !== null);