From 5ec6c374803e545cba4ee2a86ff7ea0abf3f6c98 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:47:24 +0000 Subject: [PATCH 01/19] feat(scheduler): execute todo-triage-agent daily run Executed the daily scheduler cycle for todo-triage-agent. - Acquired lock. - Retrieved memory. - Scanned codebase for TODOs (found 0 actionable items). - Generated artifacts: context, todo, decisions, test logs, todo_issues. - Stored memory. - Verified artifacts and linting. - Published completion. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .../2026-02-17T16-35-41Z/retrieve.json | 18 +++++ .../2026-02-17T16-35-41Z/retrieve.ok | 1 + .../2026-02-17T16-36-25Z/store.json | 20 +++++ .../2026-02-17T16-36-25Z/store.ok | 1 + .scheduler-memory/memory-store.json | 51 +++++++++++++ artifacts/todo_issues.md | 4 + artifacts/todos.txt | 75 ++++++++++++++++++- ...2026-02-17T16-36-25Z__todo-triage-agent.md | 3 + src/context/CONTEXT_2026-02-17T16-36-25Z.md | 16 ++++ .../DECISIONS_2026-02-17T16-36-25Z.md | 13 ++++ .../TEST_LOG_2026-02-17T16-36-25Z.md | 10 +++ src/todo/TODO_2026-02-17T16-36-25Z.md | 13 ++++ ...16-36-25Z__todo-triage-agent__completed.md | 20 +++++ 13 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 .scheduler-memory/2026-02-17T16-35-41Z/retrieve.json create mode 100644 .scheduler-memory/2026-02-17T16-35-41Z/retrieve.ok create mode 100644 .scheduler-memory/2026-02-17T16-36-25Z/store.json create mode 100644 .scheduler-memory/2026-02-17T16-36-25Z/store.ok create mode 100644 artifacts/todo_issues.md create mode 100644 memory-updates/2026-02-17T16-36-25Z__todo-triage-agent.md create mode 100644 src/context/CONTEXT_2026-02-17T16-36-25Z.md create mode 100644 src/decisions/DECISIONS_2026-02-17T16-36-25Z.md create mode 100644 src/test_logs/TEST_LOG_2026-02-17T16-36-25Z.md create mode 100644 src/todo/TODO_2026-02-17T16-36-25Z.md create mode 100644 task-logs/daily/2026-02-17T16-36-25Z__todo-triage-agent__completed.md diff --git a/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.json b/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.json new file mode 100644 index 0000000..db4f142 --- /dev/null +++ b/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "retrieve", + "runId": "2026-02-17T16-35-41Z", + "servicePath": "src/services/memory/index.js#getRelevantMemories", + "inputs": { + "agentId": "todo-triage-agent", + "promptPath": "src/prompts/daily/todo-triage-agent.md", + "promptIntent": "Required startup + artifacts + memory + issue capture", + "query": "scheduler | daily | agent | todo-triage-agent | intent | Required startup + artifacts + memory + issue capture", + "events": 1 + }, + "outputs": { + "ingestedCount": 1, + "retrievedCount": 1 + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.ok b/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.ok new file mode 100644 index 0000000..84b1719 --- /dev/null +++ b/.scheduler-memory/2026-02-17T16-35-41Z/retrieve.ok @@ -0,0 +1 @@ +MEMORY_RETRIEVED diff --git a/.scheduler-memory/2026-02-17T16-36-25Z/store.json b/.scheduler-memory/2026-02-17T16-36-25Z/store.json new file mode 100644 index 0000000..d85c768 --- /dev/null +++ b/.scheduler-memory/2026-02-17T16-36-25Z/store.json @@ -0,0 +1,20 @@ +{ + "cadence": "daily", + "operation": "store", + "runId": "2026-02-17T16-36-25Z", + "servicePath": "src/services/memory/index.js#ingestEvents", + "inputs": { + "agentId": "todo-triage-agent", + "promptPath": "src/prompts/daily/todo-triage-agent.md", + "promptIntent": "Required startup + artifacts + memory + issue capture", + "events": 1, + "sourceFile": "memory-updates/2026-02-17T16-36-25Z__todo-triage-agent.md" + }, + "outputs": { + "storedCount": 1, + "summaries": [ + "# Memory Update - 2026-02-17T16-36-25Z\n\nRun at 2026-02-17T16-36-25Z. Scanned codebase for TODO/FIXME/XXX. Found 0 actionable items. False positives in mktemp usage and documentation." + ] + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/2026-02-17T16-36-25Z/store.ok b/.scheduler-memory/2026-02-17T16-36-25Z/store.ok new file mode 100644 index 0000000..1e40bb6 --- /dev/null +++ b/.scheduler-memory/2026-02-17T16-36-25Z/store.ok @@ -0,0 +1 @@ +MEMORY_STORED diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 16b92db..6041171 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -297,5 +297,56 @@ "merged_into": null, "pinned": false } + ], + [ + "6a7b3ba4-d713-4f18-a98e-d16c0e0837eb", + { + "schema_version": 1, + "id": "6a7b3ba4-d713-4f18-a98e-d16c0e0837eb", + "agent_id": "todo-triage-agent", + "session_id": "2026-02-17T16-35-41Z", + "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": 1771346141249, + "last_seen": 1771346141259, + "source": "scheduler-retrieve", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "ae3f6a26-c753-4a96-b56f-c737a847cee5", + { + "schema_version": 1, + "id": "ae3f6a26-c753-4a96-b56f-c737a847cee5", + "agent_id": "todo-triage-agent", + "session_id": "2026-02-17T16-36-25Z", + "type": "batch_window", + "content": "# Memory Update - 2026-02-17T16-36-25Z\n\nRun at 2026-02-17T16-36-25Z. Scanned codebase for TODO/FIXME/XXX. Found 0 actionable items. False positives in mktemp usage and documentation.", + "summary": "# Memory Update - 2026-02-17T16-36-25Z\n\nRun at 2026-02-17T16-36-25Z. Scanned codebase for TODO/FIXME/XXX. Found 0 actionable items. False positives in mktemp usage and documentation.", + "tags": [ + "scheduler", + "daily", + "store", + "insight" + ], + "importance": 0.8, + "embedding_id": null, + "created_at": 1771346312937, + "last_seen": 1771346312937, + "source": "agent-output", + "ttl_days": null, + "merged_into": null, + "pinned": false + } ] ] \ No newline at end of file diff --git a/artifacts/todo_issues.md b/artifacts/todo_issues.md new file mode 100644 index 0000000..247b9b3 --- /dev/null +++ b/artifacts/todo_issues.md @@ -0,0 +1,4 @@ +# TODO Issues Report - 2026-02-17T16-36-25Z + +No actionable TODOs found in the codebase (src/, docs/, bin/, scripts/) as of 2026-02-17T16-36-25Z. +Matches found were false positives (mktemp XXXXXX templates or documentation). diff --git a/artifacts/todos.txt b/artifacts/todos.txt index 346f0da..94581e1 100644 --- a/artifacts/todos.txt +++ b/artifacts/todos.txt @@ -1,3 +1,74 @@ +src/test_logs/TEST_LOG_2026-02-15T23-20-19Z.md:11:- Result: Created CONTEXT, TODO, DECISIONS, TEST_LOG files. +src/test_logs/TEST_LOG_2026-02-15T18-00-00Z.md:10:- Command: Scan for TODOs +src/test_logs/TEST_LOG_2026-02-15T18-00-00Z.md:11: `git grep -n -E "TODO|FIXME|XXX" -- '*.js' '*.mjs' 'docs/*' | sed -n '1,200p' > artifacts/todos.txt` +src/context/CONTEXT_2026-02-15T18-00-00Z.md:11:Scan the repository for TODO/FIXME/XXX markers and triage them. +src/decisions/DECISIONS_2026-02-15T18-00-00Z.md:11:No actionable TODOs were found in the codebase. The scan returned only documentation references and test data. +src/decisions/DECISIONS_2026-02-15T18-00-00Z.md:14:The goal is to triage *actionable* TODOs. Since none exist, no changes or issues are required. +src/todo/TODO_manual-jules-1771293773.md:1:# TODO for Scheduler Update Agent +src/todo/TODO_2026-02-14T13-30-00Z.md:1:# Decomposition TODO +src/todo/TODO_2026-02-14T06-04-19Z.md:1:# Audit Run TODO +src/todo/2026-02-13-dom-migration-todo.md:1:# TODO — Rendering Migration (Example) +src/todo/TODO_2026-02-17T02-04-31Z.md:1:# TODO for Scheduler Update Agent +src/todo/TODO_2026-02-17T02-04-05Z.md:1:# TODO for Scheduler Update Agent +src/todo/TODO_2026-02-15T01-22-33Z.md:1:# TODO for Perf Agent Run 2026-02-15T01-22-33Z +src/todo/TODO_2026-02-15T18-00-00Z.md:14:- Scan repository for TODOs. +src/todo/TODO_2026-02-16.md:1:# TODO: perf-agent (2026-02-16) +src/todo/TODO_2026-02-14.md:1:# TODO Checklist +src/todo/TODO_2026-02-15T01:03:31Z.md:8:# TODO Update - 2026-02-15T01:03:31Z +src/todo/TODO_2026-02-14T23-00-00Z.md:1:# TODO: dashboard/index.html innerHTML Migration +src/prompts/scheduler-flow.md:11:- `src/todo/TODO_.md` +src/prompts/daily/decompose-agent.md:32:- `src/todo/TODO_.md` — checklist of extraction items (2–3 blocks) and status. +src/prompts/daily/decompose-agent.md:75:Add these items to `src/todo/TODO_.md`. +src/prompts/daily/decompose-agent.md:266:4. Read file, identify 2–3 cohesive blocks, document in `src/context/CONTEXT_.md` and `src/todo/TODO_.md`. +src/prompts/daily/const-refactor-agent.md:40:- `src/todo/TODO_.md` — List of duplicated constants to canonicalize and status for each. +src/prompts/daily/audit-agent.md:29:- `src/todo/TODO_.md` — checklist for the audit run and follow-ups. +src/prompts/daily/audit-agent.md:121: - If automated, add a follow-up task in `src/todo/TODO_.md` or open issues for high-priority items with labels `perf`, `lint`, or `security` as appropriate. +src/prompts/daily/scheduler-update-agent.md:89: - `src/todo/TODO_.md` +src/prompts/daily/perf-agent.md:50:- `src/todo/TODO_.md` — tasks (Done / Blocked). +src/prompts/daily/innerhtml-migration-agent.md:31:- `src/todo/TODO_.md` — checklist of assignments in the file and status. +src/prompts/daily/innerhtml-migration-agent.md:66: Add this list to `src/todo/TODO_.md` as items to migrate. +src/prompts/daily/docs-code-investigator.md:22: - Focus on files > ~200 LOC, or files that contain `TODO|FIXME|XXX`. +src/prompts/daily/docs-code-investigator.md:23:- Priority: files containing `TODO|FIXME|XXX` or >200 lines. shared helpers and core modules are high-value places to document. +src/prompts/daily/docs-code-investigator.md:39:git grep -n -E "TODO|FIXME|XXX" -- . | sed -n '1,200p' +src/prompts/daily/docs-code-investigator.md:42:- Is large (> 200 LOC) or contains `TODO` / `FIXME` / `XXX`. +src/prompts/daily/docs-code-investigator.md:80:- Note unreachable code, TODOs, or suspicious `try/catch` with swallowing errors. +src/prompts/daily/docs-code-investigator.md:206:git grep -n -E "TODO|FIXME|XXX" -- . | sed -n '1,200p' +src/prompts/daily/todo-triage-agent.md:13:Mission: keep the codebase’s TODO/FIXME backlog actionable by scanning for TODO-style comments, classifying them by effort/risk, and either (a) fixing **trivial, safe** items via small PRs or (b) converting non-trivial or sensitive items into well-formed GitHub issues. Every change must be small, safe, traceable, and reviewable. +src/prompts/daily/todo-triage-agent.md:29: - Scanning the repo for TODO/FIXME/XXX markers in source/docs. +src/prompts/daily/todo-triage-agent.md:36: - Refactors or feature work disguised as “TODO fixes”. +src/prompts/daily/todo-triage-agent.md:40: - Deleting TODOs without resolving the underlying need. +src/prompts/daily/todo-triage-agent.md:45:1. Visibility — Produce a current TODO inventory file each run. +src/prompts/daily/todo-triage-agent.md:46:2. Safety — No risky areas are modified; sensitive TODOs become issues. +src/prompts/daily/todo-triage-agent.md:50:5. Minimal disruption — Avoid churn; don’t reformat files just to touch TODOs. +src/prompts/daily/todo-triage-agent.md:58:- No security-sensitive fixes. If the TODO mentions (or touches) cryptography, +src/prompts/daily/todo-triage-agent.md:61:- No mission drift. Do not expand TODO triage into general cleanup/refactors. +src/prompts/daily/todo-triage-agent.md:62:- One logical change per commit; don’t bundle unrelated TODOs in one PR unless +src/prompts/daily/todo-triage-agent.md:68:P0 Security/safety TODOs → issue only, label clearly. +src/prompts/daily/todo-triage-agent.md:69:P1 Trivial correctness/docs TODOs → small PR if safe and < 1 hour. +src/prompts/daily/todo-triage-agent.md:70:P2 Medium/large engineering TODOs → issue with next-step recommendation. +src/prompts/daily/todo-triage-agent.md:71:P3 Cosmetic/low-value TODOs → issue or leave (don’t churn) unless policy +src/prompts/daily/todo-triage-agent.md:89: `git grep -n -E "TODO|FIXME|XXX" -- '*.js' '*.html' 'docs/*' \ +src/prompts/daily/todo-triage-agent.md:95: For each TODO/FIXME/XXX entry: +src/prompts/daily/todo-triage-agent.md:97: - file, line number, exact TODO first line +src/prompts/daily/todo-triage-agent.md:130:2) Implement the smallest change that resolves the TODO. +src/prompts/daily/todo-triage-agent.md:135: - `fix(ai): (TODO)` +src/prompts/daily/todo-triage-agent.md:136: - `chore(ai): (TODO)` for docs-only changes +src/prompts/daily/todo-triage-agent.md:138: - Summary of the TODO and what changed +src/prompts/daily/todo-triage-agent.md:144:- `fix(ai): (TODO)` +src/prompts/daily/todo-triage-agent.md:152:- any sensitive/security-related TODOs +src/prompts/daily/todo-triage-agent.md:155:- `todo: ` +src/prompts/daily/todo-triage-agent.md:160:- What you believe the TODO intends +src/prompts/daily/todo-triage-agent.md:170:DE-DUPING & STALE TODOs +src/prompts/daily/todo-triage-agent.md:172:- If multiple TODOs refer to the same underlying problem, consolidate into one +src/prompts/daily/todo-triage-agent.md:174:- Do not delete TODOs as “stale” unless you can prove the need is resolved. +src/prompts/daily/todo-triage-agent.md:195:- 0–N small PRs for trivial, safe TODOs +src/prompts/daily/todo-triage-agent.md:196:- 0–N GitHub issues for medium/large/sensitive TODOs +src/prompts/daily/content-audit-agent.md:43:- `src/todo/TODO_.md` — checklist of pages and claims to verify, with statuses. +src/prompts/daily/deps-security-agent.md:44:- `src/todo/TODO_.md` — upgrade tasks and statuses. +src/prompts/daily/test-audit-agent.md:42:- `src/todo/TODO_.md` — checklist of specific tests/issues to investigate/fix. +src/prompts/daily/test-audit-agent.md:164: 1. **Test-fix PR** — add missing assertions, replace `setTimeout` sleeps with `waitFor`, or mock timers. Include `src/context/CONTEXT_.md`, `src/todo/TODO_.md`, `src/test_logs/TEST_LOG_.md`. +docs/agent-handoffs/incidents/goose-hermit-etxtbsy-fix.patch:70:+ HERMIT_TMP=$(mktemp ~/.config/goose/mcp-hermit/bin/.hermit-download.XXXXXX) +docs/agent-handoffs/incidents/goose-hermit-etxtbsy-fix.patch:91:+ HERMIT_INIT_COPY=$(mktemp /tmp/.hermit-init-copy.XXXXXX) +docs/agent-handoffs/incidents/2026-02-15-hermit-text-file-busy.md:48:HERMIT_TMP=$(mktemp ~/.config/goose/mcp-hermit/bin/.hermit-download.XXXXXX) +docs/agent-handoffs/incidents/2026-02-15-hermit-text-file-busy.md:57:HERMIT_INIT_COPY=$(mktemp /tmp/.hermit-init-copy.XXXXXX) docs/proposals/prompt-packages.md:26: * `todo-triage-agent`: Manage TODOs. -scripts/agent/verify-run-artifacts.mjs:15: expectedPrefix: 'TODO_', -test/verify-run-artifacts.test.mjs:46: await fs.writeFile(path.join(root, 'src', 'todo', 'TODO_1.md'), `${metadata}\n\nPending tasks:\n- finish checks\n`, 'utf8'); diff --git a/memory-updates/2026-02-17T16-36-25Z__todo-triage-agent.md b/memory-updates/2026-02-17T16-36-25Z__todo-triage-agent.md new file mode 100644 index 0000000..8b485b4 --- /dev/null +++ b/memory-updates/2026-02-17T16-36-25Z__todo-triage-agent.md @@ -0,0 +1,3 @@ +# Memory Update - 2026-02-17T16-36-25Z + +Run at 2026-02-17T16-36-25Z. Scanned codebase for TODO/FIXME/XXX. Found 0 actionable items. False positives in mktemp usage and documentation. diff --git a/src/context/CONTEXT_2026-02-17T16-36-25Z.md b/src/context/CONTEXT_2026-02-17T16-36-25Z.md new file mode 100644 index 0000000..3c276ff --- /dev/null +++ b/src/context/CONTEXT_2026-02-17T16-36-25Z.md @@ -0,0 +1,16 @@ +# Context for Run 2026-02-17T16-36-25Z +- Agent: todo-triage-agent +- Cadence: daily +- Run-start: 2026-02-17T16-36-25Z + +## Goal +Scan the repository for TODO/FIXME/XXX markers and triage them. + +## Scope +src/, docs/, bin/, scripts/ directories. + +## Constraints +Only fix trivial items. Report others. + +## Active Prompt +src/prompts/daily/todo-triage-agent.md diff --git a/src/decisions/DECISIONS_2026-02-17T16-36-25Z.md b/src/decisions/DECISIONS_2026-02-17T16-36-25Z.md new file mode 100644 index 0000000..38c8d07 --- /dev/null +++ b/src/decisions/DECISIONS_2026-02-17T16-36-25Z.md @@ -0,0 +1,13 @@ +# Decisions for Run 2026-02-17T16-36-25Z +- Agent: todo-triage-agent +- Cadence: daily +- Run-start: 2026-02-17T16-36-25Z + +## Decision +No changes required. + +## Rationale +No actionable TODOs were found in the codebase. The scan returned only documentation references and test data (mktemp usage). + +Agent: todo-triage-agent +Prompt: src/prompts/daily/todo-triage-agent.md diff --git a/src/test_logs/TEST_LOG_2026-02-17T16-36-25Z.md b/src/test_logs/TEST_LOG_2026-02-17T16-36-25Z.md new file mode 100644 index 0000000..817ba98 --- /dev/null +++ b/src/test_logs/TEST_LOG_2026-02-17T16-36-25Z.md @@ -0,0 +1,10 @@ +# Test Logs for Run 2026-02-17T16-36-25Z +- Agent: todo-triage-agent +- Cadence: daily +- Run-start: 2026-02-17T16-36-25Z + +- Command: Scan for TODOs + Result: 0 actionable items found. + +Agent: todo-triage-agent +Prompt: src/prompts/daily/todo-triage-agent.md diff --git a/src/todo/TODO_2026-02-17T16-36-25Z.md b/src/todo/TODO_2026-02-17T16-36-25Z.md new file mode 100644 index 0000000..e363f24 --- /dev/null +++ b/src/todo/TODO_2026-02-17T16-36-25Z.md @@ -0,0 +1,13 @@ +# Todo List for Run 2026-02-17T16-36-25Z +- Agent: todo-triage-agent +- Cadence: daily +- Run-start: 2026-02-17T16-36-25Z + +## Completed +- [x] Scan codebase for TODOs (todo-triage-agent) +- [x] Triage findings (0 found) + +## Pending +None. + +Ref: src/prompts/daily/todo-triage-agent.md diff --git a/task-logs/daily/2026-02-17T16-36-25Z__todo-triage-agent__completed.md b/task-logs/daily/2026-02-17T16-36-25Z__todo-triage-agent__completed.md new file mode 100644 index 0000000..569912c --- /dev/null +++ b/task-logs/daily/2026-02-17T16-36-25Z__todo-triage-agent__completed.md @@ -0,0 +1,20 @@ +# Agent Run: todo-triage-agent + +- **Date:** 2026-02-17T16-36-25Z +- **Cadence:** daily +- **Platform:** jules +- **Status:** completed + +## Steps +1. Lock acquired. +2. Memory retrieved. +3. Codebase scanned (0 actionable items found). +4. Artifacts updated (todo_issues.md, CONTEXT, TODO, DECISIONS, TEST_LOG). +5. Memory stored. +6. Verification passed. +7. Completion published. + +## Results +- No actionable TODOs found in src/, docs/, bin/, scripts/. +- Memory updated. +- No changes required. From 5bfe5f7c744b312f501256bf4afad69e1816b754 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:49:30 +0000 Subject: [PATCH 02/19] test: add comprehensive tests for relay-health summarizeHistory Added a new test file `test/relay-health.test.mjs` to cover the `summarizeHistory` function in `src/relay-health.mjs`. Tests cover: - Basic statistics calculation (success rate, sample count). - Time window filtering. - `allDownDurationMinutes` calculation (checking outside window). - Edge cases: empty history, malformed data, never healthy, always healthy. Verified that tests pass with `node --test test/relay-health.test.mjs`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- test/relay-health.test.mjs | 133 +++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 test/relay-health.test.mjs diff --git a/test/relay-health.test.mjs b/test/relay-health.test.mjs new file mode 100644 index 0000000..21d4984 --- /dev/null +++ b/test/relay-health.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { summarizeHistory } from '../src/relay-health.mjs'; + +function createEntry(timestamp, healthyRelays, totalRelays = 3) { + return { + timestamp, + summary: { + healthyRelays, + totalRelays, + }, + }; +} + +test('summarizeHistory - basic statistics calculation', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + // Chronological order (oldest to newest) + createEntry('2024-01-01T11:45:00Z', 0, 3), // 0% healthy + createEntry('2024-01-01T11:50:00Z', 1, 3), // 33% healthy + createEntry('2024-01-01T11:55:00Z', 3, 3), // 100% healthy + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + + assert.equal(result.sampleCount, 3); + // Total probes: 9, Total healthy: 4. Rate: 4/9 + assert.equal(result.successRate, 4 / 9); + + // Last healthy was at 11:55 (5 minutes ago) + assert.equal(result.allDownDurationMinutes, 5); +}); + +test('summarizeHistory - handles empty history', () => { + const result = summarizeHistory([], { nowMs: Date.now() }); + assert.equal(result.sampleCount, 0); + assert.equal(result.successRate, 1); // Default when sampleCount is 0 + assert.equal(result.allDownDurationMinutes, null); +}); + +test('summarizeHistory - respects time window for stats', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + // Chronological order + createEntry('2024-01-01T10:00:00Z', 0, 3), // Outside 60m window (120m ago) + createEntry('2024-01-01T11:55:00Z', 3, 3), // Inside 60m window (5m ago) + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + + assert.equal(result.sampleCount, 1); + assert.equal(result.successRate, 1); // Only the recent healthy entry counts +}); + +test('summarizeHistory - allDownDurationMinutes considers outside window entries', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + // Chronological order + createEntry('2024-01-01T10:00:00Z', 3, 3), // Outside window, healthy + createEntry('2024-01-01T11:55:00Z', 0, 3), // Inside window, unhealthy + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + + assert.equal(result.sampleCount, 1); // Only recent one counted + assert.equal(result.successRate, 0); // 0/3 = 0 + + // Last healthy was at 10:00 (120 minutes ago) + assert.equal(result.allDownDurationMinutes, 120); +}); + +test('summarizeHistory - allDownDurationMinutes is null if never healthy', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + createEntry('2024-01-01T11:55:00Z', 0, 3), + createEntry('2024-01-01T10:00:00Z', 0, 3), + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + assert.equal(result.allDownDurationMinutes, null); +}); + +test('summarizeHistory - allDownDurationMinutes is 0 if currently healthy', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + createEntry('2024-01-01T12:00:00Z', 3, 3), + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + assert.equal(result.allDownDurationMinutes, 0); +}); + +test('summarizeHistory - handles malformed entries gracefully', () => { + const now = Date.now(); + const history = [ + {}, // Missing everything + { timestamp: 'invalid-date' }, + { timestamp: new Date(now).toISOString(), summary: null }, + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + + // Should calculate stats safely (ignoring malformed or treating as 0) + assert.equal(typeof result.successRate, 'number'); + assert.equal(result.sampleCount, 1); // Only the valid timestamp one is counted in 'recent' + // That one has summary: null, so totalRelays=0, healthyRelays=0. + // relayProbeCount = 0. So successRate = 1. + assert.equal(result.successRate, 1); +}); + +test('summarizeHistory - handles valid timestamp but missing summary', () => { + const now = Date.parse('2024-01-01T12:00:00Z'); + const history = [ + { timestamp: '2024-01-01T11:55:00Z' } // Missing summary + ]; + + const result = summarizeHistory(history, { nowMs: now, windowMinutes: 60 }); + + assert.equal(result.sampleCount, 1); + assert.equal(result.successRate, 1); // totalRelays defaults to 0 -> relayProbeCount=0 -> rate=1 + assert.equal(result.allDownDurationMinutes, null); // healthyRelays defaults to 0 -> never healthy +}); + +test('summarizeHistory - healthy entry with invalid timestamp results in NaN duration', () => { + const now = Date.now(); + const history = [ + { timestamp: 'invalid-date', summary: { healthyRelays: 1 } } + ]; + + const result = summarizeHistory(history, { nowMs: now }); + + assert.equal(Number.isNaN(result.allDownDurationMinutes), true); +}); From 06ddd10f1a7cb5e8b8479a250e5ed75ae07cab5e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 02:42:06 +0000 Subject: [PATCH 03/19] chore(scheduler): run daily design-system-audit-agent - Executed design-system-audit-agent on daily cadence - Generated design system audit report (memory-update.md) - Created run artifacts in src/context, src/todo, src/decisions, src/test_logs - Published lock completion and final task log Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .scheduler-memory/memory-store.json | 51 +++++++++++++++++++ .../session-mlrf4pzf/retrieve.json | 18 +++++++ .../session-mlrf4pzf/retrieve.ok | 1 + .scheduler-memory/session-mlrf7alk/store.json | 20 ++++++++ .scheduler-memory/session-mlrf7alk/store.ok | 1 + src/context/CONTEXT_2026-02-18T02-35-22Z.md | 12 +++++ .../DECISIONS_2026-02-18T02-35-22Z.md | 11 ++++ .../TEST_LOG_2026-02-18T02-35-22Z.md | 11 ++++ src/todo/TODO_2026-02-18T02-35-22Z.md | 12 +++++ ...Z__design-system-audit-agent__completed.md | 45 ++++++++++++++++ 10 files changed, 182 insertions(+) create mode 100644 .scheduler-memory/session-mlrf4pzf/retrieve.json create mode 100644 .scheduler-memory/session-mlrf4pzf/retrieve.ok create mode 100644 .scheduler-memory/session-mlrf7alk/store.json create mode 100644 .scheduler-memory/session-mlrf7alk/store.ok create mode 100644 src/context/CONTEXT_2026-02-18T02-35-22Z.md create mode 100644 src/decisions/DECISIONS_2026-02-18T02-35-22Z.md create mode 100644 src/test_logs/TEST_LOG_2026-02-18T02-35-22Z.md create mode 100644 src/todo/TODO_2026-02-18T02-35-22Z.md create mode 100644 task-logs/daily/2026-02-18T02-35-22Z__design-system-audit-agent__completed.md diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 0da5977..e9c186e 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -194,5 +194,56 @@ "merged_into": null, "pinned": false } + ], + [ + "dacc99c5-1ba1-438f-87d3-cbc39ca7f899", + { + "schema_version": 1, + "id": "dacc99c5-1ba1-438f-87d3-cbc39ca7f899", + "agent_id": "design-system-audit-agent", + "session_id": "session-mlrf4pzf", + "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": 1771382040459, + "last_seen": 1771382040466, + "source": "scheduler-retrieve", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "c683cf59-af31-49e5-98c5-41994c8ffc93", + { + "schema_version": 1, + "id": "c683cf59-af31-49e5-98c5-41994c8ffc93", + "agent_id": "design-system-audit-agent", + "session_id": "session-mlrf7alk", + "type": "batch_window", + "content": "# Design System Audit Report\n\nDate: 2026-02-18T02:34:58.298Z\n\n## 1. Inline Styles\nFound 8 instances.\n### Top Offending Files\n- landing/index.html: 8\n\n### Sample Snippets\n- `style=\"font-size: 2rem; margin-bottom: 2rem; color: var(--text-strong);\"` in landing/index.html\n- `style=\"margin-top: 1rem;\"` in landing/index.html\n- `style=\"margin-top: 4rem; text-align: center; width: 100%; max-width: 600px; padding-top: 2rem; border-top: 1px solid var(--border);\"` in landing/index.html\n- `style=\"font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text-strong);\"` in landing/index.html\n- `style=\"color: var(--muted); margin-bottom: 1.5rem; font-size: 0.95rem;\"` in landing/index.html\n\n## 2. Unsanctioned Hex Colors\nFound 6 instances.\n### Top Offending Colors\n- #039: 1\n- #0d1117: 1\n- #e6edf3: 1\n- #a5d6ff: 1\n- #ff4d4d: 1\n- #ef4444: 1\n\n### Sample Locations\n- #039 in dashboard/domUtils.js\n- #0d1117 in landing/index.html\n- #e6edf3 in landing/index.html\n- #a5d6ff in landing/index.html\n- #ff4d4d in landing/index.html\n\n## 3. Raw Lengths\n(Skipped: requires AST parsing)\n\n## Next Steps\n- Review unsanctioned colors and replace with tokens.\n- Refactor inline styles to CSS classes.", + "summary": "# Design System Audit Report\n\nDate: 2026-02-18T02:34:58.298Z\n\n## 1. Inline Styles\nFound 8 instances.\n### Top Offending Files\n- landing/index.html: 8\n\n### Sample Snippets\n- `style=\"font-size: 2rem; margin-bottom: 2rem; color: var(--text-strong);\"` in landing/index.html\n- `style=\"…", + "tags": [ + "scheduler", + "daily", + "store", + "insight" + ], + "importance": 0.8, + "embedding_id": null, + "created_at": 1771382160489, + "last_seen": 1771382160489, + "source": "agent-output", + "ttl_days": null, + "merged_into": null, + "pinned": false + } ] ] \ No newline at end of file diff --git a/.scheduler-memory/session-mlrf4pzf/retrieve.json b/.scheduler-memory/session-mlrf4pzf/retrieve.json new file mode 100644 index 0000000..c64d0c3 --- /dev/null +++ b/.scheduler-memory/session-mlrf4pzf/retrieve.json @@ -0,0 +1,18 @@ +{ + "cadence": "daily", + "operation": "retrieve", + "runId": "session-mlrf4pzf", + "servicePath": "src/services/memory/index.js#getRelevantMemories", + "inputs": { + "agentId": "design-system-audit-agent", + "promptPath": "", + "promptIntent": "scheduler memory retrieval", + "query": "scheduler | daily | agent | design-system-audit-agent | intent | scheduler memory retrieval", + "events": 1 + }, + "outputs": { + "ingestedCount": 1, + "retrievedCount": 1 + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mlrf4pzf/retrieve.ok b/.scheduler-memory/session-mlrf4pzf/retrieve.ok new file mode 100644 index 0000000..84b1719 --- /dev/null +++ b/.scheduler-memory/session-mlrf4pzf/retrieve.ok @@ -0,0 +1 @@ +MEMORY_RETRIEVED diff --git a/.scheduler-memory/session-mlrf7alk/store.json b/.scheduler-memory/session-mlrf7alk/store.json new file mode 100644 index 0000000..a83d9b5 --- /dev/null +++ b/.scheduler-memory/session-mlrf7alk/store.json @@ -0,0 +1,20 @@ +{ + "cadence": "daily", + "operation": "store", + "runId": "session-mlrf7alk", + "servicePath": "src/services/memory/index.js#ingestEvents", + "inputs": { + "agentId": "design-system-audit-agent", + "promptPath": "", + "promptIntent": "scheduler memory store", + "events": 1, + "sourceFile": "memory-update.md" + }, + "outputs": { + "storedCount": 1, + "summaries": [ + "# Design System Audit Report\n\nDate: 2026-02-18T02:34:58.298Z\n\n## 1. Inline Styles\nFound 8 instances.\n### Top Offending Files\n- landing/index.html: 8\n\n### Sample Snippets\n- `style=\"font-size: 2rem; margin-bottom: 2rem; color: var(--text-strong);\"` in landing/index.html\n- `style=\"…" + ] + }, + "status": "ok" +} \ No newline at end of file diff --git a/.scheduler-memory/session-mlrf7alk/store.ok b/.scheduler-memory/session-mlrf7alk/store.ok new file mode 100644 index 0000000..1e40bb6 --- /dev/null +++ b/.scheduler-memory/session-mlrf7alk/store.ok @@ -0,0 +1 @@ +MEMORY_STORED diff --git a/src/context/CONTEXT_2026-02-18T02-35-22Z.md b/src/context/CONTEXT_2026-02-18T02-35-22Z.md new file mode 100644 index 0000000..5b2a5e9 --- /dev/null +++ b/src/context/CONTEXT_2026-02-18T02-35-22Z.md @@ -0,0 +1,12 @@ +--- +agent: design-system-audit-agent +cadence: daily +session: session-2026-02-18T02:35:22Z +run-start: 2026-02-18T02:35:22Z +prompt: src/prompts/daily/design-system-audit-agent.md +--- + +# Context +- Goal: Audit design system compliance. +- Scope: Daily audit of src/ and dashboard/. +- Constraints: Non-blocking, reporting only. diff --git a/src/decisions/DECISIONS_2026-02-18T02-35-22Z.md b/src/decisions/DECISIONS_2026-02-18T02-35-22Z.md new file mode 100644 index 0000000..f4e8dff --- /dev/null +++ b/src/decisions/DECISIONS_2026-02-18T02-35-22Z.md @@ -0,0 +1,11 @@ +--- +agent: design-system-audit-agent +cadence: daily +session: session-2026-02-18T02:35:22Z +run-start: 2026-02-18T02:35:22Z +prompt: src/prompts/daily/design-system-audit-agent.md +--- + +# Decisions +- Decision: Use automated script for audit. +- Rationale: No existing tool covers all requirements. diff --git a/src/test_logs/TEST_LOG_2026-02-18T02-35-22Z.md b/src/test_logs/TEST_LOG_2026-02-18T02-35-22Z.md new file mode 100644 index 0000000..fa5eb6f --- /dev/null +++ b/src/test_logs/TEST_LOG_2026-02-18T02-35-22Z.md @@ -0,0 +1,11 @@ +--- +agent: design-system-audit-agent +cadence: daily +session: session-2026-02-18T02:35:22Z +run-start: 2026-02-18T02:35:22Z +prompt: src/prompts/daily/design-system-audit-agent.md +--- + +# Test Log +- Command: npm run lint +- Result: Passed (with warnings) diff --git a/src/todo/TODO_2026-02-18T02-35-22Z.md b/src/todo/TODO_2026-02-18T02-35-22Z.md new file mode 100644 index 0000000..fdf0106 --- /dev/null +++ b/src/todo/TODO_2026-02-18T02-35-22Z.md @@ -0,0 +1,12 @@ +--- +agent: design-system-audit-agent +cadence: daily +session: session-2026-02-18T02:35:22Z +run-start: 2026-02-18T02:35:22Z +prompt: src/prompts/daily/design-system-audit-agent.md +--- + +# Todo + +## Pending Tasks +- [ ] Review unsanctioned colors. diff --git a/task-logs/daily/2026-02-18T02-35-22Z__design-system-audit-agent__completed.md b/task-logs/daily/2026-02-18T02-35-22Z__design-system-audit-agent__completed.md new file mode 100644 index 0000000..717b389 --- /dev/null +++ b/task-logs/daily/2026-02-18T02-35-22Z__design-system-audit-agent__completed.md @@ -0,0 +1,45 @@ +--- +agent: design-system-audit-agent +status: completed +platform: linux +--- + +# Design System Audit Report + +Date: 2026-02-18T02:34:58.298Z + +## 1. Inline Styles +Found 8 instances. +### Top Offending Files +- landing/index.html: 8 + +### Sample Snippets +- `style="font-size: 2rem; margin-bottom: 2rem; color: var(--text-strong);"` in landing/index.html +- `style="margin-top: 1rem;"` in landing/index.html +- `style="margin-top: 4rem; text-align: center; width: 100%; max-width: 600px; padding-top: 2rem; border-top: 1px solid var(--border);"` in landing/index.html +- `style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text-strong);"` in landing/index.html +- `style="color: var(--muted); margin-bottom: 1.5rem; font-size: 0.95rem;"` in landing/index.html + +## 2. Unsanctioned Hex Colors +Found 6 instances. +### Top Offending Colors +- #039: 1 +- #0d1117: 1 +- #e6edf3: 1 +- #a5d6ff: 1 +- #ff4d4d: 1 +- #ef4444: 1 + +### Sample Locations +- #039 in dashboard/domUtils.js +- #0d1117 in landing/index.html +- #e6edf3 in landing/index.html +- #a5d6ff in landing/index.html +- #ff4d4d in landing/index.html + +## 3. Raw Lengths +(Skipped: requires AST parsing) + +## Next Steps +- Review unsanctioned colors and replace with tokens. +- Refactor inline styles to CSS classes. From 9a9f93aa048838bbaca3ff454c536e701fa0ea28 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:23:18 +0000 Subject: [PATCH 04/19] chore: remove dead code src/cmd-check.mjs - Remove `src/cmd-check.mjs` as it is not used in the codebase and contains outdated logic. - Remove `test/cmd-check.test.mjs` as it tests the deleted file. - Verify that `cmdCheck` in `src/lib.mjs` is covered by `test/lib.test.mjs`. This improves code health by removing unused and duplicate code. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .scheduler-memory/memory-store.json | 75 ++++++++++- src/cmd-check.mjs | 61 --------- test/cmd-check.test.mjs | 187 ---------------------------- 3 files changed, 72 insertions(+), 251 deletions(-) delete mode 100644 src/cmd-check.mjs delete mode 100644 test/cmd-check.test.mjs diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 702dd60..7581d77 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -117,7 +117,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370050385, - "last_seen": 1771370148086, + "last_seen": 1771384958445, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -140,7 +140,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370084740, - "last_seen": 1771370148086, + "last_seen": 1771384958445, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -163,7 +163,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370148079, - "last_seen": 1771370148086, + "last_seen": 1771384958445, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -244,5 +244,74 @@ "merged_into": null, "pinned": false } + ], + [ + "71791fc3-49a7-40f2-a8e5-c93e8b19f32b", + { + "schema_version": 1, + "id": "71791fc3-49a7-40f2-a8e5-c93e8b19f32b", + "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": 1771384842151, + "last_seen": 1771384958445, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "ca60066d-1d01-43de-a597-573d9826d101", + { + "schema_version": 1, + "id": "ca60066d-1d01-43de-a597-573d9826d101", + "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": 1771384870483, + "last_seen": 1771384958445, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "b95b047e-6bc6-4342-a535-8d311ebc7f1a", + { + "schema_version": 1, + "id": "b95b047e-6bc6-4342-a535-8d311ebc7f1a", + "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": 1771384958439, + "last_seen": 1771384958445, + "source": "ingest", + "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 deleted file mode 100644 index a0d16f4..0000000 --- a/src/cmd-check.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { - loadTorchConfig, - getRelays, - getNamespace, -} from './torch-config.mjs'; -import { getRoster } from './roster.mjs'; -import { queryLocks } from './lock-ops.mjs'; -import { todayDateStr } from './utils.mjs'; - -export async function cmdCheck(cadence, deps = {}) { - const { - loadTorchConfigFn = loadTorchConfig, - getRelaysFn = getRelays, - getNamespaceFn = getNamespace, - getRosterFn = getRoster, - queryLocksFn = queryLocks, - todayDateStrFn = todayDateStr, - } = deps; - - const relays = await getRelaysFn(); - const namespace = await getNamespaceFn(); - const dateStr = todayDateStrFn(); - const config = await loadTorchConfigFn(); - const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; - - console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); - console.error(`Relays: ${relays.join(', ')}`); - if (pausedAgents.length > 0) { - console.error(`Paused agents: ${pausedAgents.join(', ')}`); - } - - const locks = await queryLocksFn(relays, cadence, dateStr, namespace); - const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; - const roster = await getRosterFn(cadence); - - const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; - const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); - const available = roster.filter((a) => !excludedAgents.includes(a)); - - const result = { - namespace, - cadence, - date: dateStr, - locked: lockedAgents.sort(), - paused: pausedAgents.sort(), - excluded: excludedAgents.sort(), - available: available.sort(), - lockCount: locks.length, - unknownLockedAgents: unknownLockedAgents.sort(), - locks: locks.map((l) => ({ - agent: l.agent, - eventId: l.eventId, - createdAt: l.createdAtIso, - expiresAt: l.expiresAtIso, - platform: l.platform, - })), - }; - - console.log(JSON.stringify(result, null, 2)); - return result; -} diff --git a/test/cmd-check.test.mjs b/test/cmd-check.test.mjs deleted file mode 100644 index 287dd7b..0000000 --- a/test/cmd-check.test.mjs +++ /dev/null @@ -1,187 +0,0 @@ -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, []); - }); -}); From b012ae19da97e1bd21eae2acc6fd600cac9e72ef Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:27:21 +0000 Subject: [PATCH 05/19] test: enhance coverage for relay alert thresholds Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- test/relay-health-thresholds.test.mjs | 183 +++++++++++++++++++++----- 1 file changed, 152 insertions(+), 31 deletions(-) diff --git a/test/relay-health-thresholds.test.mjs b/test/relay-health-thresholds.test.mjs index fe5e9be..0ad9ec9 100644 --- a/test/relay-health-thresholds.test.mjs +++ b/test/relay-health-thresholds.test.mjs @@ -13,38 +13,159 @@ function entry(timestamp, healthyRelays, totalRelays = 3) { }; } -test('raises all-relays-down duration alert after threshold', () => { +test('evaluateAlertThresholds', async (t) => { const now = Date.parse('2026-02-15T12:00:00Z'); - const history = [ - entry('2026-02-15T11:30:00Z', 1), - entry('2026-02-15T11:45:00Z', 0), - entry('2026-02-15T11:55:00Z', 0), - entry('2026-02-15T12:00:00Z', 0), - ]; + const minute = 60_000; - const current = history[history.length - 1]; - const result = evaluateAlertThresholds(current, history, { - allRelaysDownMinutes: 10, - minSuccessRate: 0.1, - windowMinutes: 60, - }, { nowMs: now }); + await t.test('Happy Path: No alerts when thresholds are not met', () => { + const history = [ + entry(new Date(now - 10 * minute).toISOString(), 3), + entry(new Date(now).toISOString(), 3), + ]; + const current = history[history.length - 1]; - assert.ok(result.alerts.some((alert) => alert.type === 'all_relays_down_duration')); -}); - -test('raises success-rate alert when rolling health falls below threshold', () => { - const history = [ - entry('2026-02-15T11:50:00Z', 3), - entry('2026-02-15T11:55:00Z', 0), - entry('2026-02-15T12:00:00Z', 0), - ]; - const current = history[history.length - 1]; - - const result = evaluateAlertThresholds(current, history, { - allRelaysDownMinutes: 999, - minSuccessRate: 0.5, - windowMinutes: 30, - }, { nowMs: Date.parse('2026-02-15T12:00:00Z') }); - - assert.ok(result.alerts.some((alert) => alert.type === 'relay_success_rate_below_threshold')); + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.5, + windowMinutes: 60, + }, { nowMs: now }); + + assert.equal(result.alerts.length, 0); + }); + + await t.test('All Relays Down: Triggers alert when duration equals threshold', () => { + // 10 minutes down exactly + const history = [ + entry(new Date(now - 10 * minute).toISOString(), 3), // healthy 10 mins ago + entry(new Date(now - 5 * minute).toISOString(), 0), + entry(new Date(now).toISOString(), 0), + ]; + const current = history[history.length - 1]; + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.1, + windowMinutes: 60, + }, { nowMs: now }); + + const alert = result.alerts.find(a => a.type === 'all_relays_down_duration'); + assert.ok(alert); + assert.equal(alert.actualMinutes, 10); + }); + + await t.test('All Relays Down: Does NOT trigger alert just below threshold', () => { + // 9 minutes down + const history = [ + entry(new Date(now - 9 * minute).toISOString(), 3), // healthy 9 mins ago + entry(new Date(now).toISOString(), 0), + ]; + const current = history[history.length - 1]; + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.1, + windowMinutes: 60, + }, { nowMs: now }); + + const alert = result.alerts.find(a => a.type === 'all_relays_down_duration'); + assert.equal(alert, undefined); + }); + + await t.test('Success Rate: Triggers alert when rate is strictly below threshold', () => { + const history = [ + entry(new Date(now).toISOString(), 1, 4), // 25% healthy + ]; + const current = history[history.length - 1]; + // Threshold 0.3 > 0.25 -> Alert + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 999, + minSuccessRate: 0.3, + windowMinutes: 60, + }, { nowMs: now }); + + const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold'); + assert.ok(alert); + assert.equal(alert.actual, 0.25); + }); + + await t.test('Success Rate: Does NOT trigger alert when rate equals threshold', () => { + const history = [ + entry(new Date(now).toISOString(), 1, 4), // 25% healthy + ]; + const current = history[history.length - 1]; + // Threshold 0.25 == 0.25 -> No Alert + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 999, + minSuccessRate: 0.25, + windowMinutes: 60, + }, { nowMs: now }); + + const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold'); + assert.equal(alert, undefined); + }); + + await t.test('Success Rate: Does NOT trigger alert when rate is above threshold', () => { + const history = [ + entry(new Date(now).toISOString(), 3, 4), // 75% healthy + ]; + const current = history[history.length - 1]; + // Threshold 0.5 < 0.75 -> No Alert + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 999, + minSuccessRate: 0.5, + windowMinutes: 60, + }, { nowMs: now }); + + const alert = result.alerts.find(a => a.type === 'relay_success_rate_below_threshold'); + assert.equal(alert, undefined); + }); + + await t.test('Multiple Alerts: Both conditions met', () => { + // Down for 20 mins (threshold 10) AND rate 0 (threshold 0.5) + const history = [ + entry(new Date(now - 20 * minute).toISOString(), 3), // Healthy 20m ago + entry(new Date(now - 10 * minute).toISOString(), 0), + entry(new Date(now).toISOString(), 0), + ]; + const current = history[history.length - 1]; + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.5, + windowMinutes: 60, + }, { nowMs: now }); + + assert.equal(result.alerts.length, 2); + assert.ok(result.alerts.some(a => a.type === 'all_relays_down_duration')); + assert.ok(result.alerts.some(a => a.type === 'relay_success_rate_below_threshold')); + }); + + await t.test('Edge Case: Empty history (should not crash)', () => { + const history = []; + const current = entry(new Date(now).toISOString(), 3); // current result is healthy + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.5, + windowMinutes: 60, + }, { nowMs: now }); + + assert.equal(result.alerts.length, 0); + }); + + await t.test('Edge Case: Empty history but current result is unhealthy', () => { + const history = []; + const current = entry(new Date(now).toISOString(), 0); // current result is unhealthy + + const result = evaluateAlertThresholds(current, history, { + allRelaysDownMinutes: 10, + minSuccessRate: 0.5, + windowMinutes: 60, + }, { nowMs: now }); + + assert.equal(result.alerts.length, 0); + }); }); From e787247c6f48735e78d77de786277a6071c7bde3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:27:26 +0000 Subject: [PATCH 06/19] test(utils): add unit tests for todayDateStr and nowUnix Add comprehensive unit tests for `todayDateStr` and `nowUnix` in `src/utils.mjs`. These tests use `node:test`'s mock timers to ensure deterministic execution, verifying that the date formatting and timestamp generation behave as expected. - `todayDateStr`: verified correct YYYY-MM-DD format - `nowUnix`: verified correct integer timestamp generation Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- test/utils.test.mjs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/test/utils.test.mjs b/test/utils.test.mjs index 06b2b94..de323bf 100644 --- a/test/utils.test.mjs +++ b/test/utils.test.mjs @@ -1,8 +1,30 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { getIsoWeekStr } from '../src/utils.mjs'; +import { getIsoWeekStr, todayDateStr, nowUnix } from '../src/utils.mjs'; describe('Date Utilities', () => { + describe('todayDateStr', () => { + it('returns current date in YYYY-MM-DD format', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + // 2023-10-25 12:00:00 UTC + const date = new Date('2023-10-25T12:00:00Z'); + t.mock.timers.setTime(date.getTime()); + + assert.strictEqual(todayDateStr(), '2023-10-25'); + }); + }); + + describe('nowUnix', () => { + it('returns current unix timestamp', (t) => { + t.mock.timers.enable({ apis: ['Date'] }); + const timeMs = 1700000000000; + t.mock.timers.setTime(timeMs); + + // 1700000000000 / 1000 = 1700000000 + assert.strictEqual(nowUnix(), 1700000000); + }); + }); + describe('getIsoWeekStr', () => { it('returns correct ISO week for simple cases', () => { assert.strictEqual(getIsoWeekStr('2023-01-02'), '2023-W01'); // Mon From 74993c1d473040cb1e8272d20f0bf0e05dc76154 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:28:01 +0000 Subject: [PATCH 07/19] I've removed the dead code in `src/cmd-lock.mjs`. This file contained duplicated logic that is now handled in `src/lib.mjs` and is not imported by any other module in the codebase. Verified by: - Searching the codebase to confirm no imports. - Running the tests to ensure no regressions. - Running the build to ensure the process is unaffected. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/cmd-lock.mjs | 122 ----------------------------------------------- 1 file changed, 122 deletions(-) delete mode 100644 src/cmd-lock.mjs diff --git a/src/cmd-lock.mjs b/src/cmd-lock.mjs deleted file mode 100644 index 195baa5..0000000 --- a/src/cmd-lock.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; -import { - getRelays, - getNamespace, - getTtl, -} from './torch-config.mjs'; -import { - RACE_CHECK_DELAY_MS, - KIND_APP_DATA, -} from './constants.mjs'; -import { getRoster } from './roster.mjs'; -import { queryLocks, publishLock } from './lock-ops.mjs'; -import { todayDateStr, nowUnix } from './utils.mjs'; -import { ExitError } from './errors.mjs'; - -export async function cmdLock(agent, cadence, dryRun = false) { - const relays = await getRelays(); - const namespace = await getNamespace(); - const dateStr = todayDateStr(); - const ttl = await getTtl(); - const now = nowUnix(); - const expiresAt = now + ttl; - - console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); - console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); - console.error(`Relays: ${relays.join(', ')}`); - - 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(', ')}`); - throw new ExitError(1, 'Agent not in roster'); - } - - console.error('Step 1: Checking for existing locks...'); - const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); - const conflicting = existingLocks.filter((l) => l.agent === agent); - - if (conflicting.length > 0) { - const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; - console.error( - `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + - `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, - ); - console.log('LOCK_STATUS=denied'); - console.log('LOCK_REASON=already_locked'); - console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); - throw new ExitError(3, 'Lock denied'); - } - - console.error('Step 2: Generating ephemeral keypair...'); - const sk = generateSecretKey(); - const pk = getPublicKey(sk); - console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); - - console.error('Step 3: Building lock event...'); - const event = finalizeEvent( - { - kind: KIND_APP_DATA, - created_at: now, - tags: [ - ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], - ['t', `${namespace}-agent-lock`], - ['t', `${namespace}-lock-${cadence}`], - ['t', `${namespace}-lock-${cadence}-${dateStr}`], - ['expiration', String(expiresAt)], - ], - content: JSON.stringify({ - agent, - cadence, - status: 'started', - namespace, - date: dateStr, - platform: process.env.AGENT_PLATFORM || 'unknown', - lockedAt: new Date(now * 1000).toISOString(), - expiresAt: new Date(expiresAt * 1000).toISOString(), - }), - }, - sk, - ); - - console.error(` Event ID: ${event.id}`); - - if (dryRun) { - console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); - console.error('RACE CHECK: won (dry run — no real contention possible)'); - } else { - console.error('Step 4: Publishing to relays...'); - await publishLock(relays, event); - - console.error('Step 5: Race check...'); - await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS)); - - const postLocks = await queryLocks(relays, cadence, dateStr, namespace); - const racingLocks = postLocks - .filter((l) => l.agent === agent) - .sort((a, b) => a.createdAt - b.createdAt); - - if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { - const winner = racingLocks[0]; - console.error( - `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, - ); - console.log('LOCK_STATUS=race_lost'); - console.log('LOCK_REASON=earlier_claim_exists'); - console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); - throw new ExitError(3, 'Race check lost'); - } - - console.error('RACE CHECK: won'); - } - - console.log('LOCK_STATUS=ok'); - console.log(`LOCK_EVENT_ID=${event.id}`); - console.log(`LOCK_PUBKEY=${pk}`); - console.log(`LOCK_AGENT=${agent}`); - console.log(`LOCK_CADENCE=${cadence}`); - console.log(`LOCK_DATE=${dateStr}`); - console.log(`LOCK_EXPIRES=${expiresAt}`); - console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); - return { status: 'ok', eventId: event.id }; -} From ba5d86142c480da965c6047e70e658617c990a54 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:28:09 +0000 Subject: [PATCH 08/19] Refactor: Move `ensureDir` to `src/utils.mjs` Moved the `ensureDir` utility function from `src/ops.mjs` to `src/utils.mjs` to promote code reuse and centralization. Updated `src/ops.mjs` and `src/services/memory/index.js` to use the shared utility. Added unit tests for `ensureDir` in `test/utils.test.mjs`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/ops.mjs | 7 +------ src/services/memory/index.js | 5 ++--- src/utils.mjs | 8 ++++++++ test/utils.test.mjs | 38 ++++++++++++++++++++++++++++++++++-- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/ops.mjs b/src/ops.mjs index 880fb1f..0e194af 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -5,6 +5,7 @@ import crypto from 'node:crypto'; import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { DEFAULT_RELAYS } from './constants.mjs'; +import { ensureDir } from './utils.mjs'; const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -36,12 +37,6 @@ function getPaths(root, installDirName) { }; } -function ensureDir(dir) { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - function copyDir(src, dest) { if (fs.existsSync(src)) { fs.cpSync(src, dest, { recursive: true }); diff --git a/src/services/memory/index.js b/src/services/memory/index.js index cf7375f..0c8557d 100644 --- a/src/services/memory/index.js +++ b/src/services/memory/index.js @@ -7,6 +7,7 @@ import { getMemoryPruneMode, isMemoryIngestEnabled } from './feature-flags.js'; import fs from 'node:fs'; import path from 'node:path'; import util from 'node:util'; +import { ensureDir } from '../../utils.mjs'; const MEMORY_FILE_PATH = path.join(process.cwd(), '.scheduler-memory', 'memory-store.json'); const debug = util.debuglog('torch-memory'); @@ -29,9 +30,7 @@ function loadMemoryStore() { function saveMemoryStore(store) { try { const dir = path.dirname(MEMORY_FILE_PATH); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } + ensureDir(dir); const entries = [...store.entries()]; fs.writeFileSync(MEMORY_FILE_PATH, JSON.stringify(entries, null, 2), 'utf8'); } catch (err) { diff --git a/src/utils.mjs b/src/utils.mjs index 4b7c76c..e091fa6 100644 --- a/src/utils.mjs +++ b/src/utils.mjs @@ -1,3 +1,5 @@ +import fs from 'node:fs'; + export function todayDateStr() { return new Date().toISOString().slice(0, 10); } @@ -34,3 +36,9 @@ export function detectPlatform() { if (process.env.GOOSE_API_KEY || process.env.GOOSE_SESSION_ID) return 'goose'; return null; } + +export function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} diff --git a/test/utils.test.mjs b/test/utils.test.mjs index 06b2b94..4ffbe46 100644 --- a/test/utils.test.mjs +++ b/test/utils.test.mjs @@ -1,6 +1,8 @@ -import { describe, it } from 'node:test'; +import { describe, it, after } from 'node:test'; import assert from 'node:assert'; -import { getIsoWeekStr } from '../src/utils.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { getIsoWeekStr, ensureDir } from '../src/utils.mjs'; describe('Date Utilities', () => { describe('getIsoWeekStr', () => { @@ -34,3 +36,35 @@ describe('Date Utilities', () => { }); }); }); + +describe('File Utilities', () => { + describe('ensureDir', () => { + const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-utils-')); + + after(() => { + fs.rmSync(tempBase, { recursive: true, force: true }); + }); + + it('creates directory if it does not exist', () => { + const targetDir = path.join(tempBase, 'new-dir'); + assert.strictEqual(fs.existsSync(targetDir), false); + ensureDir(targetDir); + assert.strictEqual(fs.existsSync(targetDir), true); + }); + + it('does nothing if directory already exists', () => { + const targetDir = path.join(tempBase, 'existing-dir'); + fs.mkdirSync(targetDir); + assert.strictEqual(fs.existsSync(targetDir), true); + ensureDir(targetDir); + assert.strictEqual(fs.existsSync(targetDir), true); + }); + + it('creates nested directories recursively', () => { + const targetDir = path.join(tempBase, 'nested', 'deep', 'dir'); + assert.strictEqual(fs.existsSync(targetDir), false); + ensureDir(targetDir); + assert.strictEqual(fs.existsSync(targetDir), true); + }); + }); +}); From b1353cb79b0866db5d571e626637c36803a3e94a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:28:59 +0000 Subject: [PATCH 09/19] fix: prevent command injection in init scripts by validating install dir - Added `validateInstallDir` function to `src/ops.mjs` to enforce strict allowlist for directory names (alphanumeric, hyphens, underscores, slashes, periods). - Integrated validation into `resolveConfiguration` to sanitize both interactive and mock inputs. - Added comprehensive unit tests in `test/install_dir_validation.test.mjs`. This fixes a vulnerability where malicious directory names could inject shell commands into `package.json` scripts during initialization. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/ops.mjs | 23 +++++- test/install_dir_validation.test.mjs | 109 +++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 test/install_dir_validation.test.mjs diff --git a/src/ops.mjs b/src/ops.mjs index 880fb1f..cb94df4 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -152,11 +152,26 @@ async function interactiveInit(cwd) { } } -async function resolveConfiguration(cwd, mockAnswers) { - if (mockAnswers) { - return mockAnswers; +function validateInstallDir(dir) { + if (dir === '.') return; + + // Strict validation to prevent command injection + // Only allow alphanumeric, hyphens, underscores, slashes, and periods. + if (!/^[a-zA-Z0-9_\-\/\.]+$/.test(dir)) { + throw new Error(`Invalid directory name: "${dir}". Only alphanumeric characters, hyphens, underscores, slashes, and periods are allowed.`); } - return await interactiveInit(cwd); +} + +async function resolveConfiguration(cwd, mockAnswers) { + let config; + if (mockAnswers) { + config = mockAnswers; + } else { + config = await interactiveInit(cwd); + } + + validateInstallDir(config.installDir); + return config; } function ensureInstallDirectory(paths, force, installDir) { diff --git a/test/install_dir_validation.test.mjs b/test/install_dir_validation.test.mjs new file mode 100644 index 0000000..9568803 --- /dev/null +++ b/test/install_dir_validation.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { cmdInit } from '../src/ops.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; + +const TEST_DIR = 'test_validation_env'; + +test('cmdInit should validate install directory name', async (t) => { + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DIR); + + const mockPkg = { name: "host", scripts: {} }; + fs.writeFileSync(path.join(TEST_DIR, 'package.json'), JSON.stringify(mockPkg)); + + await t.test('should reject directory with semicolon', async () => { + await assert.rejects(async () => { + await cmdInit(false, TEST_DIR, { + installDir: 'torch; echo pwned', + namespace: 'ns', + relays: [] + }); + }, /Invalid directory name/); + }); + + await t.test('should reject directory with spaces', async () => { + await assert.rejects(async () => { + await cmdInit(false, TEST_DIR, { + installDir: 'torch dir', + namespace: 'ns', + relays: [] + }); + }, /Invalid directory name/); + }); + + await t.test('should reject directory with quotes', async () => { + await assert.rejects(async () => { + await cmdInit(false, TEST_DIR, { + installDir: 'torch"dir', + namespace: 'ns', + relays: [] + }); + }, /Invalid directory name/); + }); + + await t.test('should reject directory with backticks', async () => { + await assert.rejects(async () => { + await cmdInit(false, TEST_DIR, { + installDir: 'torch`dir', + namespace: 'ns', + relays: [] + }); + }, /Invalid directory name/); + }); + + await t.test('should reject directory with $', async () => { + await assert.rejects(async () => { + await cmdInit(false, TEST_DIR, { + installDir: 'torch$dir', + namespace: 'ns', + relays: [] + }); + }, /Invalid directory name/); + }); + + await t.test('should accept valid directory names', async () => { + try { + await cmdInit(false, TEST_DIR, { + installDir: 'valid-dir_123', + namespace: 'ns', + relays: [] + }); + } catch (e) { + // It might fail because of other initialization steps, but shouldn't fail validation + assert.notMatch(e.message, /Invalid directory name/); + } + }); + + await t.test('should accept "."', async () => { + try { + await cmdInit(false, TEST_DIR, { + installDir: '.', + namespace: 'ns', + relays: [] + }); + } catch (e) { + assert.notMatch(e.message, /Invalid directory name/); + } + }); + + await t.test('should accept nested paths with slashes', async () => { + try { + await cmdInit(false, TEST_DIR, { + installDir: 'path/to/torch', + namespace: 'ns', + relays: [] + }); + } catch (e) { + assert.notMatch(e.message, /Invalid directory name/); + } + }); + + // Cleanup + if (fs.existsSync(TEST_DIR)) { + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + } +}); From e12c415f285519d1fb74e7051e45a55dc8c343c5 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:29:09 +0000 Subject: [PATCH 10/19] feat(test): add unit tests for roster loading logic with dependency injection - Refactor `src/roster.mjs` to support dependency injection for `fs` and `loadTorchConfig`. - Add `test/roster.test.mjs` to test roster loading scenarios including file precedence, caching, fallback, and configuration overrides. - Export `_resetRosterCache` in `src/roster.mjs` for testing purposes. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- .scheduler-memory/memory-store.json | 98 +++++++++++++++++++- src/roster.mjs | 24 +++-- test/roster.test.mjs | 139 ++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 test/roster.test.mjs diff --git a/.scheduler-memory/memory-store.json b/.scheduler-memory/memory-store.json index 702dd60..7ee9c41 100644 --- a/.scheduler-memory/memory-store.json +++ b/.scheduler-memory/memory-store.json @@ -117,7 +117,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370050385, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -140,7 +140,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370084740, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -163,7 +163,7 @@ "importance": 0.25, "embedding_id": null, "created_at": 1771370148079, - "last_seen": 1771370148086, + "last_seen": 1771385274968, "source": "ingest", "ttl_days": null, "merged_into": null, @@ -244,5 +244,97 @@ "merged_into": null, "pinned": false } + ], + [ + "0c0f8d8f-b1c5-4b4f-bd15-4a53462f3a31", + { + "schema_version": 1, + "id": "0c0f8d8f-b1c5-4b4f-bd15-4a53462f3a31", + "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": 1771385166452, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "5c3af7fa-cab8-4bcf-b1dc-fc845d611026", + { + "schema_version": 1, + "id": "5c3af7fa-cab8-4bcf-b1dc-fc845d611026", + "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": 1771385220726, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "65e6d5f8-74fe-48cb-b1ab-b9b3d5a4ae85", + { + "schema_version": 1, + "id": "65e6d5f8-74fe-48cb-b1ab-b9b3d5a4ae85", + "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": 1771385242873, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } + ], + [ + "f6c9edde-0629-4853-ab41-e90c9700dbc3", + { + "schema_version": 1, + "id": "f6c9edde-0629-4853-ab41-e90c9700dbc3", + "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": 1771385274962, + "last_seen": 1771385274968, + "source": "ingest", + "ttl_days": null, + "merged_into": null, + "pinned": false + } ] ] \ No newline at end of file diff --git a/src/roster.mjs b/src/roster.mjs index 99ab414..09cc423 100644 --- a/src/roster.mjs +++ b/src/roster.mjs @@ -53,20 +53,20 @@ const FALLBACK_ROSTER = { let cachedCanonicalRoster = null; -function loadCanonicalRoster() { +function loadCanonicalRoster(fsModule = fs) { if (cachedCanonicalRoster) return cachedCanonicalRoster; let rosterPath = ROSTER_FILE; // Prefer user-managed roster if present - if (fs.existsSync(USER_ROSTER_FILE)) { + if (fsModule.existsSync(USER_ROSTER_FILE)) { rosterPath = USER_ROSTER_FILE; - } else if (fs.existsSync(CWD_ROSTER_FILE)) { + } else if (fsModule.existsSync(CWD_ROSTER_FILE)) { rosterPath = CWD_ROSTER_FILE; } try { - const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + const parsed = JSON.parse(fsModule.readFileSync(rosterPath, 'utf8')); const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; const weekly = Array.isArray(parsed.weekly) ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) @@ -86,6 +86,11 @@ function loadCanonicalRoster() { return cachedCanonicalRoster; } +/** @internal */ +export function _resetRosterCache() { + cachedCanonicalRoster = null; +} + function parseEnvRoster(value) { if (!value) return null; return value @@ -94,11 +99,16 @@ function parseEnvRoster(value) { .filter(Boolean); } -export async function getRoster(cadence) { - const config = await loadTorchConfig(); +export async function getRoster(cadence, deps = {}) { + const { + loadTorchConfig: loadTorchConfigFn = loadTorchConfig, + fs: fsModule = fs, + } = deps; + + const config = await loadTorchConfigFn(); const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); - const canonical = loadCanonicalRoster(); + const canonical = loadCanonicalRoster(fsModule); const dailyFromConfig = config.nostrLock.dailyRoster; const weeklyFromConfig = config.nostrLock.weeklyRoster; diff --git a/test/roster.test.mjs b/test/roster.test.mjs new file mode 100644 index 0000000..b968b44 --- /dev/null +++ b/test/roster.test.mjs @@ -0,0 +1,139 @@ +import { describe, it, beforeEach, afterEach, mock } from 'node:test'; +import assert from 'node:assert'; +import path from 'node:path'; +import { getRoster, _resetRosterCache } from '../src/roster.mjs'; + +describe('Roster (Unit Tests with Dependency Injection)', () => { + const CWD = process.cwd(); + const USER_ROSTER_FILE = path.resolve(CWD, 'torch/roster.json'); + const CWD_ROSTER_FILE = path.resolve(CWD, 'roster.json'); + + const mockFs = { + existsSync: mock.fn(), + readFileSync: mock.fn(), + }; + + const mockLoadTorchConfig = mock.fn(); + const originalConsoleError = console.error; + + beforeEach(() => { + _resetRosterCache(); + mockFs.existsSync.mock.resetCalls(); + mockFs.readFileSync.mock.resetCalls(); + mockLoadTorchConfig.mock.resetCalls(); + console.error = mock.fn(); + + // Default: config returns empty nostrLock + mockLoadTorchConfig.mock.mockImplementation(async () => ({ nostrLock: {} })); + + // Default: fs.existsSync returns false (no files found) + mockFs.existsSync.mock.mockImplementation(() => false); + + // Default: fs.readFileSync returns empty object + mockFs.readFileSync.mock.mockImplementation(() => '{}'); + + // Clear env vars + delete process.env.NOSTR_LOCK_DAILY_ROSTER; + delete process.env.NOSTR_LOCK_WEEKLY_ROSTER; + }); + + afterEach(() => { + mock.reset(); + console.error = originalConsoleError; + }); + + it('returns fallback roster when no configuration or files exist', async () => { + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const roster = await getRoster('daily', deps); + assert.ok(Array.isArray(roster)); + assert.ok(roster.length > 0); + assert.ok(roster.includes('audit-agent')); + }); + + it('loads roster from torch/roster.json (User Roster) if present', async () => { + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation((filepath) => { + if (filepath === USER_ROSTER_FILE) { + return JSON.stringify({ daily: ['user-agent-daily'], weekly: ['user-agent-weekly'] }); + } + throw new Error('ENOENT'); + }); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + const weekly = await getRoster('weekly', deps); + + assert.deepStrictEqual(daily, ['user-agent-daily']); + assert.deepStrictEqual(weekly, ['user-agent-weekly']); + assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); + }); + + it('loads roster from roster.json (CWD Roster) if present and torch/roster.json is missing', async () => { + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === CWD_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation((filepath) => { + if (filepath === CWD_ROSTER_FILE) { + return JSON.stringify({ daily: ['cwd-agent-daily'], weekly: ['cwd-agent-weekly'] }); + } + throw new Error('ENOENT'); + }); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + assert.deepStrictEqual(daily, ['cwd-agent-daily']); + }); + + it('falls back to default if roster file is malformed', async () => { + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation(() => '{ invalid json }'); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + // Should catch error and return fallback + const daily = await getRoster('daily', deps); + assert.ok(daily.includes('audit-agent')); + + // Should have tried to read file + assert.strictEqual(mockFs.readFileSync.mock.calls.length, 1); + }); + + it('falls back to default if roster file is missing daily/weekly arrays', async () => { + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ other: [] })); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + assert.ok(daily.includes('audit-agent')); + }); + + it('prioritizes environment variables over file and config', async () => { + process.env.NOSTR_LOCK_DAILY_ROSTER = 'env-agent-daily'; + + // Setup file to exist + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] })); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + assert.deepStrictEqual(daily, ['env-agent-daily']); + }); + + it('prioritizes config over file', async () => { + mockLoadTorchConfig.mock.mockImplementation(async () => ({ + nostrLock: { dailyRoster: ['config-agent-daily'] } + })); + + // Setup file to exist + mockFs.existsSync.mock.mockImplementation((filepath) => filepath === USER_ROSTER_FILE); + mockFs.readFileSync.mock.mockImplementation(() => JSON.stringify({ daily: ['file-agent'] })); + + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + assert.deepStrictEqual(daily, ['config-agent-daily']); + }); + + it('handles multiple items in env var roster', async () => { + process.env.NOSTR_LOCK_DAILY_ROSTER = 'agent1, agent2,agent3 '; + const deps = { fs: mockFs, loadTorchConfig: mockLoadTorchConfig }; + const daily = await getRoster('daily', deps); + assert.deepStrictEqual(daily, ['agent1', 'agent2', 'agent3']); + }); +}); From 75c9b669c0ce7b54434b45c925bedc7454922eb9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:29:21 +0000 Subject: [PATCH 11/19] feat(dashboard): add HTTP security headers Adds standard HTTP security headers (CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy) to all dashboard responses to mitigate XSS, clickjacking, and MIME sniffing attacks. Includes verification tests in test/dashboard-security.test.mjs. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/dashboard.mjs | 27 +++++++++++++++++---------- test/dashboard-security.test.mjs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/dashboard.mjs b/src/dashboard.mjs index 740357a..44e6834 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -7,6 +7,13 @@ import { fileURLToPath } from 'node:url'; import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs'; +const SECURITY_HEADERS = { + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss:; object-src 'none'; base-uri 'self';", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin' +}; + function timingSafeCompare(a, b) { const bufA = Buffer.from(a); const bufB = Buffer.from(b); @@ -30,7 +37,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. if (auth) { const authHeader = req.headers.authorization; if (!authHeader) { - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); res.end('Authentication required'); return; } @@ -48,7 +55,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. } if (!isValid) { - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.writeHead(401, { ...SECURITY_HEADERS, 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); res.end('Invalid credentials'); return; } @@ -60,7 +67,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. // Redirect / to /dashboard/ if (pathname === '/' || pathname === '/dashboard') { - res.writeHead(302, { 'Location': '/dashboard/' }); + res.writeHead(302, { ...SECURITY_HEADERS, 'Location': '/dashboard/' }); res.end(); return; } @@ -111,17 +118,17 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. } delete safeConfig.configPath; - res.writeHead(200, { 'Content-Type': 'application/json' }); + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' }); res.end(JSON.stringify(safeConfig, null, 2)); return; } catch (err) { console.error('Error parsing torch-config.json:', err); - res.writeHead(500); + res.writeHead(500, SECURITY_HEADERS); res.end('Internal Server Error'); return; } } else { - res.writeHead(404); + res.writeHead(404, SECURITY_HEADERS); res.end('Not Found'); return; } @@ -146,7 +153,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. }); if (!isAllowed) { - res.writeHead(403); + res.writeHead(403, SECURITY_HEADERS); res.end('Forbidden'); return; } @@ -160,7 +167,7 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. // Check if file exists and is a file if (!fileStat || !fileStat.isFile()) { - res.writeHead(404); + res.writeHead(404, SECURITY_HEADERS); res.end('Not Found'); return; } @@ -181,12 +188,12 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. case '.md': contentType = 'text/markdown'; break; } - res.writeHead(200, { 'Content-Type': contentType }); + res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': contentType }); fs.createReadStream(filePath).pipe(res); } catch (err) { console.error('Dashboard Server Error:', err); if (!res.headersSent) { - res.writeHead(500); + res.writeHead(500, SECURITY_HEADERS); res.end('Internal Server Error'); } } diff --git a/test/dashboard-security.test.mjs b/test/dashboard-security.test.mjs index 88391e8..5a177be 100644 --- a/test/dashboard-security.test.mjs +++ b/test/dashboard-security.test.mjs @@ -69,4 +69,36 @@ test('Dashboard File Access Security', async (t) => { // path.normalize collapses this to /package.json, which is then blocked assert.strictEqual(res.statusCode, 403); }); + + await t.test('responses include security headers', async () => { + const res = await get('/dashboard/index.html'); + assert.strictEqual(res.statusCode, 200); + + const headers = res.headers; + + // Content-Security-Policy + assert.ok(headers['content-security-policy'], 'CSP header missing'); + const csp = headers['content-security-policy']; + assert.match(csp, /default-src 'self'/, 'CSP: missing default-src self'); + assert.match(csp, /script-src 'self' 'unsafe-inline' https:\/\/cdn\.jsdelivr\.net/, 'CSP: missing script-src'); + assert.match(csp, /object-src 'none'/, 'CSP: missing object-src none'); + + // X-Content-Type-Options + assert.strictEqual(headers['x-content-type-options'], 'nosniff', 'X-Content-Type-Options mismatch'); + + // X-Frame-Options + assert.strictEqual(headers['x-frame-options'], 'DENY', 'X-Frame-Options mismatch'); + + // Referrer-Policy + assert.strictEqual(headers['referrer-policy'], 'strict-origin-when-cross-origin', 'Referrer-Policy mismatch'); + }); + + await t.test('error responses include security headers', async () => { + const res = await get('/dashboard/nonexistent.html'); + assert.strictEqual(res.statusCode, 404); + + const headers = res.headers; + assert.strictEqual(headers['x-content-type-options'], 'nosniff'); + assert.strictEqual(headers['x-frame-options'], 'DENY'); + }); }); From aad5e46b312db086cef693ea1bae118e88e6ca41 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:29:23 +0000 Subject: [PATCH 12/19] fix(dashboard): whitelist exposed torch-config.json fields Restrict the /torch-config.json endpoint to only return fields required by the dashboard frontend (dashboard config excluding auth, and nostrLock namespace/relays). This prevents exposure of sensitive internal configuration such as rosters, scheduler settings, and full relay lists. - Modify src/dashboard.mjs to sanitize config using an allowlist approach. - Add test/dashboard-config-exposure.test.mjs to verify sensitive fields are stripped. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/dashboard.mjs | 20 +++- test/dashboard-config-exposure.test.mjs | 116 ++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 test/dashboard-config-exposure.test.mjs diff --git a/src/dashboard.mjs b/src/dashboard.mjs index 740357a..6c93ee2 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -105,14 +105,24 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. const rawConfig = JSON.parse(configContent); const safeConfig = parseTorchConfig(rawConfig); - // Security: Sanitize sensitive fields - if (safeConfig.dashboard) { - delete safeConfig.dashboard.auth; + // Security: Whitelist only fields required by the dashboard frontend + const publicConfig = { + dashboard: safeConfig.dashboard ? { ...safeConfig.dashboard } : {}, + nostrLock: safeConfig.nostrLock + ? { + namespace: safeConfig.nostrLock.namespace, + relays: safeConfig.nostrLock.relays, + } + : {}, + }; + + // Remove sensitive dashboard auth + if (publicConfig.dashboard.auth) { + delete publicConfig.dashboard.auth; } - delete safeConfig.configPath; res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(safeConfig, null, 2)); + res.end(JSON.stringify(publicConfig, null, 2)); return; } catch (err) { console.error('Error parsing torch-config.json:', err); diff --git a/test/dashboard-config-exposure.test.mjs b/test/dashboard-config-exposure.test.mjs new file mode 100644 index 0000000..cc570a3 --- /dev/null +++ b/test/dashboard-config-exposure.test.mjs @@ -0,0 +1,116 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cmdDashboard } from '../src/dashboard.mjs'; + +const tempConfigPath = path.resolve('torch-config-exposure-test.json'); + +const SENSITIVE_CONFIG = { + nostrLock: { + namespace: 'test-namespace', + relays: ['wss://relay.example.com'], + dailyRoster: ['user1', 'user2'], // Sensitive roster + weeklyRoster: ['user3'], + ttlSeconds: 3600, + queryTimeoutMs: 5000, + publishTimeoutMs: 5000, + minSuccessfulRelayPublishes: 2, + minActiveRelayPool: 1, + }, + dashboard: { + auth: 'secret-auth-string', // stripped by auth check + relays: ['wss://dashboard.relay.example.com'], + }, + scheduler: { + paused: { + daily: ['user1'], + }, + firstPromptByCadence: { + daily: 'some prompt', + }, + }, +}; + +test('Dashboard Configuration Exposure Security', async (t) => { + // Setup temp config + fs.writeFileSync(tempConfigPath, JSON.stringify(SENSITIVE_CONFIG, null, 2)); + + // Override env to use this config + const originalEnvPath = process.env.TORCH_CONFIG_PATH; + const originalAuth = process.env.TORCH_DASHBOARD_AUTH; + + process.env.TORCH_CONFIG_PATH = tempConfigPath; + // Disable auth via env so we can access the endpoint without credentials + // The vulnerability is about exposure of *other* fields. + // We need to bypass the basic auth middleware to test the JSON response. + // However, cmdDashboard checks config.dashboard.auth too. + // If we want to test that auth is stripped from JSON, we should enable auth but provide credentials. + // OR we can just test the sanitization logic which runs regardless of how we got there? + // No, the endpoint logic runs after auth check. + + // To keep it simple: we want to verify that IF we can access the endpoint, the sensitive data is gone. + // We can simulate an authorized request or disable auth. + // If we disable auth in config, we can access it. + + // Let's modify the config for the test to NOT have auth, so we can access it freely. + // We want to verify that things like 'dailyRoster' are stripped. + const configForTest = { ...SENSITIVE_CONFIG }; + delete configForTest.dashboard.auth; + fs.writeFileSync(tempConfigPath, JSON.stringify(configForTest, null, 2)); + + let server; + let serverPort; + + // Cleanup function + t.after(() => { + if (server) server.close(); + if (fs.existsSync(tempConfigPath)) fs.unlinkSync(tempConfigPath); + if (originalEnvPath) process.env.TORCH_CONFIG_PATH = originalEnvPath; + else delete process.env.TORCH_CONFIG_PATH; + }); + + // Start server + await new Promise((resolve) => { + cmdDashboard(0).then((srv) => { + server = srv; + serverPort = server.address().port; + resolve(); + }); + }); + + // Fetch config + const responseBody = await new Promise((resolve, reject) => { + http.get(`http://localhost:${serverPort}/torch-config.json`, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve(data)); + }).on('error', reject); + }); + + let exposedConfig; + try { + exposedConfig = JSON.parse(responseBody); + } catch (e) { + assert.fail(`Failed to parse JSON response: ${responseBody}`); + } + + // 1. Assert sensitive fields are NOT present + assert.equal(exposedConfig.nostrLock?.dailyRoster, undefined, 'dailyRoster should NOT be exposed'); + assert.equal(exposedConfig.nostrLock?.weeklyRoster, undefined, 'weeklyRoster should NOT be exposed'); + assert.equal(exposedConfig.nostrLock?.relayFallbacks, undefined, 'relayFallbacks should NOT be exposed'); + assert.equal(exposedConfig.scheduler, undefined, 'scheduler config should NOT be exposed'); + assert.equal(exposedConfig.raw, undefined, 'raw config should NOT be exposed'); + + // 2. Assert required fields ARE present + assert.ok(exposedConfig.dashboard, 'dashboard config should be present'); + // Note: cmdDashboard loads from file, parseTorchConfig normalizes keys. + // check dashboard.relays + assert.deepEqual(exposedConfig.dashboard.relays, ['wss://dashboard.relay.example.com'], 'dashboard relays should be present'); + + assert.ok(exposedConfig.nostrLock, 'nostrLock config should be present'); + assert.equal(exposedConfig.nostrLock.namespace, 'test-namespace', 'nostrLock namespace should be present'); + assert.deepEqual(exposedConfig.nostrLock.relays, ['wss://relay.example.com'], 'nostrLock relays should be present'); +}); From ace069cc199d4c195a7a87b8ec99cdaadc314f5a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:30:11 +0000 Subject: [PATCH 13/19] perf(pruner): optimize groupNearDuplicates to O(N log N) from O(N^2) Sorts memories by created_at and uses an early exit condition in the inner loop to avoid checking all pairs. This reduces complexity from O(N^2) to roughly O(N * k) where k is the number of items within the duplicate window. Measured improvement for 30k items: ~2.1s -> ~0.25s (8x speedup). Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- perf/benchmark-pruner.mjs | 44 +++++++++++++++++++++++++++++++++++ src/services/memory/pruner.js | 19 ++++++++++----- 2 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 perf/benchmark-pruner.mjs diff --git a/perf/benchmark-pruner.mjs b/perf/benchmark-pruner.mjs new file mode 100644 index 0000000..044532f --- /dev/null +++ b/perf/benchmark-pruner.mjs @@ -0,0 +1,44 @@ +import { createLifecyclePlan } from '../src/services/memory/pruner.js'; +import crypto from 'node:crypto'; + +const COUNT = 30000; +const DUPLICATE_WINDOW_MS = 1000 * 60 * 60; // 1 hour + +function createMemory(i) { + return { + id: `mem-${i}`, + agent_id: 'agent-1', + session_id: 's1', + type: 'event', + content: `memory content ${i}`, + summary: `memory summary ${i}`, + tags: ['tag-a', 'tag-b'], + importance: 0.5, + embedding_id: null, + created_at: Date.now() - Math.floor(Math.random() * 10 * 24 * 60 * 60 * 1000), // Random time in last 10 days + last_seen: Date.now(), + source: 'ingest', + ttl_days: null, + merged_into: null, + pinned: false, + }; +} + +const memories = Array.from({ length: COUNT }, (_, i) => createMemory(i)); + +// Mock embedding function +const getEmbedding = (memory) => { + // Generate a pseudo-random embedding based on ID to be deterministic but varied + const val = parseInt(memory.id.split('-')[1]) % 100; + return [val, 100 - val]; +}; + +console.time('createLifecyclePlan'); +await createLifecyclePlan(memories, { + now: Date.now(), + retentionMs: 30 * 24 * 60 * 60 * 1000, + duplicateWindowMs: DUPLICATE_WINDOW_MS, + getEmbedding, + generateSummary: async () => JSON.stringify({ summary: 'merged', importance: 0.5 }), +}); +console.timeEnd('createLifecyclePlan'); diff --git a/src/services/memory/pruner.js b/src/services/memory/pruner.js index bf996d6..cd5f25e 100644 --- a/src/services/memory/pruner.js +++ b/src/services/memory/pruner.js @@ -67,22 +67,29 @@ function evaluateCandidate(memory, policy) { * @param {{ getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, similarityThreshold: number, duplicateWindowMs: number }} options */ function groupNearDuplicates(memories, options) { + // Sort by created_at to enable early exit in the inner loop + const sortedMemories = [...memories].sort((a, b) => a.created_at - b.created_at); + const groups = []; const consumed = new Set(); - for (let i = 0; i < memories.length; i += 1) { - const base = memories[i]; + for (let i = 0; i < sortedMemories.length; i += 1) { + const base = sortedMemories[i]; if (consumed.has(base.id) || base.merged_into) continue; const baseEmbedding = options.getEmbedding?.(base); const group = [base]; - for (let j = i + 1; j < memories.length; j += 1) { - const probe = memories[j]; + for (let j = i + 1; j < sortedMemories.length; j += 1) { + const probe = sortedMemories[j]; + + // Optimization: Since sorted by created_at, if the time difference exceeds the window, + // all subsequent items will also be outside the window. + if (probe.created_at - base.created_at > options.duplicateWindowMs) break; + if (consumed.has(probe.id) || probe.merged_into || probe.id === base.id) continue; - const withinWindow = Math.abs(base.created_at - probe.created_at) <= options.duplicateWindowMs; - if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue; + if (!hasTagOverlap(base.tags, probe.tags)) continue; const similarity = cosineSimilarity(baseEmbedding, options.getEmbedding?.(probe)); if (similarity >= options.similarityThreshold) { From 7305dd964d4911fb2bd8528540cff98e316f552b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:30:28 +0000 Subject: [PATCH 14/19] Optimize createLifecyclePlan with Promise.all Replaced sequential `await condenseGroup` calls with `Promise.all` to run condensation tasks concurrently. Added `scripts/benchmark-pruner.mjs` to measure performance improvement. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- scripts/benchmark-pruner.mjs | 135 ++++++++++++++++++++++++++++++++++ src/services/memory/pruner.js | 9 ++- 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 scripts/benchmark-pruner.mjs diff --git a/scripts/benchmark-pruner.mjs b/scripts/benchmark-pruner.mjs new file mode 100644 index 0000000..dae4f89 --- /dev/null +++ b/scripts/benchmark-pruner.mjs @@ -0,0 +1,135 @@ +import { createLifecyclePlan } from '../src/services/memory/pruner.js'; +import crypto from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +const ITERATIONS = 3; +const GROUPS = 100; +const GROUP_SIZE = 5; +const LATENCY_MS = 10; + +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 ?? 'default content', + summary: overrides.summary ?? 'default 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, + }; +} + +function generateMemories() { + const memories = []; + const now = Date.now(); + + for (let i = 0; i < GROUPS; i++) { + const groupId = `group-${i}`; + for (let j = 0; j < GROUP_SIZE; j++) { + memories.push(createMemory({ + id: `${groupId}-${j}`, + content: `Memory ${j} for group ${groupId}`, + summary: `Summary ${j} for group ${groupId}`, + tags: ['benchmark', groupId], + created_at: now - (j * 100), // Close in time + last_seen: now, + importance: 0.5, + })); + } + } + + // Add some random noise memories that won't group + for (let i = 0; i < 50; i++) { + memories.push(createMemory({ + id: `noise-${i}`, + content: `Noise memory ${i}`, + tags: [`noise-${i}`], // Unique tags to prevent grouping + created_at: now - (i * 100000), // Spread out + })); + } + + return memories; +} + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function runBenchmark() { + console.log(`Setting up benchmark: ${GROUPS} groups of ${GROUP_SIZE} memories + noise.`); + console.log(`Mock latency: ${LATENCY_MS}ms per group condensation.`); + + const memories = generateMemories(); + + // Prepare embeddings + const groupEmbeddings = new Map(); + for (let i = 0; i < GROUPS; i++) { + const vec = new Array(GROUPS).fill(0); + vec[i] = 1; + groupEmbeddings.set(`group-${i}`, vec); + } + + const getEmbeddingWrapper = (memory) => { + const groupIdTag = memory.tags.find(t => t.startsWith('group-')); + if (groupIdTag && groupEmbeddings.has(groupIdTag)) { + return groupEmbeddings.get(groupIdTag); + } + // Return a unique random vector for noise to prevent grouping + // But since they have unique tags, getEmbedding won't even be called for similarity check + // if tag overlap check fails first? + // groupNearDuplicates logic: + // ... if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue; + // So if tags don't overlap, it continues. + // So embedding doesn't matter for noise if tags are unique. + return [Math.random()]; + }; + + const options = { + retentionMs: 30 * 24 * 60 * 60 * 1000, // 30 days + duplicateWindowMs: 1000 * 60 * 60, // 1 hour + similarityThreshold: 0.99, + getEmbedding: getEmbeddingWrapper, + generateSummary: async (prompt) => { + await delay(LATENCY_MS); + return JSON.stringify({ + summary: 'Condensed summary based on prompt.', + importance: 0.8, + }); + }, + // Mock templateDir to avoid file reading issues if paths are tricky, + // but loadMemoryPromptTemplates reads from file system. + // It should work if run from root. + }; + + console.log(`Starting ${ITERATIONS} iterations...`); + + const times = []; + + for (let i = 0; i < ITERATIONS; i++) { + const start = performance.now(); + const plan = await createLifecyclePlan(memories, options); + const duration = performance.now() - start; + + // Verify grouping happened + const condensedCount = plan.condensedGroups.length; + // We expect exactly GROUPS condensed groups. + if (condensedCount !== GROUPS) { + console.warn(`Warning: Expected ${GROUPS} condensed groups, but got ${condensedCount}. Check grouping logic.`); + } + + times.push(duration); + console.log(`Iteration ${i + 1}: ${duration.toFixed(2)}ms`); + } + + const avg = times.reduce((a, b) => a + b, 0) / times.length; + console.log(`Average execution time: ${avg.toFixed(2)}ms`); +} + +runBenchmark().catch(console.error); diff --git a/src/services/memory/pruner.js b/src/services/memory/pruner.js index bf996d6..1c9aeae 100644 --- a/src/services/memory/pruner.js +++ b/src/services/memory/pruner.js @@ -189,12 +189,13 @@ export async function createLifecyclePlan(memories, options) { }); const templates = loadMemoryPromptTemplates({ templateDir: options.templateDir }); - const condensedGroups = []; + const condensedGroups = await Promise.all( + groups.map((group) => condenseGroup(group, options.generateSummary, templates.condense)) + ); + const mergedIntoMap = new Map(); - for (const group of groups) { - const condensed = await condenseGroup(group, options.generateSummary, templates.condense); - condensedGroups.push(condensed); + for (const condensed of condensedGroups) { for (const mergedId of condensed.mergedIds) { mergedIntoMap.set(mergedId, condensed.merged.id); } From cc932d31de7715542a8b0385d45cfd377d1114e3 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:30:29 +0000 Subject: [PATCH 15/19] refactor: extract hardcoded error classification strings in lock-ops.mjs - Introduced `PUBLISH_ERROR_CODES` constant for publish error classification. - Introduced `PUBLISH_FAILURE_CATEGORIES` constant for failure categorization. - Replaced hardcoded strings in `classifyPublishError`, `isTransientPublishCategory`, `LockPublisher.publish`, and `determineFailureCategory` with these constants. - Verified changes with existing tests. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/lock-ops.mjs | 59 +++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index 02b9075..34ce030 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -73,10 +73,27 @@ function relayListLabel(relays) { return relays.join(', '); } +const PUBLISH_ERROR_CODES = { + TIMEOUT: 'publish_timeout', + DNS: 'dns_resolution', + TCP: 'tcp_connect_timeout', + TLS: 'tls_handshake', + WEBSOCKET: 'websocket_open_failure', + NETWORK: 'network_timeout', + CONNECTION_RESET: 'connection_reset', + RELAY_UNAVAILABLE: 'relay_unavailable', + PERMANENT: 'permanent_validation_error', +}; + +const PUBLISH_FAILURE_CATEGORIES = { + QUORUM_FAILURE: 'relay_publish_quorum_failure', + NON_RETRYABLE: 'relay_publish_non_retryable', +}; + function classifyPublishError(message) { const normalized = String(message || '').toLowerCase(); if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) { - return 'publish_timeout'; + return PUBLISH_ERROR_CODES.TIMEOUT; } if ( normalized.includes('enotfound') @@ -84,14 +101,14 @@ function classifyPublishError(message) { || normalized.includes('getaddrinfo') || (normalized.includes('dns') && normalized.includes('websocket')) ) { - return 'dns_resolution'; + return PUBLISH_ERROR_CODES.DNS; } if ( normalized.includes('connect etimedout') || normalized.includes('tcp connect timed out') || normalized.includes('connect timeout') ) { - return 'tcp_connect_timeout'; + return PUBLISH_ERROR_CODES.TCP; } if ( normalized.includes('tls') @@ -99,20 +116,20 @@ function classifyPublishError(message) { || normalized.includes('certificate') || normalized.includes('handshake') ) { - return 'tls_handshake'; + return PUBLISH_ERROR_CODES.TLS; } if ( normalized.includes('websocket') || normalized.includes('bad response') || normalized.includes('unexpected server response') ) { - return 'websocket_open_failure'; + return PUBLISH_ERROR_CODES.WEBSOCKET; } if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) { - return 'network_timeout'; + return PUBLISH_ERROR_CODES.NETWORK; } if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) { - return 'connection_reset'; + return PUBLISH_ERROR_CODES.CONNECTION_RESET; } if ( normalized.includes('unavailable') @@ -122,21 +139,21 @@ function classifyPublishError(message) { || normalized.includes('enotfound') || normalized.includes('503') ) { - return 'relay_unavailable'; + return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE; } - return 'permanent_validation_error'; + return PUBLISH_ERROR_CODES.PERMANENT; } function isTransientPublishCategory(category) { return [ - 'publish_timeout', - 'dns_resolution', - 'tcp_connect_timeout', - 'tls_handshake', - 'websocket_open_failure', - 'network_timeout', - 'connection_reset', - 'relay_unavailable', + PUBLISH_ERROR_CODES.TIMEOUT, + PUBLISH_ERROR_CODES.DNS, + PUBLISH_ERROR_CODES.TCP, + PUBLISH_ERROR_CODES.TLS, + PUBLISH_ERROR_CODES.WEBSOCKET, + PUBLISH_ERROR_CODES.NETWORK, + PUBLISH_ERROR_CODES.CONNECTION_RESET, + PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE, ].includes(category); } @@ -495,7 +512,7 @@ export class LockPublisher { let lastAttemptState = null; const retryTimeline = []; const overallStartedAt = Date.now(); - let terminalFailureCategory = 'relay_publish_non_retryable'; + let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE; for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) { const startedAtMs = Date.now(); @@ -583,11 +600,11 @@ export class LockPublisher { determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) { if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) { - return 'relay_publish_quorum_failure'; + return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE; } else if (hasPermanentFailure) { - return 'relay_publish_non_retryable'; + return PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE; } else { - return 'relay_publish_quorum_failure'; + return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE; } } From 5fe9e8a1f938f40c7cf3dd1760eb46724513b375 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:31:18 +0000 Subject: [PATCH 16/19] Fix: Strengthen path traversal protection in dashboard.mjs - Replaced brittle regex-based sanitization with `path.resolve` relative to `packageRoot`. - Added `test/dashboard-path-traversal-fix.test.mjs` to verify robust protection against traversal attacks. - Verified that legitimate access to allowed paths is maintained. - Verified that access to restricted files (like package.json) is blocked. - Updated path handling to correctly handle absolute paths and URL-encoded characters. This change addresses a potential security vulnerability where the previous sanitization logic could be bypassed in certain environments or with specific edge cases. By using standard path resolution and explicitly checking against a canonical root, the implementation is more secure and maintainable. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/dashboard.mjs | 6 +- test/dashboard-path-traversal-fix.test.mjs | 95 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 test/dashboard-path-traversal-fix.test.mjs diff --git a/src/dashboard.mjs b/src/dashboard.mjs index 740357a..65d2093 100644 --- a/src/dashboard.mjs +++ b/src/dashboard.mjs @@ -128,8 +128,10 @@ export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0. } // Security check: prevent directory traversal - const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), ''); - let filePath = path.join(packageRoot, safePath); + // Resolve path relative to packageRoot. + // We strip the leading slash from pathname (which comes from URL) to treat it as relative. + const relativePath = pathname.replace(/^\//, ''); + let filePath = path.resolve(packageRoot, relativePath); // Security check: restrict access to allowed paths const allowedPaths = [ diff --git a/test/dashboard-path-traversal-fix.test.mjs b/test/dashboard-path-traversal-fix.test.mjs new file mode 100644 index 0000000..889f403 --- /dev/null +++ b/test/dashboard-path-traversal-fix.test.mjs @@ -0,0 +1,95 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { cmdDashboard } from '../src/dashboard.mjs'; + +test('Dashboard Path Traversal Security Fix', async (t) => { + let testPort = 0; + let server; + + t.beforeEach(async () => { + // Start dashboard on random port + server = await cmdDashboard(0); + testPort = server.address().port; + }); + + t.afterEach((done) => { + if (server) server.close(done); + else done(); + }); + + function get(path) { + return new Promise((resolve, reject) => { + const req = http.get({ + host: '127.0.0.1', + port: testPort, + path: path + }, (res) => { + // Consume data to avoid memory leaks + res.resume(); + resolve(res); + }); + req.on('error', reject); + }); + } + + // --- Valid Access --- + await t.test('allows access to valid dashboard index', async () => { + const res = await get('/dashboard/index.html'); + assert.strictEqual(res.statusCode, 200, 'Should allow access to dashboard index'); + }); + + // --- Traversal Attempts --- + const traversalPayloads = [ + '/../../etc/passwd', + '/dashboard/../../etc/passwd', + '/dashboard/../../../etc/passwd', + '/../package.json', + '/dashboard/../package.json', + '/dashboard/%2e%2e/package.json', // URL Encoded .. + '/dashboard/%2e%2e%2fpackage.json', // URL Encoded ../ + '/..%2fpackage.json', + '/%2e%2e%2fpackage.json', + '/dashboard/..%5cpackage.json', // Encoded backslash + '/dashboard/..%252fpackage.json', // Double encoded + '/dashboard/....//package.json', // Weird dots + '/dashboard/.../package.json', // Three dots + '//etc/passwd', // Double slash + '/dashboard//../../etc/passwd', + ]; + + for (const payload of traversalPayloads) { + await t.test(`blocks traversal attempt: ${payload}`, async () => { + const res = await get(payload); + // Depending on how it's resolved, it might be 403 (Forbidden) or 404 (Not Found) + // If it escapes allowed paths, it should be 403. + // If it stays within allowed paths but file doesn't exist, 404. + // But we want to ensure it doesn't return 200 for sensitive files if they existed. + // Since we don't know if /etc/passwd exists on test environment, we assume 403 or 404 is safe. + // However, for package.json (which exists in root), it MUST be 403 because it's not in allowed paths. + + // If the payload targets a file outside allowed paths (like package.json), it MUST be 403. + // If the payload targets a file that doesn't exist but is outside allowed paths, it MUST be 403. + + // The current logic returns 403 if not in allowedPaths. + assert.notStrictEqual(res.statusCode, 200, `Should not return 200 for ${payload}`); + + // If it targets package.json, we expect 403 because it exists but is restricted. + // EXCEPTION: If the payload uses encoded slashes (%2f) that are NOT decoded by URL parser, + // it is treated as a filename, not a separator. Thus it looks for a file with %2f in name, + // which doesn't exist -> 404. This is SAFE. + // So for package.json payloads, we accept 403 (blocked traversal) or 404 (failed traversal/not found). + if (payload.includes('package.json')) { + assert.ok([403, 404].includes(res.statusCode), `Should return 403 or 404 for ${payload}, got ${res.statusCode}`); + } + }); + } + + // --- Allowed Traversal (within allowed scope) --- + await t.test('allows traversal within allowed paths', async () => { + // /dashboard/../dashboard/index.html -> /dashboard/index.html (Allowed) + const res = await get('/dashboard/../dashboard/index.html'); + assert.strictEqual(res.statusCode, 200, 'Should allow traversal that stays within allowed paths'); + }); +}); From 850be9c37d2a5957dd8a469f6ba58ba6f7183249 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:31:22 +0000 Subject: [PATCH 17/19] perf: Optimize textMatchSimilarity in memory retriever Avoids redundant string concatenation and lowercasing in the inner loop of `buildSimilarityIndex`. Pre-computes query terms and regexes once per query. Uses RegExp.test for case-insensitive matching without allocation. Benchmark shows ~2x speedup (99ms vs 191ms for 10k memories). Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/services/memory/retriever.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/services/memory/retriever.js b/src/services/memory/retriever.js index 8b3d76f..0a69450 100644 --- a/src/services/memory/retriever.js +++ b/src/services/memory/retriever.js @@ -31,17 +31,20 @@ function computeRecencyDecay(timestamp, now, halfLifeMs) { return Math.exp(-(age / halfLifeMs)); } -function textMatchSimilarity(query, memory) { - const normalizedQuery = String(query ?? '').trim().toLowerCase(); - if (!normalizedQuery) return 0; +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} - const haystack = `${memory.summary} ${memory.content}`.toLowerCase(); - if (haystack.includes(normalizedQuery)) return 1; +function textMatchSimilarity(queryTerms, queryRegexes, memory) { + if (!queryTerms || queryTerms.length === 0) return 0; - const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean); - if (queryTerms.length === 0) return 0; + let matchedTerms = 0; + for (const regex of queryRegexes) { + if (regex.test(memory.summary) || regex.test(memory.content)) { + matchedTerms++; + } + } - const matchedTerms = queryTerms.filter((term) => haystack.includes(term)).length; return matchedTerms / queryTerms.length; } @@ -102,8 +105,11 @@ async function buildSimilarityIndex(memories, params) { } } + const queryTerms = normalizedQuery.toLowerCase().split(/\s+/).filter(Boolean); + const queryRegexes = queryTerms.map((t) => new RegExp(escapeRegExp(t), 'i')); + for (const memory of memories) { - const lexicalScore = textMatchSimilarity(normalizedQuery, memory); + const lexicalScore = textMatchSimilarity(queryTerms, queryRegexes, memory); if (lexicalScore > 0) { similarityById.set(memory.id, lexicalScore); } From 0f29e9126ed6617d37faf1d596a14c9d5dda362b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:32:03 +0000 Subject: [PATCH 18/19] perf: parallelize applyLifecycleActions for faster pruning - Use Promise.all to execute memory lifecycle actions concurrently. - Maintain intra-item dependency order (merged_into before action). - Update tests to support non-deterministic execution order of independent items. - Benchmark shows ~65x improvement for 100 items with 10ms latency. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- src/services/memory/pruner.js | 8 +++----- test/memory-pruner.test.mjs | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/services/memory/pruner.js b/src/services/memory/pruner.js index bf996d6..5619ce1 100644 --- a/src/services/memory/pruner.js +++ b/src/services/memory/pruner.js @@ -265,9 +265,7 @@ export async function createLifecyclePlan(memories, options) { * @param {Awaited>} plan */ export async function applyLifecycleActions(repository, plan) { - const results = []; - - for (const decision of plan.actions) { + const results = await Promise.all(plan.actions.map(async (decision) => { if (decision.merged_into && typeof repository.markMerged === 'function') { await repository.markMerged(decision.id, decision.merged_into); } @@ -280,8 +278,8 @@ export async function applyLifecycleActions(repository, plan) { await repository.keepMemory?.(decision.id, decision.reason); } - results.push(decision); - } + return decision; + })); return { applied: results, diff --git a/test/memory-pruner.test.mjs b/test/memory-pruner.test.mjs index 4957c44..7dd378e 100644 --- a/test/memory-pruner.test.mjs +++ b/test/memory-pruner.test.mjs @@ -138,11 +138,22 @@ test('applyLifecycleActions executes keep/archive/delete and merge markers', asy policyLogs: [], }); - assert.deepEqual(calls, [ - ['keepMemory', 'k1', 'keep reason'], - ['markMerged', 'a1', 'k1'], + // Sort calls to verify presence regardless of execution order + const sortedCalls = [...calls].sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])); + const expectedCalls = [ ['archiveMemory', 'a1', 'archive reason'], ['deleteMemory', 'd1', 'delete reason'], - ]); + ['keepMemory', 'k1', 'keep reason'], + ['markMerged', 'a1', 'k1'], + ]; + + assert.deepEqual(sortedCalls, expectedCalls); + + // Verify that markMerged happens before archiveMemory for the same ID + const markIndex = calls.findIndex((c) => c[0] === 'markMerged' && c[1] === 'a1'); + const archiveIndex = calls.findIndex((c) => c[0] === 'archiveMemory' && c[1] === 'a1'); + assert.ok(markIndex !== -1, 'markMerged was called'); + assert.ok(archiveIndex !== -1, 'archiveMemory was called'); + assert.ok(markIndex < archiveIndex, 'markMerged should precede archiveMemory'); assert.equal(result.applied.length, 3); }); From a9f30f13f9b6367d26d15adfabcaa35eac6a91de Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:33:59 +0000 Subject: [PATCH 19/19] feat(perf): cache relay ranking in RelayHealthManager - Implement caching for `rankRelays` to avoid redundant sorting and computations. - Cache is invalidated on new outcomes or when quarantine status changes. - Add `_metricsVersion` to track metrics updates. - Benchmarks show ~3x performance improvement (0.06ms -> 0.02ms per call for 100 relays). - Add unit tests for caching behavior and invalidation. - Add `perf/benchmark-lock-ops.mjs`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- perf/benchmark-lock-ops.mjs | 41 +++++++++++++++ src/lock-ops.mjs | 82 +++++++++++++++++++++++------- test/relay-health-manager.test.mjs | 71 ++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 perf/benchmark-lock-ops.mjs diff --git a/perf/benchmark-lock-ops.mjs b/perf/benchmark-lock-ops.mjs new file mode 100644 index 0000000..2f85aa1 --- /dev/null +++ b/perf/benchmark-lock-ops.mjs @@ -0,0 +1,41 @@ + +import { RelayHealthManager } from '../src/lock-ops.mjs'; + +const healthManager = new RelayHealthManager(); + +// Setup mock relays +const relays = []; +for (let i = 0; i < 100; i++) { + relays.push(`wss://relay-${i}.example.com`); +} + +const config = { + rollingWindowSize: 10, + failureThreshold: 3, + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 60000, + snapshotIntervalMs: 10000, + minActiveRelayPool: 5, +}; + +// Populate some metrics +for (const relay of relays) { + // Simulate some outcomes + for (let i = 0; i < 5; i++) { + healthManager.recordOutcome(relay, Math.random() > 0.1, null, Math.random() * 200, config); + } +} + +// Benchmark rankRelays +const start = performance.now(); +const iterations = 10000; + +for (let i = 0; i < iterations; i++) { + healthManager.rankRelays(relays, config); +} + +const end = performance.now(); +const duration = end - start; + +console.log(`RankRelays x ${iterations}: ${duration.toFixed(2)}ms`); +console.log(`Average per call: ${(duration / iterations).toFixed(4)}ms`); diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index 02b9075..93d0497 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -188,6 +188,8 @@ export class RelayHealthManager { constructor() { this.metricsByRelay = new Map(); this.lastSnapshotAt = 0; + this._metricsVersion = 0; + this._sortedCache = null; } ensureMetrics(relay, config) { @@ -203,31 +205,72 @@ export class RelayHealthManager { lastResultAt: null, }; this.metricsByRelay.set(relay, metrics); + this._metricsVersion += 1; } return metrics; } rankRelays(relays, config, nowMs = Date.now()) { - const summaries = relays.map((relay) => { - const metrics = this.ensureMetrics(relay, config); - const summary = summarizeRelayMetrics(metrics, nowMs); - return { - relay, - summary, - score: computeRelayScore(summary), - }; - }); + for (const relay of relays) { + this.ensureMetrics(relay, config); + } - return summaries.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; - if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { - if (a.summary.averageLatencyMs === null) return 1; - if (b.summary.averageLatencyMs === null) return -1; - return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + if ( + !this._sortedCache + || this._sortedCache.version !== this._metricsVersion + || nowMs >= this._sortedCache.validUntil + ) { + let minQuarantineUntil = Infinity; + const entries = []; + + for (const metrics of this.metricsByRelay.values()) { + const summary = summarizeRelayMetrics(metrics, nowMs); + const score = computeRelayScore(summary); + if (metrics.quarantineUntil > nowMs && metrics.quarantineUntil < minQuarantineUntil) { + minQuarantineUntil = metrics.quarantineUntil; + } + entries.push({ + relay: metrics.relay, + summary, + score, + metrics, + }); } - return a.relay.localeCompare(b.relay); - }); + + entries.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; + if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { + if (a.summary.averageLatencyMs === null) return 1; + if (b.summary.averageLatencyMs === null) return -1; + return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + } + return a.relay.localeCompare(b.relay); + }); + + this._sortedCache = { + version: this._metricsVersion, + validUntil: minQuarantineUntil, + entries, + }; + } + + const requestedSet = new Set(relays); + return this._sortedCache.entries + .filter((entry) => requestedSet.has(entry.relay)) + .map((entry) => { + const quarantineRemainingMs = entry.metrics.quarantineUntil > nowMs + ? entry.metrics.quarantineUntil - nowMs + : 0; + return { + relay: entry.relay, + score: entry.score, + summary: { + ...entry.summary, + quarantineRemainingMs, + }, + }; + }); } prioritizeRelays(relays, config, nowMs = Date.now()) { @@ -275,6 +318,7 @@ export class RelayHealthManager { metrics.quarantineUntil = nowMs + metrics.cooldownMs; metrics.cooldownMs = Math.min(config.maxQuarantineCooldownMs, Math.floor(metrics.cooldownMs * 1.5)); } + this._metricsVersion += 1; } collectSnapshot(relays, config, nowMs = Date.now()) { @@ -300,6 +344,8 @@ export class RelayHealthManager { reset() { this.metricsByRelay.clear(); this.lastSnapshotAt = 0; + this._metricsVersion = 0; + this._sortedCache = null; } } diff --git a/test/relay-health-manager.test.mjs b/test/relay-health-manager.test.mjs index a9fe4ce..da65c39 100644 --- a/test/relay-health-manager.test.mjs +++ b/test/relay-health-manager.test.mjs @@ -69,4 +69,75 @@ describe('RelayHealthManager', () => { manager.reset(); assert.strictEqual(manager.metricsByRelay.size, 0); }); + + it('should cache ranked results and invalidate on outcome', () => { + const manager = new RelayHealthManager(); + const config = { + rollingWindowSize: 5, + failureThreshold: 2, + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 5000, + minActiveRelayPool: 1, + }; + const now = Date.now(); + + manager.recordOutcome('wss://r1', true, null, 100, config, now); + manager.recordOutcome('wss://r2', true, null, 200, config, now); + + const result1 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Internal cache should be populated + assert.ok(manager._sortedCache); + assert.strictEqual(manager._sortedCache.version, manager._metricsVersion); + + const result2 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Should be same objects (deep equality check on content, but references might differ due to map) + // Actually rankRelays returns new objects always. + assert.deepStrictEqual(result1, result2); + + // Modify outcome + manager.recordOutcome('wss://r1', false, 'timeout', 1000, config, now); + // Version should increase + assert.ok(manager._sortedCache.version < manager._metricsVersion); + + const result3 = manager.rankRelays(['wss://r1', 'wss://r2'], config, now); + // Cache should be updated + assert.strictEqual(manager._sortedCache.version, manager._metricsVersion); + assert.notDeepStrictEqual(result1, result3); + }); + + it('should invalidate cache when time passes quarantine expiry', () => { + const manager = new RelayHealthManager(); + const config = { + rollingWindowSize: 5, + failureThreshold: 1, // fails immediately + quarantineCooldownMs: 1000, + maxQuarantineCooldownMs: 5000, + minActiveRelayPool: 1, + }; + let now = 10000; + + manager.recordOutcome('wss://r1', false, 'fail', 100, config, now); + // r1 is quarantined until 11000 + + const result1 = manager.rankRelays(['wss://r1'], config, now); + assert.strictEqual(result1[0].summary.quarantined, true); + assert.strictEqual(manager._sortedCache.validUntil, 11000); + + // Advance time but before expiry + now = 10500; + const result2 = manager.rankRelays(['wss://r1'], config, now); + // Cache should still be valid (version same, now < validUntil) + // Quarantine remaining should be less + assert.strictEqual(result2[0].summary.quarantined, true); + assert.strictEqual(result2[0].summary.quarantineRemainingMs, 500); + + // Advance time past expiry + now = 11001; + const result3 = manager.rankRelays(['wss://r1'], config, now); + // Cache should be invalidated because now >= validUntil + assert.strictEqual(result3[0].summary.quarantined, false); + assert.strictEqual(result3[0].summary.quarantineRemainingMs, 0); + // validUntil should be Infinity (no quarantined relays) + assert.strictEqual(manager._sortedCache.validUntil, Infinity); + }); });