@critical-path/core 0.3.0 → 0.4.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/engine/index.d.ts +8 -1
  4. package/dist/engine/index.d.ts.map +1 -1
  5. package/dist/engine/index.js +90 -2
  6. package/dist/engine/index.js.map +1 -1
  7. package/dist/index.test.js +44 -0
  8. package/dist/index.test.js.map +1 -1
  9. package/dist/store/firebase.d.ts +6 -1
  10. package/dist/store/firebase.d.ts.map +1 -1
  11. package/dist/store/firebase.js +37 -0
  12. package/dist/store/firebase.js.map +1 -1
  13. package/dist/store/firebase.test.js +16 -0
  14. package/dist/store/firebase.test.js.map +1 -1
  15. package/dist/store/index.d.ts +12 -1
  16. package/dist/store/index.d.ts.map +1 -1
  17. package/dist/store/index.js +35 -0
  18. package/dist/store/index.js.map +1 -1
  19. package/dist/store/sqlite.d.ts +7 -1
  20. package/dist/store/sqlite.d.ts.map +1 -1
  21. package/dist/store/sqlite.js +81 -10
  22. package/dist/store/sqlite.js.map +1 -1
  23. package/dist/store/sqlite.test.js +21 -0
  24. package/dist/store/sqlite.test.js.map +1 -1
  25. package/dist/types/index.d.ts +32 -1
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/utils/index.d.ts +1 -0
  28. package/dist/utils/index.d.ts.map +1 -1
  29. package/dist/utils/index.js +1 -0
  30. package/dist/utils/index.js.map +1 -1
  31. package/dist/utils/workflow.d.ts +13 -0
  32. package/dist/utils/workflow.d.ts.map +1 -0
  33. package/dist/utils/workflow.js +101 -0
  34. package/dist/utils/workflow.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/engine/index.ts +103 -3
  37. package/src/index.test.ts +54 -0
  38. package/src/store/firebase.test.ts +21 -0
  39. package/src/store/firebase.ts +42 -1
  40. package/src/store/index.ts +48 -1
  41. package/src/store/sqlite.test.ts +28 -0
  42. package/src/store/sqlite.ts +114 -7
  43. package/src/types/index.ts +38 -1
  44. package/src/utils/index.ts +2 -0
  45. package/src/utils/workflow.ts +128 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,101 @@
1
+ import { DEFAULT_STATUS_DEFINITIONS } from './status.js';
2
+ export class WorkflowValidationError extends Error {
3
+ fromStatus;
4
+ toStatus;
5
+ workflowId;
6
+ constructor(fromStatus, toStatus, workflowId, message) {
7
+ const msg = message || `Illegal status transition from "${fromStatus}" to "${toStatus}"${workflowId ? ` in workflow "${workflowId}"` : ''}.`;
8
+ super(msg);
9
+ this.name = 'WorkflowValidationError';
10
+ this.fromStatus = fromStatus;
11
+ this.toStatus = toStatus;
12
+ this.workflowId = workflowId;
13
+ }
14
+ }
15
+ export const DEFAULT_TASK_TYPES = [
16
+ { key: 'task', label: 'Task', description: 'A standard work item', icon: 'check-square' },
17
+ { key: 'bug', label: 'Bug', description: 'A problem or defect that needs fixing', icon: 'bug' },
18
+ { key: 'feature', label: 'Feature', description: 'A new capability or enhancement', icon: 'sparkles' },
19
+ { key: 'epic', label: 'Epic', description: 'A large body of work that spans multiple tasks', icon: 'layers' },
20
+ { key: 'subtask', label: 'Subtask', description: 'A piece of work required to complete a parent task', icon: 'corner-down-right' }
21
+ ];
22
+ export const DEFAULT_SOFTWARE_WORKFLOW = {
23
+ id: 'wf_software_default',
24
+ name: 'Software Development Workflow',
25
+ description: 'Standard software development lifecycle with backlog, review, and completion states.',
26
+ statuses: [
27
+ DEFAULT_STATUS_DEFINITIONS.backlog,
28
+ DEFAULT_STATUS_DEFINITIONS.todo,
29
+ DEFAULT_STATUS_DEFINITIONS.in_progress,
30
+ DEFAULT_STATUS_DEFINITIONS.in_review,
31
+ DEFAULT_STATUS_DEFINITIONS.done,
32
+ DEFAULT_STATUS_DEFINITIONS.canceled
33
+ ],
34
+ transitions: [
35
+ { name: 'Start Work', fromStatusKey: 'backlog', toStatusKey: 'todo' },
36
+ { name: 'Start Implementation', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
37
+ { name: 'Complete Task', fromStatusKey: 'todo', toStatusKey: 'done' },
38
+ { name: 'Submit for Review', fromStatusKey: 'in_progress', toStatusKey: 'in_review' },
39
+ { name: 'Complete Implementation', fromStatusKey: 'in_progress', toStatusKey: 'done' },
40
+ { name: 'Approve & Complete', fromStatusKey: 'in_review', toStatusKey: 'done' },
41
+ { name: 'Request Changes', fromStatusKey: 'in_review', toStatusKey: 'in_progress' },
42
+ { name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' },
43
+ { name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
44
+ { name: 'Restore', fromStatusKey: 'canceled', toStatusKey: 'todo' }
45
+ ],
46
+ taskTypes: DEFAULT_TASK_TYPES,
47
+ defaultStatusKey: 'todo',
48
+ isDefault: true,
49
+ createdAt: '2026-01-01T00:00:00.000Z',
50
+ updatedAt: '2026-01-01T00:00:00.000Z'
51
+ };
52
+ export const DEFAULT_SIMPLE_WORKFLOW = {
53
+ id: 'wf_simple_default',
54
+ name: 'Simple Task Workflow',
55
+ description: 'Lightweight workflow with To Do, In Progress, and Done states.',
56
+ statuses: [
57
+ DEFAULT_STATUS_DEFINITIONS.todo,
58
+ DEFAULT_STATUS_DEFINITIONS.in_progress,
59
+ DEFAULT_STATUS_DEFINITIONS.done
60
+ ],
61
+ transitions: [
62
+ { name: 'Start', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
63
+ { name: 'Complete', fromStatusKey: 'todo', toStatusKey: 'done' },
64
+ { name: 'Complete', fromStatusKey: 'in_progress', toStatusKey: 'done' },
65
+ { name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' }
66
+ ],
67
+ taskTypes: DEFAULT_TASK_TYPES.filter((t) => ['task', 'subtask'].includes(t.key)),
68
+ defaultStatusKey: 'todo',
69
+ isDefault: true,
70
+ createdAt: '2026-01-01T00:00:00.000Z',
71
+ updatedAt: '2026-01-01T00:00:00.000Z'
72
+ };
73
+ export function validateTransition(workflow, fromStatus, toStatus) {
74
+ if (fromStatus === toStatus)
75
+ return true;
76
+ if (!workflow || !workflow.transitions || workflow.transitions.length === 0)
77
+ return true;
78
+ return workflow.transitions.some((t) => (t.fromStatusKey === '*' || t.fromStatusKey === fromStatus) &&
79
+ (t.toStatusKey === '*' || t.toStatusKey === toStatus));
80
+ }
81
+ export function getAllowedNextStatuses(workflow, currentStatus) {
82
+ if (!workflow || !workflow.statuses || workflow.statuses.length === 0) {
83
+ return Object.keys(DEFAULT_STATUS_DEFINITIONS);
84
+ }
85
+ const allStatusKeys = workflow.statuses.map((s) => s.key);
86
+ if (!workflow.transitions || workflow.transitions.length === 0) {
87
+ return allStatusKeys;
88
+ }
89
+ const matches = workflow.transitions.filter((t) => t.fromStatusKey === '*' || t.fromStatusKey === currentStatus);
90
+ if (matches.some((t) => t.toStatusKey === '*')) {
91
+ return allStatusKeys;
92
+ }
93
+ const allowed = new Set();
94
+ for (const t of matches) {
95
+ if (allStatusKeys.includes(t.toStatusKey)) {
96
+ allowed.add(t.toStatusKey);
97
+ }
98
+ }
99
+ return Array.from(allowed);
100
+ }
101
+ //# sourceMappingURL=workflow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow.js","sourceRoot":"","sources":["../../src/utils/workflow.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@critical-path/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Core headless domain engine, plugin architecture, and data stores for Critical Path",
5
5
  "author": "Jack James",
6
6
  "license": "MIT",
@@ -6,11 +6,18 @@ import type {
6
6
  Team,
7
7
  TaskContainer,
8
8
  Iteration,
9
- WebhookEvent
9
+ WebhookEvent,
10
+ Workflow
10
11
  } from '../types/index.js';
11
12
  import { StorageAdapter, InMemoryStore } from '../store/index.js';
12
13
  import { PluginRegistry } from '../plugins/index.js';
13
14
  import { deriveTaskLifecycleState, type TaskLifecycleState } from '../utils/status.js';
15
+ import {
16
+ validateTransition,
17
+ getAllowedNextStatuses,
18
+ WorkflowValidationError,
19
+ DEFAULT_SOFTWARE_WORKFLOW
20
+ } from '../utils/workflow.js';
14
21
 
15
22
  export class CriticalPathEngine {
16
23
  public readonly store: StorageAdapter;
@@ -35,6 +42,11 @@ export class CriticalPathEngine {
35
42
  }
36
43
 
37
44
  private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
45
+ if (data.workflows) {
46
+ for (const wf of data.workflows) {
47
+ await this.store.createWorkflow(wf);
48
+ }
49
+ }
38
50
  if (data.projects) {
39
51
  for (const p of data.projects) {
40
52
  await this.store.createProject(p);
@@ -62,6 +74,81 @@ export class CriticalPathEngine {
62
74
  }
63
75
  }
64
76
 
77
+ // --- Workflows ---
78
+ async getWorkflows(): Promise<Workflow[]> {
79
+ return this.store.getWorkflows();
80
+ }
81
+
82
+ async getWorkflow(id: string): Promise<Workflow | null> {
83
+ return this.store.getWorkflow(id);
84
+ }
85
+
86
+ async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
87
+ const created = await this.store.createWorkflow(workflow);
88
+ await this.store.logActivity({
89
+ actorId: 'system',
90
+ action: 'workflow.created',
91
+ details: { name: created.name }
92
+ });
93
+ this.dispatchWebhook('workflow.created', { workflow: created });
94
+ return created;
95
+ }
96
+
97
+ async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
98
+ const existing = await this.store.getWorkflow(id);
99
+ if (!existing) return null;
100
+
101
+ const updated = await this.store.updateWorkflow(id, updates);
102
+ if (updated) {
103
+ await this.store.logActivity({
104
+ actorId: 'system',
105
+ action: 'workflow.updated',
106
+ details: { name: updated.name }
107
+ });
108
+ this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
109
+ }
110
+ return updated;
111
+ }
112
+
113
+ async deleteWorkflow(id: string): Promise<boolean> {
114
+ const existing = await this.store.getWorkflow(id);
115
+ if (!existing) return false;
116
+
117
+ const deleted = await this.store.deleteWorkflow(id);
118
+ if (deleted) {
119
+ await this.store.logActivity({
120
+ actorId: 'system',
121
+ action: 'workflow.deleted',
122
+ details: { name: existing.name }
123
+ });
124
+ this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
125
+ }
126
+ return deleted;
127
+ }
128
+
129
+ async resolveProjectWorkflow(projectId: string): Promise<Workflow | null> {
130
+ const project = await this.store.getProject(projectId);
131
+ if (project?.workflow) return project.workflow;
132
+ if (project?.workflowId) {
133
+ const wf = await this.store.getWorkflow(project.workflowId);
134
+ if (wf) return wf;
135
+ }
136
+
137
+ const workflows = await this.store.getWorkflows();
138
+ const defaultWf = workflows.find((w) => w.isDefault);
139
+ if (defaultWf) return defaultWf;
140
+ if (workflows.length > 0) return workflows[0];
141
+
142
+ return DEFAULT_SOFTWARE_WORKFLOW;
143
+ }
144
+
145
+ async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
146
+ const task = await this.store.getTask(taskId);
147
+ if (!task) return [];
148
+ const workflow = await this.resolveProjectWorkflow(task.projectId);
149
+ return getAllowedNextStatuses(workflow || undefined, task.status);
150
+ }
151
+
65
152
  // --- Projects ---
66
153
  async getProjects(): Promise<Project[]> {
67
154
  return this.store.getProjects();
@@ -94,13 +181,18 @@ export class CriticalPathEngine {
94
181
 
95
182
  async createTask(taskInput: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
96
183
  const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
184
+ const projectId = processedInput.projectId || taskInput.projectId;
185
+ const workflow = await this.resolveProjectWorkflow(projectId);
186
+
187
+ const defaultStatus = workflow?.defaultStatusKey || 'todo';
97
188
 
98
189
  const created = await this.store.createTask({
99
- projectId: processedInput.projectId || taskInput.projectId,
190
+ projectId,
100
191
  title: processedInput.title || taskInput.title,
101
192
  description: processedInput.description ?? taskInput.description,
102
- status: processedInput.status || taskInput.status || 'todo',
193
+ status: processedInput.status || taskInput.status || defaultStatus,
103
194
  priority: processedInput.priority || taskInput.priority || 'medium',
195
+ taskType: processedInput.taskType || taskInput.taskType || 'task',
104
196
  assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
105
197
  reporterId: processedInput.reporterId ?? taskInput.reporterId,
106
198
  reviewerId: processedInput.reviewerId ?? taskInput.reviewerId,
@@ -142,6 +234,14 @@ export class CriticalPathEngine {
142
234
  const existing = await this.store.getTask(id);
143
235
  if (!existing) return null;
144
236
 
237
+ if (updates.status && updates.status !== existing.status) {
238
+ const workflow = await this.resolveProjectWorkflow(existing.projectId);
239
+ const isValid = validateTransition(workflow || undefined, existing.status, updates.status);
240
+ if (!isValid) {
241
+ throw new WorkflowValidationError(existing.status, updates.status, workflow?.id);
242
+ }
243
+ }
244
+
145
245
  const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, updates);
146
246
  const updated = await this.store.updateTask(id, processedUpdates);
147
247
  if (!updated) return null;
package/src/index.test.ts CHANGED
@@ -172,4 +172,58 @@ describe('CriticalPathEngine Core Tests', () => {
172
172
  const graphB = await engine.getTaskDependencyGraph(taskB.id);
173
173
  expect(graphB.upstreamTasks.map((t) => t.id)).toContain(taskA.id);
174
174
  });
175
+
176
+ it('manages workflows and enforces status transitions', async () => {
177
+ const engine = new CriticalPathEngine();
178
+
179
+ // Create a strict workflow
180
+ const workflow = await engine.createWorkflow({
181
+ name: 'Strict Software Workflow',
182
+ defaultStatusKey: 'backlog',
183
+ statuses: [
184
+ { key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' },
185
+ { key: 'in_progress', label: 'In Progress', completionState: 'not_done', executionState: 'active' },
186
+ { key: 'done', label: 'Done', completionState: 'done', executionState: 'inactive' }
187
+ ],
188
+ transitions: [
189
+ { id: 't1', fromStatusKey: 'backlog', toStatusKey: 'in_progress', name: 'Start Work' },
190
+ { id: 't2', fromStatusKey: 'in_progress', toStatusKey: 'done', name: 'Complete Work' }
191
+ ]
192
+ });
193
+
194
+ expect(workflow.id).toBeDefined();
195
+
196
+ // Create project tied to workflow
197
+ const proj = await engine.createProject({
198
+ key: 'WF',
199
+ name: 'Workflow Project',
200
+ workflowId: workflow.id
201
+ });
202
+
203
+ // Create task
204
+ const task = await engine.createTask({
205
+ projectId: proj.id,
206
+ title: 'Workflow Task',
207
+ status: 'backlog',
208
+ priority: 'high'
209
+ });
210
+
211
+ expect(task.status).toBe('backlog');
212
+ expect(task.taskType).toBe('task');
213
+
214
+ // Check allowed transitions
215
+ const allowed = await engine.getAllowedTaskTransitions(task.id);
216
+ expect(allowed).toEqual(['in_progress']);
217
+
218
+ // Valid transition: backlog -> in_progress
219
+ const updated = await engine.updateTask(task.id, { status: 'in_progress' });
220
+ expect(updated?.status).toBe('in_progress');
221
+
222
+ // Invalid transition: in_progress -> backlog (not allowed in strict workflow)
223
+ await expect(engine.updateTask(task.id, { status: 'backlog' })).rejects.toThrow();
224
+
225
+ // Valid transition: in_progress -> done
226
+ const doneTask = await engine.updateTask(task.id, { status: 'done' });
227
+ expect(doneTask?.status).toBe('done');
228
+ });
175
229
  });
@@ -56,4 +56,25 @@ describe('FirebaseStore', () => {
56
56
  const remaining = await store.getTasks(project.id);
57
57
  expect(remaining).toHaveLength(0);
58
58
  });
59
+
60
+ it('should create, update, and delete workflows in FirebaseStore', async () => {
61
+ const workflow = await store.createWorkflow({
62
+ name: 'Firebase Workflow',
63
+ isDefault: false,
64
+ defaultStatusKey: 'backlog',
65
+ statuses: [{ key: 'backlog', label: 'Backlog', completionState: 'not_done', executionState: 'inactive' }],
66
+ transitions: []
67
+ });
68
+
69
+ expect(workflow.id).toBeDefined();
70
+
71
+ const fetched = await store.getWorkflow(workflow.id);
72
+ expect(fetched?.name).toBe('Firebase Workflow');
73
+
74
+ const updated = await store.updateWorkflow(workflow.id, { isDefault: true });
75
+ expect(updated?.isDefault).toBe(true);
76
+
77
+ const deleted = await store.deleteWorkflow(workflow.id);
78
+ expect(deleted).toBe(true);
79
+ });
59
80
  });
@@ -9,7 +9,8 @@ import type {
9
9
  TimeEntry,
10
10
  Activity,
11
11
  Webhook,
12
- TaskDependency
12
+ TaskDependency,
13
+ Workflow
13
14
  } from '../types/index.js';
14
15
  import { generateProjectKey } from '../utils/key.js';
15
16
 
@@ -153,6 +154,46 @@ export class FirebaseStore implements StorageAdapter {
153
154
  return true;
154
155
  }
155
156
 
157
+ // --- Workflows ---
158
+ async getWorkflows(): Promise<Workflow[]> {
159
+ const snap = await this.db.collection('workflows').get();
160
+ return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
161
+ }
162
+
163
+ async getWorkflow(id: string): Promise<Workflow | null> {
164
+ const snap = await this.db.collection('workflows').doc(id).get();
165
+ if (!snap.exists) return null;
166
+ return { ...snap.data(), id: snap.id };
167
+ }
168
+
169
+ async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
170
+ const docRef = this.db.collection('workflows').doc();
171
+ const now = new Date().toISOString();
172
+ const newWf: Workflow = { ...workflow, id: docRef.id, createdAt: now, updatedAt: now };
173
+ await docRef.set(newWf);
174
+ return newWf;
175
+ }
176
+
177
+ async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
178
+ const existing = await this.getWorkflow(id);
179
+ if (!existing) return null;
180
+
181
+ const updated: Workflow = {
182
+ ...existing,
183
+ ...updates,
184
+ updatedAt: new Date().toISOString()
185
+ };
186
+ await this.db.collection('workflows').doc(id).set(updated, { merge: true });
187
+ return updated;
188
+ }
189
+
190
+ async deleteWorkflow(id: string): Promise<boolean> {
191
+ const existing = await this.getWorkflow(id);
192
+ if (!existing) return false;
193
+ await this.db.collection('workflows').doc(id).delete();
194
+ return true;
195
+ }
196
+
156
197
  // --- Teams ---
157
198
  async getTeams(): Promise<Team[]> {
158
199
  const snap = await this.db.collection('teams').get();
@@ -11,7 +11,8 @@ import type {
11
11
  TimeEntry,
12
12
  Activity,
13
13
  Webhook,
14
- TaskDependency
14
+ TaskDependency,
15
+ Workflow
15
16
  } from '../types/index.js';
16
17
  import { generateProjectKey } from '../utils/key.js';
17
18
 
@@ -23,6 +24,13 @@ export interface StorageAdapter {
23
24
  updateProject(id: string, updates: Partial<Project>): Promise<Project | null>;
24
25
  deleteProject(id: string): Promise<boolean>;
25
26
 
27
+ // Workflows
28
+ getWorkflows(): Promise<Workflow[]>;
29
+ getWorkflow(id: string): Promise<Workflow | null>;
30
+ createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow>;
31
+ updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null>;
32
+ deleteWorkflow(id: string): Promise<boolean>;
33
+
26
34
  // Tasks
27
35
  getTasks(projectId?: string): Promise<Task[]>;
28
36
  getTask(id: string): Promise<Task | null>;
@@ -72,6 +80,7 @@ export interface StorageAdapter {
72
80
 
73
81
  export class InMemoryStore implements StorageAdapter {
74
82
  private projects = new Map<string, Project>();
83
+ private workflows = new Map<string, Workflow>();
75
84
  private tasks = new Map<string, Task>();
76
85
  private teams = new Map<string, Team>();
77
86
  private containers = new Map<string, TaskContainer>();
@@ -121,6 +130,44 @@ export class InMemoryStore implements StorageAdapter {
121
130
  return this.projects.delete(id);
122
131
  }
123
132
 
133
+ // Workflows
134
+ async getWorkflows(): Promise<Workflow[]> {
135
+ return Array.from(this.workflows.values());
136
+ }
137
+
138
+ async getWorkflow(id: string): Promise<Workflow | null> {
139
+ return this.workflows.get(id) || null;
140
+ }
141
+
142
+ async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
143
+ const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
144
+ const now = new Date().toISOString();
145
+ const newWorkflow: Workflow = {
146
+ ...workflow,
147
+ id,
148
+ createdAt: now,
149
+ updatedAt: now
150
+ };
151
+ this.workflows.set(id, newWorkflow);
152
+ return newWorkflow;
153
+ }
154
+
155
+ async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
156
+ const existing = this.workflows.get(id);
157
+ if (!existing) return null;
158
+ const updated: Workflow = {
159
+ ...existing,
160
+ ...updates,
161
+ updatedAt: new Date().toISOString()
162
+ };
163
+ this.workflows.set(id, updated);
164
+ return updated;
165
+ }
166
+
167
+ async deleteWorkflow(id: string): Promise<boolean> {
168
+ return this.workflows.delete(id);
169
+ }
170
+
124
171
  async getTasks(projectId?: string): Promise<Task[]> {
125
172
  const all = Array.from(this.tasks.values());
126
173
  if (projectId) {
@@ -75,4 +75,32 @@ describe('SQLiteStore', () => {
75
75
  const entries = await store.getTimeEntries('task_1');
76
76
  expect(entries).toHaveLength(1);
77
77
  });
78
+
79
+ it('should create, retrieve, update, and delete workflows', async () => {
80
+ const workflow = await store.createWorkflow({
81
+ name: 'SQLite Workflow',
82
+ isDefault: true,
83
+ defaultStatusKey: 'todo',
84
+ statuses: [{ key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' }],
85
+ transitions: []
86
+ });
87
+
88
+ expect(workflow.id).toMatch(/^wf_/);
89
+ expect(workflow.name).toBe('SQLite Workflow');
90
+
91
+ const fetched = await store.getWorkflow(workflow.id);
92
+ expect(fetched?.isDefault).toBe(true);
93
+
94
+ const updated = await store.updateWorkflow(workflow.id, { name: 'Updated SQLite Workflow' });
95
+ expect(updated?.name).toBe('Updated SQLite Workflow');
96
+
97
+ const all = await store.getWorkflows();
98
+ expect(all).toHaveLength(1);
99
+
100
+ const deleted = await store.deleteWorkflow(workflow.id);
101
+ expect(deleted).toBe(true);
102
+
103
+ const remaining = await store.getWorkflows();
104
+ expect(remaining).toHaveLength(0);
105
+ });
78
106
  });