@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.
- package/CHANGELOG.md +26 -0
- package/README.md +4 -3
- package/dist/domain/cpm.d.ts.map +1 -1
- package/dist/domain/cpm.js +20 -27
- package/dist/domain/cpm.js.map +1 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +4 -6
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +7 -0
- package/dist/domain/errors.d.ts.map +1 -1
- package/dist/domain/errors.js +10 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/domain/graph.d.ts +12 -2
- package/dist/domain/graph.d.ts.map +1 -1
- package/dist/domain/graph.js +23 -6
- package/dist/domain/graph.js.map +1 -1
- package/dist/domain/ladder.d.ts.map +1 -1
- package/dist/domain/ladder.js +6 -10
- package/dist/domain/ladder.js.map +1 -1
- package/dist/domain/metrics.js +1 -1
- package/dist/engine/index.d.ts +33 -4
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +140 -66
- package/dist/engine/index.js.map +1 -1
- package/dist/schemas/index.d.ts +11 -63
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +8 -12
- package/dist/schemas/index.js.map +1 -1
- package/dist/testing/conformance.js +2 -2
- package/dist/types/index.d.ts +11 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +6 -15
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -1
package/dist/engine/index.js
CHANGED
|
@@ -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
|
-
|
|
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 ===
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
669
|
-
inProgressSince:
|
|
670
|
-
blockedSince:
|
|
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 =
|
|
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
|
-
//
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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,
|
|
980
|
-
*
|
|
981
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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)
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
|
1226
|
-
|
|
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(
|
|
1230
|
-
next.push(
|
|
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
|
-
|
|
1247
|
-
|
|
1319
|
+
const edge = precedenceOf(dep);
|
|
1320
|
+
if (edge?.after === taskId) {
|
|
1321
|
+
upstreamTaskIds.add(edge.before);
|
|
1248
1322
|
}
|
|
1249
|
-
else if (
|
|
1250
|
-
downstreamTaskIds.add(
|
|
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);
|