@critical-path/core 0.21.0 → 0.23.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 +53 -0
- package/README.md +14 -0
- package/dist/domain/ddd.test.js +12 -0
- package/dist/domain/ddd.test.js.map +1 -1
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +3 -2
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/errors.d.ts +13 -0
- package/dist/domain/errors.d.ts.map +1 -0
- package/dist/domain/errors.js +19 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/events.d.ts +9 -1
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -1
- package/dist/domain/index.js +1 -0
- package/dist/domain/index.js.map +1 -1
- package/dist/engine/actor.test.d.ts +2 -0
- package/dist/engine/actor.test.d.ts.map +1 -0
- package/dist/engine/actor.test.js +53 -0
- package/dist/engine/actor.test.js.map +1 -0
- package/dist/engine/index.d.ts +23 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +125 -29
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +20 -0
- package/dist/index.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +5 -1
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +20 -0
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +7 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/fractional-index.d.ts.map +1 -1
- package/dist/utils/fractional-index.js +22 -26
- package/dist/utils/fractional-index.js.map +1 -1
- package/dist/utils/fractional-index.test.js +31 -0
- package/dist/utils/fractional-index.test.js.map +1 -1
- package/package.json +1 -5
- package/src/domain/ddd.test.ts +22 -0
- package/src/domain/entities.ts +3 -2
- package/src/domain/errors.ts +19 -0
- package/src/domain/events.ts +6 -0
- package/src/domain/index.ts +1 -0
- package/src/engine/actor.test.ts +62 -0
- package/src/engine/index.ts +134 -30
- package/src/index.test.ts +24 -0
- package/src/store/sqlite.test.ts +22 -0
- package/src/store/sqlite.ts +7 -1
- package/src/types/index.ts +8 -0
- package/src/utils/fractional-index.test.ts +37 -0
- package/src/utils/fractional-index.ts +20 -26
- package/tsconfig.tsbuildinfo +1 -1
package/src/domain/entities.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { validateTransition, WorkflowValidationError } from '../utils/workflow.j
|
|
|
29
29
|
import { resolveStatusDefinition } from '../utils/status.js';
|
|
30
30
|
import { validateCustomFieldValues } from './custom-fields.js';
|
|
31
31
|
import { generateProjectKey } from '../utils/key.js';
|
|
32
|
+
import { ValidationError } from './errors.js';
|
|
32
33
|
|
|
33
34
|
export abstract class BaseEntity {
|
|
34
35
|
public readonly id: string;
|
|
@@ -294,8 +295,8 @@ export class TaskEntity extends BaseEntity {
|
|
|
294
295
|
public logTime(
|
|
295
296
|
entry: { hours: number; isBillable?: boolean; userId?: string; description?: string }
|
|
296
297
|
): TimeEntry {
|
|
297
|
-
if (entry.hours <= 0) {
|
|
298
|
-
throw new
|
|
298
|
+
if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
|
|
299
|
+
throw new ValidationError('Logged hours must be a positive number.');
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
this.loggedHours = (this.loggedHours || 0) + entry.hours;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown when input fails a domain rule. Route handlers map it to HTTP 400.
|
|
3
|
+
*/
|
|
4
|
+
export class ValidationError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'ValidationError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Thrown when a referenced entity does not exist. Route handlers map it to HTTP 404.
|
|
13
|
+
*/
|
|
14
|
+
export class NotFoundError extends Error {
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'NotFoundError';
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/domain/events.ts
CHANGED
|
@@ -134,6 +134,11 @@ export interface ProjectUpdatedEvent extends DomainEvent<{ project: Project; pre
|
|
|
134
134
|
readonly aggregateType: 'Project';
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
export interface ProjectDeletedEvent extends DomainEvent<{ projectId: string; name: string; deletedTaskIds: string[] }> {
|
|
138
|
+
readonly name: 'project.deleted';
|
|
139
|
+
readonly aggregateType: 'Project';
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
export interface WorkflowCreatedEvent extends DomainEvent<{ workflow: Workflow }> {
|
|
138
143
|
readonly name: 'workflow.created';
|
|
139
144
|
readonly aggregateType: 'Workflow';
|
|
@@ -207,6 +212,7 @@ export type CriticalPathDomainEvent =
|
|
|
207
212
|
| TaskDependencyAddedEvent
|
|
208
213
|
| ProjectCreatedEvent
|
|
209
214
|
| ProjectUpdatedEvent
|
|
215
|
+
| ProjectDeletedEvent
|
|
210
216
|
| WorkflowCreatedEvent
|
|
211
217
|
| WorkflowUpdatedEvent
|
|
212
218
|
| WorkflowDeletedEvent
|
package/src/domain/index.ts
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CriticalPathEngine } from '../index.js';
|
|
3
|
+
|
|
4
|
+
describe('CriticalPathEngine.withActor', () => {
|
|
5
|
+
it('attributes mutations to the scoped actor and ignores identity claimed in payloads', async () => {
|
|
6
|
+
const engine = new CriticalPathEngine();
|
|
7
|
+
const alice = engine.withActor({ userId: 'alice', username: 'Alice' });
|
|
8
|
+
|
|
9
|
+
const project = await alice.createProject({ key: 'ACT', name: 'Actors', ownerId: 'mallory' });
|
|
10
|
+
const task = await alice.createTask({ projectId: project.id, title: 'Scoped' });
|
|
11
|
+
expect(task.reporterId).toBe('alice');
|
|
12
|
+
|
|
13
|
+
await alice.updateTask(task.id, { title: 'Renamed', actorId: 'ceo', actor: { userId: 'ceo' } });
|
|
14
|
+
const comment = await alice.addComment({ taskId: task.id, content: 'hi', authorId: 'ceo', authorType: 'agent' });
|
|
15
|
+
const reacted = await alice.addCommentReaction(comment.id, { emoji: '👍', userId: 'ceo' });
|
|
16
|
+
const entry = await alice.logTime({ taskId: task.id, hours: 1, userId: 'ceo' } as any);
|
|
17
|
+
const attachment = await alice.createAttachment({
|
|
18
|
+
taskId: task.id,
|
|
19
|
+
uploaderId: 'ceo',
|
|
20
|
+
filename: 'spec.md',
|
|
21
|
+
mimeType: 'text/markdown',
|
|
22
|
+
sizeBytes: 1,
|
|
23
|
+
url: 'https://example.com/spec.md'
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(comment.authorId).toBe('alice');
|
|
27
|
+
expect(comment.authorType).toBe('user');
|
|
28
|
+
expect(reacted?.reactions?.[0].userId).toBe('alice');
|
|
29
|
+
expect(entry.userId).toBe('alice');
|
|
30
|
+
expect(attachment.uploaderId).toBe('alice');
|
|
31
|
+
|
|
32
|
+
const activities = await engine.store.getActivities({ projectId: project.id });
|
|
33
|
+
expect(activities.length).toBeGreaterThan(0);
|
|
34
|
+
expect(activities.every((a) => a.actorId === 'alice')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not change the base engine, and concurrent views stay isolated', async () => {
|
|
38
|
+
const engine = new CriticalPathEngine();
|
|
39
|
+
const project = await engine.createProject({ key: 'ISO', name: 'Isolation' });
|
|
40
|
+
|
|
41
|
+
const [a, b] = await Promise.all([
|
|
42
|
+
engine.withActor({ userId: 'a' }).createTask({ projectId: project.id, title: 'A' }),
|
|
43
|
+
engine.withActor({ userId: 'b' }).createTask({ projectId: project.id, title: 'B' })
|
|
44
|
+
]);
|
|
45
|
+
expect(a.reporterId).toBe('a');
|
|
46
|
+
expect(b.reporterId).toBe('b');
|
|
47
|
+
expect(engine.actor).toBeUndefined();
|
|
48
|
+
|
|
49
|
+
// Views share state with the base engine
|
|
50
|
+
expect(await engine.getTask(a.id)).not.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('still lets explicit updateTask options override the scoped actor', async () => {
|
|
54
|
+
const engine = new CriticalPathEngine();
|
|
55
|
+
const project = await engine.createProject({ key: 'OPT', name: 'Options' });
|
|
56
|
+
const task = await engine.createTask({ projectId: project.id, title: 'T' });
|
|
57
|
+
|
|
58
|
+
await engine.withActor({ userId: 'alice' }).updateTask(task.id, { title: 'T2' }, { actorId: 'automation' });
|
|
59
|
+
const activities = await engine.store.getActivities({ taskId: task.id });
|
|
60
|
+
expect(activities.some((a) => a.actorId === 'automation')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
Actor,
|
|
2
3
|
CriticalPathConfig,
|
|
3
4
|
Project,
|
|
4
5
|
Task,
|
|
@@ -56,6 +57,7 @@ import {
|
|
|
56
57
|
TaskDeletedEvent,
|
|
57
58
|
ProjectCreatedEvent,
|
|
58
59
|
ProjectUpdatedEvent,
|
|
60
|
+
ProjectDeletedEvent,
|
|
59
61
|
WorkflowCreatedEvent,
|
|
60
62
|
WorkflowUpdatedEvent,
|
|
61
63
|
WorkflowDeletedEvent,
|
|
@@ -80,6 +82,7 @@ import {
|
|
|
80
82
|
import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
|
|
81
83
|
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
82
84
|
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
85
|
+
import { ValidationError, NotFoundError } from '../domain/errors.js';
|
|
83
86
|
import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
|
|
84
87
|
import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
|
|
85
88
|
import {
|
|
@@ -96,6 +99,8 @@ export class CriticalPathEngine {
|
|
|
96
99
|
public readonly plugins: PluginRegistry;
|
|
97
100
|
public readonly events: DomainEventBus;
|
|
98
101
|
public readonly ready: Promise<void> = Promise.resolve();
|
|
102
|
+
/** The identity mutations are attributed to. Set only on views returned by `withActor`. */
|
|
103
|
+
public readonly actor?: Actor;
|
|
99
104
|
|
|
100
105
|
constructor(config: CriticalPathConfig = {}) {
|
|
101
106
|
this.config = config;
|
|
@@ -118,6 +123,23 @@ export class CriticalPathEngine {
|
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Returns a view of this engine that attributes every mutation to `actor`: activity log
|
|
128
|
+
* entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
|
|
129
|
+
* Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
|
|
130
|
+
* someone else. The view shares the store, plugins and event bus, and does not modify this engine,
|
|
131
|
+
* so it is safe to create one per request.
|
|
132
|
+
*/
|
|
133
|
+
withActor(actor: Actor): CriticalPathEngine {
|
|
134
|
+
const scoped = Object.create(this) as CriticalPathEngine;
|
|
135
|
+
Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
|
|
136
|
+
return scoped;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private actorIdOr(fallback: string): string {
|
|
140
|
+
return this.actor?.userId ?? fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
|
|
122
144
|
if (data.workflows) {
|
|
123
145
|
for (const wf of data.workflows) {
|
|
@@ -180,7 +202,7 @@ export class CriticalPathEngine {
|
|
|
180
202
|
await this.events.publish(event);
|
|
181
203
|
|
|
182
204
|
await this.store.logActivity({
|
|
183
|
-
actorId: 'system',
|
|
205
|
+
actorId: this.actorIdOr('system'),
|
|
184
206
|
action: 'workflow.created',
|
|
185
207
|
details: { name: created.name }
|
|
186
208
|
});
|
|
@@ -206,7 +228,7 @@ export class CriticalPathEngine {
|
|
|
206
228
|
await this.events.publish(event);
|
|
207
229
|
|
|
208
230
|
await this.store.logActivity({
|
|
209
|
-
actorId: 'system',
|
|
231
|
+
actorId: this.actorIdOr('system'),
|
|
210
232
|
action: 'workflow.updated',
|
|
211
233
|
details: { name: updated.name }
|
|
212
234
|
});
|
|
@@ -233,7 +255,7 @@ export class CriticalPathEngine {
|
|
|
233
255
|
await this.events.publish(event);
|
|
234
256
|
|
|
235
257
|
await this.store.logActivity({
|
|
236
|
-
actorId: 'system',
|
|
258
|
+
actorId: this.actorIdOr('system'),
|
|
237
259
|
action: 'workflow.deleted',
|
|
238
260
|
details: { name: existing.name }
|
|
239
261
|
});
|
|
@@ -304,7 +326,7 @@ export class CriticalPathEngine {
|
|
|
304
326
|
|
|
305
327
|
await this.store.logActivity({
|
|
306
328
|
projectId: created.id,
|
|
307
|
-
actorId: project.ownerId || 'system',
|
|
329
|
+
actorId: this.actorIdOr(project.ownerId || 'system'),
|
|
308
330
|
action: 'project.created',
|
|
309
331
|
details: { name: created.name, key: created.key }
|
|
310
332
|
});
|
|
@@ -331,7 +353,7 @@ export class CriticalPathEngine {
|
|
|
331
353
|
|
|
332
354
|
await this.store.logActivity({
|
|
333
355
|
projectId: updated.id,
|
|
334
|
-
actorId: 'system',
|
|
356
|
+
actorId: this.actorIdOr('system'),
|
|
335
357
|
action: 'project.updated',
|
|
336
358
|
details: { name: updated.name }
|
|
337
359
|
});
|
|
@@ -340,6 +362,46 @@ export class CriticalPathEngine {
|
|
|
340
362
|
return updated;
|
|
341
363
|
}
|
|
342
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Deletes a project and its tasks. Each task goes through `deleteTask`, so plugin
|
|
367
|
+
* hooks run and `task.deleted` events fire before `project.deleted` is published.
|
|
368
|
+
*/
|
|
369
|
+
async deleteProject(id: string): Promise<boolean> {
|
|
370
|
+
const existing = await this.store.getProject(id);
|
|
371
|
+
if (!existing) return false;
|
|
372
|
+
|
|
373
|
+
const tasks = await this.store.getTasks(id);
|
|
374
|
+
const deletedTaskIds: string[] = [];
|
|
375
|
+
for (const task of tasks) {
|
|
376
|
+
if (await this.deleteTask(task.id)) {
|
|
377
|
+
deletedTaskIds.push(task.id);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const deleted = await this.store.deleteProject(id);
|
|
382
|
+
if (deleted) {
|
|
383
|
+
const now = new Date().toISOString();
|
|
384
|
+
const event: ProjectDeletedEvent = {
|
|
385
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
386
|
+
name: 'project.deleted',
|
|
387
|
+
aggregateId: id,
|
|
388
|
+
aggregateType: 'Project',
|
|
389
|
+
occurredAt: now,
|
|
390
|
+
payload: { projectId: id, name: existing.name, deletedTaskIds }
|
|
391
|
+
};
|
|
392
|
+
await this.events.publish(event);
|
|
393
|
+
|
|
394
|
+
await this.store.logActivity({
|
|
395
|
+
projectId: id,
|
|
396
|
+
actorId: this.actorIdOr('system'),
|
|
397
|
+
action: 'project.deleted',
|
|
398
|
+
details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
|
|
399
|
+
});
|
|
400
|
+
this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
|
|
401
|
+
}
|
|
402
|
+
return deleted;
|
|
403
|
+
}
|
|
404
|
+
|
|
343
405
|
// --- Tasks ---
|
|
344
406
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
345
407
|
return this.store.getTasks(projectId);
|
|
@@ -350,6 +412,9 @@ export class CriticalPathEngine {
|
|
|
350
412
|
}
|
|
351
413
|
|
|
352
414
|
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
415
|
+
if (this.actor && !taskInput.reporterId) {
|
|
416
|
+
taskInput = { ...taskInput, reporterId: this.actor.userId };
|
|
417
|
+
}
|
|
353
418
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
354
419
|
const projectId = processedInput.projectId || taskInput.projectId;
|
|
355
420
|
const project = await this.store.getProject(projectId);
|
|
@@ -435,7 +500,7 @@ export class CriticalPathEngine {
|
|
|
435
500
|
await this.store.logActivity({
|
|
436
501
|
projectId: created.projectId,
|
|
437
502
|
taskId: created.id,
|
|
438
|
-
actorId: created.reporterId || 'system',
|
|
503
|
+
actorId: this.actorIdOr(created.reporterId || 'system'),
|
|
439
504
|
action: 'task.created',
|
|
440
505
|
details: { title: created.title, status: created.status }
|
|
441
506
|
});
|
|
@@ -462,25 +527,30 @@ export class CriticalPathEngine {
|
|
|
462
527
|
const existing = await this.store.getTask(id);
|
|
463
528
|
if (!existing) return null;
|
|
464
529
|
|
|
530
|
+
// On a withActor view, the scoped actor replaces any identity claimed in the payload.
|
|
531
|
+
// Explicit options from trusted server-side code still take precedence.
|
|
532
|
+
options = options ?? (this.actor ? { actor: this.actor } : undefined);
|
|
533
|
+
const claimed = this.actor ? undefined : updates;
|
|
534
|
+
|
|
465
535
|
const actorId =
|
|
466
536
|
options?.actorId ||
|
|
467
|
-
|
|
537
|
+
claimed?.actorId ||
|
|
468
538
|
options?.actor?.userId ||
|
|
469
|
-
|
|
539
|
+
claimed?.actor?.userId ||
|
|
470
540
|
'system';
|
|
471
541
|
const actorName =
|
|
472
542
|
options?.actorName ||
|
|
473
|
-
|
|
543
|
+
claimed?.actorName ||
|
|
474
544
|
options?.actor?.username ||
|
|
475
|
-
|
|
545
|
+
claimed?.actor?.username ||
|
|
476
546
|
(actorId === 'system' ? 'System' : undefined);
|
|
477
547
|
const actorType =
|
|
478
548
|
options?.actorType ||
|
|
479
|
-
|
|
549
|
+
claimed?.actorType ||
|
|
480
550
|
options?.actor?.actorType ||
|
|
481
|
-
|
|
551
|
+
claimed?.actor?.actorType ||
|
|
482
552
|
(actorId === 'system' ? 'system' : 'user');
|
|
483
|
-
const actorObj = options?.actor ||
|
|
553
|
+
const actorObj = options?.actor || claimed?.actor || {
|
|
484
554
|
userId: actorId,
|
|
485
555
|
username: actorName,
|
|
486
556
|
actorType
|
|
@@ -861,7 +931,7 @@ export class CriticalPathEngine {
|
|
|
861
931
|
await this.store.logActivity({
|
|
862
932
|
projectId: downstream.projectId,
|
|
863
933
|
taskId: downstream.id,
|
|
864
|
-
actorId: 'system',
|
|
934
|
+
actorId: this.actorIdOr('system'),
|
|
865
935
|
action: 'task.unblocked',
|
|
866
936
|
details: { unblockedByTaskId: completedTask.id }
|
|
867
937
|
});
|
|
@@ -905,7 +975,7 @@ export class CriticalPathEngine {
|
|
|
905
975
|
await this.store.logActivity({
|
|
906
976
|
projectId: existing.projectId,
|
|
907
977
|
taskId: id,
|
|
908
|
-
actorId: 'system',
|
|
978
|
+
actorId: this.actorIdOr('system'),
|
|
909
979
|
action: 'task.deleted',
|
|
910
980
|
details: { title: existing.title }
|
|
911
981
|
});
|
|
@@ -916,14 +986,9 @@ export class CriticalPathEngine {
|
|
|
916
986
|
|
|
917
987
|
// --- Task Dependencies & Graph ---
|
|
918
988
|
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
919
|
-
const existingDependencies = await this.store.getDependencies(dep.taskId);
|
|
920
|
-
const allProjectDeps = [
|
|
921
|
-
...existingDependencies,
|
|
922
|
-
...(await this.store.getDependencies(dep.dependsOnTaskId))
|
|
923
|
-
];
|
|
924
|
-
|
|
925
989
|
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
926
|
-
const
|
|
990
|
+
const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
|
|
991
|
+
const cycleCheck = detectDependencyCycle(reachableDeps, dep);
|
|
927
992
|
if (cycleCheck.hasCycle) {
|
|
928
993
|
throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
|
|
929
994
|
}
|
|
@@ -944,6 +1009,37 @@ export class CriticalPathEngine {
|
|
|
944
1009
|
return created;
|
|
945
1010
|
}
|
|
946
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* Collects every dependency edge reachable upstream from `startTaskId`. A new edge
|
|
1014
|
+
* `taskId -> dependsOnTaskId` creates a cycle exactly when `taskId` is reachable from
|
|
1015
|
+
* `dependsOnTaskId`, so this is the full set of edges the cycle check needs.
|
|
1016
|
+
*/
|
|
1017
|
+
private async collectUpstreamDependencies(startTaskId: string): Promise<TaskDependency[]> {
|
|
1018
|
+
const edges = new Map<string, TaskDependency>();
|
|
1019
|
+
const visited = new Set<string>();
|
|
1020
|
+
let frontier = [startTaskId];
|
|
1021
|
+
|
|
1022
|
+
while (frontier.length > 0) {
|
|
1023
|
+
const next: string[] = [];
|
|
1024
|
+
const results = await Promise.all(frontier.map((id) => this.store.getDependencies(id)));
|
|
1025
|
+
frontier.forEach((id) => visited.add(id));
|
|
1026
|
+
|
|
1027
|
+
for (const deps of results) {
|
|
1028
|
+
for (const d of deps) {
|
|
1029
|
+
// getDependencies returns edges in both directions; follow only outgoing ones.
|
|
1030
|
+
if (!visited.has(d.taskId)) continue;
|
|
1031
|
+
edges.set(d.id, d);
|
|
1032
|
+
if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
|
|
1033
|
+
next.push(d.dependsOnTaskId);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
frontier = next;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return Array.from(edges.values());
|
|
1041
|
+
}
|
|
1042
|
+
|
|
947
1043
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
948
1044
|
const dependencies = await this.store.getDependencies(taskId);
|
|
949
1045
|
const upstreamTaskIds = new Set<string>();
|
|
@@ -996,8 +1092,8 @@ export class CriticalPathEngine {
|
|
|
996
1092
|
|
|
997
1093
|
// --- Time Tracking ---
|
|
998
1094
|
async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
|
|
999
|
-
if (entry.hours <= 0) {
|
|
1000
|
-
throw new
|
|
1095
|
+
if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
|
|
1096
|
+
throw new ValidationError('Logged hours must be a positive number.');
|
|
1001
1097
|
}
|
|
1002
1098
|
|
|
1003
1099
|
const task = await this.store.getTask(entry.taskId);
|
|
@@ -1014,7 +1110,7 @@ export class CriticalPathEngine {
|
|
|
1014
1110
|
|
|
1015
1111
|
const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
|
|
1016
1112
|
...entry,
|
|
1017
|
-
userId: entry.userId || task?.assigneeId || 'system',
|
|
1113
|
+
userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
|
|
1018
1114
|
loggedAt: entry.loggedAt
|
|
1019
1115
|
};
|
|
1020
1116
|
|
|
@@ -1044,6 +1140,9 @@ export class CriticalPathEngine {
|
|
|
1044
1140
|
}
|
|
1045
1141
|
|
|
1046
1142
|
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
1143
|
+
if (this.actor) {
|
|
1144
|
+
comment = { ...comment, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' };
|
|
1145
|
+
}
|
|
1047
1146
|
const mentions = comment.mentions ?? extractMentions(comment.content);
|
|
1048
1147
|
const created = await this.store.addComment({ ...comment, mentions });
|
|
1049
1148
|
const now = new Date().toISOString();
|
|
@@ -1110,6 +1209,7 @@ export class CriticalPathEngine {
|
|
|
1110
1209
|
}
|
|
1111
1210
|
|
|
1112
1211
|
async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
|
|
1212
|
+
if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
|
|
1113
1213
|
const existing = await this.store.getComment(commentId);
|
|
1114
1214
|
if (!existing) return null;
|
|
1115
1215
|
|
|
@@ -1155,6 +1255,7 @@ export class CriticalPathEngine {
|
|
|
1155
1255
|
}
|
|
1156
1256
|
|
|
1157
1257
|
async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
|
|
1258
|
+
if (this.actor) reaction = { ...reaction, userId: this.actor.userId };
|
|
1158
1259
|
const existing = await this.store.getComment(commentId);
|
|
1159
1260
|
if (!existing) return null;
|
|
1160
1261
|
|
|
@@ -1199,6 +1300,9 @@ export class CriticalPathEngine {
|
|
|
1199
1300
|
}
|
|
1200
1301
|
|
|
1201
1302
|
async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
|
|
1303
|
+
if (this.actor) {
|
|
1304
|
+
attachment = { ...attachment, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' };
|
|
1305
|
+
}
|
|
1202
1306
|
validateAttachmentUrl(attachment.url);
|
|
1203
1307
|
const created = await this.store.createAttachment(attachment);
|
|
1204
1308
|
const now = new Date().toISOString();
|
|
@@ -1288,7 +1392,7 @@ export class CriticalPathEngine {
|
|
|
1288
1392
|
async readAttachmentText(id: string): Promise<string> {
|
|
1289
1393
|
const attachment = await this.getAttachment(id);
|
|
1290
1394
|
if (!attachment) {
|
|
1291
|
-
throw new
|
|
1395
|
+
throw new NotFoundError(`Attachment with id "${id}" not found.`);
|
|
1292
1396
|
}
|
|
1293
1397
|
|
|
1294
1398
|
if (attachment.storageKey && this.fileStorage && typeof this.fileStorage.download === 'function') {
|
|
@@ -1430,7 +1534,7 @@ export class CriticalPathEngine {
|
|
|
1430
1534
|
|
|
1431
1535
|
await this.store.logActivity({
|
|
1432
1536
|
projectId: created.projectId,
|
|
1433
|
-
actorId: 'system',
|
|
1537
|
+
actorId: this.actorIdOr('system'),
|
|
1434
1538
|
action: 'deliverable.created',
|
|
1435
1539
|
details: { title: created.title, format: created.format }
|
|
1436
1540
|
});
|
|
@@ -1483,7 +1587,7 @@ export class CriticalPathEngine {
|
|
|
1483
1587
|
|
|
1484
1588
|
await this.store.logActivity({
|
|
1485
1589
|
projectId: updated.projectId,
|
|
1486
|
-
actorId: 'system',
|
|
1590
|
+
actorId: this.actorIdOr('system'),
|
|
1487
1591
|
action: 'deliverable.updated',
|
|
1488
1592
|
details: { title: updated.title, status: updated.status }
|
|
1489
1593
|
});
|
|
@@ -1511,7 +1615,7 @@ export class CriticalPathEngine {
|
|
|
1511
1615
|
|
|
1512
1616
|
await this.store.logActivity({
|
|
1513
1617
|
projectId: existing.projectId,
|
|
1514
|
-
actorId: 'system',
|
|
1618
|
+
actorId: this.actorIdOr('system'),
|
|
1515
1619
|
action: 'deliverable.deleted',
|
|
1516
1620
|
details: { title: existing.title }
|
|
1517
1621
|
});
|
|
@@ -1661,7 +1765,7 @@ export class CriticalPathEngine {
|
|
|
1661
1765
|
): Promise<TimelineLadder> {
|
|
1662
1766
|
const project = await this.store.getProject(projectId);
|
|
1663
1767
|
if (!project) {
|
|
1664
|
-
throw new
|
|
1768
|
+
throw new NotFoundError(`Project with ID "${projectId}" not found.`);
|
|
1665
1769
|
}
|
|
1666
1770
|
|
|
1667
1771
|
let tasks = await this.store.getTasks(projectId);
|
package/src/index.test.ts
CHANGED
|
@@ -3,6 +3,30 @@ import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } fr
|
|
|
3
3
|
import type { CriticalPathPlugin } from './types/index.js';
|
|
4
4
|
|
|
5
5
|
describe('CriticalPathEngine Core Tests', () => {
|
|
6
|
+
it('deletes a project with its tasks and publishes deletion events', async () => {
|
|
7
|
+
const engine = new CriticalPathEngine();
|
|
8
|
+
const proj = await engine.createProject({ key: 'DEL', name: 'Doomed Project' });
|
|
9
|
+
const keep = await engine.createProject({ key: 'KEEP', name: 'Survivor' });
|
|
10
|
+
const t1 = await engine.createTask({ projectId: proj.id, title: 'One' });
|
|
11
|
+
const t2 = await engine.createTask({ projectId: proj.id, title: 'Two' });
|
|
12
|
+
const other = await engine.createTask({ projectId: keep.id, title: 'Other' });
|
|
13
|
+
|
|
14
|
+
const published: string[] = [];
|
|
15
|
+
engine.events.subscribe('*', (event) => {
|
|
16
|
+
published.push(event.name);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(await engine.deleteProject(proj.id)).toBe(true);
|
|
20
|
+
expect(await engine.getProject(proj.id)).toBeNull();
|
|
21
|
+
expect(await engine.getTask(t1.id)).toBeNull();
|
|
22
|
+
expect(await engine.getTask(t2.id)).toBeNull();
|
|
23
|
+
expect(await engine.getTask(other.id)).not.toBeNull();
|
|
24
|
+
expect(published.filter((n) => n === 'task.deleted')).toHaveLength(2);
|
|
25
|
+
expect(published.at(-1)).toBe('project.deleted');
|
|
26
|
+
|
|
27
|
+
expect(await engine.deleteProject(proj.id)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
6
30
|
it('creates projects and tasks with activity logs', async () => {
|
|
7
31
|
const engine = new CriticalPathEngine();
|
|
8
32
|
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
2
5
|
import { SQLiteStore } from './sqlite.js';
|
|
3
6
|
|
|
4
7
|
describe('SQLiteStore', () => {
|
|
@@ -194,3 +197,22 @@ describe('SQLiteStore', () => {
|
|
|
194
197
|
expect(remaining).toHaveLength(0);
|
|
195
198
|
});
|
|
196
199
|
});
|
|
200
|
+
|
|
201
|
+
describe('SQLiteStore outside the vitest runtime', () => {
|
|
202
|
+
const distEntry = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
|
|
203
|
+
|
|
204
|
+
// vitest injects `require` into ESM modules, which once hid an `eval("require(...)")` failure
|
|
205
|
+
// that broke `new SQLiteStore({ filename })` in plain Node. Run the built package in a real Node process.
|
|
206
|
+
it.skipIf(!existsSync(distEntry))('constructs from a filename in a plain Node ESM process', () => {
|
|
207
|
+
const script = `
|
|
208
|
+
const { SQLiteStore } = await import(${JSON.stringify(pathToFileURL(distEntry).href)});
|
|
209
|
+
const store = new SQLiteStore({ filename: ':memory:' });
|
|
210
|
+
const project = await store.createProject({ key: 'SMOKE', name: 'Smoke' });
|
|
211
|
+
process.stdout.write(project.key);
|
|
212
|
+
`;
|
|
213
|
+
const output = execFileSync(process.execPath, ['--input-type=module', '--no-warnings', '-e', script], {
|
|
214
|
+
encoding: 'utf8'
|
|
215
|
+
});
|
|
216
|
+
expect(output).toBe('SMOKE');
|
|
217
|
+
});
|
|
218
|
+
});
|
package/src/store/sqlite.ts
CHANGED
|
@@ -41,7 +41,13 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
41
41
|
throw new Error('SQLiteStore is only available in Node.js environments.');
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
|
-
|
|
44
|
+
// `require` is undefined in ESM, and a static import would break browser bundles of core.
|
|
45
|
+
const nodeSqlite = (globalThis as any).process?.getBuiltinModule?.('node:sqlite') as
|
|
46
|
+
| typeof import('node:sqlite')
|
|
47
|
+
| undefined;
|
|
48
|
+
if (!nodeSqlite) {
|
|
49
|
+
throw new Error('process.getBuiltinModule is unavailable (requires Node.js 22.3+)');
|
|
50
|
+
}
|
|
45
51
|
this.db = new nodeSqlite.DatabaseSync(config.filename || ':memory:');
|
|
46
52
|
} catch (err: any) {
|
|
47
53
|
throw new Error(`Failed to load node:sqlite module: ${err.message}`);
|
package/src/types/index.ts
CHANGED
|
@@ -300,6 +300,13 @@ export interface Iteration {
|
|
|
300
300
|
|
|
301
301
|
export type AuthorType = 'user' | 'agent' | 'system';
|
|
302
302
|
|
|
303
|
+
/** The identity a mutation is attributed to (see `CriticalPathEngine.withActor`). */
|
|
304
|
+
export interface Actor {
|
|
305
|
+
userId: string;
|
|
306
|
+
username?: string;
|
|
307
|
+
actorType?: AuthorType;
|
|
308
|
+
}
|
|
309
|
+
|
|
303
310
|
export interface CommentReaction {
|
|
304
311
|
emoji: string;
|
|
305
312
|
userId: string;
|
|
@@ -409,6 +416,7 @@ export interface Webhook {
|
|
|
409
416
|
export type WebhookEvent =
|
|
410
417
|
| 'project.created'
|
|
411
418
|
| 'project.updated'
|
|
419
|
+
| 'project.deleted'
|
|
412
420
|
| 'task.created'
|
|
413
421
|
| 'task.updated'
|
|
414
422
|
| 'task.deleted'
|
|
@@ -120,6 +120,43 @@ describe('fractional-index', () => {
|
|
|
120
120
|
current = next;
|
|
121
121
|
}
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
it('handles repeated insertions directly after the same lower bound', () => {
|
|
125
|
+
const lower = 'a0';
|
|
126
|
+
let upper = 'a1';
|
|
127
|
+
|
|
128
|
+
// Repeatedly drop a card just below the first card
|
|
129
|
+
for (let i = 0; i < 50; i++) {
|
|
130
|
+
const next = generateKeyBetween(lower, upper);
|
|
131
|
+
expect(lower < next).toBe(true);
|
|
132
|
+
expect(next < upper).toBe(true);
|
|
133
|
+
upper = next;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps keys strictly ordered under random insertions', () => {
|
|
138
|
+
const keys = ['a0', 'a1'];
|
|
139
|
+
let seed = 42;
|
|
140
|
+
const random = () => {
|
|
141
|
+
seed = (seed * 1103515245 + 12345) % 2147483648;
|
|
142
|
+
return seed / 2147483648;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < 500; i++) {
|
|
146
|
+
const idx = Math.floor(random() * (keys.length + 1));
|
|
147
|
+
const next = generateKeyBetween(keys[idx - 1] ?? null, keys[idx] ?? null);
|
|
148
|
+
keys.splice(idx, 0, next);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let i = 1; i < keys.length; i++) {
|
|
152
|
+
expect(keys[i - 1] < keys[i]).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('throws when the lower bound does not sort before the upper bound', () => {
|
|
157
|
+
expect(() => generateKeyBetween('a1', 'a0')).toThrow(RangeError);
|
|
158
|
+
expect(() => generateKeyBetween('a0', 'a0')).toThrow(RangeError);
|
|
159
|
+
});
|
|
123
160
|
});
|
|
124
161
|
|
|
125
162
|
describe('generateNKeysBetween', () => {
|