hzl-core 2.3.0 → 2.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.
- package/dist/__tests__/concurrency/stress.test.js +13 -9
- package/dist/__tests__/concurrency/stress.test.js.map +1 -1
- package/dist/__tests__/properties/invariants.test.js +31 -10
- package/dist/__tests__/properties/invariants.test.js.map +1 -1
- package/dist/db/__tests__/transaction.test.d.ts +2 -0
- package/dist/db/__tests__/transaction.test.d.ts.map +1 -0
- package/dist/db/__tests__/transaction.test.js +67 -0
- package/dist/db/__tests__/transaction.test.js.map +1 -0
- package/dist/db/migrations/index.d.ts +8 -3
- package/dist/db/migrations/index.d.ts.map +1 -1
- package/dist/db/migrations/index.js +14 -3
- package/dist/db/migrations/index.js.map +1 -1
- package/dist/db/migrations/index.test.d.ts +2 -0
- package/dist/db/migrations/index.test.d.ts.map +1 -0
- package/dist/db/migrations/index.test.js +53 -0
- package/dist/db/migrations/index.test.js.map +1 -0
- package/dist/db/schema.d.ts +2 -2
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +3 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/db/transaction.d.ts.map +1 -1
- package/dist/db/transaction.js +16 -4
- package/dist/db/transaction.js.map +1 -1
- package/dist/events/store.d.ts +3 -1
- package/dist/events/store.d.ts.map +1 -1
- package/dist/events/store.js +23 -9
- package/dist/events/store.js.map +1 -1
- package/dist/events/store.test.js +49 -2
- package/dist/events/store.test.js.map +1 -1
- package/dist/events/types.d.ts +1 -0
- package/dist/events/types.d.ts.map +1 -1
- package/dist/events/types.js +1 -0
- package/dist/events/types.js.map +1 -1
- package/dist/events/upcasters.d.ts +16 -0
- package/dist/events/upcasters.d.ts.map +1 -0
- package/dist/events/upcasters.js +35 -0
- package/dist/events/upcasters.js.map +1 -0
- package/dist/events/upcasters.test.d.ts +2 -0
- package/dist/events/upcasters.test.d.ts.map +1 -0
- package/dist/events/upcasters.test.js +47 -0
- package/dist/events/upcasters.test.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +0 -1
- package/dist/index.test.js.map +1 -1
- package/dist/projections/comments-checkpoints.d.ts +2 -2
- package/dist/projections/comments-checkpoints.d.ts.map +1 -1
- package/dist/projections/comments-checkpoints.js +4 -3
- package/dist/projections/comments-checkpoints.js.map +1 -1
- package/dist/projections/comments-checkpoints.test.js +30 -0
- package/dist/projections/comments-checkpoints.test.js.map +1 -1
- package/dist/projections/dependencies.d.ts +2 -2
- package/dist/projections/dependencies.d.ts.map +1 -1
- package/dist/projections/dependencies.js +5 -4
- package/dist/projections/dependencies.js.map +1 -1
- package/dist/projections/dependencies.test.js +29 -0
- package/dist/projections/dependencies.test.js.map +1 -1
- package/dist/projections/projects.d.ts +2 -2
- package/dist/projections/projects.d.ts.map +1 -1
- package/dist/projections/projects.js +8 -9
- package/dist/projections/projects.js.map +1 -1
- package/dist/projections/projects.test.js +34 -0
- package/dist/projections/projects.test.js.map +1 -1
- package/dist/projections/search.d.ts +2 -2
- package/dist/projections/search.d.ts.map +1 -1
- package/dist/projections/search.js +15 -12
- package/dist/projections/search.js.map +1 -1
- package/dist/projections/search.test.js +37 -0
- package/dist/projections/search.test.js.map +1 -1
- package/dist/projections/tags.d.ts +2 -2
- package/dist/projections/tags.d.ts.map +1 -1
- package/dist/projections/tags.js +4 -3
- package/dist/projections/tags.js.map +1 -1
- package/dist/projections/tags.test.js +29 -0
- package/dist/projections/tags.test.js.map +1 -1
- package/dist/projections/tasks-current.d.ts +2 -2
- package/dist/projections/tasks-current.d.ts.map +1 -1
- package/dist/projections/tasks-current.js +19 -14
- package/dist/projections/tasks-current.js.map +1 -1
- package/dist/projections/tasks-current.test.js +87 -0
- package/dist/projections/tasks-current.test.js.map +1 -1
- package/dist/projections/types.d.ts +14 -0
- package/dist/projections/types.d.ts.map +1 -1
- package/dist/projections/types.js +23 -1
- package/dist/projections/types.js.map +1 -1
- package/dist/services/search-service.js +1 -1
- package/dist/services/search-service.js.map +1 -1
- package/dist/services/search-service.test.js +23 -0
- package/dist/services/search-service.test.js.map +1 -1
- package/dist/services/task-service.d.ts +29 -4
- package/dist/services/task-service.d.ts.map +1 -1
- package/dist/services/task-service.js +272 -69
- package/dist/services/task-service.js.map +1 -1
- package/dist/services/task-service.test.js +313 -15
- package/dist/services/task-service.test.js.map +1 -1
- package/dist/services/workflow-service.test.js +525 -161
- package/dist/services/workflow-service.test.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { ProjectsProjector } from '../projections/projects.js';
|
|
|
11
11
|
import { ProjectService } from './project-service.js';
|
|
12
12
|
import { TaskService } from './task-service.js';
|
|
13
13
|
import { WorkflowService } from './workflow-service.js';
|
|
14
|
-
import { TaskStatus } from '../events/types.js';
|
|
14
|
+
import { EventType, TaskStatus } from '../events/types.js';
|
|
15
15
|
describe('WorkflowService', () => {
|
|
16
16
|
let db;
|
|
17
17
|
let eventStore;
|
|
@@ -41,173 +41,537 @@ describe('WorkflowService', () => {
|
|
|
41
41
|
const workflows = workflowService.listWorkflows();
|
|
42
42
|
expect(workflows.map((workflow) => workflow.name)).toEqual(['start', 'handoff', 'delegate']);
|
|
43
43
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
44
|
+
describe('start', () => {
|
|
45
|
+
it('shows start workflow with explicit auto-op-id guardrail note', () => {
|
|
46
|
+
const definition = workflowService.showWorkflow('start');
|
|
47
|
+
expect(definition.supports_auto_op_id).toBe(false);
|
|
48
|
+
expect(definition.notes.join(' ')).toMatch(/auto-op-id/i);
|
|
49
|
+
});
|
|
50
|
+
it('resumes existing in_progress task before claiming next', () => {
|
|
51
|
+
const resumeTask = taskService.createTask({
|
|
52
|
+
title: 'Resume me',
|
|
53
|
+
project: 'inbox',
|
|
54
|
+
initial_status: TaskStatus.InProgress,
|
|
55
|
+
agent: 'agent-1',
|
|
56
|
+
});
|
|
57
|
+
const readyTask = taskService.createTask({ title: 'Ready', project: 'inbox', priority: 3 });
|
|
58
|
+
taskService.setStatus(readyTask.task_id, TaskStatus.Ready);
|
|
59
|
+
const result = workflowService.runStart({
|
|
60
|
+
agent: 'agent-1',
|
|
61
|
+
resume_policy: 'priority',
|
|
62
|
+
});
|
|
63
|
+
expect(result.mode).toBe('resume');
|
|
64
|
+
expect(result.selected?.task_id).toBe(resumeTask.task_id);
|
|
65
|
+
expect(taskService.getTaskById(readyTask.task_id)?.status).toBe(TaskStatus.Ready);
|
|
66
|
+
});
|
|
67
|
+
it('claims next eligible task when nothing is in_progress', () => {
|
|
68
|
+
const low = taskService.createTask({ title: 'Low', project: 'inbox', priority: 0 });
|
|
69
|
+
const high = taskService.createTask({ title: 'High', project: 'inbox', priority: 3 });
|
|
70
|
+
taskService.setStatus(low.task_id, TaskStatus.Ready);
|
|
71
|
+
taskService.setStatus(high.task_id, TaskStatus.Ready);
|
|
72
|
+
const result = workflowService.runStart({
|
|
73
|
+
agent: 'agent-2',
|
|
74
|
+
resume_policy: 'priority',
|
|
75
|
+
});
|
|
76
|
+
expect(result.mode).toBe('claim_next');
|
|
77
|
+
expect(result.selected?.task_id).toBe(high.task_id);
|
|
78
|
+
expect(taskService.getTaskById(high.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
79
|
+
});
|
|
80
|
+
it('respects tag filtering when claiming next task', () => {
|
|
81
|
+
const tagged = taskService.createTask({
|
|
82
|
+
title: 'Tagged',
|
|
83
|
+
project: 'inbox',
|
|
84
|
+
priority: 2,
|
|
85
|
+
tags: ['alpha'],
|
|
86
|
+
});
|
|
87
|
+
const other = taskService.createTask({
|
|
88
|
+
title: 'Other',
|
|
89
|
+
project: 'inbox',
|
|
90
|
+
priority: 3,
|
|
91
|
+
tags: ['beta'],
|
|
92
|
+
});
|
|
93
|
+
taskService.setStatus(tagged.task_id, TaskStatus.Ready);
|
|
94
|
+
taskService.setStatus(other.task_id, TaskStatus.Ready);
|
|
95
|
+
const result = workflowService.runStart({
|
|
96
|
+
agent: 'agent-tags',
|
|
97
|
+
tags: ['alpha'],
|
|
98
|
+
});
|
|
99
|
+
expect(result.selected?.task_id).toBe(tagged.task_id);
|
|
100
|
+
expect(taskService.getTaskById(other.task_id)?.status).toBe(TaskStatus.Ready);
|
|
101
|
+
});
|
|
102
|
+
it('respects project filtering when claiming next task', () => {
|
|
103
|
+
projectService.createProject('project-a');
|
|
104
|
+
projectService.createProject('project-b');
|
|
105
|
+
const projectATask = taskService.createTask({ title: 'A', project: 'project-a', priority: 1 });
|
|
106
|
+
const projectBTask = taskService.createTask({ title: 'B', project: 'project-b', priority: 3 });
|
|
107
|
+
taskService.setStatus(projectATask.task_id, TaskStatus.Ready);
|
|
108
|
+
taskService.setStatus(projectBTask.task_id, TaskStatus.Ready);
|
|
109
|
+
const result = workflowService.runStart({
|
|
110
|
+
agent: 'agent-project',
|
|
111
|
+
project: 'project-a',
|
|
112
|
+
});
|
|
113
|
+
expect(result.selected?.task_id).toBe(projectATask.task_id);
|
|
114
|
+
expect(taskService.getTaskById(projectBTask.task_id)?.status).toBe(TaskStatus.Ready);
|
|
115
|
+
});
|
|
116
|
+
it('sets lease_until on claimed task when lease_minutes is provided', () => {
|
|
117
|
+
const task = taskService.createTask({ title: 'Leased', project: 'inbox' });
|
|
118
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
119
|
+
const before = Date.now();
|
|
120
|
+
const result = workflowService.runStart({
|
|
121
|
+
agent: 'agent-lease',
|
|
122
|
+
lease_minutes: 15,
|
|
123
|
+
});
|
|
124
|
+
const after = Date.now();
|
|
125
|
+
expect(result.selected?.lease_until).toBeTruthy();
|
|
126
|
+
const leaseMs = Date.parse(result.selected.lease_until);
|
|
127
|
+
expect(leaseMs).toBeGreaterThan(before);
|
|
128
|
+
expect(leaseMs).toBeLessThanOrEqual(after + 16 * 60 * 1000);
|
|
129
|
+
});
|
|
130
|
+
it('resume_policy first picks oldest claimed_at', () => {
|
|
131
|
+
const oldest = taskService.createTask({
|
|
132
|
+
title: 'Oldest',
|
|
133
|
+
project: 'inbox',
|
|
134
|
+
initial_status: TaskStatus.InProgress,
|
|
135
|
+
agent: 'agent-resume',
|
|
136
|
+
});
|
|
137
|
+
const newest = taskService.createTask({
|
|
138
|
+
title: 'Newest',
|
|
139
|
+
project: 'inbox',
|
|
140
|
+
initial_status: TaskStatus.InProgress,
|
|
141
|
+
agent: 'agent-resume',
|
|
142
|
+
});
|
|
143
|
+
db.prepare('UPDATE tasks_current SET claimed_at = ? WHERE task_id = ?').run('2026-01-01T00:00:00.000Z', oldest.task_id);
|
|
144
|
+
db.prepare('UPDATE tasks_current SET claimed_at = ? WHERE task_id = ?').run('2026-01-02T00:00:00.000Z', newest.task_id);
|
|
145
|
+
const result = workflowService.runStart({
|
|
146
|
+
agent: 'agent-resume',
|
|
147
|
+
resume_policy: 'first',
|
|
148
|
+
});
|
|
149
|
+
expect(result.mode).toBe('resume');
|
|
150
|
+
expect(result.selected?.task_id).toBe(oldest.task_id);
|
|
151
|
+
});
|
|
152
|
+
it('resume_policy latest picks newest claimed_at', () => {
|
|
153
|
+
const oldest = taskService.createTask({
|
|
154
|
+
title: 'Oldest',
|
|
155
|
+
project: 'inbox',
|
|
156
|
+
initial_status: TaskStatus.InProgress,
|
|
157
|
+
agent: 'agent-latest',
|
|
158
|
+
});
|
|
159
|
+
const newest = taskService.createTask({
|
|
160
|
+
title: 'Newest',
|
|
161
|
+
project: 'inbox',
|
|
162
|
+
initial_status: TaskStatus.InProgress,
|
|
163
|
+
agent: 'agent-latest',
|
|
164
|
+
});
|
|
165
|
+
db.prepare('UPDATE tasks_current SET claimed_at = ? WHERE task_id = ?').run('2026-01-01T00:00:00.000Z', oldest.task_id);
|
|
166
|
+
db.prepare('UPDATE tasks_current SET claimed_at = ? WHERE task_id = ?').run('2026-01-02T00:00:00.000Z', newest.task_id);
|
|
167
|
+
const result = workflowService.runStart({
|
|
168
|
+
agent: 'agent-latest',
|
|
169
|
+
resume_policy: 'latest',
|
|
170
|
+
});
|
|
171
|
+
expect(result.mode).toBe('resume');
|
|
172
|
+
expect(result.selected?.task_id).toBe(newest.task_id);
|
|
173
|
+
});
|
|
174
|
+
it('include_others false omits others list while preserving count', () => {
|
|
175
|
+
const one = taskService.createTask({
|
|
176
|
+
title: 'One',
|
|
177
|
+
project: 'inbox',
|
|
178
|
+
initial_status: TaskStatus.InProgress,
|
|
179
|
+
agent: 'agent-others',
|
|
180
|
+
priority: 1,
|
|
181
|
+
});
|
|
182
|
+
taskService.createTask({
|
|
183
|
+
title: 'Two',
|
|
184
|
+
project: 'inbox',
|
|
185
|
+
initial_status: TaskStatus.InProgress,
|
|
186
|
+
agent: 'agent-others',
|
|
187
|
+
priority: 0,
|
|
188
|
+
});
|
|
189
|
+
const result = workflowService.runStart({
|
|
190
|
+
agent: 'agent-others',
|
|
191
|
+
resume_policy: 'priority',
|
|
192
|
+
include_others: false,
|
|
193
|
+
});
|
|
194
|
+
expect(result.mode).toBe('resume');
|
|
195
|
+
expect(result.selected?.task_id).toBe(one.task_id);
|
|
196
|
+
expect(result.others_total).toBe(1);
|
|
197
|
+
expect(result.others).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
it('applies others_limit when returning alternates', () => {
|
|
200
|
+
taskService.createTask({
|
|
201
|
+
title: 'Top',
|
|
202
|
+
project: 'inbox',
|
|
203
|
+
initial_status: TaskStatus.InProgress,
|
|
204
|
+
agent: 'agent-limit',
|
|
205
|
+
priority: 3,
|
|
206
|
+
});
|
|
207
|
+
taskService.createTask({
|
|
208
|
+
title: 'Mid',
|
|
209
|
+
project: 'inbox',
|
|
210
|
+
initial_status: TaskStatus.InProgress,
|
|
211
|
+
agent: 'agent-limit',
|
|
212
|
+
priority: 2,
|
|
213
|
+
});
|
|
214
|
+
taskService.createTask({
|
|
215
|
+
title: 'Low',
|
|
216
|
+
project: 'inbox',
|
|
217
|
+
initial_status: TaskStatus.InProgress,
|
|
218
|
+
agent: 'agent-limit',
|
|
219
|
+
priority: 1,
|
|
220
|
+
});
|
|
221
|
+
taskService.createTask({
|
|
222
|
+
title: 'Lowest',
|
|
223
|
+
project: 'inbox',
|
|
224
|
+
initial_status: TaskStatus.InProgress,
|
|
225
|
+
agent: 'agent-limit',
|
|
226
|
+
priority: 0,
|
|
227
|
+
});
|
|
228
|
+
const result = workflowService.runStart({
|
|
229
|
+
agent: 'agent-limit',
|
|
230
|
+
resume_policy: 'priority',
|
|
231
|
+
others_limit: 2,
|
|
232
|
+
});
|
|
233
|
+
expect(result.mode).toBe('resume');
|
|
234
|
+
expect(result.others_total).toBe(3);
|
|
235
|
+
expect(result.others).toHaveLength(2);
|
|
236
|
+
});
|
|
237
|
+
it('avoids double-claiming under near-simultaneous runs from different agents', () => {
|
|
238
|
+
const one = taskService.createTask({ title: 'Race one', project: 'inbox', priority: 1 });
|
|
239
|
+
const two = taskService.createTask({ title: 'Race two', project: 'inbox', priority: 1 });
|
|
240
|
+
taskService.setStatus(one.task_id, TaskStatus.Ready);
|
|
241
|
+
taskService.setStatus(two.task_id, TaskStatus.Ready);
|
|
242
|
+
const workflowServiceTwo = new WorkflowService(db, eventStore, projectionEngine, taskService, db);
|
|
243
|
+
const first = workflowService.runStart({ agent: 'race-a' });
|
|
244
|
+
const second = workflowServiceTwo.runStart({ agent: 'race-b' });
|
|
245
|
+
expect(first.selected).not.toBeNull();
|
|
246
|
+
expect(second.selected).not.toBeNull();
|
|
247
|
+
expect(first.selected?.task_id).not.toBe(second.selected?.task_id);
|
|
248
|
+
});
|
|
249
|
+
it('rejects auto-op-id for start', () => {
|
|
250
|
+
expect(() => workflowService.runStart({
|
|
251
|
+
agent: 'agent-1',
|
|
252
|
+
auto_op_id: true,
|
|
253
|
+
})).toThrow(/auto-op-id is not supported/i);
|
|
254
|
+
});
|
|
112
255
|
});
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const completeSpy = vi
|
|
119
|
-
.spyOn(taskService, 'completeTask')
|
|
120
|
-
.mockImplementation(() => {
|
|
121
|
-
throw new Error('forced complete failure');
|
|
122
|
-
});
|
|
123
|
-
try {
|
|
256
|
+
describe('handoff', () => {
|
|
257
|
+
it('requires agent or project routing guardrail', () => {
|
|
258
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
259
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
260
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
124
261
|
expect(() => workflowService.runHandoff({
|
|
125
262
|
from_task_id: source.task_id,
|
|
126
263
|
title: 'Follow up',
|
|
264
|
+
})).toThrow(/requires --agent, --project, or both/i);
|
|
265
|
+
});
|
|
266
|
+
it('completes source and creates follow-on', () => {
|
|
267
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
268
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
269
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
270
|
+
taskService.addCheckpoint(source.task_id, 'state', { step: 1 });
|
|
271
|
+
const result = workflowService.runHandoff({
|
|
272
|
+
from_task_id: source.task_id,
|
|
273
|
+
title: 'Follow up',
|
|
127
274
|
project: 'inbox',
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
275
|
+
});
|
|
276
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.Done);
|
|
277
|
+
expect(result.follow_on.status).toBe(TaskStatus.Ready);
|
|
278
|
+
expect(result.carried_checkpoint_count).toBe(1);
|
|
279
|
+
});
|
|
280
|
+
it('does not complete source when follow-on creation fails', () => {
|
|
281
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
282
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
283
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
284
|
+
expect(() => workflowService.runHandoff({
|
|
285
|
+
from_task_id: source.task_id,
|
|
286
|
+
title: 'Follow up',
|
|
287
|
+
project: 'missing-project',
|
|
288
|
+
})).toThrow();
|
|
136
289
|
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
290
|
+
});
|
|
291
|
+
it('archives follow-on task when completion step fails', () => {
|
|
292
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
293
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
294
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
295
|
+
const createSpy = vi.spyOn(taskService, 'createTask');
|
|
296
|
+
const completeSpy = vi
|
|
297
|
+
.spyOn(taskService, 'completeTask')
|
|
298
|
+
.mockImplementation(() => {
|
|
299
|
+
throw new Error('forced complete failure');
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
expect(() => workflowService.runHandoff({
|
|
303
|
+
from_task_id: source.task_id,
|
|
304
|
+
title: 'Follow up',
|
|
305
|
+
project: 'inbox',
|
|
306
|
+
})).toThrow(/forced complete failure/);
|
|
307
|
+
const created = createSpy.mock.results
|
|
308
|
+
.map((result) => result.value)
|
|
309
|
+
.find((task) => task.task_id !== source.task_id);
|
|
310
|
+
expect(created).toBeDefined();
|
|
311
|
+
if (!created)
|
|
312
|
+
return;
|
|
313
|
+
expect(taskService.getTaskById(created.task_id)?.status).toBe(TaskStatus.Archived);
|
|
314
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
completeSpy.mockRestore();
|
|
318
|
+
createSpy.mockRestore();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
it('auto_op_id can replay cached result when source last_event_id is unchanged', () => {
|
|
322
|
+
const source = taskService.createTask({ title: 'Source auto', project: 'inbox' });
|
|
323
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
324
|
+
taskService.claimTask(source.task_id, { author: 'agent-auto' });
|
|
325
|
+
const before = db
|
|
326
|
+
.prepare('SELECT last_event_id FROM tasks_current WHERE task_id = ?')
|
|
327
|
+
.get(source.task_id);
|
|
328
|
+
const first = workflowService.runHandoff({
|
|
329
|
+
from_task_id: source.task_id,
|
|
330
|
+
title: 'Follow up auto',
|
|
331
|
+
project: 'inbox',
|
|
332
|
+
auto_op_id: true,
|
|
333
|
+
});
|
|
334
|
+
db.prepare('UPDATE tasks_current SET last_event_id = ? WHERE task_id = ?').run(before.last_event_id, source.task_id);
|
|
335
|
+
const replay = workflowService.runHandoff({
|
|
336
|
+
from_task_id: source.task_id,
|
|
337
|
+
title: 'Follow up auto',
|
|
338
|
+
project: 'inbox',
|
|
339
|
+
auto_op_id: true,
|
|
340
|
+
});
|
|
341
|
+
expect(first.idempotency.auto_generated).toBe(true);
|
|
342
|
+
expect(first.idempotency.op_id).toBeTruthy();
|
|
343
|
+
expect(replay.idempotency.replayed).toBe(true);
|
|
344
|
+
expect(replay.follow_on.task_id).toBe(first.follow_on.task_id);
|
|
345
|
+
});
|
|
346
|
+
it('can route follow-on across projects', () => {
|
|
347
|
+
projectService.createProject('project-a');
|
|
348
|
+
projectService.createProject('project-b');
|
|
349
|
+
const source = taskService.createTask({ title: 'Source A', project: 'project-a' });
|
|
350
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
351
|
+
taskService.claimTask(source.task_id, { author: 'agent-x' });
|
|
352
|
+
const result = workflowService.runHandoff({
|
|
353
|
+
from_task_id: source.task_id,
|
|
354
|
+
title: 'Follow up B',
|
|
355
|
+
project: 'project-b',
|
|
356
|
+
});
|
|
357
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.Done);
|
|
358
|
+
expect(result.follow_on.project).toBe('project-b');
|
|
359
|
+
});
|
|
360
|
+
it('archives follow-on when checkpoint carry step fails', () => {
|
|
361
|
+
const source = taskService.createTask({ title: 'Source checkpoints', project: 'inbox' });
|
|
362
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
363
|
+
taskService.claimTask(source.task_id, { author: 'agent-checkpoint' });
|
|
364
|
+
taskService.addCheckpoint(source.task_id, 'state', { step: 1 });
|
|
365
|
+
const originalAddCheckpoint = taskService.addCheckpoint.bind(taskService);
|
|
366
|
+
const addCheckpointSpy = vi
|
|
367
|
+
.spyOn(taskService, 'addCheckpoint')
|
|
368
|
+
.mockImplementation((taskId, name, data, opts) => {
|
|
369
|
+
if (taskId !== source.task_id) {
|
|
370
|
+
throw new Error('forced checkpoint carry failure');
|
|
371
|
+
}
|
|
372
|
+
return originalAddCheckpoint(taskId, name, data, opts);
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
expect(() => workflowService.runHandoff({
|
|
376
|
+
from_task_id: source.task_id,
|
|
377
|
+
title: 'Follow up checkpoint failure',
|
|
378
|
+
project: 'inbox',
|
|
379
|
+
})).toThrow(/forced checkpoint carry failure/);
|
|
380
|
+
const followOn = db
|
|
381
|
+
.prepare('SELECT task_id FROM tasks_current WHERE title = ? ORDER BY created_at DESC LIMIT 1')
|
|
382
|
+
.get('Follow up checkpoint failure');
|
|
383
|
+
expect(followOn).toBeDefined();
|
|
384
|
+
expect(taskService.getTaskById(followOn.task_id)?.status).toBe(TaskStatus.Archived);
|
|
385
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
addCheckpointSpy.mockRestore();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
it('replays workflow result when explicit op_id is reused', () => {
|
|
392
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
393
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
394
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
395
|
+
const first = workflowService.runHandoff({
|
|
396
|
+
from_task_id: source.task_id,
|
|
397
|
+
title: 'Follow up',
|
|
398
|
+
project: 'inbox',
|
|
399
|
+
op_id: 'handoff-1',
|
|
400
|
+
});
|
|
401
|
+
const second = workflowService.runHandoff({
|
|
402
|
+
from_task_id: source.task_id,
|
|
403
|
+
title: 'Follow up',
|
|
404
|
+
project: 'inbox',
|
|
405
|
+
op_id: 'handoff-1',
|
|
406
|
+
});
|
|
407
|
+
expect(first.follow_on.task_id).toBe(second.follow_on.task_id);
|
|
408
|
+
expect(first.idempotency.replayed).toBe(false);
|
|
409
|
+
expect(second.idempotency.replayed).toBe(true);
|
|
410
|
+
});
|
|
205
411
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
412
|
+
describe('delegate', () => {
|
|
413
|
+
it('adds dependency by default and can pause parent', () => {
|
|
414
|
+
const source = taskService.createTask({ title: 'Parent', project: 'inbox' });
|
|
415
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
416
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
417
|
+
const result = workflowService.runDelegate({
|
|
418
|
+
from_task_id: source.task_id,
|
|
419
|
+
title: 'Delegated',
|
|
420
|
+
pause_parent: true,
|
|
421
|
+
checkpoint: 'Passing this to another agent',
|
|
422
|
+
});
|
|
423
|
+
const depRow = db
|
|
424
|
+
.prepare('SELECT depends_on_id FROM task_dependencies WHERE task_id = ?')
|
|
425
|
+
.get(source.task_id);
|
|
426
|
+
expect(depRow?.depends_on_id).toBe(result.delegated.task_id);
|
|
427
|
+
expect(result.parent_paused).toBe(true);
|
|
428
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.Blocked);
|
|
429
|
+
});
|
|
430
|
+
it('with pause_parent false keeps parent in_progress', () => {
|
|
431
|
+
const source = taskService.createTask({ title: 'Source no pause', project: 'inbox' });
|
|
432
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
433
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
434
|
+
const result = workflowService.runDelegate({
|
|
435
|
+
from_task_id: source.task_id,
|
|
436
|
+
title: 'Delegated no pause',
|
|
437
|
+
pause_parent: false,
|
|
438
|
+
});
|
|
439
|
+
expect(result.parent_paused).toBe(false);
|
|
440
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
441
|
+
});
|
|
442
|
+
it('auto_op_id can replay cached result when source last_event_id is unchanged', () => {
|
|
443
|
+
const source = taskService.createTask({ title: 'Source delegate auto', project: 'inbox' });
|
|
444
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
445
|
+
taskService.claimTask(source.task_id, { author: 'agent-auto' });
|
|
446
|
+
const before = db
|
|
447
|
+
.prepare('SELECT last_event_id FROM tasks_current WHERE task_id = ?')
|
|
448
|
+
.get(source.task_id);
|
|
449
|
+
const first = workflowService.runDelegate({
|
|
450
|
+
from_task_id: source.task_id,
|
|
451
|
+
title: 'Delegated auto',
|
|
452
|
+
auto_op_id: true,
|
|
453
|
+
});
|
|
454
|
+
db.prepare('UPDATE tasks_current SET last_event_id = ? WHERE task_id = ?').run(before.last_event_id, source.task_id);
|
|
455
|
+
const replay = workflowService.runDelegate({
|
|
456
|
+
from_task_id: source.task_id,
|
|
457
|
+
title: 'Delegated auto',
|
|
458
|
+
auto_op_id: true,
|
|
459
|
+
});
|
|
460
|
+
expect(first.idempotency.auto_generated).toBe(true);
|
|
461
|
+
expect(first.idempotency.op_id).toBeTruthy();
|
|
462
|
+
expect(replay.idempotency.replayed).toBe(true);
|
|
463
|
+
expect(replay.delegated.task_id).toBe(first.delegated.task_id);
|
|
464
|
+
});
|
|
465
|
+
it('rollback removes dependency and archives delegated task when checkpoint step fails', () => {
|
|
466
|
+
const source = taskService.createTask({ title: 'Source rollback', project: 'inbox' });
|
|
467
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
468
|
+
taskService.claimTask(source.task_id, { author: 'agent-rollback' });
|
|
469
|
+
const addCheckpointSpy = vi
|
|
470
|
+
.spyOn(taskService, 'addCheckpoint')
|
|
471
|
+
.mockImplementation(() => {
|
|
472
|
+
throw new Error('forced delegate checkpoint failure');
|
|
473
|
+
});
|
|
474
|
+
try {
|
|
475
|
+
expect(() => workflowService.runDelegate({
|
|
476
|
+
from_task_id: source.task_id,
|
|
477
|
+
title: 'Delegated rollback',
|
|
478
|
+
checkpoint: 'carry context',
|
|
479
|
+
pause_parent: false,
|
|
480
|
+
})).toThrow(/forced delegate checkpoint failure/);
|
|
481
|
+
const delegated = db
|
|
482
|
+
.prepare('SELECT task_id FROM tasks_current WHERE title = ? ORDER BY created_at DESC LIMIT 1')
|
|
483
|
+
.get('Delegated rollback');
|
|
484
|
+
expect(delegated).toBeDefined();
|
|
485
|
+
expect(taskService.getTaskById(delegated.task_id)?.status).toBe(TaskStatus.Archived);
|
|
486
|
+
const dep = db
|
|
487
|
+
.prepare('SELECT 1 FROM task_dependencies WHERE task_id = ? AND depends_on_id = ?')
|
|
488
|
+
.get(source.task_id, delegated.task_id);
|
|
489
|
+
expect(dep).toBeUndefined();
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
addCheckpointSpy.mockRestore();
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
it('rollback swallow paths execute when dependency and archive cleanup operations fail', () => {
|
|
496
|
+
const source = taskService.createTask({ title: 'Source rollback catches', project: 'inbox' });
|
|
497
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
498
|
+
taskService.claimTask(source.task_id, { author: 'agent-catch' });
|
|
499
|
+
const originalApply = projectionEngine.applyEvent.bind(projectionEngine);
|
|
500
|
+
const addCheckpointSpy = vi.spyOn(taskService, 'addCheckpoint').mockImplementation(() => {
|
|
501
|
+
throw new Error('forced delegate failure');
|
|
502
|
+
});
|
|
503
|
+
const archiveSpy = vi.spyOn(taskService, 'archiveTask').mockImplementation(() => {
|
|
504
|
+
throw new Error('forced archive rollback failure');
|
|
505
|
+
});
|
|
506
|
+
const applySpy = vi.spyOn(projectionEngine, 'applyEvent').mockImplementation((event) => {
|
|
507
|
+
if (event.type === EventType.DependencyRemoved) {
|
|
508
|
+
throw new Error('forced dependency rollback failure');
|
|
509
|
+
}
|
|
510
|
+
return originalApply(event);
|
|
511
|
+
});
|
|
512
|
+
try {
|
|
513
|
+
expect(() => workflowService.runDelegate({
|
|
514
|
+
from_task_id: source.task_id,
|
|
515
|
+
title: 'Delegated rollback catches',
|
|
516
|
+
checkpoint: 'context',
|
|
517
|
+
pause_parent: false,
|
|
518
|
+
})).toThrow(/forced delegate failure/);
|
|
519
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.InProgress);
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
addCheckpointSpy.mockRestore();
|
|
523
|
+
archiveSpy.mockRestore();
|
|
524
|
+
applySpy.mockRestore();
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
it('does not double-block when parent is already blocked and pause_parent=true', () => {
|
|
528
|
+
const source = taskService.createTask({ title: 'Source blocked', project: 'inbox' });
|
|
529
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
530
|
+
taskService.claimTask(source.task_id, { author: 'agent-blocked' });
|
|
531
|
+
taskService.blockTask(source.task_id, { author: 'agent-blocked', comment: 'already blocked' });
|
|
532
|
+
const result = workflowService.runDelegate({
|
|
533
|
+
from_task_id: source.task_id,
|
|
534
|
+
title: 'Delegated while blocked',
|
|
535
|
+
pause_parent: true,
|
|
536
|
+
});
|
|
537
|
+
const blockedTransitions = eventStore
|
|
538
|
+
.getByTaskId(source.task_id)
|
|
539
|
+
.filter((event) => {
|
|
540
|
+
if (event.type !== EventType.StatusChanged)
|
|
541
|
+
return false;
|
|
542
|
+
const data = event.data;
|
|
543
|
+
return data.to === TaskStatus.Blocked;
|
|
544
|
+
});
|
|
545
|
+
expect(result.parent_paused).toBe(false);
|
|
546
|
+
expect(taskService.getTaskById(source.task_id)?.status).toBe(TaskStatus.Blocked);
|
|
547
|
+
expect(blockedTransitions).toHaveLength(1);
|
|
548
|
+
});
|
|
549
|
+
it('reclaims stale processing workflow op entries', () => {
|
|
550
|
+
const source = taskService.createTask({ title: 'Source', project: 'inbox' });
|
|
551
|
+
taskService.setStatus(source.task_id, TaskStatus.Ready);
|
|
552
|
+
taskService.claimTask(source.task_id, { author: 'agent-1' });
|
|
553
|
+
const first = workflowService.runDelegate({
|
|
554
|
+
from_task_id: source.task_id,
|
|
555
|
+
title: 'Delegated',
|
|
556
|
+
project: 'inbox',
|
|
557
|
+
op_id: 'stale-op',
|
|
558
|
+
});
|
|
559
|
+
expect(first.idempotency.replayed).toBe(false);
|
|
560
|
+
const staleTs = new Date(Date.now() - 31 * 60 * 1000).toISOString();
|
|
561
|
+
db.prepare(`
|
|
562
|
+
UPDATE workflow_ops
|
|
563
|
+
SET state = 'processing', result_payload = NULL, error_payload = NULL, updated_at = ?
|
|
564
|
+
WHERE op_id = ?
|
|
565
|
+
`).run(staleTs, 'stale-op');
|
|
566
|
+
const second = workflowService.runDelegate({
|
|
567
|
+
from_task_id: source.task_id,
|
|
568
|
+
title: 'Delegated',
|
|
569
|
+
project: 'inbox',
|
|
570
|
+
op_id: 'stale-op',
|
|
571
|
+
});
|
|
572
|
+
expect(second.idempotency.replayed).toBe(false);
|
|
573
|
+
expect(second.delegated.task_id).not.toBe(first.delegated.task_id);
|
|
574
|
+
});
|
|
211
575
|
});
|
|
212
576
|
});
|
|
213
577
|
//# sourceMappingURL=workflow-service.test.js.map
|