hzl-core 2.5.0 → 2.7.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.
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import Database from 'libsql';
7
7
  import { TaskService, AmbiguousPrefixError, InvalidDueMonthError, InvalidProgressError, InvalidStatusTransitionError, TaskNotFoundError, } from './task-service.js';
8
8
  import { ProjectService, ProjectNotFoundError } from './project-service.js';
9
- import { createTestDb } from '../db/test-utils.js';
9
+ import { createTestDb, createTestDatabases } from '../db/test-utils.js';
10
10
  import { EventStore } from '../events/store.js';
11
11
  import { EventType, TaskStatus } from '../events/types.js';
12
12
  import { CACHE_SCHEMA_V1, EVENTS_SCHEMA_V2, PRAGMAS } from '../db/schema.js';
@@ -2312,5 +2312,330 @@ describe('TaskService', () => {
2312
2312
  expect(count).toBe(1);
2313
2313
  });
2314
2314
  });
2315
+ describe('getAgentRoster', () => {
2316
+ it('returns empty array when no agents in DB', () => {
2317
+ const roster = taskService.getAgentRoster();
2318
+ expect(roster).toEqual([]);
2319
+ });
2320
+ it('returns agent with one in-progress task as active', () => {
2321
+ const task = taskService.createTask({ title: 'Active work', project: 'project-a' });
2322
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
2323
+ taskService.claimTask(task.task_id, { author: 'agent-alpha' });
2324
+ const roster = taskService.getAgentRoster();
2325
+ expect(roster).toHaveLength(1);
2326
+ expect(roster[0].agent).toBe('agent-alpha');
2327
+ expect(roster[0].isActive).toBe(true);
2328
+ expect(roster[0].tasks).toHaveLength(1);
2329
+ expect(roster[0].tasks[0].taskId).toBe(task.task_id);
2330
+ expect(roster[0].tasks[0].title).toBe('Active work');
2331
+ expect(roster[0].tasks[0].claimedAt).toBeDefined();
2332
+ expect(roster[0].tasks[0].status).toBe('in_progress');
2333
+ expect(roster[0].lastActivity).toBeDefined();
2334
+ });
2335
+ it('returns agent with completed task only as idle with empty tasks', () => {
2336
+ const task = taskService.createTask({ title: 'Done work', project: 'project-a' });
2337
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
2338
+ taskService.claimTask(task.task_id, { author: 'agent-beta' });
2339
+ taskService.completeTask(task.task_id);
2340
+ const roster = taskService.getAgentRoster();
2341
+ expect(roster).toHaveLength(1);
2342
+ expect(roster[0].agent).toBe('agent-beta');
2343
+ expect(roster[0].isActive).toBe(false);
2344
+ expect(roster[0].tasks).toEqual([]);
2345
+ expect(roster[0].lastActivity).toBeDefined();
2346
+ });
2347
+ it('returns agent with multiple in-progress tasks', () => {
2348
+ const task1 = taskService.createTask({ title: 'Task 1', project: 'project-a' });
2349
+ const task2 = taskService.createTask({ title: 'Task 2', project: 'project-a' });
2350
+ taskService.setStatus(task1.task_id, TaskStatus.Ready);
2351
+ taskService.claimTask(task1.task_id, { author: 'agent-multi' });
2352
+ taskService.setStatus(task2.task_id, TaskStatus.Ready);
2353
+ taskService.claimTask(task2.task_id, { author: 'agent-multi' });
2354
+ const roster = taskService.getAgentRoster();
2355
+ expect(roster).toHaveLength(1);
2356
+ expect(roster[0].agent).toBe('agent-multi');
2357
+ expect(roster[0].isActive).toBe(true);
2358
+ expect(roster[0].tasks).toHaveLength(2);
2359
+ expect(roster[0].tasks.map(t => t.taskId).sort()).toEqual([task1.task_id, task2.task_id].sort());
2360
+ });
2361
+ it('filters by project', () => {
2362
+ const taskA = taskService.createTask({ title: 'Task A', project: 'project-a' });
2363
+ const taskB = taskService.createTask({ title: 'Task B', project: 'project-b' });
2364
+ taskService.setStatus(taskA.task_id, TaskStatus.Ready);
2365
+ taskService.claimTask(taskA.task_id, { author: 'agent-a' });
2366
+ taskService.setStatus(taskB.task_id, TaskStatus.Ready);
2367
+ taskService.claimTask(taskB.task_id, { author: 'agent-b' });
2368
+ const roster = taskService.getAgentRoster({ project: 'project-a' });
2369
+ expect(roster).toHaveLength(1);
2370
+ expect(roster[0].agent).toBe('agent-a');
2371
+ });
2372
+ it('sorts active agents by oldest claimed_at first, then idle by most recent updated_at', () => {
2373
+ // Create two active agents and one idle
2374
+ const task1 = taskService.createTask({ title: 'Older active', project: 'project-a' });
2375
+ taskService.setStatus(task1.task_id, TaskStatus.Ready);
2376
+ taskService.claimTask(task1.task_id, { author: 'agent-old-active' });
2377
+ const task2 = taskService.createTask({ title: 'Newer active', project: 'project-a' });
2378
+ taskService.setStatus(task2.task_id, TaskStatus.Ready);
2379
+ taskService.claimTask(task2.task_id, { author: 'agent-new-active' });
2380
+ const task3 = taskService.createTask({ title: 'Done task', project: 'project-a' });
2381
+ taskService.setStatus(task3.task_id, TaskStatus.Ready);
2382
+ taskService.claimTask(task3.task_id, { author: 'agent-idle' });
2383
+ taskService.completeTask(task3.task_id);
2384
+ // Set distinct timestamps so sort order is deterministic
2385
+ // (SQLite second-level precision means claims within the same second get identical timestamps)
2386
+ db.prepare("UPDATE tasks_current SET claimed_at = '2026-01-01T10:00:00Z' WHERE agent = 'agent-old-active'").run();
2387
+ db.prepare("UPDATE tasks_current SET claimed_at = '2026-01-01T11:00:00Z' WHERE agent = 'agent-new-active'").run();
2388
+ db.prepare("UPDATE tasks_current SET updated_at = '2026-01-01T09:00:00Z' WHERE agent = 'agent-idle'").run();
2389
+ const roster = taskService.getAgentRoster();
2390
+ expect(roster).toHaveLength(3);
2391
+ // Active agents first, sorted by oldest claimed_at
2392
+ expect(roster[0].agent).toBe('agent-old-active');
2393
+ expect(roster[0].isActive).toBe(true);
2394
+ expect(roster[1].agent).toBe('agent-new-active');
2395
+ expect(roster[1].isActive).toBe(true);
2396
+ // Idle agents last
2397
+ expect(roster[2].agent).toBe('agent-idle');
2398
+ expect(roster[2].isActive).toBe(false);
2399
+ });
2400
+ it('excludes tasks where agent is null or empty', () => {
2401
+ // Create a task without an agent
2402
+ taskService.createTask({ title: 'No agent task', project: 'project-a' });
2403
+ const roster = taskService.getAgentRoster();
2404
+ expect(roster).toEqual([]);
2405
+ });
2406
+ it('reflects current agent after reassignment, not history', () => {
2407
+ // agent-original claims, then agent-new re-claims (steal)
2408
+ const task = taskService.createTask({ title: 'Reassigned', project: 'project-a' });
2409
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
2410
+ taskService.claimTask(task.task_id, { author: 'agent-original' });
2411
+ // Re-claim by a different agent
2412
+ taskService.claimTask(task.task_id, { author: 'agent-new' });
2413
+ const roster = taskService.getAgentRoster();
2414
+ // Only agent-new should appear as active with this task
2415
+ const activeAgents = roster.filter(r => r.isActive);
2416
+ expect(activeAgents).toHaveLength(1);
2417
+ expect(activeAgents[0].agent).toBe('agent-new');
2418
+ expect(activeAgents[0].tasks).toHaveLength(1);
2419
+ expect(activeAgents[0].tasks[0].taskId).toBe(task.task_id);
2420
+ });
2421
+ it('returns progress in task entries when set', () => {
2422
+ const task = taskService.createTask({ title: 'With progress', project: 'project-a' });
2423
+ taskService.setStatus(task.task_id, TaskStatus.Ready);
2424
+ taskService.claimTask(task.task_id, { author: 'agent-progress' });
2425
+ taskService.setProgress(task.task_id, 75);
2426
+ const roster = taskService.getAgentRoster();
2427
+ expect(roster).toHaveLength(1);
2428
+ expect(roster[0].tasks[0].progress).toBe(75);
2429
+ });
2430
+ it('filters by sinceDays', () => {
2431
+ const t1 = taskService.createTask({ title: 'Old task', project: 'project-a' });
2432
+ taskService.setStatus(t1.task_id, TaskStatus.Ready);
2433
+ taskService.claimTask(t1.task_id, { author: 'old-agent' });
2434
+ const t2 = taskService.createTask({ title: 'Recent task', project: 'project-a' });
2435
+ taskService.setStatus(t2.task_id, TaskStatus.Ready);
2436
+ taskService.claimTask(t2.task_id, { author: 'recent-agent' });
2437
+ // Backdate old agent's task to 10 days ago
2438
+ db.prepare("UPDATE tasks_current SET updated_at = datetime('now', '-10 days') WHERE agent = ?").run('old-agent');
2439
+ const roster = taskService.getAgentRoster({ sinceDays: 3 });
2440
+ const agents = roster.map(r => r.agent);
2441
+ expect(agents).toContain('recent-agent');
2442
+ expect(agents).not.toContain('old-agent');
2443
+ });
2444
+ it('claimedAt is stable after progress update', () => {
2445
+ const t = taskService.createTask({ title: 'Progress task', project: 'project-a' });
2446
+ taskService.setStatus(t.task_id, TaskStatus.Ready);
2447
+ taskService.claimTask(t.task_id, { author: 'progress-agent' });
2448
+ const before = taskService.getAgentRoster();
2449
+ const claimedAtBefore = before.find(r => r.agent === 'progress-agent')?.tasks[0]?.claimedAt;
2450
+ expect(claimedAtBefore).toBeDefined();
2451
+ taskService.setProgress(t.task_id, 50, { author: 'progress-agent' });
2452
+ const after = taskService.getAgentRoster();
2453
+ const claimedAtAfter = after.find(r => r.agent === 'progress-agent')?.tasks[0]?.claimedAt;
2454
+ expect(claimedAtAfter).toBe(claimedAtBefore);
2455
+ });
2456
+ });
2457
+ describe('getAgentEvents', () => {
2458
+ // These tests use a split-DB setup (separate events DB and cache DB)
2459
+ // to mirror the production architecture where events.db and cache.db
2460
+ // are separate SQLite files.
2461
+ let splitEventsDb;
2462
+ let splitCacheDb;
2463
+ let splitEventStore;
2464
+ let splitProjectionEngine;
2465
+ let splitProjectService;
2466
+ let splitTaskService;
2467
+ beforeEach(() => {
2468
+ const dbs = createTestDatabases();
2469
+ splitEventsDb = dbs.eventsDb;
2470
+ splitCacheDb = dbs.cacheDb;
2471
+ splitEventStore = new EventStore(splitEventsDb);
2472
+ splitProjectionEngine = new ProjectionEngine(splitCacheDb, splitEventsDb);
2473
+ registerProjectors(splitProjectionEngine);
2474
+ splitProjectService = new ProjectService(splitCacheDb, splitEventStore, splitProjectionEngine);
2475
+ splitProjectService.ensureInboxExists();
2476
+ splitProjectService.createProject('project-a');
2477
+ splitTaskService = new TaskService(splitCacheDb, splitEventStore, splitProjectionEngine, splitProjectService, splitEventsDb);
2478
+ });
2479
+ afterEach(() => {
2480
+ splitEventsDb.close();
2481
+ splitCacheDb.close();
2482
+ });
2483
+ it('returns empty events and total=0 for agent with no tasks', () => {
2484
+ const result = splitTaskService.getAgentEvents('nonexistent-agent');
2485
+ expect(result.events).toEqual([]);
2486
+ expect(result.total).toBe(0);
2487
+ });
2488
+ it('returns events sorted by id descending, enriched with task titles', () => {
2489
+ const task = splitTaskService.createTask({ title: 'Research API', project: 'inbox' });
2490
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2491
+ splitTaskService.claimTask(task.task_id, { author: 'agent-alpha' });
2492
+ const result = splitTaskService.getAgentEvents('agent-alpha');
2493
+ expect(result.events.length).toBeGreaterThan(0);
2494
+ expect(result.total).toBe(result.events.length);
2495
+ // Events should be sorted by id descending (newest first)
2496
+ for (let i = 1; i < result.events.length; i++) {
2497
+ expect(result.events[i - 1].id).toBeGreaterThan(result.events[i].id);
2498
+ }
2499
+ // Every event should be enriched with the task title
2500
+ for (const event of result.events) {
2501
+ expect(event.taskTitle).toBe('Research API');
2502
+ expect(event.taskId).toBe(task.task_id);
2503
+ }
2504
+ });
2505
+ it('paginates with limit and offset', () => {
2506
+ const task = splitTaskService.createTask({ title: 'Paginated task', project: 'inbox' });
2507
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2508
+ splitTaskService.claimTask(task.task_id, { author: 'agent-page' });
2509
+ splitTaskService.addComment(task.task_id, 'Comment 1');
2510
+ splitTaskService.addComment(task.task_id, 'Comment 2');
2511
+ splitTaskService.addComment(task.task_id, 'Comment 3');
2512
+ // Get total count first
2513
+ const all = splitTaskService.getAgentEvents('agent-page');
2514
+ const totalEvents = all.total;
2515
+ expect(totalEvents).toBeGreaterThanOrEqual(5); // created + status_changed + claimed + 3 comments
2516
+ // First page: limit 2
2517
+ const page1 = splitTaskService.getAgentEvents('agent-page', { limit: 2, offset: 0 });
2518
+ expect(page1.events).toHaveLength(2);
2519
+ expect(page1.total).toBe(totalEvents);
2520
+ // Second page: limit 2, offset 2
2521
+ const page2 = splitTaskService.getAgentEvents('agent-page', { limit: 2, offset: 2 });
2522
+ expect(page2.events).toHaveLength(2);
2523
+ expect(page2.total).toBe(totalEvents);
2524
+ // First page has newer events than second page
2525
+ expect(page1.events[0].id).toBeGreaterThan(page2.events[0].id);
2526
+ // No overlap between pages
2527
+ const page1Ids = page1.events.map(e => e.id);
2528
+ const page2Ids = page2.events.map(e => e.id);
2529
+ for (const id of page1Ids) {
2530
+ expect(page2Ids).not.toContain(id);
2531
+ }
2532
+ });
2533
+ it('returns events from multiple tasks interleaved by id', () => {
2534
+ const task1 = splitTaskService.createTask({ title: 'Task Alpha', project: 'inbox' });
2535
+ splitTaskService.setStatus(task1.task_id, TaskStatus.Ready);
2536
+ splitTaskService.claimTask(task1.task_id, { author: 'agent-multi' });
2537
+ const task2 = splitTaskService.createTask({ title: 'Task Beta', project: 'inbox' });
2538
+ splitTaskService.setStatus(task2.task_id, TaskStatus.Ready);
2539
+ splitTaskService.claimTask(task2.task_id, { author: 'agent-multi' });
2540
+ const result = splitTaskService.getAgentEvents('agent-multi');
2541
+ // Should have events from both tasks
2542
+ const taskIds = new Set(result.events.map(e => e.taskId));
2543
+ expect(taskIds.size).toBe(2);
2544
+ expect(taskIds).toContain(task1.task_id);
2545
+ expect(taskIds).toContain(task2.task_id);
2546
+ // Events enriched with correct titles
2547
+ for (const event of result.events) {
2548
+ if (event.taskId === task1.task_id) {
2549
+ expect(event.taskTitle).toBe('Task Alpha');
2550
+ }
2551
+ else {
2552
+ expect(event.taskTitle).toBe('Task Beta');
2553
+ }
2554
+ }
2555
+ // Total count should be accurate
2556
+ expect(result.total).toBe(result.events.length);
2557
+ });
2558
+ it('total count is accurate across pages', () => {
2559
+ const task = splitTaskService.createTask({ title: 'Count test', project: 'inbox' });
2560
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2561
+ splitTaskService.claimTask(task.task_id, { author: 'agent-count' });
2562
+ splitTaskService.addComment(task.task_id, 'Note 1');
2563
+ splitTaskService.addComment(task.task_id, 'Note 2');
2564
+ const page1 = splitTaskService.getAgentEvents('agent-count', { limit: 2, offset: 0 });
2565
+ const page2 = splitTaskService.getAgentEvents('agent-count', { limit: 2, offset: 2 });
2566
+ // Total should be the same on all pages
2567
+ expect(page1.total).toBe(page2.total);
2568
+ expect(page1.total).toBeGreaterThanOrEqual(4); // created + status_changed + claimed + 2 comments
2569
+ });
2570
+ it('returns events for agent with done tasks (agent is preserved on completion)', () => {
2571
+ const task = splitTaskService.createTask({ title: 'Completed task', project: 'inbox' });
2572
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2573
+ splitTaskService.claimTask(task.task_id, { author: 'agent-done' });
2574
+ splitTaskService.completeTask(task.task_id, { author: 'agent-done' });
2575
+ const result = splitTaskService.getAgentEvents('agent-done');
2576
+ expect(result.events.length).toBeGreaterThan(0);
2577
+ expect(result.total).toBe(result.events.length);
2578
+ // Task status should be 'done' in enrichment
2579
+ for (const event of result.events) {
2580
+ expect(event.taskTitle).toBe('Completed task');
2581
+ expect(event.taskStatus).toBe('done');
2582
+ }
2583
+ });
2584
+ it('clamps limit to max 200', () => {
2585
+ const task = splitTaskService.createTask({ title: 'Limit test', project: 'inbox' });
2586
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2587
+ splitTaskService.claimTask(task.task_id, { author: 'agent-limit' });
2588
+ // Requesting limit > 200 should not error, just clamp
2589
+ const result = splitTaskService.getAgentEvents('agent-limit', { limit: 500 });
2590
+ expect(result.events.length).toBeGreaterThan(0);
2591
+ });
2592
+ it('returns empty results when eventsDb is not provided', () => {
2593
+ // Create a TaskService without eventsDb
2594
+ const noEventsTaskService = new TaskService(splitCacheDb, splitEventStore, splitProjectionEngine, splitProjectService);
2595
+ // Create a task via the split service (so it exists in cache)
2596
+ const task = splitTaskService.createTask({ title: 'No events DB', project: 'inbox' });
2597
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2598
+ splitTaskService.claimTask(task.task_id, { author: 'agent-nodb' });
2599
+ const result = noEventsTaskService.getAgentEvents('agent-nodb');
2600
+ expect(result.events).toEqual([]);
2601
+ expect(result.total).toBe(0);
2602
+ });
2603
+ it('returns events with correct structure', () => {
2604
+ const task = splitTaskService.createTask({ title: 'Structure test', project: 'inbox' });
2605
+ splitTaskService.setStatus(task.task_id, TaskStatus.Ready);
2606
+ splitTaskService.claimTask(task.task_id, { author: 'agent-struct' });
2607
+ const result = splitTaskService.getAgentEvents('agent-struct');
2608
+ expect(result.events.length).toBeGreaterThan(0);
2609
+ const event = result.events[0];
2610
+ expect(event).toHaveProperty('id');
2611
+ expect(event).toHaveProperty('eventId');
2612
+ expect(event).toHaveProperty('taskId');
2613
+ expect(event).toHaveProperty('type');
2614
+ expect(event).toHaveProperty('data');
2615
+ expect(event).toHaveProperty('timestamp');
2616
+ expect(event).toHaveProperty('taskTitle');
2617
+ expect(event).toHaveProperty('taskStatus');
2618
+ expect(typeof event.id).toBe('number');
2619
+ expect(typeof event.eventId).toBe('string');
2620
+ expect(typeof event.taskId).toBe('string');
2621
+ expect(typeof event.type).toBe('string');
2622
+ expect(typeof event.timestamp).toBe('string');
2623
+ expect(typeof event.taskTitle).toBe('string');
2624
+ expect(typeof event.taskStatus).toBe('string');
2625
+ });
2626
+ it('returns empty result for nonexistent agent', () => {
2627
+ const result = splitTaskService.getAgentEvents('nonexistent');
2628
+ expect(result.events).toEqual([]);
2629
+ expect(result.total).toBe(0);
2630
+ });
2631
+ it('returns events when agent identity comes from agent_id', () => {
2632
+ const t = splitTaskService.createTask({ title: 'Agent ID task', project: 'project-a' });
2633
+ splitTaskService.setStatus(t.task_id, TaskStatus.Ready);
2634
+ splitTaskService.claimTask(t.task_id, { agent_id: 'agent-via-id' });
2635
+ const result = splitTaskService.getAgentEvents('agent-via-id');
2636
+ expect(result.events.length).toBeGreaterThan(0);
2637
+ expect(result.events[0].taskId).toBe(t.task_id);
2638
+ });
2639
+ });
2315
2640
  });
2316
2641
  //# sourceMappingURL=task-service.test.js.map