hzl-core 2.3.0 → 2.5.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
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// packages/hzl-core/src/services/task-service.test.ts
|
|
2
2
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import Database from 'libsql';
|
|
7
|
+
import { TaskService, AmbiguousPrefixError, InvalidDueMonthError, InvalidProgressError, InvalidStatusTransitionError, TaskNotFoundError, } from './task-service.js';
|
|
4
8
|
import { ProjectService, ProjectNotFoundError } from './project-service.js';
|
|
5
9
|
import { createTestDb } from '../db/test-utils.js';
|
|
6
10
|
import { EventStore } from '../events/store.js';
|
|
7
11
|
import { EventType, TaskStatus } from '../events/types.js';
|
|
12
|
+
import { CACHE_SCHEMA_V1, EVENTS_SCHEMA_V2, PRAGMAS } from '../db/schema.js';
|
|
8
13
|
import { ProjectionEngine } from '../projections/engine.js';
|
|
9
14
|
import { TasksCurrentProjector } from '../projections/tasks-current.js';
|
|
10
15
|
import { DependenciesProjector } from '../projections/dependencies.js';
|
|
@@ -12,6 +17,14 @@ import { TagsProjector } from '../projections/tags.js';
|
|
|
12
17
|
import { CommentsCheckpointsProjector } from '../projections/comments-checkpoints.js';
|
|
13
18
|
import { SearchProjector } from '../projections/search.js';
|
|
14
19
|
import { ProjectsProjector } from '../projections/projects.js';
|
|
20
|
+
function registerProjectors(engine) {
|
|
21
|
+
engine.register(new TasksCurrentProjector());
|
|
22
|
+
engine.register(new DependenciesProjector());
|
|
23
|
+
engine.register(new TagsProjector());
|
|
24
|
+
engine.register(new CommentsCheckpointsProjector());
|
|
25
|
+
engine.register(new SearchProjector());
|
|
26
|
+
engine.register(new ProjectsProjector());
|
|
27
|
+
}
|
|
15
28
|
describe('TaskService', () => {
|
|
16
29
|
let db;
|
|
17
30
|
let eventStore;
|
|
@@ -23,12 +36,7 @@ describe('TaskService', () => {
|
|
|
23
36
|
// Schema applied by createTestDb
|
|
24
37
|
eventStore = new EventStore(db);
|
|
25
38
|
projectionEngine = new ProjectionEngine(db);
|
|
26
|
-
projectionEngine
|
|
27
|
-
projectionEngine.register(new DependenciesProjector());
|
|
28
|
-
projectionEngine.register(new TagsProjector());
|
|
29
|
-
projectionEngine.register(new CommentsCheckpointsProjector());
|
|
30
|
-
projectionEngine.register(new SearchProjector());
|
|
31
|
-
projectionEngine.register(new ProjectsProjector());
|
|
39
|
+
registerProjectors(projectionEngine);
|
|
32
40
|
projectService = new ProjectService(db, eventStore, projectionEngine);
|
|
33
41
|
projectService.ensureInboxExists();
|
|
34
42
|
projectService.createProject('project-a');
|
|
@@ -264,6 +272,60 @@ describe('TaskService', () => {
|
|
|
264
272
|
expect(moved.project).toBe('target');
|
|
265
273
|
});
|
|
266
274
|
});
|
|
275
|
+
describe('setStatus transition rules', () => {
|
|
276
|
+
const allStatuses = Object.values(TaskStatus);
|
|
277
|
+
const createTaskInStatus = (status) => {
|
|
278
|
+
const task = taskService.createTask({ title: `Status ${status}`, project: 'inbox' });
|
|
279
|
+
switch (status) {
|
|
280
|
+
case TaskStatus.Backlog:
|
|
281
|
+
return task;
|
|
282
|
+
case TaskStatus.Ready:
|
|
283
|
+
return taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
284
|
+
case TaskStatus.InProgress:
|
|
285
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
286
|
+
return taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
287
|
+
case TaskStatus.Blocked:
|
|
288
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
289
|
+
taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
290
|
+
return taskService.blockTask(task.task_id);
|
|
291
|
+
case TaskStatus.Done:
|
|
292
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
293
|
+
taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
294
|
+
return taskService.completeTask(task.task_id);
|
|
295
|
+
case TaskStatus.Archived:
|
|
296
|
+
return taskService.archiveTask(task.task_id);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
it('allows any transition except from archived', () => {
|
|
300
|
+
const nonArchived = allStatuses.filter(s => s !== TaskStatus.Archived);
|
|
301
|
+
for (const fromStatus of nonArchived) {
|
|
302
|
+
for (const toStatus of allStatuses) {
|
|
303
|
+
if (fromStatus === toStatus)
|
|
304
|
+
continue; // skip self-transitions (tested separately)
|
|
305
|
+
const task = createTaskInStatus(fromStatus);
|
|
306
|
+
const updated = taskService.setStatus(task.task_id, toStatus);
|
|
307
|
+
expect(updated.status).toBe(toStatus);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
it('throws InvalidStatusTransitionError when transitioning from archived', () => {
|
|
312
|
+
const nonArchived = allStatuses.filter(s => s !== TaskStatus.Archived);
|
|
313
|
+
for (const toStatus of nonArchived) {
|
|
314
|
+
const task = createTaskInStatus(TaskStatus.Archived);
|
|
315
|
+
expect(() => taskService.setStatus(task.task_id, toStatus)).toThrow(InvalidStatusTransitionError);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
it('no-ops on self-transition (returns task, no new event)', () => {
|
|
319
|
+
for (const status of allStatuses) {
|
|
320
|
+
const task = createTaskInStatus(status);
|
|
321
|
+
const eventsBefore = eventStore.getByTaskId(task.task_id);
|
|
322
|
+
const result = taskService.setStatus(task.task_id, status);
|
|
323
|
+
const eventsAfter = eventStore.getByTaskId(task.task_id);
|
|
324
|
+
expect(result.status).toBe(status);
|
|
325
|
+
expect(eventsAfter.length).toBe(eventsBefore.length);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
267
329
|
describe('claimTask', () => {
|
|
268
330
|
it('claims a ready task with no dependencies', () => {
|
|
269
331
|
const task = taskService.createTask({ title: 'Ready task', project: 'inbox' });
|
|
@@ -299,19 +361,48 @@ describe('TaskService', () => {
|
|
|
299
361
|
const claimed = taskService.claimTask(task.task_id, { lease_until: leaseUntil });
|
|
300
362
|
expect(claimed.lease_until).toBe(leaseUntil);
|
|
301
363
|
});
|
|
302
|
-
it('
|
|
364
|
+
it('claims a task from backlog status', () => {
|
|
303
365
|
const task = taskService.createTask({ title: 'Backlog task', project: 'inbox' });
|
|
366
|
+
const claimed = taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
367
|
+
expect(claimed.status).toBe(TaskStatus.InProgress);
|
|
368
|
+
});
|
|
369
|
+
it('claims a task from in_progress status (re-claim)', () => {
|
|
370
|
+
const task = taskService.createTask({ title: 'Test', project: 'inbox' });
|
|
371
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
372
|
+
taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
373
|
+
const reclaimed = taskService.claimTask(task.task_id, { author: 'agent-2' });
|
|
374
|
+
expect(reclaimed.status).toBe(TaskStatus.InProgress);
|
|
375
|
+
});
|
|
376
|
+
it('claims a task from blocked status', () => {
|
|
377
|
+
const task = taskService.createTask({ title: 'Test', project: 'inbox' });
|
|
378
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
379
|
+
taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
380
|
+
taskService.blockTask(task.task_id);
|
|
381
|
+
const claimed = taskService.claimTask(task.task_id, { author: 'agent-2' });
|
|
382
|
+
expect(claimed.status).toBe(TaskStatus.InProgress);
|
|
383
|
+
});
|
|
384
|
+
it('throws when claiming a done task', () => {
|
|
385
|
+
const task = taskService.createTask({ title: 'Test', project: 'inbox' });
|
|
386
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
387
|
+
taskService.claimTask(task.task_id);
|
|
388
|
+
taskService.completeTask(task.task_id);
|
|
304
389
|
expect(() => taskService.claimTask(task.task_id)).toThrow(/not claimable/i);
|
|
305
390
|
});
|
|
306
|
-
it('throws
|
|
391
|
+
it('throws when claiming an archived task', () => {
|
|
392
|
+
const task = taskService.createTask({ title: 'Test', project: 'inbox' });
|
|
393
|
+
taskService.archiveTask(task.task_id);
|
|
394
|
+
expect(() => taskService.claimTask(task.task_id)).toThrow(/not claimable/i);
|
|
395
|
+
});
|
|
396
|
+
it('allows claiming a task with incomplete dependencies', () => {
|
|
307
397
|
const dep = taskService.createTask({ title: 'Incomplete dep', project: 'inbox' });
|
|
308
398
|
const task = taskService.createTask({
|
|
309
|
-
title: '
|
|
399
|
+
title: 'Dependent task',
|
|
310
400
|
project: 'inbox',
|
|
311
401
|
depends_on: [dep.task_id],
|
|
312
402
|
});
|
|
313
403
|
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
314
|
-
|
|
404
|
+
const claimed = taskService.claimTask(task.task_id, { author: 'agent-1' });
|
|
405
|
+
expect(claimed.status).toBe(TaskStatus.InProgress);
|
|
315
406
|
});
|
|
316
407
|
});
|
|
317
408
|
describe('claimNext', () => {
|
|
@@ -837,6 +928,53 @@ describe('TaskService', () => {
|
|
|
837
928
|
expect(() => taskService.listTasks({ dueMonth: 'bad-month' })).toThrow(InvalidDueMonthError);
|
|
838
929
|
expect(() => taskService.listTasks({ dueMonth: '2026-00' })).toThrow(InvalidDueMonthError);
|
|
839
930
|
});
|
|
931
|
+
it('listTasks includes tags', () => {
|
|
932
|
+
taskService.createTask({ title: 'Tagged task', project: 'project-a', tags: ['bug', 'urgent'] });
|
|
933
|
+
const tasks = taskService.listTasks({ sinceDays: 7 });
|
|
934
|
+
const found = tasks.find((t) => t.title === 'Tagged task');
|
|
935
|
+
expect(found).toBeDefined();
|
|
936
|
+
expect(found.tags).toEqual(['bug', 'urgent']);
|
|
937
|
+
});
|
|
938
|
+
it('listTasks returns empty tags array for untagged tasks', () => {
|
|
939
|
+
taskService.createTask({ title: 'No tags', project: 'project-a' });
|
|
940
|
+
const tasks = taskService.listTasks({ sinceDays: 7 });
|
|
941
|
+
const found = tasks.find((t) => t.title === 'No tags');
|
|
942
|
+
expect(found).toBeDefined();
|
|
943
|
+
expect(found.tags).toEqual([]);
|
|
944
|
+
});
|
|
945
|
+
it('listTasks filters by tag', () => {
|
|
946
|
+
projectService.createProject('test-project');
|
|
947
|
+
taskService.createTask({ title: 'Bug task', project: 'test-project', tags: ['bug'] });
|
|
948
|
+
taskService.createTask({ title: 'Feature task', project: 'test-project', tags: ['feature'] });
|
|
949
|
+
taskService.createTask({ title: 'Both', project: 'test-project', tags: ['bug', 'feature'] });
|
|
950
|
+
const bugTasks = taskService.listTasks({ sinceDays: 7, tag: 'bug' });
|
|
951
|
+
expect(bugTasks.map((t) => t.title).sort()).toEqual(['Both', 'Bug task']);
|
|
952
|
+
const featureTasks = taskService.listTasks({ sinceDays: 7, tag: 'feature' });
|
|
953
|
+
expect(featureTasks.map((t) => t.title).sort()).toEqual(['Both', 'Feature task']);
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
describe('getTagCounts', () => {
|
|
957
|
+
it('getTagCounts returns distinct tags with counts', () => {
|
|
958
|
+
projectService.createProject('test-project');
|
|
959
|
+
taskService.createTask({ title: 'A', project: 'test-project', tags: ['bug', 'urgent'] });
|
|
960
|
+
taskService.createTask({ title: 'B', project: 'test-project', tags: ['bug'] });
|
|
961
|
+
taskService.createTask({ title: 'C', project: 'test-project', tags: ['feature'] });
|
|
962
|
+
const counts = taskService.getTagCounts();
|
|
963
|
+
expect(counts).toEqual([
|
|
964
|
+
{ tag: 'bug', count: 2 },
|
|
965
|
+
{ tag: 'feature', count: 1 },
|
|
966
|
+
{ tag: 'urgent', count: 1 },
|
|
967
|
+
]);
|
|
968
|
+
});
|
|
969
|
+
it('getTagCounts excludes archived tasks', () => {
|
|
970
|
+
projectService.createProject('test-project');
|
|
971
|
+
const id = taskService.createTask({ title: 'A', project: 'test-project', tags: ['old'] });
|
|
972
|
+
taskService.claimTask(id.task_id);
|
|
973
|
+
taskService.completeTask(id.task_id);
|
|
974
|
+
taskService.archiveTask(id.task_id);
|
|
975
|
+
const counts = taskService.getTagCounts();
|
|
976
|
+
expect(counts.find((c) => c.tag === 'old')).toBeUndefined();
|
|
977
|
+
});
|
|
840
978
|
});
|
|
841
979
|
describe('getBlockedByMap', () => {
|
|
842
980
|
it('returns empty map when no tasks are blocked', () => {
|
|
@@ -1024,6 +1162,60 @@ describe('TaskService', () => {
|
|
|
1024
1162
|
expect(deps).toEqual([]);
|
|
1025
1163
|
});
|
|
1026
1164
|
});
|
|
1165
|
+
describe('updateTask', () => {
|
|
1166
|
+
it('updates multiple fields and persists task_updated events through service layer', () => {
|
|
1167
|
+
const task = taskService.createTask({
|
|
1168
|
+
title: 'Original',
|
|
1169
|
+
project: 'inbox',
|
|
1170
|
+
description: 'old',
|
|
1171
|
+
links: ['a.md'],
|
|
1172
|
+
tags: ['old'],
|
|
1173
|
+
priority: 0,
|
|
1174
|
+
});
|
|
1175
|
+
const updated = taskService.updateTask(task.task_id, {
|
|
1176
|
+
title: 'Updated',
|
|
1177
|
+
description: 'new',
|
|
1178
|
+
links: ['b.md'],
|
|
1179
|
+
tags: ['new'],
|
|
1180
|
+
priority: 2,
|
|
1181
|
+
}, { author: 'agent-x' });
|
|
1182
|
+
expect(updated.title).toBe('Updated');
|
|
1183
|
+
expect(updated.description).toBe('new');
|
|
1184
|
+
expect(updated.links).toEqual(['b.md']);
|
|
1185
|
+
expect(updated.tags).toEqual(['new']);
|
|
1186
|
+
expect(updated.priority).toBe(2);
|
|
1187
|
+
const updateEvents = eventStore
|
|
1188
|
+
.getByTaskId(task.task_id)
|
|
1189
|
+
.filter((event) => event.type === EventType.TaskUpdated);
|
|
1190
|
+
expect(updateEvents).toHaveLength(5);
|
|
1191
|
+
expect(updateEvents.every((event) => event.author === 'agent-x')).toBe(true);
|
|
1192
|
+
});
|
|
1193
|
+
it('skips unchanged values (including arrays)', () => {
|
|
1194
|
+
const task = taskService.createTask({
|
|
1195
|
+
title: 'No change',
|
|
1196
|
+
project: 'inbox',
|
|
1197
|
+
description: 'same',
|
|
1198
|
+
links: ['same.md'],
|
|
1199
|
+
tags: ['same'],
|
|
1200
|
+
priority: 1,
|
|
1201
|
+
});
|
|
1202
|
+
const updated = taskService.updateTask(task.task_id, {
|
|
1203
|
+
title: 'No change',
|
|
1204
|
+
description: 'same',
|
|
1205
|
+
links: ['same.md'],
|
|
1206
|
+
tags: ['same'],
|
|
1207
|
+
priority: 1,
|
|
1208
|
+
});
|
|
1209
|
+
expect(updated.title).toBe('No change');
|
|
1210
|
+
const updateEvents = eventStore
|
|
1211
|
+
.getByTaskId(task.task_id)
|
|
1212
|
+
.filter((event) => event.type === EventType.TaskUpdated);
|
|
1213
|
+
expect(updateEvents).toHaveLength(0);
|
|
1214
|
+
});
|
|
1215
|
+
it('throws TaskNotFoundError for unknown task', () => {
|
|
1216
|
+
expect(() => taskService.updateTask('TASK_UNKNOWN', { title: 'nope' })).toThrow(TaskNotFoundError);
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1027
1219
|
describe('getTaskTitlesByIds', () => {
|
|
1028
1220
|
it('returns empty map for empty input', () => {
|
|
1029
1221
|
const map = taskService.getTaskTitlesByIds([]);
|
|
@@ -1366,6 +1558,8 @@ describe('TaskService', () => {
|
|
|
1366
1558
|
});
|
|
1367
1559
|
it('sets progress to 100 when setStatus transitions task to done', () => {
|
|
1368
1560
|
const task = taskService.createTask({ title: 'Test', project: 'inbox' });
|
|
1561
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
1562
|
+
taskService.setStatus(task.task_id, TaskStatus.InProgress);
|
|
1369
1563
|
taskService.setProgress(task.task_id, 20);
|
|
1370
1564
|
const completed = taskService.setStatus(task.task_id, TaskStatus.Done);
|
|
1371
1565
|
expect(completed.status).toBe(TaskStatus.Done);
|
|
@@ -1602,9 +1796,19 @@ describe('TaskService', () => {
|
|
|
1602
1796
|
taskServiceWithEventsDb = new TaskService(db, eventStore, projectionEngine, projectService, db // eventsDb
|
|
1603
1797
|
);
|
|
1604
1798
|
});
|
|
1605
|
-
it('
|
|
1606
|
-
|
|
1607
|
-
|
|
1799
|
+
it('uses single-DB fallback when eventsDb is not provided', () => {
|
|
1800
|
+
const task = taskService.createTask({ title: 'Single DB prune', project: 'inbox' });
|
|
1801
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
1802
|
+
taskService.claimTask(task.task_id);
|
|
1803
|
+
taskService.completeTask(task.task_id);
|
|
1804
|
+
db.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
|
|
1805
|
+
const result = taskService.pruneEligible({ project: 'inbox', olderThanDays: 30 });
|
|
1806
|
+
expect(result.count).toBe(1);
|
|
1807
|
+
const eventsAfter = db
|
|
1808
|
+
.prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
|
|
1809
|
+
.get(task.task_id);
|
|
1810
|
+
expect(eventsAfter.cnt).toBe(0);
|
|
1811
|
+
expect(taskService.getTaskById(task.task_id)).toBeNull();
|
|
1608
1812
|
});
|
|
1609
1813
|
it('returns empty result when no eligible tasks', () => {
|
|
1610
1814
|
const result = taskServiceWithEventsDb.pruneEligible({
|
|
@@ -1779,6 +1983,95 @@ describe('TaskService', () => {
|
|
|
1779
1983
|
expect(taskServiceWithEventsDb.getTaskById(taskB.task_id)).toBeNull();
|
|
1780
1984
|
expect(taskServiceWithEventsDb.getTaskById(taskC.task_id)).toBeNull();
|
|
1781
1985
|
});
|
|
1986
|
+
it('rolls back event deletion when projection deletion fails in split DB attach mode', () => {
|
|
1987
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hzl-prune-atomic-'));
|
|
1988
|
+
const cachePath = path.join(tempDir, 'cache.db');
|
|
1989
|
+
const eventsPath = path.join(tempDir, 'events.db');
|
|
1990
|
+
const cacheDb = new Database(cachePath);
|
|
1991
|
+
const eventsDb = new Database(eventsPath);
|
|
1992
|
+
try {
|
|
1993
|
+
cacheDb.exec(PRAGMAS);
|
|
1994
|
+
eventsDb.exec(PRAGMAS);
|
|
1995
|
+
cacheDb.exec(CACHE_SCHEMA_V1);
|
|
1996
|
+
eventsDb.exec(EVENTS_SCHEMA_V2);
|
|
1997
|
+
const splitEventStore = new EventStore(eventsDb);
|
|
1998
|
+
const splitProjectionEngine = new ProjectionEngine(cacheDb, eventsDb);
|
|
1999
|
+
registerProjectors(splitProjectionEngine);
|
|
2000
|
+
const splitProjectService = new ProjectService(cacheDb, splitEventStore, splitProjectionEngine);
|
|
2001
|
+
splitProjectService.ensureInboxExists();
|
|
2002
|
+
const splitTaskService = new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
|
|
2003
|
+
const task = splitTaskService.createTask({ title: 'Split DB task', project: 'inbox' });
|
|
2004
|
+
splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
2005
|
+
splitTaskService.claimTask(task.task_id);
|
|
2006
|
+
splitTaskService.completeTask(task.task_id);
|
|
2007
|
+
cacheDb.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
|
|
2008
|
+
const eventsBefore = eventsDb
|
|
2009
|
+
.prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
|
|
2010
|
+
.get(task.task_id);
|
|
2011
|
+
expect(eventsBefore.cnt).toBeGreaterThan(0);
|
|
2012
|
+
const originalDeleteProjections = splitTaskService.deleteTasksFromProjections;
|
|
2013
|
+
splitTaskService.deleteTasksFromProjections = () => {
|
|
2014
|
+
throw new Error('simulated projection failure');
|
|
2015
|
+
};
|
|
2016
|
+
expect(() => splitTaskService.pruneEligible({ project: 'inbox', olderThanDays: 30 })).toThrow('simulated projection failure');
|
|
2017
|
+
splitTaskService.deleteTasksFromProjections = originalDeleteProjections;
|
|
2018
|
+
const eventsAfterFailure = eventsDb
|
|
2019
|
+
.prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
|
|
2020
|
+
.get(task.task_id);
|
|
2021
|
+
expect(eventsAfterFailure.cnt).toBe(eventsBefore.cnt);
|
|
2022
|
+
expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeDefined();
|
|
2023
|
+
}
|
|
2024
|
+
finally {
|
|
2025
|
+
cacheDb.close();
|
|
2026
|
+
eventsDb.close();
|
|
2027
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
2028
|
+
}
|
|
2029
|
+
});
|
|
2030
|
+
it('recovers projection cleanup from prune journal when fallback path is interrupted', () => {
|
|
2031
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hzl-prune-journal-'));
|
|
2032
|
+
const cachePath = path.join(tempDir, 'cache.db');
|
|
2033
|
+
const cacheDb = new Database(cachePath);
|
|
2034
|
+
const eventsDb = new Database(':memory:');
|
|
2035
|
+
const journalPath = path.join(tempDir, 'prune-journal.json');
|
|
2036
|
+
try {
|
|
2037
|
+
cacheDb.exec(PRAGMAS);
|
|
2038
|
+
eventsDb.exec(PRAGMAS);
|
|
2039
|
+
cacheDb.exec(CACHE_SCHEMA_V1);
|
|
2040
|
+
eventsDb.exec(EVENTS_SCHEMA_V2);
|
|
2041
|
+
const splitEventStore = new EventStore(eventsDb);
|
|
2042
|
+
const splitProjectionEngine = new ProjectionEngine(cacheDb, eventsDb);
|
|
2043
|
+
registerProjectors(splitProjectionEngine);
|
|
2044
|
+
const splitProjectService = new ProjectService(cacheDb, splitEventStore, splitProjectionEngine);
|
|
2045
|
+
splitProjectService.ensureInboxExists();
|
|
2046
|
+
const splitTaskService = new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
|
|
2047
|
+
const task = splitTaskService.createTask({ title: 'Journal fallback task', project: 'inbox' });
|
|
2048
|
+
splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
2049
|
+
splitTaskService.claimTask(task.task_id);
|
|
2050
|
+
splitTaskService.completeTask(task.task_id);
|
|
2051
|
+
cacheDb.prepare('UPDATE tasks_current SET terminal_at = ? WHERE task_id = ?').run('2020-01-01T00:00:00Z', task.task_id);
|
|
2052
|
+
const originalDeleteProjections = splitTaskService.deleteTasksFromProjections;
|
|
2053
|
+
splitTaskService.deleteTasksFromProjections = () => {
|
|
2054
|
+
throw new Error('simulated crash after events delete');
|
|
2055
|
+
};
|
|
2056
|
+
expect(() => splitTaskService.pruneEligible({ project: 'inbox', olderThanDays: 30 })).toThrow('simulated crash after events delete');
|
|
2057
|
+
splitTaskService.deleteTasksFromProjections = originalDeleteProjections;
|
|
2058
|
+
const eventsAfterCrash = eventsDb
|
|
2059
|
+
.prepare('SELECT COUNT(*) as cnt FROM events WHERE task_id = ?')
|
|
2060
|
+
.get(task.task_id);
|
|
2061
|
+
expect(eventsAfterCrash.cnt).toBe(0);
|
|
2062
|
+
expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeDefined();
|
|
2063
|
+
expect(fs.existsSync(journalPath)).toBe(true);
|
|
2064
|
+
// Constructor-time recovery should finish projection cleanup.
|
|
2065
|
+
new TaskService(cacheDb, splitEventStore, splitProjectionEngine, splitProjectService, eventsDb);
|
|
2066
|
+
expect(cacheDb.prepare('SELECT 1 FROM tasks_current WHERE task_id = ?').get(task.task_id)).toBeUndefined();
|
|
2067
|
+
expect(fs.existsSync(journalPath)).toBe(false);
|
|
2068
|
+
}
|
|
2069
|
+
finally {
|
|
2070
|
+
cacheDb.close();
|
|
2071
|
+
eventsDb.close();
|
|
2072
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
2073
|
+
}
|
|
2074
|
+
});
|
|
1782
2075
|
});
|
|
1783
2076
|
describe('getBlockedByForTasks', () => {
|
|
1784
2077
|
it('returns empty map for empty input', () => {
|
|
@@ -1835,7 +2128,9 @@ describe('TaskService', () => {
|
|
|
1835
2128
|
project: 'inbox',
|
|
1836
2129
|
depends_on: [dep.task_id],
|
|
1837
2130
|
});
|
|
1838
|
-
// Force task to done status directly (bypassing dep check)
|
|
2131
|
+
// Force task to done status directly via setStatus (bypassing dep check)
|
|
2132
|
+
taskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
2133
|
+
taskService.setStatus(task.task_id, TaskStatus.InProgress);
|
|
1839
2134
|
taskService.setStatus(task.task_id, TaskStatus.Done);
|
|
1840
2135
|
const result = taskService.getBlockedByForTasks([task.task_id]);
|
|
1841
2136
|
expect(result.has(task.task_id)).toBe(false);
|
|
@@ -1854,6 +2149,7 @@ describe('TaskService', () => {
|
|
|
1854
2149
|
project: 'inbox',
|
|
1855
2150
|
depends_on: [dep.task_id],
|
|
1856
2151
|
});
|
|
2152
|
+
taskService.setStatus(inProgressTask.task_id, TaskStatus.Ready);
|
|
1857
2153
|
taskService.setStatus(inProgressTask.task_id, TaskStatus.InProgress);
|
|
1858
2154
|
const result = taskService.getBlockedByForTasks([readyTask.task_id, inProgressTask.task_id]);
|
|
1859
2155
|
expect(result.has(readyTask.task_id)).toBe(true);
|
|
@@ -1996,6 +2292,8 @@ describe('TaskService', () => {
|
|
|
1996
2292
|
onDone: { url: 'http://127.0.0.1:18789/events/inject' },
|
|
1997
2293
|
});
|
|
1998
2294
|
const task = hookTaskService.createTask({ title: 'Set status done', project: 'inbox' });
|
|
2295
|
+
hookTaskService.setStatus(task.task_id, TaskStatus.Ready);
|
|
2296
|
+
hookTaskService.setStatus(task.task_id, TaskStatus.InProgress);
|
|
1999
2297
|
hookTaskService.setStatus(task.task_id, TaskStatus.Done);
|
|
2000
2298
|
const count = db.prepare('SELECT COUNT(*) as count FROM hook_outbox WHERE payload LIKE ?')
|
|
2001
2299
|
.get(`%"task_id":"${task.task_id}"%`).count;
|