@auxiora/workflows 1.0.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.
@@ -0,0 +1,369 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { WorkflowEngine } from '../src/engine.js';
6
+ import { AutonomousExecutor } from '../src/autonomous-executor.js';
7
+ import type { AutonomousExecutorDeps, GateCheckResult } from '../src/autonomous-executor.js';
8
+
9
+ function createMockDeps(engine: WorkflowEngine, overrides: Partial<AutonomousExecutorDeps> = {}): AutonomousExecutorDeps {
10
+ return {
11
+ workflowEngine: engine,
12
+ trustGate: {
13
+ gate: vi.fn<[string, string, number], GateCheckResult>().mockReturnValue({
14
+ allowed: true,
15
+ message: 'Allowed',
16
+ }),
17
+ },
18
+ trustEngine: {
19
+ recordOutcome: vi.fn(),
20
+ },
21
+ auditTrail: {
22
+ record: vi.fn().mockResolvedValue({ id: 'audit-1' }),
23
+ markRolledBack: vi.fn().mockResolvedValue(true),
24
+ },
25
+ executeTool: vi.fn().mockResolvedValue({ success: true, output: 'done' }),
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ async function createTestWorkflow(engine: WorkflowEngine, options?: { autonomous?: boolean }) {
31
+ const workflow = await engine.createWorkflow({
32
+ name: 'Test Autonomous',
33
+ description: 'A test workflow',
34
+ createdBy: 'system',
35
+ autonomous: options?.autonomous ?? true,
36
+ steps: [
37
+ {
38
+ name: 'Read config',
39
+ description: 'Read the config file',
40
+ assigneeId: 'system',
41
+ action: {
42
+ tool: 'file_read',
43
+ params: { path: '/etc/config.json' },
44
+ trustDomain: 'files',
45
+ trustRequired: 1,
46
+ },
47
+ },
48
+ {
49
+ name: 'Send report',
50
+ description: 'Send the report email',
51
+ assigneeId: 'system',
52
+ dependsOn: ['step-1'],
53
+ action: {
54
+ tool: 'email_compose',
55
+ params: { to: 'user@example.com', subject: 'Report' },
56
+ trustDomain: 'email',
57
+ trustRequired: 2,
58
+ },
59
+ },
60
+ ],
61
+ });
62
+ await engine.startWorkflow(workflow.id);
63
+ return workflow;
64
+ }
65
+
66
+ describe('AutonomousExecutor', () => {
67
+ let tmpDir: string;
68
+ let engine: WorkflowEngine;
69
+
70
+ beforeEach(async () => {
71
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auto-exec-'));
72
+ engine = new WorkflowEngine({ dir: tmpDir });
73
+ });
74
+
75
+ afterEach(async () => {
76
+ await fs.rm(tmpDir, { recursive: true, force: true });
77
+ });
78
+
79
+ describe('tick', () => {
80
+ it('should execute active autonomous workflow steps', async () => {
81
+ const workflow = await createTestWorkflow(engine);
82
+ const deps = createMockDeps(engine);
83
+ const executor = new AutonomousExecutor(deps);
84
+
85
+ const result = await executor.tick();
86
+
87
+ expect(result.workflowsProcessed).toBe(1);
88
+ expect(result.stepsExecuted).toBe(1); // Only step-1 (step-2 depends on it)
89
+ expect(deps.executeTool).toHaveBeenCalledWith('file_read', { path: '/etc/config.json' });
90
+ expect(deps.trustGate.gate).toHaveBeenCalledWith('files', 'file_read', 1);
91
+ expect(deps.trustEngine.recordOutcome).toHaveBeenCalledWith('files', true);
92
+ });
93
+
94
+ it('should advance dependent steps after completion', async () => {
95
+ const workflow = await createTestWorkflow(engine);
96
+ const deps = createMockDeps(engine);
97
+ const executor = new AutonomousExecutor(deps);
98
+
99
+ // First tick: execute step-1
100
+ await executor.tick();
101
+
102
+ // Second tick: step-2 should now be active
103
+ const result = await executor.tick();
104
+
105
+ expect(result.stepsExecuted).toBe(1);
106
+ expect(deps.executeTool).toHaveBeenCalledWith('email_compose', {
107
+ to: 'user@example.com',
108
+ subject: 'Report',
109
+ });
110
+ });
111
+
112
+ it('should complete workflow when all steps finish', async () => {
113
+ const workflow = await createTestWorkflow(engine);
114
+ const onCompleted = vi.fn();
115
+ const deps = createMockDeps(engine, { onWorkflowCompleted: onCompleted });
116
+ const executor = new AutonomousExecutor(deps);
117
+
118
+ // Two ticks to complete both steps
119
+ await executor.tick();
120
+ await executor.tick();
121
+
122
+ const status = await engine.getStatus(workflow.id);
123
+ expect(status?.workflow.status).toBe('completed');
124
+ expect(onCompleted).toHaveBeenCalledWith(workflow.id);
125
+ });
126
+
127
+ it('should skip non-autonomous workflows', async () => {
128
+ await engine.createWorkflow({
129
+ name: 'Human Workflow',
130
+ description: 'Not autonomous',
131
+ createdBy: 'user',
132
+ steps: [
133
+ { name: 'Manual step', description: 'Do this by hand', assigneeId: 'user' },
134
+ ],
135
+ });
136
+
137
+ const deps = createMockDeps(engine);
138
+ const executor = new AutonomousExecutor(deps);
139
+
140
+ const result = await executor.tick();
141
+
142
+ expect(result.workflowsProcessed).toBe(0);
143
+ expect(deps.executeTool).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('should skip steps without actions', async () => {
147
+ const workflow = await engine.createWorkflow({
148
+ name: 'Mixed Workflow',
149
+ description: 'Has both auto and manual steps',
150
+ createdBy: 'system',
151
+ autonomous: true,
152
+ steps: [
153
+ { name: 'Manual review', description: 'Review by human', assigneeId: 'user-bob' },
154
+ {
155
+ name: 'Auto deploy',
156
+ description: 'Auto deploy',
157
+ assigneeId: 'system',
158
+ dependsOn: ['step-1'],
159
+ action: {
160
+ tool: 'bash',
161
+ params: { command: 'deploy.sh' },
162
+ trustDomain: 'shell',
163
+ trustRequired: 3,
164
+ },
165
+ },
166
+ ],
167
+ });
168
+ await engine.startWorkflow(workflow.id);
169
+
170
+ const deps = createMockDeps(engine);
171
+ const executor = new AutonomousExecutor(deps);
172
+
173
+ const result = await executor.tick();
174
+
175
+ // step-1 is active but has no action, step-2 is pending (depends on step-1)
176
+ expect(result.stepsExecuted).toBe(0);
177
+ expect(deps.executeTool).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('should not process concurrently', async () => {
181
+ await createTestWorkflow(engine);
182
+ const deps = createMockDeps(engine, {
183
+ executeTool: vi.fn().mockImplementation(
184
+ () => new Promise((resolve) => setTimeout(() => resolve({ success: true, output: 'ok' }), 50)),
185
+ ),
186
+ });
187
+ const executor = new AutonomousExecutor(deps);
188
+
189
+ // Start two ticks simultaneously
190
+ const [r1, r2] = await Promise.all([executor.tick(), executor.tick()]);
191
+
192
+ // One should have been skipped
193
+ const total = r1.stepsExecuted + r2.stepsExecuted;
194
+ expect(total).toBe(1);
195
+ });
196
+ });
197
+
198
+ describe('trust gating', () => {
199
+ it('should skip steps when trust is denied', async () => {
200
+ await createTestWorkflow(engine);
201
+ const deps = createMockDeps(engine, {
202
+ trustGate: {
203
+ gate: vi.fn<[string, string, number], GateCheckResult>().mockReturnValue({
204
+ allowed: false,
205
+ message: 'Trust level too low',
206
+ }),
207
+ },
208
+ });
209
+ const executor = new AutonomousExecutor(deps);
210
+
211
+ const result = await executor.tick();
212
+
213
+ expect(result.stepsSkipped).toBe(1);
214
+ expect(result.stepsExecuted).toBe(0);
215
+ expect(deps.executeTool).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it('should not fail the step on trust denial', async () => {
219
+ const workflow = await createTestWorkflow(engine);
220
+ const deps = createMockDeps(engine, {
221
+ trustGate: {
222
+ gate: vi.fn<[string, string, number], GateCheckResult>().mockReturnValue({
223
+ allowed: false,
224
+ message: 'Denied',
225
+ }),
226
+ },
227
+ });
228
+ const executor = new AutonomousExecutor(deps);
229
+
230
+ await executor.tick();
231
+
232
+ // Step should still be active (not failed)
233
+ const status = await engine.getStatus(workflow.id);
234
+ const step1 = status?.workflow.steps.find((s) => s.id === 'step-1');
235
+ expect(step1?.status).toBe('active');
236
+ });
237
+ });
238
+
239
+ describe('failure handling', () => {
240
+ it('should mark step as failed when tool fails', async () => {
241
+ const workflow = await createTestWorkflow(engine);
242
+ const onFailed = vi.fn();
243
+ const deps = createMockDeps(engine, {
244
+ executeTool: vi.fn().mockResolvedValue({ success: false, error: 'File not found' }),
245
+ onStepFailed: onFailed,
246
+ });
247
+ const executor = new AutonomousExecutor(deps);
248
+
249
+ const result = await executor.tick();
250
+
251
+ expect(result.stepsFailed).toBe(1);
252
+ expect(deps.trustEngine.recordOutcome).toHaveBeenCalledWith('files', false);
253
+ expect(onFailed).toHaveBeenCalledWith(workflow.id, 'step-1', 'File not found');
254
+ });
255
+
256
+ it('should handle tool execution exceptions', async () => {
257
+ const workflow = await createTestWorkflow(engine);
258
+ const deps = createMockDeps(engine, {
259
+ executeTool: vi.fn().mockRejectedValue(new Error('Connection refused')),
260
+ });
261
+ const executor = new AutonomousExecutor(deps);
262
+
263
+ const result = await executor.tick();
264
+
265
+ expect(result.stepsFailed).toBe(1);
266
+ expect(deps.trustEngine.recordOutcome).toHaveBeenCalledWith('files', false);
267
+ });
268
+
269
+ it('should attempt rollback on failure when rollback tool is defined', async () => {
270
+ const workflow = await engine.createWorkflow({
271
+ name: 'Rollback Test',
272
+ description: 'Test rollback',
273
+ createdBy: 'system',
274
+ autonomous: true,
275
+ steps: [
276
+ {
277
+ name: 'Write file',
278
+ description: 'Write then rollback',
279
+ assigneeId: 'system',
280
+ action: {
281
+ tool: 'file_write',
282
+ params: { path: '/tmp/test.txt', content: 'data' },
283
+ trustDomain: 'files',
284
+ trustRequired: 2,
285
+ rollbackTool: 'file_write',
286
+ rollbackParams: { path: '/tmp/test.txt', content: '' },
287
+ },
288
+ },
289
+ ],
290
+ });
291
+ await engine.startWorkflow(workflow.id);
292
+
293
+ const executeTool = vi.fn()
294
+ .mockResolvedValueOnce({ success: false, error: 'Disk full' }) // Main tool fails
295
+ .mockResolvedValueOnce({ success: true, output: 'Rolled back' }); // Rollback succeeds
296
+
297
+ const deps = createMockDeps(engine, { executeTool });
298
+ const executor = new AutonomousExecutor(deps);
299
+
300
+ await executor.tick();
301
+
302
+ expect(executeTool).toHaveBeenCalledTimes(2);
303
+ expect(executeTool).toHaveBeenNthCalledWith(2, 'file_write', {
304
+ path: '/tmp/test.txt',
305
+ content: '',
306
+ });
307
+ expect(deps.auditTrail.markRolledBack).toHaveBeenCalledWith('audit-1');
308
+ });
309
+ });
310
+
311
+ describe('audit trail', () => {
312
+ it('should record audit entries for executed steps', async () => {
313
+ await createTestWorkflow(engine);
314
+ const deps = createMockDeps(engine);
315
+ const executor = new AutonomousExecutor(deps);
316
+
317
+ await executor.tick();
318
+
319
+ // Two audit records: one pending (before execution), one success (after)
320
+ expect(deps.auditTrail.record).toHaveBeenCalledTimes(2);
321
+
322
+ const firstCall = (deps.auditTrail.record as any).mock.calls[0][0];
323
+ expect(firstCall.outcome).toBe('pending');
324
+ expect(firstCall.domain).toBe('files');
325
+
326
+ const secondCall = (deps.auditTrail.record as any).mock.calls[1][0];
327
+ expect(secondCall.outcome).toBe('success');
328
+ });
329
+ });
330
+
331
+ describe('callbacks', () => {
332
+ it('should call onStepCompleted on success', async () => {
333
+ const workflow = await createTestWorkflow(engine);
334
+ const onCompleted = vi.fn();
335
+ const deps = createMockDeps(engine, { onStepCompleted: onCompleted });
336
+ const executor = new AutonomousExecutor(deps);
337
+
338
+ await executor.tick();
339
+
340
+ expect(onCompleted).toHaveBeenCalledWith(workflow.id, 'step-1', 'done');
341
+ });
342
+ });
343
+
344
+ describe('start/stop', () => {
345
+ it('should start and stop the timer', () => {
346
+ const deps = createMockDeps(engine);
347
+ const executor = new AutonomousExecutor(deps);
348
+
349
+ expect(executor.isRunning()).toBe(false);
350
+
351
+ executor.start(60_000);
352
+ expect(executor.isRunning()).toBe(true);
353
+
354
+ executor.stop();
355
+ expect(executor.isRunning()).toBe(false);
356
+ });
357
+
358
+ it('should not start twice', () => {
359
+ const deps = createMockDeps(engine);
360
+ const executor = new AutonomousExecutor(deps);
361
+
362
+ executor.start(60_000);
363
+ executor.start(60_000); // Should be no-op
364
+
365
+ expect(executor.isRunning()).toBe(true);
366
+ executor.stop();
367
+ });
368
+ });
369
+ });
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { WorkflowEngine } from '../src/engine.js';
6
+ import { ApprovalManager } from '../src/approval.js';
7
+ import { ReminderService } from '../src/reminder.js';
8
+
9
+ describe('WorkflowEngine', () => {
10
+ let tmpDir: string;
11
+ let engine: WorkflowEngine;
12
+
13
+ beforeEach(async () => {
14
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'workflows-'));
15
+ engine = new WorkflowEngine({ dir: tmpDir });
16
+ });
17
+
18
+ afterEach(async () => {
19
+ await fs.rm(tmpDir, { recursive: true, force: true });
20
+ });
21
+
22
+ it('should create a workflow', async () => {
23
+ const workflow = await engine.createWorkflow({
24
+ name: 'Deploy Feature',
25
+ description: 'Deploy the new feature to production',
26
+ createdBy: 'user-alice',
27
+ steps: [
28
+ { name: 'Code Review', description: 'Review the code', assigneeId: 'user-bob' },
29
+ { name: 'QA Test', description: 'Run QA tests', assigneeId: 'user-charlie', dependsOn: ['step-1'] },
30
+ { name: 'Deploy', description: 'Deploy to prod', assigneeId: 'user-alice', dependsOn: ['step-2'] },
31
+ ],
32
+ });
33
+
34
+ expect(workflow.id).toMatch(/^wf-/);
35
+ expect(workflow.status).toBe('pending');
36
+ expect(workflow.steps).toHaveLength(3);
37
+ expect(workflow.events).toHaveLength(1);
38
+ expect(workflow.events[0].type).toBe('created');
39
+ });
40
+
41
+ it('should start a workflow and activate first steps', async () => {
42
+ const created = await engine.createWorkflow({
43
+ name: 'Test',
44
+ description: 'Test workflow',
45
+ createdBy: 'user-alice',
46
+ steps: [
47
+ { name: 'Step 1', description: 'First', assigneeId: 'user-bob' },
48
+ { name: 'Step 2', description: 'Second', assigneeId: 'user-charlie', dependsOn: ['step-1'] },
49
+ ],
50
+ });
51
+
52
+ const started = await engine.startWorkflow(created.id);
53
+ expect(started).toBeDefined();
54
+ expect(started!.status).toBe('active');
55
+ expect(started!.steps[0].status).toBe('active');
56
+ expect(started!.steps[1].status).toBe('pending');
57
+ });
58
+
59
+ it('should complete a step and advance dependencies', async () => {
60
+ const created = await engine.createWorkflow({
61
+ name: 'Sequential',
62
+ description: 'Sequential workflow',
63
+ createdBy: 'user-alice',
64
+ steps: [
65
+ { name: 'Step 1', description: 'First', assigneeId: 'user-bob' },
66
+ { name: 'Step 2', description: 'Second', assigneeId: 'user-charlie', dependsOn: ['step-1'] },
67
+ ],
68
+ });
69
+
70
+ await engine.startWorkflow(created.id);
71
+ const updated = await engine.completeStep(created.id, 'step-1', 'user-bob', 'Looks good');
72
+
73
+ expect(updated).toBeDefined();
74
+ expect(updated!.steps[0].status).toBe('completed');
75
+ expect(updated!.steps[0].completedBy).toBe('user-bob');
76
+ expect(updated!.steps[1].status).toBe('active'); // Advanced!
77
+ });
78
+
79
+ it('should complete workflow when all steps done', async () => {
80
+ const created = await engine.createWorkflow({
81
+ name: 'Simple',
82
+ description: 'Simple workflow',
83
+ createdBy: 'user-alice',
84
+ steps: [
85
+ { name: 'Only Step', description: 'The only step', assigneeId: 'user-bob' },
86
+ ],
87
+ });
88
+
89
+ await engine.startWorkflow(created.id);
90
+ const updated = await engine.completeStep(created.id, 'step-1', 'user-bob');
91
+
92
+ expect(updated!.status).toBe('completed');
93
+ expect(updated!.completedAt).toBeDefined();
94
+ });
95
+
96
+ it('should get workflow status with progress', async () => {
97
+ const created = await engine.createWorkflow({
98
+ name: 'Progress',
99
+ description: 'Track progress',
100
+ createdBy: 'user-alice',
101
+ steps: [
102
+ { name: 'Step 1', description: 'First', assigneeId: 'user-bob' },
103
+ { name: 'Step 2', description: 'Second', assigneeId: 'user-bob' },
104
+ ],
105
+ });
106
+
107
+ await engine.startWorkflow(created.id);
108
+ await engine.completeStep(created.id, 'step-1', 'user-bob');
109
+
110
+ const status = await engine.getStatus(created.id);
111
+ expect(status).toBeDefined();
112
+ expect(status!.progress).toBe(0.5);
113
+ });
114
+
115
+ it('should list active workflows', async () => {
116
+ await engine.createWorkflow({
117
+ name: 'Active',
118
+ description: 'Will be started',
119
+ createdBy: 'user-alice',
120
+ steps: [{ name: 'Step', description: 'Step', assigneeId: 'user-bob' }],
121
+ });
122
+
123
+ const active = await engine.listActive();
124
+ expect(active).toHaveLength(1);
125
+ });
126
+
127
+ it('should cancel a workflow', async () => {
128
+ const created = await engine.createWorkflow({
129
+ name: 'Cancel Me',
130
+ description: 'To be cancelled',
131
+ createdBy: 'user-alice',
132
+ steps: [{ name: 'Step', description: 'Step', assigneeId: 'user-bob' }],
133
+ });
134
+
135
+ await engine.startWorkflow(created.id);
136
+ const cancelled = await engine.cancelWorkflow(created.id);
137
+ expect(cancelled).toBe(true);
138
+
139
+ const workflow = await engine.getWorkflow(created.id);
140
+ expect(workflow!.status).toBe('cancelled');
141
+ });
142
+
143
+ it('should list workflows by user', async () => {
144
+ await engine.createWorkflow({
145
+ name: 'Alice Workflow',
146
+ description: 'Created by alice',
147
+ createdBy: 'user-alice',
148
+ steps: [{ name: 'Step', description: 'For bob', assigneeId: 'user-bob' }],
149
+ });
150
+
151
+ const aliceWorkflows = await engine.listByUser('user-alice');
152
+ expect(aliceWorkflows).toHaveLength(1);
153
+
154
+ const bobWorkflows = await engine.listByUser('user-bob');
155
+ expect(bobWorkflows).toHaveLength(1); // Bob is assignee
156
+ });
157
+
158
+ it('should handle parallel steps', async () => {
159
+ const created = await engine.createWorkflow({
160
+ name: 'Parallel',
161
+ description: 'Steps run in parallel',
162
+ createdBy: 'user-alice',
163
+ steps: [
164
+ { name: 'Review A', description: 'Review part A', assigneeId: 'user-bob' },
165
+ { name: 'Review B', description: 'Review part B', assigneeId: 'user-charlie' },
166
+ { name: 'Merge', description: 'Merge results', assigneeId: 'user-alice', dependsOn: ['step-1', 'step-2'] },
167
+ ],
168
+ });
169
+
170
+ await engine.startWorkflow(created.id);
171
+ let wf = await engine.getWorkflow(created.id);
172
+ expect(wf!.steps[0].status).toBe('active');
173
+ expect(wf!.steps[1].status).toBe('active');
174
+ expect(wf!.steps[2].status).toBe('pending');
175
+
176
+ await engine.completeStep(created.id, 'step-1', 'user-bob');
177
+ wf = await engine.getWorkflow(created.id);
178
+ expect(wf!.steps[2].status).toBe('pending'); // Still waiting for step-2
179
+
180
+ await engine.completeStep(created.id, 'step-2', 'user-charlie');
181
+ wf = await engine.getWorkflow(created.id);
182
+ expect(wf!.steps[2].status).toBe('active'); // Now ready
183
+ });
184
+ });
185
+
186
+ describe('ApprovalManager', () => {
187
+ let tmpDir: string;
188
+ let manager: ApprovalManager;
189
+
190
+ beforeEach(async () => {
191
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'approvals-'));
192
+ manager = new ApprovalManager({ dir: tmpDir });
193
+ });
194
+
195
+ afterEach(async () => {
196
+ await fs.rm(tmpDir, { recursive: true, force: true });
197
+ });
198
+
199
+ it('should create an approval request', async () => {
200
+ const request = await manager.requestApproval(
201
+ 'wf-1',
202
+ 'step-1',
203
+ 'user-alice',
204
+ ['user-bob', 'user-charlie'],
205
+ 'Please approve the deploy',
206
+ );
207
+
208
+ expect(request.id).toMatch(/^appr-/);
209
+ expect(request.status).toBe('pending');
210
+ expect(request.approverIds).toContain('user-bob');
211
+ });
212
+
213
+ it('should approve a request', async () => {
214
+ const request = await manager.requestApproval(
215
+ 'wf-1', 'step-1', 'user-alice', ['user-bob'], 'Approve this',
216
+ );
217
+
218
+ const approved = await manager.approve(request.id, 'user-bob', 'Looks good');
219
+ expect(approved).toBeDefined();
220
+ expect(approved!.status).toBe('approved');
221
+ expect(approved!.decidedBy).toBe('user-bob');
222
+ });
223
+
224
+ it('should reject a request', async () => {
225
+ const request = await manager.requestApproval(
226
+ 'wf-1', 'step-1', 'user-alice', ['user-bob'], 'Approve this',
227
+ );
228
+
229
+ const rejected = await manager.reject(request.id, 'user-bob', 'Needs changes');
230
+ expect(rejected).toBeDefined();
231
+ expect(rejected!.status).toBe('rejected');
232
+ expect(rejected!.decisionReason).toBe('Needs changes');
233
+ });
234
+
235
+ it('should not allow non-approver to decide', async () => {
236
+ const request = await manager.requestApproval(
237
+ 'wf-1', 'step-1', 'user-alice', ['user-bob'], 'Approve this',
238
+ );
239
+
240
+ const result = await manager.approve(request.id, 'user-charlie');
241
+ expect(result).toBeUndefined();
242
+ });
243
+
244
+ it('should list pending approvals for a user', async () => {
245
+ await manager.requestApproval('wf-1', 'step-1', 'user-alice', ['user-bob'], 'Request 1');
246
+ await manager.requestApproval('wf-1', 'step-2', 'user-alice', ['user-charlie'], 'Request 2');
247
+
248
+ const bobPending = await manager.getPending('user-bob');
249
+ expect(bobPending).toHaveLength(1);
250
+
251
+ const allPending = await manager.getPending();
252
+ expect(allPending).toHaveLength(2);
253
+ });
254
+
255
+ it('should not return already-decided approvals', async () => {
256
+ const request = await manager.requestApproval(
257
+ 'wf-1', 'step-1', 'user-alice', ['user-bob'], 'Approve this',
258
+ );
259
+ await manager.approve(request.id, 'user-bob');
260
+
261
+ const pending = await manager.getPending('user-bob');
262
+ expect(pending).toHaveLength(0);
263
+ });
264
+ });
265
+
266
+ describe('ReminderService', () => {
267
+ let service: ReminderService;
268
+
269
+ beforeEach(() => {
270
+ service = new ReminderService();
271
+ });
272
+
273
+ afterEach(() => {
274
+ service.shutdown();
275
+ });
276
+
277
+ it('should track active reminders', () => {
278
+ expect(service.getActiveCount()).toBe(0);
279
+ });
280
+
281
+ it('should cancel reminders', () => {
282
+ service.cancelReminder('wf-1', 'step-1');
283
+ expect(service.getActiveCount()).toBe(0);
284
+ });
285
+
286
+ it('should shutdown cleanly', () => {
287
+ service.shutdown();
288
+ expect(service.getActiveCount()).toBe(0);
289
+ });
290
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../core" },
10
+ { "path": "../logger" },
11
+ { "path": "../audit" }
12
+ ]
13
+ }