diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json deleted file mode 100644 index bc808a5..0000000 --- a/.scheduler-memory/memory-store.json +++ /dev/null @@ -1,403 +0,0 @@ -[ - [ - "638c2d47-3218-42f5-bc2d-e3adeb4626d0", - { - "schema_version": 1, - "id": "638c2d47-3218-42f5-bc2d-e3adeb4626d0", - "agent_id": "test-agent", - "session_id": "test-run", - "type": "batch_window", - "content": "Store memory event A for daily :: scheduler memory store\nStore memory event B for daily :: scheduler memory store", - "summary": "Store memory event A for daily :: scheduler memory store Store memory event B for daily :: scheduler memory store", - "tags": [ - "scheduler", - "daily", - "store" - ], - "importance": 0.55, - "embedding_id": null, - "created_at": 1771295950236, - "last_seen": 1771295958659, - "source": "scheduler-store", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "5c458e0c-8239-46f9-99d1-b61cf05acc34", - { - "schema_version": 1, - "id": "5c458e0c-8239-46f9-99d1-b61cf05acc34", - "agent_id": "test-agent", - "session_id": "test-run-2", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: scheduler memory retrieval", - "summary": "Memory retrieval seed for daily :: scheduler memory retrieval", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771295958645, - "last_seen": 1771295958659, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "2e9129bd-c366-4d82-ac2b-7e50574532a9", - { - "schema_version": 1, - "id": "2e9129bd-c366-4d82-ac2b-7e50574532a9", - "agent_id": "onboarding-audit-agent", - "session_id": "initial-seed", - "type": "batch_window", - "content": "Store memory event A for daily :: Required startup + artifacts + memory + issue capture\nStore memory event B for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Store memory event A for daily :: Required startup + artifacts + memory + issue capture Store memory event B for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "store" - ], - "importance": 0.55, - "embedding_id": null, - "created_at": 1771296574057, - "last_seen": 1771296574172, - "source": "scheduler-store", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "2612b9ce-1aa7-4731-acb2-c3a852eeaac6", - { - "schema_version": 1, - "id": "2612b9ce-1aa7-4731-acb2-c3a852eeaac6", - "agent_id": "onboarding-audit-agent", - "session_id": "initial-seed", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771296574158, - "last_seen": 1771296574172, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "facf005a-dad0-4066-921c-49a8e408665c", - { - "schema_version": 1, - "id": "facf005a-dad0-4066-921c-49a8e408665c", - "agent_id": "agent-telemetry", - "session_id": "s-telemetry", - "type": "batch_window", - "content": "customer token ABC123 secret", - "summary": "customer token ABC123 secret", - "tags": [ - "incident" - ], - "importance": 0.25, - "embedding_id": null, - "created_at": 1771296636016, - "last_seen": 1771301051605, - "source": "ingest", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "19ef312e-a725-4544-850e-651713e2d9dc", - { - "schema_version": 1, - "id": "19ef312e-a725-4544-850e-651713e2d9dc", - "agent_id": "bug-reproducer-agent", - "session_id": "session-mlq2vq5p", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771300999213, - "last_seen": 1771300999222, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "bfbb3c6e-095d-4ccd-ad5a-140b6927421c", - { - "schema_version": 1, - "id": "bfbb3c6e-095d-4ccd-ad5a-140b6927421c", - "agent_id": "agent-telemetry", - "session_id": "s-telemetry", - "type": "batch_window", - "content": "customer token ABC123 secret", - "summary": "customer token ABC123 secret", - "tags": [ - "incident" - ], - "importance": 0.25, - "embedding_id": null, - "created_at": 1771301051598, - "last_seen": 1771301051605, - "source": "ingest", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "d74e3e34-6cf1-465e-b4a8-64acd0a8d803", - { - "schema_version": 1, - "id": "d74e3e34-6cf1-465e-b4a8-64acd0a8d803", - "agent_id": "bug-reproducer-agent", - "session_id": "session-mlq2ysd2", - "type": "batch_window", - "content": "Reproduced npm test timeout issue. Created reproduction script in artifacts/reproducers/npm-test-timeout/. Confirmed that `npm test` hangs/times out as expected.", - "summary": "Reproduced npm test timeout issue. Created reproduction script in artifacts/reproducers/npm-test-timeout/. Confirmed that `npm test` hangs/times out as expected.", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771301142040, - "last_seen": 1771301142040, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "3df7bccf-f3f1-45b9-ab15-88a1a30c6453", - { - "schema_version": 1, - "id": "3df7bccf-f3f1-45b9-ab15-88a1a30c6453", - "agent_id": "changelog-agent", - "session_id": "session-mlq51t0r", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771304642091, - "last_seen": 1771304642101, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "5b32be34-78f9-44ac-b7b1-e301e34f04d0", - { - "schema_version": 1, - "id": "5b32be34-78f9-44ac-b7b1-e301e34f04d0", - "agent_id": "changelog-agent", - "session_id": "session-mlq53the", - "type": "batch_window", - "content": "# Changelog Update - 2026-02-17\n\nGenerated draft release notes covering 212 commits.\nKey areas:\n- Scheduler automation improvements\n- Memory management features\n- Codebase modularization", - "summary": "# Changelog Update - 2026-02-17\n\nGenerated draft release notes covering 212 commits.\nKey areas:\n- Scheduler automation improvements\n- Memory management features\n- Codebase modularization", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771304736005, - "last_seen": 1771304736005, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "40f6bf1a-9715-4906-82d1-8f68be78eb3f", - { - "schema_version": 1, - "id": "40f6bf1a-9715-4906-82d1-8f68be78eb3f", - "agent_id": "dead-code-agent", - "session_id": "1771338346", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771338346815, - "last_seen": 1771338346833, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "267412f9-9704-4739-8481-8317a89e6769", - { - "schema_version": 1, - "id": "267412f9-9704-4739-8481-8317a89e6769", - "agent_id": "dead-code-agent", - "session_id": "1771338501", - "type": "batch_window", - "content": "# Weekly Dead Code Analysis - 2026-02-17\n\n**Agent:** dead-code-agent\n**Cadence:** weekly\n**Status:** Completed\n\n## Summary\nExecuted a safety-first dead-code sweep.\n\n## Findings\n- Identified `scripts/fix-duplication.mjs` as an unused standalone script.\n- Identified `src/services/memory/db-adapter.js` as an unused module (exports `createMemoryRepository` but not imported anywhere).\n\n## Actions\n- Verified zero usage for both files using `grep`.\n- Deleted both files in branch `ai/dead-code-20260217`.\n- Verified deletions with `ls` and ran `npm run lint` and `npm run test:unit:lock-backend`.\n- Artifacts generated in `artifacts/dead-code-20260217/`.", - "summary": "# Weekly Dead Code Analysis - 2026-02-17\n\n**Agent:** dead-code-agent\n**Cadence:** weekly\n**Status:** Completed\n\n## Summary\nExecuted a safety-first dead-code sweep.\n\n## Findings\n- Identified `scripts/fix-duplication.mjs` as an unused standalone script.\n- Identified `src/services/…", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771338501891, - "last_seen": 1771338501891, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "fd852345-c9c5-4c5c-8076-7e16a7b90016", - { - "schema_version": 1, - "id": "fd852345-c9c5-4c5c-8076-7e16a7b90016", - "agent_id": "feature-proposer-agent", - "session_id": "session-1771346034", - "type": "batch_window", - "content": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for weekly :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "weekly", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771346034674, - "last_seen": 1771346034684, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "abe5bddd-5b7a-4e18-aabf-9d517bd59a8e", - { - "schema_version": 1, - "id": "abe5bddd-5b7a-4e18-aabf-9d517bd59a8e", - "agent_id": "feature-proposer-agent", - "session_id": "session-1771346112", - "type": "batch_window", - "content": "# Memory Update - Feature Proposer Agent\n\n**Date**: 2026-02-17T15-00-00Z\n**Agent**: feature-proposer-agent\n\n## Insights\n- The `task-logs` structure is consistent and easy to parse.\n- A simple CLI tool (`features/log-summary.mjs`) was added to visualize this data.\n- This pattern of \"small, standalone utility scripts\" in `features/` is effective for incremental improvement.\n\n## Actions\n- Implemented `features/log-summary.mjs`.\n- Verified with manual execution.\n\n## Recommendations\n- Future agents could enhance this script to filter by status (e.g., show only failed runs).", - "summary": "# Memory Update - Feature Proposer Agent\n\n**Date**: 2026-02-17T15-00-00Z\n**Agent**: feature-proposer-agent\n\n## Insights\n- The `task-logs` structure is consistent and easy to parse.\n- A simple CLI tool (`features/log-summary.mjs`) was added to visualize this data.\n- This pattern …", - "tags": [ - "scheduler", - "weekly", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771346112755, - "last_seen": 1771346112755, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "9f17b770-803f-401b-8bec-4c8ce413f232", - { - "schema_version": 1, - "id": "9f17b770-803f-401b-8bec-4c8ce413f232", - "agent_id": "audit-agent", - "session_id": "session-mlqwxsc8", - "type": "batch_window", - "content": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "summary": "Memory retrieval seed for daily :: Required startup + artifacts + memory + issue capture", - "tags": [ - "scheduler", - "daily", - "retrieve" - ], - "importance": 0.4, - "embedding_id": null, - "created_at": 1771351483834, - "last_seen": 1771351483843, - "source": "scheduler-retrieve", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ], - [ - "30b53ec3-5d17-4b38-9d80-4f9c15e3cd7e", - { - "schema_version": 1, - "id": "30b53ec3-5d17-4b38-9d80-4f9c15e3cd7e", - "agent_id": "audit-agent", - "session_id": "session-mlqwzwl4", - "type": "batch_window", - "content": "# Audit Agent Report (2026-02-17)\n\n## Metrics\n- Oversized files: 18 (4010 excess lines)\n- InnerHTML assignments: 5\n- Lint failures: 13\n\n## Findings\n- Top oversized: scripts/agent/run-scheduler-cycle.mjs (1092 lines)\n- InnerHTML usage in dashboard/index.html and landing/index.html.\n- Lint warnings mostly unused vars.", - "summary": "# Audit Agent Report (2026-02-17)\n\n## Metrics\n- Oversized files: 18 (4010 excess lines)\n- InnerHTML assignments: 5\n- Lint failures: 13\n\n## Findings\n- Top oversized: scripts/agent/run-scheduler-cycle.mjs (1092 lines)\n- InnerHTML usage in dashboard/index.html and landing/index.htm…", - "tags": [ - "scheduler", - "daily", - "store", - "insight" - ], - "importance": 0.8, - "embedding_id": null, - "created_at": 1771351582653, - "last_seen": 1771351582653, - "source": "agent-output", - "ttl_days": null, - "merged_into": null, - "pinned": false - } - ] -] \ No newline at end of file diff --git a/src/cmd-check.mjs b/src/cmd-check.mjs index ce98ea4..72964d6 100644 --- a/src/cmd-check.mjs +++ b/src/cmd-check.mjs @@ -7,11 +7,20 @@ import { getRoster } from './roster.mjs'; import { queryLocks } from './lock-ops.mjs'; import { todayDateStr } from './utils.mjs'; -export async function cmdCheck(cadence) { - const relays = getRelays(); - const namespace = getNamespace(); - const dateStr = todayDateStr(); - const config = loadTorchConfig(); +export async function cmdCheck(cadence, deps = {}) { + const { + getRelaysFn = getRelays, + getNamespaceFn = getNamespace, + loadTorchConfigFn = loadTorchConfig, + getRosterFn = getRoster, + queryLocksFn = queryLocks, + todayDateStrFn = todayDateStr, + } = deps; + + const relays = getRelaysFn(); + const namespace = getNamespaceFn(); + const dateStr = todayDateStrFn(); + const config = loadTorchConfigFn(); const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); @@ -20,9 +29,9 @@ export async function cmdCheck(cadence) { console.error(`Paused agents: ${pausedAgents.join(', ')}`); } - const locks = await queryLocks(relays, cadence, dateStr, namespace); + const locks = await queryLocksFn(relays, cadence, dateStr, namespace); const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; - const roster = getRoster(cadence); + const roster = getRosterFn(cadence); const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); diff --git a/test/cmd-check.test.mjs b/test/cmd-check.test.mjs new file mode 100644 index 0000000..287dd7b --- /dev/null +++ b/test/cmd-check.test.mjs @@ -0,0 +1,187 @@ +import { describe, it, mock, afterEach, after } from 'node:test'; +import assert from 'node:assert'; +import { cmdCheck } from '../src/cmd-check.mjs'; + +// Mocks +const mockLoadTorchConfig = mock.fn(() => ({ + scheduler: { + paused: { + daily: [], + weekly: [], + }, + }, +})); +const mockGetRelays = mock.fn(() => ['wss://mock-relay']); +const mockGetNamespace = mock.fn(() => 'mock-namespace'); +const mockGetRoster = mock.fn(() => []); +const mockQueryLocks = mock.fn(async () => []); +const mockTodayDateStr = mock.fn(() => '2023-10-27'); + +const deps = { + loadTorchConfigFn: mockLoadTorchConfig, + getRelaysFn: mockGetRelays, + getNamespaceFn: mockGetNamespace, + getRosterFn: mockGetRoster, + queryLocksFn: mockQueryLocks, + todayDateStrFn: mockTodayDateStr, +}; + +describe('cmdCheck', () => { + // Capture console output + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + let consoleLogOutput = []; + let consoleErrorOutput = []; + + const mockConsoleLog = mock.fn((msg) => { + consoleLogOutput.push(msg); + }); + const mockConsoleError = mock.fn((msg) => { + consoleErrorOutput.push(msg); + }); + + // Setup console mocks + console.log = mockConsoleLog; + console.error = mockConsoleError; + + afterEach(() => { + // Reset mocks + mockLoadTorchConfig.mock.resetCalls(); + mockGetRelays.mock.resetCalls(); + mockGetNamespace.mock.resetCalls(); + mockGetRoster.mock.resetCalls(); + mockQueryLocks.mock.resetCalls(); + mockTodayDateStr.mock.resetCalls(); + + // Clear captured output + consoleLogOutput = []; + consoleErrorOutput = []; + }); + + // Restore console after all tests + after(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('correctly reports available agents when no locks exist', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.strictEqual(result.cadence, 'daily'); + assert.strictEqual(result.namespace, 'mock-namespace'); + assert.strictEqual(result.date, '2023-10-27'); + assert.deepStrictEqual(result.available, ['agent1', 'agent2']); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, []); + assert.deepStrictEqual(result.excluded, []); + assert.strictEqual(result.lockCount, 0); + + // Verify console output was JSON + assert.strictEqual(consoleLogOutput.length, 1); + const logOutput = JSON.parse(consoleLogOutput[0]); + assert.deepStrictEqual(logOutput.available, ['agent1', 'agent2']); + }); + + it('correctly identifies locked agents', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2', 'agent3']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1', createdAtIso: '2023-10-27T00:00:00Z', expiresAtIso: '2023-10-27T01:00:00Z', platform: 'linux' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent2', 'agent3']); + assert.deepStrictEqual(result.locked, ['agent1']); + assert.deepStrictEqual(result.excluded, ['agent1']); + assert.strictEqual(result.lockCount, 1); + assert.strictEqual(result.locks[0].agent, 'agent1'); + }); + + it('correctly identifies paused agents', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2', 'agent3']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: ['agent2'] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent1', 'agent3']); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, ['agent2']); + assert.deepStrictEqual(result.excluded, ['agent2']); + }); + + it('handles agents that are both locked and paused', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1', 'agent2']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: ['agent1'] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, ['agent2']); + assert.deepStrictEqual(result.locked, ['agent1']); + assert.deepStrictEqual(result.paused, ['agent1']); + // excluded should contain unique agents + assert.deepStrictEqual(result.excluded, ['agent1']); + }); + + it('identifies unknown locked agents (not in roster)', async () => { + mockGetRoster.mock.mockImplementation(() => ['agent1']); + mockQueryLocks.mock.mockImplementation(async () => [ + { agent: 'agent1', eventId: 'evt1' }, + { agent: 'unknown-agent', eventId: 'evt2' } + ]); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, []); + assert.deepStrictEqual(result.locked, ['agent1', 'unknown-agent']); + assert.deepStrictEqual(result.unknownLockedAgents, ['unknown-agent']); + assert.deepStrictEqual(result.excluded, ['agent1', 'unknown-agent']); + }); + + it('handles weekly cadence', async () => { + mockGetRoster.mock.mockImplementation(() => ['weekly1', 'weekly2']); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { weekly: ['weekly2'] } } + })); + + const result = await cmdCheck('weekly', deps); + + assert.strictEqual(result.cadence, 'weekly'); + assert.deepStrictEqual(result.available, ['weekly1']); + assert.deepStrictEqual(result.paused, ['weekly2']); + }); + + it('handles empty roster', async () => { + mockGetRoster.mock.mockImplementation(() => []); + mockQueryLocks.mock.mockImplementation(async () => []); + mockLoadTorchConfig.mock.mockImplementation(() => ({ + scheduler: { paused: { daily: [] } } + })); + + const result = await cmdCheck('daily', deps); + + assert.deepStrictEqual(result.available, []); + assert.deepStrictEqual(result.locked, []); + assert.deepStrictEqual(result.paused, []); + }); +}); diff --git a/test/lock-utils.test.mjs b/test/lock-utils.test.mjs new file mode 100644 index 0000000..1bdac59 --- /dev/null +++ b/test/lock-utils.test.mjs @@ -0,0 +1,84 @@ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCompletedAgents } from '../src/lock-utils.mjs'; + +describe('lock-utils', () => { + describe('getCompletedAgents', () => { + // Helper to create deps + const createDeps = (overrides = {}) => ({ + readdir: async () => [], + getDateStr: () => '2023-01-01', + getIsoWeek: () => '2023-W01', + ...overrides, + }); + + it('returns completed agents for daily cadence', async () => { + const deps = createDeps({ + readdir: async () => [ + '2023-01-01T10-00-00Z__agent1__completed.md', + '2023-01-01T11-00-00Z__agent2__failed.md', + '2023-01-02T10-00-00Z__agent3__completed.md', // different day + ], + getDateStr: () => '2023-01-01', + }); + const result = await getCompletedAgents('daily', 'logs', deps); + assert.deepEqual([...result], ['agent1']); + }); + + it('returns completed agents for weekly cadence', async () => { + const deps = createDeps({ + readdir: async () => [ + '2023-01-01T10-00-00Z__agent1__completed.md', // Week 52 of 2022 (Sunday) - assuming mock logic + '2023-01-02T10-00-00Z__agent2__completed.md', // Week 01 of 2023 (Monday) + ], + getIsoWeek: (date) => { + if (!date) return '2023-W01'; // current week + if (date === '2023-01-01') return '2022-W52'; + if (date === '2023-01-02') return '2023-W01'; + return 'UNKNOWN'; + } + }); + // cadence is weekly + const result = await getCompletedAgents('weekly', 'logs', deps); + assert.deepEqual([...result], ['agent2']); + }); + + it('handles ENOENT gracefully', async () => { + const error = new Error('No such file or directory'); + error.code = 'ENOENT'; + const deps = createDeps({ + readdir: async () => { throw error; } + }); + + // Spy on console.error to ensure it is NOT called + const consoleError = mock.method(console, 'error'); + + const result = await getCompletedAgents('daily', 'logs', deps); + + assert.equal(consoleError.mock.callCount(), 0); + assert.deepEqual([...result], []); + consoleError.mock.restore(); + }); + + it('logs error for non-ENOENT errors', async () => { + const error = new Error('Permission denied'); + error.code = 'EACCES'; + const deps = createDeps({ + readdir: async () => { throw error; } + }); + + // Spy on console.error to verify it IS called + const consoleError = mock.method(console, 'error'); + + const result = await getCompletedAgents('daily', 'logs', deps); + + assert.equal(consoleError.mock.callCount(), 1); + const call = consoleError.mock.calls[0]; + assert.match(call.arguments[0], /Warning: Failed to read log dir/); + assert.match(call.arguments[0], /Permission denied/); + + assert.deepEqual([...result], []); + consoleError.mock.restore(); + }); + }); +}); diff --git a/test/memory-run-prune-cycle.test.mjs b/test/memory-run-prune-cycle.test.mjs new file mode 100644 index 0000000..a9092a3 --- /dev/null +++ b/test/memory-run-prune-cycle.test.mjs @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +const originalCwd = process.cwd(); +let tmpDir; + +function createMemory(overrides = {}) { + return { + schema_version: 1, + id: overrides.id ?? crypto.randomUUID(), + agent_id: overrides.agent_id ?? 'agent-1', + session_id: overrides.session_id ?? 's1', + type: overrides.type ?? 'event', + content: overrides.content ?? 'test content', + summary: overrides.summary ?? 'test summary', + tags: overrides.tags ?? [], + importance: overrides.importance ?? 0.5, + embedding_id: overrides.embedding_id ?? null, + created_at: overrides.created_at ?? Date.now(), + last_seen: overrides.last_seen ?? Date.now(), + source: overrides.source ?? 'ingest', + ttl_days: overrides.ttl_days ?? null, + merged_into: overrides.merged_into ?? null, + pinned: overrides.pinned ?? false, + }; +} + +// Global state variables for the module under test +let runPruneCycle; +let listMemories; + +// Setup temp environment and load module +test.before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-prune-test-')); + + // Create .scheduler-memory directory + const memDir = path.join(tmpDir, '.scheduler-memory'); + await fs.mkdir(memDir, { recursive: true }); + + // Create initial state + const now = 200_000; + // retentionMs will be 100,000 + // cutoff = 100,000 + + const memories = [ + createMemory({ id: 'recent', last_seen: 150_000 }), // > 100,000 -> Keep + createMemory({ id: 'pinned', last_seen: 50_000, pinned: true }), // < 100,000 but pinned -> Keep + createMemory({ id: 'stale', last_seen: 50_000 }), // < 100,000 -> Delete + createMemory({ id: 'stale-dry', last_seen: 50_000 }), // < 100,000 -> Delete candidate + createMemory({ id: 'stale-off', last_seen: 50_000 }), // < 100,000 -> Delete candidate + ]; + + // memory-store.json expects an array of entries [[id, memory], ...] + const entries = memories.map(m => [m.id, m]); + await fs.writeFile(path.join(memDir, 'memory-store.json'), JSON.stringify(entries), 'utf8'); + + // Change CWD + process.chdir(tmpDir); + + // Import module (it will load the file we just wrote) + const mod = await import('../src/services/memory/index.js'); + runPruneCycle = mod.runPruneCycle; + listMemories = mod.listMemories; +}); + +test.after(async () => { + if (originalCwd) process.chdir(originalCwd); + if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +test('runPruneCycle respects off mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'off', + }); + + assert.equal(result.mode, 'off'); + + // Verify nothing deleted + const memories = await listMemories(); + assert.equal(memories.length, 5); +}); + +test('runPruneCycle respects dry-run mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'dry-run', + }); + + assert.equal(result.mode, 'dry-run'); + // Expect 3 candidates: stale, stale-dry, stale-off + assert.equal(result.candidates.length, 3); + + // Verify nothing deleted + const memories = await listMemories(); + assert.equal(memories.length, 5); +}); + +test('runPruneCycle removes stale unpinned memories in active mode', async () => { + const now = 200_000; + const retentionMs = 100_000; + + const telemetry = []; + const metrics = []; + + const result = await runPruneCycle({ + now, + retentionMs, + pruneMode: 'active', + emitTelemetry: (event, payload) => telemetry.push({ event, payload }), + emitMetric: (metric, payload) => metrics.push({ metric, payload }), + }); + + assert.equal(result.pruned.length, 3); // stale, stale-dry, stale-off + + // Verify deletion + const memories = await listMemories(); + assert.equal(memories.length, 2); + const ids = memories.map(m => m.id); + assert.ok(ids.includes('recent')); + assert.ok(ids.includes('pinned')); + assert.ok(!ids.includes('stale')); + + // Verify telemetry + const prunedEvent = telemetry.find(t => t.event === 'memory:pruned'); + assert.ok(prunedEvent); + assert.equal(prunedEvent.payload.pruned_count, 3); + + // Verify metrics + const prunedMetric = metrics.find(m => m.metric === 'memory_pruned_total'); + assert.ok(prunedMetric); + assert.equal(prunedMetric.payload.count, 3); +}); + +test('runPruneCycle starts scheduler if requested', async () => { + const result = await runPruneCycle({ + scheduleEveryMs: 1000, + pruneMode: 'active', + }); + + assert.ok(result.scheduler); + assert.equal(typeof result.scheduler.stop, 'function'); + result.scheduler.stop(); +}); diff --git a/test/memory-stats.test.mjs b/test/memory-stats.test.mjs new file mode 100644 index 0000000..b74e559 --- /dev/null +++ b/test/memory-stats.test.mjs @@ -0,0 +1,138 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + memoryStats, + ingestEvents, + runPruneCycle, +} from '../src/services/memory/index.js'; + +test('memoryStats with empty repository returns zero counts and rates', async () => { + const repository = { + async listMemories() { + return []; + }, + }; + const stats = await memoryStats({ repository }); + + assert.deepEqual(stats.countsByType, {}); + assert.equal(stats.totals.total, 0); + assert.equal(stats.totals.pinned, 0); + assert.equal(stats.totals.archived, 0); + assert.equal(stats.rates.archivedRate, 0); + assert.ok(Number.isFinite(stats.rates.deletedRate)); + assert.equal(stats.indexSizeEstimateBytes, 0); + assert.ok(Number.isFinite(stats.ingestThroughputPerMinute)); +}); + +test('memoryStats with populated repository returns correct counts and totals', async () => { + const memories = [ + { type: 'event', pinned: false, merged_into: null }, + { type: 'event', pinned: true, merged_into: null }, + { type: 'summary', pinned: false, merged_into: 'some-id' }, + ]; + const repository = { + async listMemories() { + return memories; + }, + }; + + const stats = await memoryStats({ repository }); + + assert.equal(stats.countsByType.event, 2); + assert.equal(stats.countsByType.summary, 1); + assert.equal(stats.totals.total, 3); + assert.equal(stats.totals.pinned, 1); + assert.equal(stats.totals.archived, 1); + + // archivedRate = archived / max(1, total) = 1 / 3 + assert.equal(stats.rates.archivedRate, 1 / 3); + + // indexSizeEstimateBytes = 3 * 512 = 1536 + assert.equal(stats.indexSizeEstimateBytes, 1536); +}); + +test('memoryStats calculates ingest throughput based on window', async (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const baseTime = 1000000; + t.mock.timers.setTime(baseTime); + + const repository = { + async insertMemory() { return {}; }, + async hasIngestionFingerprint() { return false; }, + async storeIngestionFingerprint() { }, + async linkEmbedding() { }, + async listMemories() { return []; }, + }; + + // Create 3 distinct records (different agents to avoid batching into one record) + await ingestEvents([ + { content: 'e1', agent_id: 'agent-1', timestamp: baseTime }, + { content: 'e2', agent_id: 'agent-2', timestamp: baseTime }, + { content: 'e3', agent_id: 'agent-3', timestamp: baseTime }, + ], { repository }); + + // Move time forward 30 mins + const checkTime = baseTime + 30 * 60 * 1000; + t.mock.timers.setTime(checkTime); + + // Check stats with 60 min window (should include baseTime) + // Window: [checkTime - 60min, checkTime] = [baseTime - 30min, baseTime + 30min] + const stats1 = await memoryStats({ + repository, + now: checkTime, + windowMs: 60 * 60 * 1000 + }); + + // We expect 3 ingested items in this window. + // throughput = 3 / (60 mins) = 0.05 + assert.equal(stats1.ingestThroughputPerMinute, 3 / 60); + + // Check stats with window that excludes the events (checkTime + 90 mins) + const farTime = baseTime + 90 * 60 * 1000; + // Window: [farTime - 60min, farTime] = [baseTime + 30min, baseTime + 90min] + // Events at baseTime are < baseTime + 30min, so excluded. + + const stats2 = await memoryStats({ + repository, + now: farTime, + windowMs: 60 * 60 * 1000 + }); + + assert.equal(stats2.ingestThroughputPerMinute, 0); +}); + +test('memoryStats reflects deleted count from prune cycle', async () => { + const repository = { + async listPruneCandidates() { + // Return 2 candidates to be pruned + return [ + { id: 'p1', agent_id: 'a', type: 'event', last_seen: 0, pinned: false }, + { id: 'p2', agent_id: 'a', type: 'event', last_seen: 0, pinned: false }, + ]; + }, + async listMemories() { return []; } + }; + + // Run prune cycle + await runPruneCycle({ repository, retentionMs: 100, now: 1000 }); + + const stats = await memoryStats({ repository }); + + // deletedObserved should be increased by 2. + assert.ok(stats.totals.deletedObserved >= 2); + + // deletedRate should be > 0. + assert.ok(stats.rates.deletedRate > 0); +}); + +test('memoryStats handles windowMs parameter correctly', async () => { + const repository = { async listMemories() { return []; } }; + + // Test with invalid windowMs, should default to 1 hour (60 mins) + const statsDefault = await memoryStats({ repository, windowMs: 'invalid' }); + assert.ok(Number.isFinite(statsDefault.ingestThroughputPerMinute)); + + // Test with very small window + const statsSmall = await memoryStats({ repository, windowMs: 1 }); + assert.ok(Number.isFinite(statsSmall.ingestThroughputPerMinute)); +});