@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/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
 
@@ -546,8 +546,18 @@ export class FirebaseStore implements StorageAdapter {
546
546
 
547
547
  // --- Dependencies ---
548
548
  async getDependencies(taskId: string): Promise<TaskDependency[]> {
549
- const snap = await this.db.collection('dependencies').where('taskId', '==', taskId).get();
550
- return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
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> {
@@ -369,6 +369,7 @@ export type WebhookEvent =
369
369
  | 'task.updated'
370
370
  | 'task.deleted'
371
371
  | 'task.status_changed'
372
+ | 'task.unblocked'
372
373
  | 'comment.created'
373
374
  | 'comment.updated'
374
375
  | 'comment.deleted'
@@ -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: 'Address Revisions', fromStatusKey: 'revision_requested', toStatusKey: 'in_production' },
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: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' }
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: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' }
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