mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Merge branch 'main' into test-improvement-get-relevant-memories-15158310533650216935
This commit is contained in:
20 files changed
+1159
-539
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
+5
-5
@@ -8,10 +8,10 @@ import { queryLocks } from './lock-ops.mjs';
|
||||
import { todayDateStr } from './utils.mjs';
|
||||
|
||||
export async function cmdCheck(cadence) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const config = loadTorchConfig();
|
||||
const config = await loadTorchConfig();
|
||||
const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || [];
|
||||
|
||||
console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`);
|
||||
@@ -20,9 +20,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 = await getRoster(cadence);
|
||||
|
||||
const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])];
|
||||
const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent));
|
||||
|
||||
+3
-3
@@ -7,8 +7,8 @@ import { queryLocks } from './lock-ops.mjs';
|
||||
import { todayDateStr, nowUnix } from './utils.mjs';
|
||||
|
||||
export async function cmdList(cadence) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const cadences = cadence ? [cadence] : ['daily', 'weekly'];
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function cmdList(cadence) {
|
||||
);
|
||||
}
|
||||
|
||||
const roster = getRoster(c);
|
||||
const roster = await getRoster(c);
|
||||
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
|
||||
const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent));
|
||||
const available = roster.filter((a) => !lockedAgents.has(a));
|
||||
|
||||
+4
-4
@@ -14,10 +14,10 @@ import { todayDateStr, nowUnix } from './utils.mjs';
|
||||
import { ExitError } from './errors.mjs';
|
||||
|
||||
export async function cmdLock(agent, cadence, dryRun = false) {
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const dateStr = todayDateStr();
|
||||
const ttl = getTtl();
|
||||
const ttl = await getTtl();
|
||||
const now = nowUnix();
|
||||
const expiresAt = now + ttl;
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function cmdLock(agent, cadence, dryRun = false) {
|
||||
console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
|
||||
console.error(`Relays: ${relays.join(', ')}`);
|
||||
|
||||
const roster = getRoster(cadence);
|
||||
const roster = await getRoster(cadence);
|
||||
if (!roster.includes(agent)) {
|
||||
console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
|
||||
console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
|
||||
|
||||
+23
-14
@@ -22,9 +22,10 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
|
||||
// Resolve package root relative to this file (src/dashboard.mjs)
|
||||
// this file is in <root>/src/dashboard.mjs, so '..' goes to <root>
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const auth = getDashboardAuth();
|
||||
const auth = await getDashboardAuth();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
// Basic Auth check
|
||||
if (auth) {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -181,20 +182,28 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
|
||||
console.log(`Dashboard running at ${listenUrl}`);
|
||||
if (auth) {
|
||||
console.log('Authentication: enabled (Basic Auth)');
|
||||
} else {
|
||||
console.warn('Authentication: DISABLED (Dashboard is public)');
|
||||
} catch (err) {
|
||||
console.error('Dashboard Server Error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
console.log(`Serving files from ${packageRoot}`);
|
||||
console.log(`Using configuration from ${process.cwd()}`);
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
return server;
|
||||
return new Promise((resolve, reject) => {
|
||||
server.listen(port, host, () => {
|
||||
const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`;
|
||||
console.log(`Dashboard running at ${listenUrl}`);
|
||||
if (auth) {
|
||||
console.log('Authentication: enabled (Basic Auth)');
|
||||
} else {
|
||||
console.warn('Authentication: DISABLED (Dashboard is public)');
|
||||
}
|
||||
console.log(`Serving files from ${packageRoot}`);
|
||||
console.log(`Using configuration from ${process.cwd()}`);
|
||||
resolve(server);
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
+15
-15
@@ -75,10 +75,10 @@ export async function cmdCheck(cadence, deps = {}) {
|
||||
quiet = false,
|
||||
} = deps;
|
||||
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const dateStr = getDateStr();
|
||||
const config = loadTorchConfig();
|
||||
const config = await loadTorchConfig();
|
||||
const pausedAgents = config.scheduler.paused[cadence] || [];
|
||||
|
||||
if (!quiet) {
|
||||
@@ -99,7 +99,7 @@ export async function cmdCheck(cadence, deps = {}) {
|
||||
|
||||
const locks = await queryLocks(relays, cadence, dateStr, namespace);
|
||||
const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))];
|
||||
const roster = getRoster(cadence);
|
||||
const roster = await getRoster(cadence);
|
||||
const rosterSet = new Set(roster);
|
||||
|
||||
const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]);
|
||||
@@ -180,11 +180,11 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const hashtag = getHashtag();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const hashtag = await getHashtag();
|
||||
const dateStr = getDateStr();
|
||||
const ttl = getTtl();
|
||||
const ttl = await getTtl();
|
||||
const now = nowUnix();
|
||||
const expiresAt = now + ttl;
|
||||
|
||||
@@ -193,7 +193,7 @@ export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}
|
||||
error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`);
|
||||
error(`Relays: ${relays.join(', ')}`);
|
||||
|
||||
const roster = getRoster(cadence);
|
||||
const roster = await getRoster(cadence);
|
||||
if (!roster.includes(agent)) {
|
||||
error(`ERROR: agent "${agent}" is not in the ${cadence} roster`);
|
||||
error(`Allowed ${cadence} agents: ${roster.join(', ')}`);
|
||||
@@ -319,8 +319,8 @@ export async function cmdList(cadence, deps = {}) {
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const dateStr = getDateStr();
|
||||
const cadences = cadence ? [cadence] : [...VALID_CADENCES];
|
||||
|
||||
@@ -365,7 +365,7 @@ export async function cmdList(cadence, deps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
const roster = getRoster(c);
|
||||
const roster = await getRoster(c);
|
||||
const rosterSet = new Set(roster);
|
||||
const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean));
|
||||
const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent));
|
||||
@@ -413,9 +413,9 @@ export async function cmdComplete(agent, cadence, optionsOrDryRun = false, deps
|
||||
error = console.error
|
||||
} = deps;
|
||||
|
||||
const relays = getRelays();
|
||||
const namespace = getNamespace();
|
||||
const hashtag = getHashtag();
|
||||
const relays = await getRelays();
|
||||
const namespace = await getNamespace();
|
||||
const hashtag = await getHashtag();
|
||||
const dateStr = getDateStr();
|
||||
const now = nowUnix();
|
||||
|
||||
|
||||
+7
-7
@@ -306,11 +306,11 @@ export async function queryLocks(relays, cadence, dateStr, namespace, deps = {})
|
||||
|
||||
const pool = poolFactory();
|
||||
const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`;
|
||||
const queryTimeoutMs = getQueryTimeoutMsFn();
|
||||
const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay));
|
||||
const queryTimeoutMs = await getQueryTimeoutMsFn();
|
||||
const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay));
|
||||
const healthConfig = buildRelayHealthConfig({
|
||||
...deps,
|
||||
minActiveRelayPool: getMinActiveRelayPoolFn(),
|
||||
minActiveRelayPool: await getMinActiveRelayPoolFn(),
|
||||
});
|
||||
|
||||
const runQuery = async (relaySet, phase) => {
|
||||
@@ -419,13 +419,13 @@ export async function publishLock(relays, event, deps = {}) {
|
||||
const attemptId = diagnostics.attemptId || process.env.SCHEDULER_LOCK_ATTEMPT_ID || '1';
|
||||
|
||||
const pool = poolFactory();
|
||||
const publishTimeoutMs = getPublishTimeoutMsFn();
|
||||
const minSuccesses = getMinSuccessfulRelayPublishesFn();
|
||||
const fallbackRelays = getRelayFallbacksFn().filter((relay) => !relays.includes(relay));
|
||||
const publishTimeoutMs = await getPublishTimeoutMsFn();
|
||||
const minSuccesses = await getMinSuccessfulRelayPublishesFn();
|
||||
const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay));
|
||||
const maxAttempts = Math.max(1, Math.floor(retryAttempts));
|
||||
const healthConfig = buildRelayHealthConfig({
|
||||
...deps,
|
||||
minActiveRelayPool: getMinActiveRelayPoolFn(),
|
||||
minActiveRelayPool: await getMinActiveRelayPoolFn(),
|
||||
});
|
||||
|
||||
const publishOnce = async () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { loadTorchConfig, _resetTorchConfigCache } from '../torch-config.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const configPath = path.resolve(process.cwd(), 'torch-config.json');
|
||||
const originalConfig = fs.existsSync(configPath) ? fs.readFileSync(configPath) : null;
|
||||
|
||||
// Ensure a config file exists for benchmark
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
nostrLock: {
|
||||
relays: ["wss://relay.damus.io"],
|
||||
namespace: "test-ns"
|
||||
}
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
const ITERATIONS = 1000;
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
async function run() {
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
_resetTorchConfigCache();
|
||||
try {
|
||||
const res = await loadTorchConfig();
|
||||
// Use res to prevent dead code elimination if JIT is smart (though side effects exist)
|
||||
if (!res) throw new Error("Failed");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const end = process.hrtime.bigint();
|
||||
const duration = Number(end - start) / 1e6; // ms
|
||||
|
||||
console.log(`[ASYNC] Loaded config ${ITERATIONS} times in ${duration.toFixed(2)}ms`);
|
||||
console.log(`[ASYNC] Average: ${(duration / ITERATIONS).toFixed(4)}ms per load`);
|
||||
|
||||
// Cleanup
|
||||
if (!originalConfig && fs.existsSync(configPath)) {
|
||||
fs.unlinkSync(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
+10
-3
@@ -196,15 +196,22 @@ async function appendHistory(historyPath, entry) {
|
||||
export async function runRelayHealthCheck(options = {}) {
|
||||
const {
|
||||
cadence = 'daily',
|
||||
relays = getRelays(),
|
||||
namespace = getNamespace(),
|
||||
timeoutMs = 6000,
|
||||
historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`),
|
||||
allRelaysDownMinutes = 10,
|
||||
minSuccessRate = 0.7,
|
||||
windowMinutes = 60,
|
||||
} = options;
|
||||
|
||||
let {
|
||||
relays,
|
||||
namespace,
|
||||
historyPath,
|
||||
} = options;
|
||||
|
||||
if (!relays) relays = await getRelays();
|
||||
if (!namespace) namespace = await getNamespace();
|
||||
if (!historyPath) historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`);
|
||||
|
||||
const relayResults = [];
|
||||
for (const relay of relays) {
|
||||
const relayResult = {
|
||||
|
||||
+2
-2
@@ -94,8 +94,8 @@ function parseEnvRoster(value) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function getRoster(cadence) {
|
||||
const config = loadTorchConfig();
|
||||
export async function getRoster(cadence) {
|
||||
const config = await loadTorchConfig();
|
||||
const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER);
|
||||
const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER);
|
||||
const canonical = loadCanonicalRoster();
|
||||
|
||||
+28
-26
@@ -127,18 +127,20 @@ export function _resetTorchConfigCache() {
|
||||
cachedConfig = null;
|
||||
}
|
||||
|
||||
export function loadTorchConfig(fileSystem = fs) {
|
||||
export async function loadTorchConfig(fileSystem = fs) {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
|
||||
const configPath = getTorchConfigPath();
|
||||
let raw = {};
|
||||
|
||||
if (fileSystem.existsSync(configPath)) {
|
||||
try {
|
||||
raw = JSON.parse(fileSystem.readFileSync(configPath, 'utf8'));
|
||||
} catch (err) {
|
||||
try {
|
||||
const content = await fileSystem.promises.readFile(configPath, 'utf8');
|
||||
raw = JSON.parse(content);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err });
|
||||
}
|
||||
// If file missing (ENOENT), we use empty object defaults
|
||||
}
|
||||
|
||||
cachedConfig = parseTorchConfig(raw, configPath);
|
||||
@@ -199,8 +201,8 @@ function resolveIntegerConfig(envKey, configValue, defaultValue, validator, conf
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getRelays() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getRelays() {
|
||||
const config = await loadTorchConfig();
|
||||
const envRelays = process.env.NOSTR_LOCK_RELAYS;
|
||||
if (envRelays) {
|
||||
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS');
|
||||
@@ -214,8 +216,8 @@ export function getRelays() {
|
||||
return DEFAULT_RELAYS;
|
||||
}
|
||||
|
||||
export function getRelayFallbacks() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getRelayFallbacks() {
|
||||
const config = await loadTorchConfig();
|
||||
const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS;
|
||||
if (envRelays) {
|
||||
return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS');
|
||||
@@ -223,14 +225,14 @@ export function getRelayFallbacks() {
|
||||
return config.nostrLock.relayFallbacks || [];
|
||||
}
|
||||
|
||||
export function getNamespace() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getNamespace() {
|
||||
const config = await loadTorchConfig();
|
||||
const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim();
|
||||
return namespace || DEFAULT_NAMESPACE;
|
||||
}
|
||||
|
||||
export function getTtl() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getTtl() {
|
||||
const config = await loadTorchConfig();
|
||||
const envTtl = process.env.NOSTR_LOCK_TTL;
|
||||
if (envTtl) {
|
||||
const parsed = parseInt(envTtl, 10);
|
||||
@@ -242,8 +244,8 @@ export function getTtl() {
|
||||
return DEFAULT_TTL;
|
||||
}
|
||||
|
||||
export function getQueryTimeoutMs() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getQueryTimeoutMs() {
|
||||
const config = await loadTorchConfig();
|
||||
return resolveIntegerConfig(
|
||||
'NOSTR_LOCK_QUERY_TIMEOUT_MS',
|
||||
config.nostrLock.queryTimeoutMs,
|
||||
@@ -253,8 +255,8 @@ export function getQueryTimeoutMs() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublishTimeoutMs() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getPublishTimeoutMs() {
|
||||
const config = await loadTorchConfig();
|
||||
return resolveIntegerConfig(
|
||||
'NOSTR_LOCK_PUBLISH_TIMEOUT_MS',
|
||||
config.nostrLock.publishTimeoutMs,
|
||||
@@ -264,8 +266,8 @@ export function getPublishTimeoutMs() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getMinSuccessfulRelayPublishes() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getMinSuccessfulRelayPublishes() {
|
||||
const config = await loadTorchConfig();
|
||||
return resolveIntegerConfig(
|
||||
'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES',
|
||||
config.nostrLock.minSuccessfulRelayPublishes,
|
||||
@@ -275,8 +277,8 @@ export function getMinSuccessfulRelayPublishes() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getMinActiveRelayPool() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getMinActiveRelayPool() {
|
||||
const config = await loadTorchConfig();
|
||||
return resolveIntegerConfig(
|
||||
'NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL',
|
||||
config.nostrLock.minActiveRelayPool,
|
||||
@@ -286,8 +288,8 @@ export function getMinActiveRelayPool() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getHashtag() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getHashtag() {
|
||||
const config = await loadTorchConfig();
|
||||
const envValue = process.env.NOSTR_LOCK_HASHTAG;
|
||||
if (envValue) {
|
||||
return envValue.trim();
|
||||
@@ -295,12 +297,12 @@ export function getHashtag() {
|
||||
if (config.dashboard.hashtag) {
|
||||
return config.dashboard.hashtag;
|
||||
}
|
||||
const namespace = getNamespace();
|
||||
const namespace = await getNamespace();
|
||||
return `${namespace}-agent-lock`;
|
||||
}
|
||||
|
||||
export function getDashboardAuth() {
|
||||
const config = loadTorchConfig();
|
||||
export async function getDashboardAuth() {
|
||||
const config = await loadTorchConfig();
|
||||
const envValue = process.env.TORCH_DASHBOARD_AUTH;
|
||||
if (envValue) {
|
||||
return envValue.trim();
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -5,15 +5,17 @@ import http from 'node:http';
|
||||
import { cmdDashboard } from '../src/dashboard.mjs';
|
||||
|
||||
test('Dashboard File Access Security', async (t) => {
|
||||
const testPort = 4175;
|
||||
let testPort = 0;
|
||||
let server;
|
||||
|
||||
t.beforeEach(async () => {
|
||||
server = await cmdDashboard(testPort);
|
||||
server = await cmdDashboard(0);
|
||||
testPort = server.address().port;
|
||||
});
|
||||
|
||||
t.afterEach(() => {
|
||||
if (server) server.close();
|
||||
t.afterEach((done) => {
|
||||
if (server) server.close(done);
|
||||
else done();
|
||||
});
|
||||
|
||||
function get(path) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -224,3 +224,218 @@ test('renderPromptWithMemoryContext handles circular agentContext gracefully', a
|
||||
assert.ok(capturedQuery.includes('[object Object]'), 'Query should contain the stringified circular object fallback');
|
||||
assert.match(output, /Base prompt/);
|
||||
});
|
||||
|
||||
// Added tests below
|
||||
|
||||
test('renderPromptWithMemoryContext handles agentContext as array of strings', async () => {
|
||||
let capturedQuery = '';
|
||||
const memoryService = {
|
||||
async getRelevantMemories({ query }) {
|
||||
capturedQuery = query;
|
||||
return [];
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agentContext: ['context1', 'context2'],
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
await renderPromptWithMemoryContext(params);
|
||||
assert.ok(capturedQuery.includes('context1'));
|
||||
assert.ok(capturedQuery.includes('context2'));
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext handles agentContext as plain object', async () => {
|
||||
let capturedQuery = '';
|
||||
const memoryService = {
|
||||
async getRelevantMemories({ query }) {
|
||||
capturedQuery = query;
|
||||
return [];
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agentContext: { key: 'value' },
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
await renderPromptWithMemoryContext(params);
|
||||
assert.ok(capturedQuery.includes('{"key":"value"}'));
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext handles agentContext as primitives', async () => {
|
||||
let capturedQuery = '';
|
||||
const memoryService = {
|
||||
async getRelevantMemories({ query }) {
|
||||
capturedQuery = query;
|
||||
return [];
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agentContext: 12345,
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
await renderPromptWithMemoryContext(params);
|
||||
assert.ok(capturedQuery.includes('12345'));
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext calls updateMemoryUsage for retrieved memories', async () => {
|
||||
const updatedIds = [];
|
||||
const memoryService = {
|
||||
async getRelevantMemories() {
|
||||
return [
|
||||
createMemory({ id: 'm1' }),
|
||||
createMemory({ id: 'm2' })
|
||||
];
|
||||
},
|
||||
async updateMemoryUsage(id) {
|
||||
updatedIds.push(id);
|
||||
}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
await renderPromptWithMemoryContext(params);
|
||||
assert.deepEqual(updatedIds, ['m1', 'm2']);
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext handles empty memories gracefully', async () => {
|
||||
const memoryService = {
|
||||
async getRelevantMemories() {
|
||||
return [];
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
assert.equal(output, 'Base prompt');
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext handles null/undefined memories gracefully', async () => {
|
||||
const memoryService = {
|
||||
async getRelevantMemories() {
|
||||
return null;
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
assert.equal(output, 'Base prompt');
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext respects TORCH_MEMORY_ENABLED=false', async () => {
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService: {},
|
||||
env: {
|
||||
TORCH_MEMORY_ENABLED: 'false',
|
||||
TORCH_MEMORY_RETRIEVAL_ENABLED: 'true'
|
||||
}
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
assert.equal(output, 'Base prompt');
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext respects retrieval allowlist', async () => {
|
||||
const memoryService = {
|
||||
async getRelevantMemories() { return [createMemory({summary: 'Should not see'})]; }
|
||||
};
|
||||
|
||||
// agent-2 is not in the allowlist
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-2',
|
||||
memoryService,
|
||||
env: {
|
||||
TORCH_MEMORY_ENABLED: 'true',
|
||||
TORCH_MEMORY_RETRIEVAL_ENABLED: 'agent-1'
|
||||
}
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
assert.equal(output, 'Base prompt');
|
||||
|
||||
// agent-1 is in the allowlist
|
||||
const paramsAllowed = {
|
||||
...params,
|
||||
agent_id: 'agent-1'
|
||||
};
|
||||
const outputAllowed = await renderPromptWithMemoryContext(paramsAllowed);
|
||||
assert.match(outputAllowed, /\[CONTEXT — Relevant memories\]/);
|
||||
});
|
||||
|
||||
test('renderPromptWithMemoryContext passes k and tokenBudget', async () => {
|
||||
const memoryService = {
|
||||
async getRelevantMemories({ k }) {
|
||||
assert.equal(k, 3, 'Should pass k=3');
|
||||
// Return more memories than k to verify formatter truncation if needed,
|
||||
// but getRelevantMemories is responsible for limiting too.
|
||||
// Here we just check if it gets called with k.
|
||||
return [
|
||||
createMemory({id: 'm1'}),
|
||||
createMemory({id: 'm2'}),
|
||||
createMemory({id: 'm3'}),
|
||||
createMemory({id: 'm4'}),
|
||||
];
|
||||
},
|
||||
async updateMemoryUsage() {}
|
||||
};
|
||||
|
||||
const params = {
|
||||
basePrompt: 'Base prompt',
|
||||
userRequest: 'Hello',
|
||||
agent_id: 'agent-1',
|
||||
memoryService,
|
||||
retrievalK: 3,
|
||||
tokenBudget: 50, // Small budget
|
||||
env: { TORCH_MEMORY_RETRIEVAL_ENABLED: 'true' }
|
||||
};
|
||||
|
||||
const output = await renderPromptWithMemoryContext(params);
|
||||
// With small budget, it might truncate m3 or m2.
|
||||
assert.match(output, /m1/);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ingestEvents, getRelevantMemories } from '../src/services/memory/index.js';
|
||||
|
||||
describe('ingestEvents', () => {
|
||||
let repository;
|
||||
let telemetryEvents = [];
|
||||
let metricsEvents = [];
|
||||
let storedMemories = [];
|
||||
let storedFingerprints = new Map();
|
||||
let linkedEmbeddings = new Map();
|
||||
|
||||
const mockOptions = {
|
||||
embedText: async (text) => Array(128).fill(0.1), // Deterministic embedding
|
||||
emitTelemetry: (event, payload) => telemetryEvents.push({ event, payload }),
|
||||
metrics: {
|
||||
emit: (metric, payload) => metricsEvents.push({ metric, payload }),
|
||||
},
|
||||
// Mock environment to ensure enabled by default
|
||||
env: {
|
||||
TORCH_MEMORY_ENABLED: 'true',
|
||||
TORCH_MEMORY_INGEST_ENABLED: 'true',
|
||||
},
|
||||
now: 1600000000000, // Fixed time
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
telemetryEvents = [];
|
||||
metricsEvents = [];
|
||||
storedMemories = [];
|
||||
storedFingerprints = new Map();
|
||||
linkedEmbeddings = new Map();
|
||||
|
||||
repository = {
|
||||
async insertMemory(memory) {
|
||||
storedMemories.push(memory);
|
||||
return memory;
|
||||
},
|
||||
async hasIngestionFingerprint(key) {
|
||||
return storedFingerprints.has(key);
|
||||
},
|
||||
async storeIngestionFingerprint(key, payload) {
|
||||
storedFingerprints.set(key, payload);
|
||||
},
|
||||
async linkEmbedding(id, embedding) {
|
||||
linkedEmbeddings.set(id, embedding);
|
||||
},
|
||||
// Minimal implementation for getRelevantMemories to work for cache check
|
||||
async listMemories() {
|
||||
return storedMemories;
|
||||
},
|
||||
async updateMemoryUsage() {},
|
||||
};
|
||||
});
|
||||
|
||||
test('happy path: successfully ingests events', async () => {
|
||||
const events = [
|
||||
{
|
||||
agent_id: 'agent-1',
|
||||
content: 'User logged in',
|
||||
timestamp: mockOptions.now - 1000,
|
||||
metadata: { session_id: 'session-1', source: 'test' },
|
||||
},
|
||||
{
|
||||
agent_id: 'agent-1',
|
||||
content: 'User clicked button',
|
||||
timestamp: mockOptions.now - 500,
|
||||
metadata: { session_id: 'session-1', source: 'test' },
|
||||
},
|
||||
];
|
||||
|
||||
const result = await ingestEvents(events, { ...mockOptions, repository });
|
||||
|
||||
assert.equal(result.length, 1); // Should be chunked into one memory
|
||||
assert.equal(storedMemories.length, 1);
|
||||
assert.equal(storedMemories[0].agent_id, 'agent-1');
|
||||
assert.match(storedMemories[0].content, /User logged in/);
|
||||
assert.match(storedMemories[0].content, /User clicked button/);
|
||||
|
||||
// Verify telemetry
|
||||
const ingestedEvent = telemetryEvents.find((e) => e.event === 'memory:ingested');
|
||||
assert.ok(ingestedEvent, 'Should emit memory:ingested telemetry');
|
||||
assert.equal(ingestedEvent.payload.agent_id, 'agent-1');
|
||||
assert.equal(ingestedEvent.payload.chunk_events, 2);
|
||||
|
||||
// Verify metrics
|
||||
const ingestedMetric = metricsEvents.find((e) => e.metric === 'memory_ingested_total');
|
||||
assert.ok(ingestedMetric, 'Should emit memory_ingested_total metric');
|
||||
assert.equal(ingestedMetric.payload.count, 1);
|
||||
|
||||
// Verify embedding link
|
||||
assert.ok(linkedEmbeddings.has(storedMemories[0].id), 'Should link embedding');
|
||||
});
|
||||
|
||||
test('skips ingestion when feature flag is disabled', async () => {
|
||||
const events = [{ agent_id: 'agent-1', content: 'test', timestamp: Date.now() }];
|
||||
const options = {
|
||||
...mockOptions,
|
||||
repository,
|
||||
env: { TORCH_MEMORY_INGEST_ENABLED: 'false' },
|
||||
};
|
||||
|
||||
const result = await ingestEvents(events, options);
|
||||
|
||||
assert.equal(result.length, 0);
|
||||
assert.equal(storedMemories.length, 0);
|
||||
|
||||
const skippedEvent = telemetryEvents.find((e) => e.event === 'memory:ingest_skipped');
|
||||
assert.ok(skippedEvent, 'Should emit memory:ingest_skipped telemetry');
|
||||
assert.equal(skippedEvent.payload.reason, 'flag_disabled');
|
||||
});
|
||||
|
||||
test('clears cache after ingestion', async () => {
|
||||
const agentId = 'agent-cache-test';
|
||||
|
||||
// 1. Populate cache
|
||||
await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions });
|
||||
|
||||
// 2. Verify cache hit (by checking telemetry from a second call)
|
||||
telemetryEvents = []; // Reset telemetry
|
||||
await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions });
|
||||
const hitEvent = telemetryEvents.find(e => e.event === 'memory:retrieved');
|
||||
assert.ok(hitEvent.payload.cache_hit, 'Should hit cache before ingestion');
|
||||
|
||||
// 3. Ingest event
|
||||
await ingestEvents(
|
||||
[{ agent_id: agentId, content: 'new event', timestamp: mockOptions.now }],
|
||||
{ ...mockOptions, repository }
|
||||
);
|
||||
|
||||
// 4. Verify cache miss
|
||||
telemetryEvents = []; // Reset telemetry
|
||||
await getRelevantMemories({ agent_id: agentId, repository, ...mockOptions });
|
||||
const missEvent = telemetryEvents.find(e => e.event === 'memory:retrieved');
|
||||
assert.equal(missEvent.payload.cache_hit, false, 'Should miss cache after ingestion (cache cleared)');
|
||||
});
|
||||
|
||||
test('skips duplicate windows', async () => {
|
||||
const events = [{ agent_id: 'agent-dedupe', content: 'repeat', timestamp: mockOptions.now }];
|
||||
|
||||
// First ingestion
|
||||
await ingestEvents(events, { ...mockOptions, repository });
|
||||
assert.equal(storedMemories.length, 1);
|
||||
|
||||
// Second ingestion (should be duplicate)
|
||||
await ingestEvents(events, { ...mockOptions, repository });
|
||||
assert.equal(storedMemories.length, 1, 'Should not insert duplicate memory');
|
||||
});
|
||||
|
||||
test('propagates repository errors', async () => {
|
||||
const errorRepository = {
|
||||
...repository,
|
||||
async insertMemory() {
|
||||
throw new Error('Database connection failed');
|
||||
},
|
||||
};
|
||||
|
||||
const events = [{ agent_id: 'agent-err', content: 'fail', timestamp: mockOptions.now }];
|
||||
|
||||
await assert.rejects(
|
||||
async () => ingestEvents(events, { ...mockOptions, repository: errorRepository }),
|
||||
/Database connection failed/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -7,83 +7,96 @@ describe('loadTorchConfig (Unit Tests with Mocked FS)', () => {
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('loads valid config correctly', () => {
|
||||
it('loads valid config correctly', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => true),
|
||||
readFileSync: mock.fn(() => JSON.stringify({
|
||||
nostrLock: { namespace: 'unit-test-ns' }
|
||||
}))
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({
|
||||
nostrLock: { namespace: 'unit-test-ns' }
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
const config = loadTorchConfig(mockFs);
|
||||
const config = await loadTorchConfig(mockFs);
|
||||
|
||||
assert.strictEqual(config.nostrLock.namespace, 'unit-test-ns');
|
||||
assert.strictEqual(mockFs.existsSync.mock.calls.length, 1);
|
||||
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1);
|
||||
// loadTorchConfig no longer calls existsSync on the passed fs, only readFile (and catches ENOENT)
|
||||
// Actually, getTorchConfigPath calls existsSync on REAL fs.
|
||||
// So checking mockFs.existsSync is wrong if loadTorchConfig doesn't call it.
|
||||
// In my change, loadTorchConfig calls await fileSystem.promises.readFile.
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('returns default config when file is missing', () => {
|
||||
it('returns default config when file is missing', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => false),
|
||||
readFileSync: mock.fn()
|
||||
promises: {
|
||||
readFile: mock.fn(async () => {
|
||||
const err = new Error('ENOENT');
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const config = loadTorchConfig(mockFs);
|
||||
const config = await loadTorchConfig(mockFs);
|
||||
|
||||
assert.strictEqual(config.nostrLock.namespace, null);
|
||||
assert.strictEqual(mockFs.existsSync.mock.calls.length, 1);
|
||||
assert.strictEqual(mockFs.readFileSync.mock.calls.length, 0);
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('throws error on malformed JSON', () => {
|
||||
it('throws error on malformed JSON', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => true),
|
||||
readFileSync: mock.fn(() => '{ invalid json }')
|
||||
promises: {
|
||||
readFile: mock.fn(async () => '{ invalid json }')
|
||||
}
|
||||
};
|
||||
|
||||
assert.throws(() => {
|
||||
loadTorchConfig(mockFs);
|
||||
await assert.rejects(async () => {
|
||||
await loadTorchConfig(mockFs);
|
||||
}, /Failed to parse/);
|
||||
});
|
||||
|
||||
it('caches the config and ignores subsequent fs calls', () => {
|
||||
it('caches the config and ignores subsequent fs calls', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => true),
|
||||
readFileSync: mock.fn(() => JSON.stringify({ nostrLock: { namespace: 'cached' } }))
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'cached' } }))
|
||||
}
|
||||
};
|
||||
|
||||
const config1 = loadTorchConfig(mockFs);
|
||||
const config1 = await loadTorchConfig(mockFs);
|
||||
assert.strictEqual(config1.nostrLock.namespace, 'cached');
|
||||
|
||||
// Second call with a different "fs" (or same) should not trigger fs operations
|
||||
const mockFs2 = {
|
||||
existsSync: mock.fn(() => true),
|
||||
readFileSync: mock.fn(() => JSON.stringify({ nostrLock: { namespace: 'changed' } }))
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({ nostrLock: { namespace: 'changed' } }))
|
||||
}
|
||||
};
|
||||
|
||||
const config2 = loadTorchConfig(mockFs2);
|
||||
const config2 = await loadTorchConfig(mockFs2);
|
||||
|
||||
assert.strictEqual(config2.nostrLock.namespace, 'cached');
|
||||
assert.strictEqual(config1, config2);
|
||||
assert.strictEqual(mockFs.existsSync.mock.calls.length, 1);
|
||||
assert.strictEqual(mockFs2.existsSync.mock.calls.length, 0);
|
||||
assert.strictEqual(mockFs.promises.readFile.mock.calls.length, 1);
|
||||
assert.strictEqual(mockFs2.promises.readFile.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('returns null for empty string lists (new consistent behavior)', () => {
|
||||
it('returns null for empty string lists (new consistent behavior)', async () => {
|
||||
const mockFs = {
|
||||
existsSync: mock.fn(() => true),
|
||||
readFileSync: mock.fn(() => JSON.stringify({
|
||||
nostrLock: {
|
||||
relays: [],
|
||||
relayFallbacks: []
|
||||
},
|
||||
dashboard: {
|
||||
relays: []
|
||||
}
|
||||
}))
|
||||
promises: {
|
||||
readFile: mock.fn(async () => JSON.stringify({
|
||||
nostrLock: {
|
||||
relays: [],
|
||||
relayFallbacks: []
|
||||
},
|
||||
dashboard: {
|
||||
relays: []
|
||||
}
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
const config = loadTorchConfig(mockFs);
|
||||
const config = await loadTorchConfig(mockFs);
|
||||
|
||||
// New behavior: all return null
|
||||
assert.strictEqual(config.nostrLock.relays, null);
|
||||
|
||||
+14
-14
@@ -78,22 +78,22 @@ describe('torch-config', () => {
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('throws fatal error for invalid relay URL', () => {
|
||||
it('throws fatal error for invalid relay URL', async () => {
|
||||
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { relays: ['https://bad'] } }));
|
||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||
assert.throws(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/);
|
||||
await assert.rejects(() => loadTorchConfig(), /Invalid relay URL in nostrLock\.relays/);
|
||||
});
|
||||
|
||||
it('throws fatal error for invalid timeout range', () => {
|
||||
it('throws fatal error for invalid timeout range', async () => {
|
||||
fs.writeFileSync(tempConfigPath, JSON.stringify({ nostrLock: { queryTimeoutMs: 50 } }));
|
||||
process.env.TORCH_CONFIG_PATH = tempConfigPath;
|
||||
assert.throws(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/);
|
||||
await assert.rejects(() => loadTorchConfig(), /Invalid nostrLock\.queryTimeoutMs/);
|
||||
});
|
||||
|
||||
it('throws error for malformed JSON', () => {
|
||||
it('throws error for malformed JSON', async () => {
|
||||
fs.writeFileSync(badConfigPath, '{ invalid json }');
|
||||
process.env.TORCH_CONFIG_PATH = badConfigPath;
|
||||
assert.throws(() => loadTorchConfig(), /Failed to parse/);
|
||||
await assert.rejects(() => loadTorchConfig(), /Failed to parse/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,28 +125,28 @@ describe('torch-config', () => {
|
||||
_resetTorchConfigCache();
|
||||
});
|
||||
|
||||
it('returns default relays', () => {
|
||||
assert.deepStrictEqual(getRelays(), DEFAULT_RELAYS);
|
||||
it('returns default relays', async () => {
|
||||
assert.deepStrictEqual(await getRelays(), DEFAULT_RELAYS);
|
||||
});
|
||||
|
||||
it('validates env knob values', () => {
|
||||
it('validates env knob values', async () => {
|
||||
process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '99';
|
||||
assert.throws(() => getQueryTimeoutMs(), /NOSTR_LOCK_QUERY_TIMEOUT_MS/);
|
||||
await assert.rejects(() => getQueryTimeoutMs(), /NOSTR_LOCK_QUERY_TIMEOUT_MS/);
|
||||
|
||||
process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS = '5000';
|
||||
process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = 'abc';
|
||||
assert.throws(() => getPublishTimeoutMs(), /NOSTR_LOCK_PUBLISH_TIMEOUT_MS/);
|
||||
await assert.rejects(() => getPublishTimeoutMs(), /NOSTR_LOCK_PUBLISH_TIMEOUT_MS/);
|
||||
|
||||
process.env.NOSTR_LOCK_PUBLISH_TIMEOUT_MS = '8000';
|
||||
process.env.NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES = '0';
|
||||
assert.throws(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/);
|
||||
await assert.rejects(() => getMinSuccessfulRelayPublishes(), /NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES/);
|
||||
|
||||
process.env.NOSTR_LOCK_RELAY_FALLBACKS = 'https://bad';
|
||||
assert.throws(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/);
|
||||
await assert.rejects(() => getRelayFallbacks(), /NOSTR_LOCK_RELAY_FALLBACKS/);
|
||||
|
||||
process.env.NOSTR_LOCK_RELAY_FALLBACKS = '';
|
||||
process.env.NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL = '0';
|
||||
assert.throws(() => getMinActiveRelayPool(), /NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL/);
|
||||
await assert.rejects(() => getMinActiveRelayPool(), /NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user