@critical-path/core 0.15.0 → 0.15.1
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 +6 -0
- package/dist/domain/events.d.ts +8 -1
- package/dist/domain/events.d.ts.map +1 -1
- package/dist/domain/events.js.map +1 -1
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +80 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/index.test.js +56 -1
- package/dist/index.test.js.map +1 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +12 -2
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +13 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/workflow.d.ts +1 -0
- package/dist/utils/workflow.d.ts.map +1 -1
- package/dist/utils/workflow.js +50 -3
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/events.ts +9 -0
- package/src/engine/index.ts +93 -0
- package/src/index.test.ts +69 -1
- package/src/store/firebase.test.ts +16 -0
- package/src/store/firebase.ts +12 -2
- package/src/types/index.ts +1 -0
- package/src/utils/workflow.ts +60 -3
package/src/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CriticalPathEngine, deriveTaskLifecycleState } from './index.js';
|
|
2
|
+
import { CriticalPathEngine, deriveTaskLifecycleState, DEFAULT_VFX_WORKFLOW } from './index.js';
|
|
3
3
|
import type { CriticalPathPlugin } from './types/index.js';
|
|
4
4
|
|
|
5
5
|
describe('CriticalPathEngine Core Tests', () => {
|
|
@@ -372,6 +372,74 @@ describe('CriticalPathEngine Core Tests', () => {
|
|
|
372
372
|
const backlogPrev = await engine.getAllowedPreviousTaskTransitions(backlogTask.id);
|
|
373
373
|
expect(backlogPrev).toEqual([]);
|
|
374
374
|
});
|
|
375
|
+
|
|
376
|
+
it('automatically unblocks downstream tasks and publishes task.unblocked event when upstream task completes', async () => {
|
|
377
|
+
const engine = new CriticalPathEngine();
|
|
378
|
+
const proj = await engine.createProject({ key: 'UNBLK', name: 'Auto-Unblock Project' });
|
|
379
|
+
|
|
380
|
+
const upstream = await engine.createTask({
|
|
381
|
+
projectId: proj.id,
|
|
382
|
+
title: 'Upstream Task',
|
|
383
|
+
status: 'in_progress'
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const downstream = await engine.createTask({
|
|
387
|
+
projectId: proj.id,
|
|
388
|
+
title: 'Downstream Task',
|
|
389
|
+
status: 'blocked',
|
|
390
|
+
customFields: { isBlocked: true, blockedReason: 'Waiting on upstream' }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
await engine.addDependency({
|
|
394
|
+
taskId: downstream.id,
|
|
395
|
+
dependsOnTaskId: upstream.id,
|
|
396
|
+
type: 'blocking'
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const unblockedEvents: any[] = [];
|
|
400
|
+
engine.events.subscribe('task.unblocked', async (evt) => {
|
|
401
|
+
unblockedEvents.push(evt);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Complete upstream task
|
|
405
|
+
const completedUpstream = await engine.updateTask(upstream.id, { status: 'done' });
|
|
406
|
+
expect(completedUpstream?.status).toBe('done');
|
|
407
|
+
|
|
408
|
+
// Downstream task should now be unblocked
|
|
409
|
+
expect(unblockedEvents).toHaveLength(1);
|
|
410
|
+
expect(unblockedEvents[0].payload.task.id).toBe(downstream.id);
|
|
411
|
+
expect(unblockedEvents[0].payload.upstreamTaskId).toBe(upstream.id);
|
|
412
|
+
|
|
413
|
+
const refreshedDownstream = await engine.getTask(downstream.id);
|
|
414
|
+
expect(refreshedDownstream?.status).toBe('todo');
|
|
415
|
+
expect(refreshedDownstream?.customFields?.isBlocked).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('allows VFX workflow transitions from revision_requested to in_production', async () => {
|
|
419
|
+
const engine = new CriticalPathEngine();
|
|
420
|
+
const vfxWorkflow = await engine.createWorkflow(DEFAULT_VFX_WORKFLOW);
|
|
421
|
+
const proj = await engine.createProject({
|
|
422
|
+
key: 'VFX',
|
|
423
|
+
name: 'VFX Film Project',
|
|
424
|
+
workflowId: vfxWorkflow.id
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const task = await engine.createTask({
|
|
428
|
+
projectId: proj.id,
|
|
429
|
+
title: 'Compositing Shot 010',
|
|
430
|
+
status: 'revision_requested'
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const allowed = await engine.getAllowedTaskTransitions(task.id);
|
|
434
|
+
expect(allowed).toContain('in_production');
|
|
435
|
+
expect(allowed).toContain('internal_review');
|
|
436
|
+
|
|
437
|
+
const allowedPrevious = await engine.getAllowedPreviousTaskTransitions(task.id);
|
|
438
|
+
expect(allowedPrevious).toContain('in_production');
|
|
439
|
+
|
|
440
|
+
const updated = await engine.updateTask(task.id, { status: 'in_production' });
|
|
441
|
+
expect(updated?.status).toBe('in_production');
|
|
442
|
+
});
|
|
375
443
|
});
|
|
376
444
|
|
|
377
445
|
|
|
@@ -91,5 +91,21 @@ describe('FirebaseStore', () => {
|
|
|
91
91
|
expect(doc?.name).toBe('Undefined Test');
|
|
92
92
|
expect('description' in (doc as any)).toBe(false);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it('should retrieve dependencies when queried as taskId or dependsOnTaskId', async () => {
|
|
96
|
+
const dep = await store.addDependency({
|
|
97
|
+
taskId: 'task-downstream',
|
|
98
|
+
dependsOnTaskId: 'task-upstream',
|
|
99
|
+
type: 'blocking'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const byDownstream = await store.getDependencies('task-downstream');
|
|
103
|
+
expect(byDownstream).toHaveLength(1);
|
|
104
|
+
expect(byDownstream[0].id).toBe(dep.id);
|
|
105
|
+
|
|
106
|
+
const byUpstream = await store.getDependencies('task-upstream');
|
|
107
|
+
expect(byUpstream).toHaveLength(1);
|
|
108
|
+
expect(byUpstream[0].id).toBe(dep.id);
|
|
109
|
+
});
|
|
94
110
|
});
|
|
95
111
|
|
package/src/store/firebase.ts
CHANGED
|
@@ -546,8 +546,18 @@ export class FirebaseStore implements StorageAdapter {
|
|
|
546
546
|
|
|
547
547
|
// --- Dependencies ---
|
|
548
548
|
async getDependencies(taskId: string): Promise<TaskDependency[]> {
|
|
549
|
-
const
|
|
550
|
-
|
|
549
|
+
const [snap1, snap2] = await Promise.all([
|
|
550
|
+
this.db.collection('dependencies').where('taskId', '==', taskId).get(),
|
|
551
|
+
this.db.collection('dependencies').where('dependsOnTaskId', '==', taskId).get()
|
|
552
|
+
]);
|
|
553
|
+
const depMap = new Map<string, TaskDependency>();
|
|
554
|
+
for (const doc of snap1.docs) {
|
|
555
|
+
depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
|
|
556
|
+
}
|
|
557
|
+
for (const doc of snap2.docs) {
|
|
558
|
+
depMap.set(doc.id, { ...doc.data(), id: doc.id } as TaskDependency);
|
|
559
|
+
}
|
|
560
|
+
return Array.from(depMap.values());
|
|
551
561
|
}
|
|
552
562
|
|
|
553
563
|
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
package/src/types/index.ts
CHANGED
package/src/utils/workflow.ts
CHANGED
|
@@ -101,14 +101,27 @@ export const DEFAULT_VFX_WORKFLOW: Workflow = {
|
|
|
101
101
|
],
|
|
102
102
|
transitions: [
|
|
103
103
|
{ name: 'Award Bid', fromStatusKey: 'bidding', toStatusKey: 'awarded' },
|
|
104
|
+
{ name: 'Start Directly from Bid', fromStatusKey: 'bidding', toStatusKey: 'in_production' },
|
|
104
105
|
{ name: 'Start Work', fromStatusKey: 'awarded', toStatusKey: 'in_production' },
|
|
106
|
+
{ name: 'Reopen Bid', fromStatusKey: 'awarded', toStatusKey: 'bidding' },
|
|
105
107
|
{ name: 'Submit to Lead Review', fromStatusKey: 'in_production', toStatusKey: 'internal_review' },
|
|
108
|
+
{ name: 'Fast-Track Client Review', fromStatusKey: 'in_production', toStatusKey: 'client_review' },
|
|
109
|
+
{ name: 'Direct Approval', fromStatusKey: 'in_production', toStatusKey: 'approved' },
|
|
106
110
|
{ name: 'Submit to Client Review', fromStatusKey: 'internal_review', toStatusKey: 'client_review' },
|
|
107
111
|
{ name: 'Request Internal Revisions', fromStatusKey: 'internal_review', toStatusKey: 'revision_requested' },
|
|
112
|
+
{ name: 'Return to Production', fromStatusKey: 'internal_review', toStatusKey: 'in_production' },
|
|
113
|
+
{ name: 'Approve from Lead Review', fromStatusKey: 'internal_review', toStatusKey: 'approved' },
|
|
108
114
|
{ name: 'Request Client Revisions', fromStatusKey: 'client_review', toStatusKey: 'revision_requested' },
|
|
109
|
-
{ name: '
|
|
115
|
+
{ name: 'Return to Lead Review', fromStatusKey: 'client_review', toStatusKey: 'internal_review' },
|
|
110
116
|
{ name: 'Approve Final Shot', fromStatusKey: 'client_review', toStatusKey: 'approved' },
|
|
111
|
-
{ name: '
|
|
117
|
+
{ name: 'Address Revisions in Production', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
|
|
118
|
+
{ name: 'Resubmit to Lead Review', fromStatusKey: 'revision_requested', toStatusKey: 'internal_review' },
|
|
119
|
+
{ name: 'Resubmit to Client Review', fromStatusKey: 'revision_requested', toStatusKey: 'client_review' },
|
|
120
|
+
{ name: 'Reopen Shot', fromStatusKey: 'approved', toStatusKey: 'in_production' },
|
|
121
|
+
{ name: 'Reopen Lead Review', fromStatusKey: 'approved', toStatusKey: 'internal_review' },
|
|
122
|
+
{ name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
|
|
123
|
+
{ name: 'Restore to Bidding', fromStatusKey: 'canceled', toStatusKey: 'bidding' },
|
|
124
|
+
{ name: 'Restore to Production', fromStatusKey: 'canceled', toStatusKey: 'in_production' }
|
|
112
125
|
],
|
|
113
126
|
taskTypes: [
|
|
114
127
|
{ key: 'vfx_task', label: 'VFX Task', description: 'Department task (e.g. Matchmove, Animation, FX, Comp)', icon: 'film' },
|
|
@@ -139,14 +152,23 @@ export const DEFAULT_CREATIVE_WORKFLOW: Workflow = {
|
|
|
139
152
|
transitions: [
|
|
140
153
|
{ name: 'Start Concepts', fromStatusKey: 'briefing', toStatusKey: 'concept' },
|
|
141
154
|
{ name: 'Approve Concept for Production', fromStatusKey: 'concept', toStatusKey: 'in_production' },
|
|
155
|
+
{ name: 'Return to Briefing', fromStatusKey: 'concept', toStatusKey: 'briefing' },
|
|
142
156
|
{ name: 'Submit for Internal Review', fromStatusKey: 'in_production', toStatusKey: 'internal_review' },
|
|
157
|
+
{ name: 'Submit for Client Review', fromStatusKey: 'in_production', toStatusKey: 'client_review' },
|
|
143
158
|
{ name: 'Submit for Client Review', fromStatusKey: 'internal_review', toStatusKey: 'client_review' },
|
|
144
159
|
{ name: 'Request Revisions (Internal)', fromStatusKey: 'internal_review', toStatusKey: 'revision_requested' },
|
|
160
|
+
{ name: 'Return to Production', fromStatusKey: 'internal_review', toStatusKey: 'in_production' },
|
|
161
|
+
{ name: 'Approve from Lead Review', fromStatusKey: 'internal_review', toStatusKey: 'approved' },
|
|
145
162
|
{ name: 'Request Revisions (Client)', fromStatusKey: 'client_review', toStatusKey: 'revision_requested' },
|
|
163
|
+
{ name: 'Return to Internal Review', fromStatusKey: 'client_review', toStatusKey: 'internal_review' },
|
|
146
164
|
{ name: 'Address Revisions', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
|
|
165
|
+
{ name: 'Resubmit to Lead Review', fromStatusKey: 'revision_requested', toStatusKey: 'internal_review' },
|
|
166
|
+
{ name: 'Resubmit to Client Review', fromStatusKey: 'revision_requested', toStatusKey: 'client_review' },
|
|
147
167
|
{ name: 'Approve Work', fromStatusKey: 'client_review', toStatusKey: 'approved' },
|
|
148
168
|
{ name: 'Deliver Final Assets', fromStatusKey: 'approved', toStatusKey: 'delivered' },
|
|
149
|
-
{ name: '
|
|
169
|
+
{ name: 'Reopen Deliverable', fromStatusKey: 'delivered', toStatusKey: 'in_production' },
|
|
170
|
+
{ name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
|
|
171
|
+
{ name: 'Restore', fromStatusKey: 'canceled', toStatusKey: 'briefing' }
|
|
150
172
|
],
|
|
151
173
|
taskTypes: [
|
|
152
174
|
{ key: 'deliverable', label: 'Deliverable', description: 'Finished asset, cutdown, or packaged export', icon: 'package' },
|
|
@@ -211,6 +233,41 @@ export function getAllowedNextStatuses(
|
|
|
211
233
|
return [allStatusKeys[currentIndex + 1]];
|
|
212
234
|
}
|
|
213
235
|
|
|
236
|
+
export function getAllowedTransitions(
|
|
237
|
+
workflow: Workflow | undefined,
|
|
238
|
+
currentStatus: string
|
|
239
|
+
): string[] {
|
|
240
|
+
if (!workflow || !workflow.statuses || workflow.statuses.length === 0) {
|
|
241
|
+
return Object.keys(DEFAULT_STATUS_DEFINITIONS);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const allStatusKeys = workflow.statuses.map((s) => s.key);
|
|
245
|
+
if (!allStatusKeys.includes(currentStatus)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (workflow.transitions && workflow.transitions.length > 0) {
|
|
250
|
+
const matches = workflow.transitions.filter(
|
|
251
|
+
(t) => t.fromStatusKey === '*' || t.fromStatusKey === currentStatus
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const allowed = new Set<string>();
|
|
255
|
+
for (const t of matches) {
|
|
256
|
+
if (t.toStatusKey === '*') {
|
|
257
|
+
allStatusKeys.filter((k) => k !== currentStatus).forEach((k) => allowed.add(k));
|
|
258
|
+
} else if (t.toStatusKey !== currentStatus && allStatusKeys.includes(t.toStatusKey)) {
|
|
259
|
+
allowed.add(t.toStatusKey);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return Array.from(allowed);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const next = getAllowedNextStatuses(workflow, currentStatus);
|
|
267
|
+
const prev = getAllowedPreviousStatuses(workflow, currentStatus);
|
|
268
|
+
return Array.from(new Set([...next, ...prev]));
|
|
269
|
+
}
|
|
270
|
+
|
|
214
271
|
export function getAllowedPreviousStatuses(
|
|
215
272
|
workflow: Workflow | undefined,
|
|
216
273
|
currentStatus: string
|