mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-03 19:34:24 +00:00
Merge pull request #332 from PR0M3TH3AN/claude/review-project-improvements-nTZRz
Refactor dashboard script to external module and improve code quality
This commit is contained in:
40 files changed
+1167
-949
No files matched your search
@@ -0,0 +1,47 @@
|
||||
# TORCH Environment Variables
|
||||
# Copy this file to .env and fill in the values for your deployment.
|
||||
|
||||
# ─── Dashboard ────────────────────────────────────────────────────────
|
||||
# Basic Auth credentials for the dashboard (user:password format)
|
||||
# TORCH_DASHBOARD_AUTH=admin:changeme
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────
|
||||
# Path to torch-config.json (optional — overrides default lookup)
|
||||
# TORCH_CONFIG_PATH=./torch-config.json
|
||||
|
||||
# ─── Agent Platform Detection ─────────────────────────────────────────
|
||||
# Override automatic platform detection (jules | claude | codex | goose)
|
||||
# AGENT_PLATFORM=
|
||||
# AGENT_MODEL=
|
||||
|
||||
# Platform-specific API keys (used for automatic platform detection)
|
||||
# CLAUDE_API_KEY=
|
||||
# JULES_API_KEY=
|
||||
# CODEX_API_KEY=
|
||||
# GOOSE_API_KEY=
|
||||
|
||||
# ─── Nostr Lock Configuration ────────────────────────────────────────
|
||||
# Comma-separated relay URLs (overrides config file)
|
||||
# NOSTR_LOCK_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
|
||||
|
||||
# Lock TTL in seconds (default: 7200)
|
||||
# NOSTR_LOCK_TTL=7200
|
||||
|
||||
# Relay connection timeout in milliseconds (default: 8000)
|
||||
# NOSTR_LOCK_TIMEOUT=8000
|
||||
|
||||
# Minimum relay quorum for successful publish (default: 1)
|
||||
# NOSTR_LOCK_QUORUM=1
|
||||
|
||||
# ─── Memory Subsystem ────────────────────────────────────────────────
|
||||
# Master toggle for the memory subsystem
|
||||
# TORCH_MEMORY_ENABLED=false
|
||||
|
||||
# Individual memory feature flags
|
||||
# TORCH_MEMORY_INGEST_ENABLED=false
|
||||
# TORCH_MEMORY_RETRIEVAL_ENABLED=false
|
||||
# TORCH_MEMORY_PRUNE_ENABLED=false
|
||||
# TORCH_MEMORY_PRUNE_MODE=off
|
||||
|
||||
# ─── Telemetry (opt-in) ──────────────────────────────────────────────
|
||||
# TELEMETRY=0
|
||||
@@ -0,0 +1,17 @@
|
||||
## Summary
|
||||
|
||||
<!-- 1-3 bullet points describing what this PR does and why -->
|
||||
|
||||
-
|
||||
|
||||
## Test plan
|
||||
|
||||
<!-- How was this tested? -->
|
||||
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm test` passes
|
||||
- [ ] Manual verification (if applicable)
|
||||
|
||||
## Related issues
|
||||
|
||||
<!-- Link related issues: Fixes #123, Closes #456 -->
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
@@ -9,14 +9,21 @@ on:
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22, 23]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 22
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
- name: Check innerHTML usage
|
||||
run: node scripts/check-innerhtml.mjs --report
|
||||
- name: Security audit
|
||||
run: npm audit --omit=dev --audit-level=high || true
|
||||
- run: npm test
|
||||
+61
-8
@@ -16,11 +16,17 @@ Thank you for your interest in contributing to TORCH!
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Copy the environment template (optional):
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Building
|
||||
|
||||
This project requires a build step for certain artifacts (e.g., distribution files, dashboard assets).
|
||||
This project requires a build step for distribution artifacts (dashboard, landing page, tarball).
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -28,32 +34,79 @@ npm run build
|
||||
|
||||
### Testing
|
||||
|
||||
Run the test suite to ensure your changes don't break existing functionality.
|
||||
Run the full test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
For faster feedback loop on unit tests (excluding integration):
|
||||
For faster feedback on lock-backend unit tests:
|
||||
|
||||
```bash
|
||||
npm run test:unit:lock-backend
|
||||
```
|
||||
|
||||
For integration/resilience tests:
|
||||
|
||||
```bash
|
||||
npm run test:extended-main
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
Ensure your code follows the project's style guidelines.
|
||||
Ensure your code follows the project's style guidelines:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
ESLint is configured with `no-unused-vars` as an error. Prefix intentionally unused parameters with `_` (e.g., `_err`, `_unused`).
|
||||
|
||||
## Commit Conventions
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
feat(scope): add new feature
|
||||
fix(scope): fix a bug
|
||||
chore(scope): maintenance task
|
||||
docs(scope): documentation update
|
||||
test(scope): add or fix tests
|
||||
```
|
||||
|
||||
Common scopes: `dashboard`, `scheduler`, `lock-ops`, `memory`, `ci`, `tests`, `security`.
|
||||
|
||||
## Adding a New Agent Prompt
|
||||
|
||||
Agent prompts live in `src/prompts/daily/` and `src/prompts/weekly/`. To add a new agent:
|
||||
|
||||
1. Create a markdown file following the naming convention: `<agent-name>.md`
|
||||
2. Add the agent to the roster in `src/prompts/roster.json`
|
||||
3. Run `npm run validate:scheduler` to verify roster/prompt consistency
|
||||
|
||||
See existing prompts for the expected format.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Core library:** `src/lib.mjs` — CLI command dispatch
|
||||
- **Lock operations:** `src/lock-ops.mjs` — Nostr relay publish/query with health tracking
|
||||
- **Dashboard:** `dashboard/` — Static web UI served by `src/dashboard.mjs`
|
||||
- **Memory subsystem:** `src/services/memory/` — Event ingestion, retrieval, pruning
|
||||
- **Scheduler:** `scripts/agent/run-scheduler-cycle.mjs` — Orchestrates agent runs
|
||||
|
||||
For detailed architecture docs, see `docs/lib-overview.md` and `docs/lock-ops-overview.md`.
|
||||
|
||||
## Test Integrity
|
||||
|
||||
When modifying tests, follow the test integrity protocol in `CLAUDE.md`. Tests are behavioral specifications — never weaken assertions to make them pass.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Please follow the repository conventions for Pull Requests.
|
||||
|
||||
> **Note:** Detailed agent policies and conventions are normally documented in `AGENTS.md`. If this file is missing, please refer to existing patterns in the codebase or open an issue for clarification.
|
||||
- Keep PR titles short (under 70 characters)
|
||||
- Include a summary of changes and test plan in the description
|
||||
- Ensure `npm run lint` and `npm test` pass before submitting
|
||||
- Reference related issues where applicable
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you encounter any bugs or have feature requests, please open an issue on GitHub.
|
||||
If you encounter bugs or have feature requests, please open an issue on GitHub.
|
||||
@@ -0,0 +1,797 @@
|
||||
/* global marked */
|
||||
import { KIND_APP_DATA } from '../src/constants.mjs';
|
||||
import { createElement } from './domUtils.js';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal Logic
|
||||
// -----------------------------------------------------------------------
|
||||
const docsModal = document.getElementById("docsModal");
|
||||
const settingsModal = document.getElementById("settingsModal");
|
||||
const openDocsBtn = document.getElementById("openDocs");
|
||||
const openSettingsBtn = document.getElementById("settingsBtn");
|
||||
const closeDocsBtn = document.getElementsByClassName("close-modal")[0];
|
||||
const closeSettingsBtn = document.getElementById("closeSettings");
|
||||
const contentDiv = document.getElementById("markdown-content");
|
||||
let docsLoaded = false;
|
||||
|
||||
// Docs Modal
|
||||
if (openDocsBtn) {
|
||||
openDocsBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
docsModal.classList.add("show");
|
||||
if (!docsLoaded) {
|
||||
fetchDocs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closeDocsBtn) {
|
||||
closeDocsBtn.onclick = function() {
|
||||
docsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Modal
|
||||
if (openSettingsBtn) {
|
||||
openSettingsBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Pre-fill values
|
||||
document.getElementById('settingHashtag').value = HASHTAG;
|
||||
document.getElementById('settingNamespace').value = DASHBOARD_CONFIG.namespace;
|
||||
document.getElementById('settingRelays').value = RELAYS.join(', ');
|
||||
|
||||
settingsModal.classList.add("show");
|
||||
}
|
||||
}
|
||||
|
||||
if (closeSettingsBtn) {
|
||||
closeSettingsBtn.onclick = function() {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('cancelSettings').onclick = function() {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
|
||||
// Handle Settings Save
|
||||
document.getElementById('settingsForm').onsubmit = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const newHashtag = document.getElementById('settingHashtag').value.trim();
|
||||
const newNamespace = document.getElementById('settingNamespace').value.trim();
|
||||
const newRelays = document.getElementById('settingRelays').value.split(',').map(r => r.trim()).filter(Boolean);
|
||||
|
||||
if (!newHashtag) {
|
||||
alert('Hashtag is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = {
|
||||
hashtag: newHashtag,
|
||||
namespace: newNamespace,
|
||||
relays: newRelays
|
||||
};
|
||||
|
||||
// Save to LocalStorage
|
||||
localStorage.setItem('torch_dashboard_prefs', JSON.stringify(prefs));
|
||||
|
||||
// Update URL to reflect new settings (so link is shareable)
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('hashtag', newHashtag);
|
||||
url.searchParams.set('namespace', newNamespace);
|
||||
url.searchParams.set('relays', newRelays.join(','));
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Reload to apply
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target == docsModal) {
|
||||
docsModal.classList.remove("show");
|
||||
}
|
||||
if (event.target == settingsModal) {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDocs() {
|
||||
try {
|
||||
const response = await fetch('../src/docs/TORCH.md');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load documentation');
|
||||
}
|
||||
const text = await response.text();
|
||||
contentDiv.innerHTML = marked.parse(text);
|
||||
docsLoaded = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-danger';
|
||||
p.textContent = `Error loading documentation: ${error.message}`;
|
||||
contentDiv.replaceChildren(p);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Configuration — matches bin/torch-lock.mjs defaults
|
||||
// Optional URL params:
|
||||
// ?namespace=torch&hashtag=torch-agent-lock&relays=wss://relay.damus.io,wss://nos.lol
|
||||
// -----------------------------------------------------------------------
|
||||
const DASHBOARD_DEFAULTS = {
|
||||
namespace: 'torch',
|
||||
hashtag: '',
|
||||
defaultCadenceView: 'daily',
|
||||
defaultStatusView: 'active',
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
],
|
||||
};
|
||||
|
||||
function normalizeCadence(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'daily' || normalized === 'weekly' || normalized === 'all'
|
||||
? normalized
|
||||
: DASHBOARD_DEFAULTS.defaultCadenceView;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ['active', 'completed', 'all'].includes(normalized)
|
||||
? normalized
|
||||
: DASHBOARD_DEFAULTS.defaultStatusView;
|
||||
}
|
||||
|
||||
async function loadTorchConfigFile() {
|
||||
const configCandidates = ['../torch-config.json', './torch-config.json'];
|
||||
|
||||
for (const path of configCandidates) {
|
||||
try {
|
||||
const response = await fetch(path, { cache: 'no-store' });
|
||||
if (!response.ok) continue;
|
||||
return await response.json();
|
||||
} catch {
|
||||
// optional config, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseDashboardConfig(torchConfig = {}) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// 1. LocalStorage Preferences
|
||||
let localPrefs = {};
|
||||
try {
|
||||
localPrefs = JSON.parse(localStorage.getItem('torch_dashboard_prefs')) || {};
|
||||
} catch { /* ignore malformed localStorage */ }
|
||||
|
||||
const dashboardConfig = torchConfig.dashboard || {};
|
||||
const lockConfig = torchConfig.nostrLock || {};
|
||||
|
||||
// Hierarchy: URL Param > LocalStorage > Config File > Default
|
||||
|
||||
// Namespace
|
||||
const configNamespace = dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace;
|
||||
const namespace = (
|
||||
params.get('namespace') ||
|
||||
localPrefs.namespace ||
|
||||
configNamespace
|
||||
).trim() || DASHBOARD_DEFAULTS.namespace;
|
||||
|
||||
// Hashtag
|
||||
const configHashtag = dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag;
|
||||
const hashtag = (
|
||||
params.get('hashtag') ||
|
||||
localPrefs.hashtag ||
|
||||
configHashtag ||
|
||||
`${namespace}-agent-lock`
|
||||
).trim();
|
||||
|
||||
// Relays
|
||||
const paramsRelays = params.get('relays') ? params.get('relays').split(',') : null;
|
||||
const localRelays = localPrefs.relays;
|
||||
const configRelays = dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays;
|
||||
|
||||
let relays = (paramsRelays || localRelays || configRelays)
|
||||
.map(r => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (relays.length === 0) relays = DASHBOARD_DEFAULTS.relays;
|
||||
|
||||
const defaultCadenceView = normalizeCadence(dashboardConfig.defaultCadenceView);
|
||||
const defaultStatusView = normalizeStatus(dashboardConfig.defaultStatusView);
|
||||
|
||||
return {
|
||||
namespace,
|
||||
hashtag,
|
||||
relays,
|
||||
defaultCadenceView,
|
||||
defaultStatusView,
|
||||
};
|
||||
}
|
||||
|
||||
let DASHBOARD_CONFIG = parseDashboardConfig({});
|
||||
|
||||
let RELAYS = DASHBOARD_CONFIG.relays;
|
||||
const LOCK_EVENT_KIND = KIND_APP_DATA;
|
||||
let HASHTAG = DASHBOARD_CONFIG.hashtag;
|
||||
|
||||
function applyConfigToHelpText() {
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const hashtagDisplay = `#${HASHTAG}`;
|
||||
|
||||
const introHashtag = byId('introHashtag');
|
||||
if (introHashtag) introHashtag.textContent = hashtagDisplay;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
/** @type {Map<string, object>} dTag -> latest parsed lock */
|
||||
const lockStore = new Map();
|
||||
/** @type {WebSocket[]} */
|
||||
const sockets = [];
|
||||
let connectedCount = 0;
|
||||
let refreshIntervalId = null;
|
||||
let isTeardown = false;
|
||||
|
||||
// DOM refs
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const lockGrid = document.getElementById('lockGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const rawLog = document.getElementById('rawLog');
|
||||
const eventCount = document.getElementById('eventCount');
|
||||
const summaryBar = document.getElementById('summaryBar');
|
||||
const cadenceFilter = document.getElementById('cadenceFilter');
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
|
||||
// Pagination DOM refs
|
||||
const paginationControls = document.getElementById('paginationControls');
|
||||
const mobilePrevBtn = document.getElementById('mobilePrevBtn');
|
||||
const mobileNextBtn = document.getElementById('mobileNextBtn');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
const pageStartEl = document.getElementById('pageStart');
|
||||
const pageEndEl = document.getElementById('pageEnd');
|
||||
const totalItemsEl = document.getElementById('totalItems');
|
||||
|
||||
// Pagination State
|
||||
let currentPage = 1;
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cleanup check
|
||||
// -----------------------------------------------------------------------
|
||||
function isAlive() {
|
||||
if (isTeardown) return false;
|
||||
// Check if our root element is still in the DOM
|
||||
const stillInDom = document.body.contains(lockGrid);
|
||||
if (!stillInDom) {
|
||||
teardown();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (isTeardown) return;
|
||||
isTeardown = true;
|
||||
|
||||
if (refreshIntervalId) {
|
||||
clearInterval(refreshIntervalId);
|
||||
refreshIntervalId = null;
|
||||
}
|
||||
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sockets.length = 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
function nowUnix() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(unixSec) {
|
||||
return new Date(unixSec * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDuration(seconds) {
|
||||
if (seconds < 0) return 'expired';
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function parseLockEvent(event) {
|
||||
const dTag = (event.tags || []).find((t) => t[0] === 'd')?.[1] ?? '';
|
||||
const expTag = (event.tags || []).find(
|
||||
(t) => t[0] === 'expiration',
|
||||
)?.[1];
|
||||
const expiresAt = expTag ? parseInt(expTag, 10) : null;
|
||||
|
||||
let content = {};
|
||||
try {
|
||||
content = JSON.parse(event.content);
|
||||
} catch {
|
||||
// non-JSON content
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
expiresAt,
|
||||
dTag,
|
||||
agent: content.agent ?? dTag.split('/')[2] ?? 'unknown',
|
||||
cadence: content.cadence ?? dTag.split('/')[1] ?? 'unknown',
|
||||
status: content.status ?? 'unknown',
|
||||
date: content.date ?? dTag.split('/')[3] ?? todayStr(),
|
||||
platform: content.platform ?? 'unknown',
|
||||
model: content.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isExpired(lock) {
|
||||
return lock.expiresAt != null && lock.expiresAt <= nowUnix();
|
||||
}
|
||||
|
||||
function shouldReplaceLock(existingLock, incomingLock) {
|
||||
if (!existingLock) return true;
|
||||
if (incomingLock.createdAt !== existingLock.createdAt) {
|
||||
return incomingLock.createdAt > existingLock.createdAt;
|
||||
}
|
||||
|
||||
// Deterministic tie-break: prefer lexicographically higher event id.
|
||||
return (incomingLock.eventId || '') > (existingLock.eventId || '');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Platform styling
|
||||
// -----------------------------------------------------------------------
|
||||
const PLATFORM_COLORS = {
|
||||
jules: { bg: 'bg-info/10', text: 'text-info', label: 'Jules' },
|
||||
'claude-code': {
|
||||
bg: 'bg-warning/10',
|
||||
text: 'text-warning',
|
||||
label: 'Claude Code',
|
||||
},
|
||||
codex: {
|
||||
bg: 'bg-success/10',
|
||||
text: 'text-success',
|
||||
label: 'Codex',
|
||||
},
|
||||
unknown: { bg: 'bg-muted/10', text: 'text-muted', label: 'Unknown' },
|
||||
};
|
||||
|
||||
function platformStyle(platform) {
|
||||
const raw = String(platform || '').toLowerCase().trim();
|
||||
if (raw === 'claude' || raw.includes('claude')) return PLATFORM_COLORS['claude-code'];
|
||||
return PLATFORM_COLORS[raw] || PLATFORM_COLORS.unknown;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
function getFilteredLocks() {
|
||||
const cadence = cadenceFilter.value;
|
||||
const statusMode = statusFilter.value;
|
||||
|
||||
return [...lockStore.values()]
|
||||
.filter((l) => {
|
||||
if (cadence !== 'all' && l.cadence !== cadence) return false;
|
||||
|
||||
if (statusMode === 'completed') {
|
||||
return l.status === 'completed';
|
||||
}
|
||||
|
||||
if (statusMode === 'active') {
|
||||
// Active means not expired AND not completed
|
||||
if (isExpired(l)) return false;
|
||||
if (l.status === 'completed') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If 'all', show everything (expired, completed, started)
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
function renderSummary(locks) {
|
||||
const active = locks.filter((l) => !isExpired(l));
|
||||
const platforms = {};
|
||||
for (const l of active) {
|
||||
const label = platformStyle(l.platform).label;
|
||||
platforms[label] = (platforms[label] || 0) + 1;
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: 'Active locks',
|
||||
value: active.length,
|
||||
color: 'text-accent-strong',
|
||||
},
|
||||
{
|
||||
label: 'Total events',
|
||||
value: locks.length,
|
||||
color: 'text-text',
|
||||
},
|
||||
{
|
||||
label: 'Platforms',
|
||||
value: Object.keys(platforms).join(', ') || 'none',
|
||||
color: 'text-info',
|
||||
},
|
||||
{
|
||||
label: 'Date',
|
||||
value: todayStr(),
|
||||
color: 'text-muted',
|
||||
},
|
||||
];
|
||||
|
||||
summaryBar.replaceChildren();
|
||||
for (const c of cards) {
|
||||
const card = createElement('div', 'rounded-lg bg-surface-alt p-4');
|
||||
const label = createElement('div', 'text-xs text-muted mb-1', c.label);
|
||||
const value = createElement('div', `text-lg font-semibold ${c.color} mono`, String(c.value));
|
||||
card.appendChild(label);
|
||||
card.appendChild(value);
|
||||
summaryBar.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function createLockCard(lock, now) {
|
||||
const expired = isExpired(lock);
|
||||
const isCompleted = lock.status === 'completed';
|
||||
const ps = platformStyle(lock.platform);
|
||||
const age = now - lock.createdAt;
|
||||
const remaining = lock.expiresAt ? lock.expiresAt - now : 0;
|
||||
const ttlTotal = lock.expiresAt
|
||||
? lock.expiresAt - lock.createdAt
|
||||
: 7200;
|
||||
const ttlPct = expired
|
||||
? 0
|
||||
: Math.max(0, Math.min(100, (remaining / ttlTotal) * 100));
|
||||
|
||||
let cardBg = 'bg-surface-alt';
|
||||
let cardBorder = expired ? 'border-border/50 opacity-60' : 'border-border';
|
||||
|
||||
if (isCompleted) {
|
||||
cardBg = 'bg-success/5';
|
||||
cardBorder = 'border-success/30';
|
||||
}
|
||||
|
||||
const card = createElement('div', `rounded-lg border ${cardBorder} ${cardBg} p-4 flex flex-col gap-3`);
|
||||
|
||||
// Header
|
||||
const header = createElement('div', 'flex items-start justify-between');
|
||||
const left = createElement('div', 'flex items-center gap-2');
|
||||
if (!expired && !isCompleted) {
|
||||
left.appendChild(createElement('span', 'pulse-dot bg-success mt-1 shrink-0'));
|
||||
}
|
||||
const meta = createElement('div');
|
||||
meta.appendChild(createElement('div', 'font-medium text-text-strong text-sm', lock.agent));
|
||||
meta.appendChild(createElement('div', 'text-xs text-muted', `${lock.cadence} \u00B7 ${lock.date}`));
|
||||
left.appendChild(meta);
|
||||
header.appendChild(left);
|
||||
|
||||
const right = createElement('div', 'flex flex-col items-end gap-1');
|
||||
const badge = createElement('span', `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ps.bg} ${ps.text}`, ps.label);
|
||||
right.appendChild(badge);
|
||||
|
||||
if (lock.model && lock.model !== 'unknown') {
|
||||
right.appendChild(createElement('span', 'text-xs text-muted mono opacity-75', lock.model));
|
||||
}
|
||||
header.appendChild(right);
|
||||
card.appendChild(header);
|
||||
|
||||
// Grid
|
||||
const grid = createElement('div', 'grid grid-cols-2 gap-x-4 gap-y-1 text-xs');
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Started'));
|
||||
grid.appendChild(createElement('div', 'mono text-text', fmtTime(lock.createdAt)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Age'));
|
||||
grid.appendChild(createElement('div', 'mono text-text', fmtDuration(age)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'TTL remaining'));
|
||||
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-text'}`, fmtDuration(remaining)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Status'));
|
||||
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-success'}`, expired ? 'expired' : lock.status));
|
||||
card.appendChild(grid);
|
||||
|
||||
// Progress Bar
|
||||
const barContainer = createElement('div', 'w-full bg-border/30 rounded-full overflow-hidden');
|
||||
const bar = createElement('div', `ttl-bar ${expired ? 'bg-danger' : 'bg-success'}`);
|
||||
bar.style.width = `${ttlPct}%`;
|
||||
barContainer.appendChild(bar);
|
||||
card.appendChild(barContainer);
|
||||
|
||||
// Footer (Event ID)
|
||||
const footer = createElement('div', 'text-xs text-muted mono truncate', lock.eventId ? lock.eventId.slice(0, 16) + '\u2026' : 'no id');
|
||||
footer.title = lock.eventId || '';
|
||||
card.appendChild(footer);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderLocks() {
|
||||
if (!isAlive()) return;
|
||||
|
||||
const allLocks = getFilteredLocks();
|
||||
const now = nowUnix();
|
||||
|
||||
eventCount.textContent = `${lockStore.size} events`;
|
||||
renderSummary(allLocks);
|
||||
|
||||
const totalItems = allLocks.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
// Adjust currentPage if out of bounds
|
||||
if (currentPage > totalPages && totalPages > 0) {
|
||||
currentPage = totalPages;
|
||||
}
|
||||
if (currentPage < 1) currentPage = 1;
|
||||
|
||||
if (totalItems === 0) {
|
||||
lockGrid.replaceChildren(emptyState);
|
||||
emptyState.textContent =
|
||||
lockStore.size === 0
|
||||
? 'Waiting for lock events from relays\u2026'
|
||||
: 'No locks match the current filters.';
|
||||
paginationControls.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice for pagination
|
||||
const startIdx = (currentPage - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
|
||||
const pageLocks = allLocks.slice(startIdx, endIdx);
|
||||
|
||||
const cards = pageLocks.map((lock) => createLockCard(lock, now));
|
||||
lockGrid.replaceChildren(...cards);
|
||||
|
||||
// Update Pagination Controls
|
||||
if (totalItems > itemsPerPage) {
|
||||
paginationControls.classList.remove('hidden');
|
||||
|
||||
// Update text
|
||||
pageStartEl.textContent = startIdx + 1;
|
||||
pageEndEl.textContent = endIdx;
|
||||
totalItemsEl.textContent = totalItems;
|
||||
|
||||
// Update buttons state
|
||||
const isFirst = currentPage === 1;
|
||||
const isLast = currentPage === totalPages;
|
||||
|
||||
prevBtn.disabled = isFirst;
|
||||
nextBtn.disabled = isLast;
|
||||
mobilePrevBtn.disabled = isFirst;
|
||||
mobileNextBtn.disabled = isLast;
|
||||
} else {
|
||||
paginationControls.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Raw log
|
||||
// -----------------------------------------------------------------------
|
||||
let rawLogInitialized = false;
|
||||
function appendRawLog(event) {
|
||||
if (!isAlive()) return;
|
||||
|
||||
if (!rawLogInitialized) {
|
||||
rawLog.replaceChildren();
|
||||
rawLogInitialized = true;
|
||||
}
|
||||
const ts = new Date(event.created_at * 1000).toISOString();
|
||||
const text = `[${ts}] ${JSON.stringify(event).slice(0, 300)}`;
|
||||
const line = createElement('div', 'mb-2 pb-2 border-b border-border/30', text);
|
||||
rawLog.prepend(line);
|
||||
|
||||
// Keep log bounded
|
||||
while (rawLog.children.length > 200) {
|
||||
rawLog.removeChild(rawLog.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebSocket relay connections
|
||||
// -----------------------------------------------------------------------
|
||||
function updateConnectionBadge() {
|
||||
if (!connectionStatus) return;
|
||||
const dot = connectionStatus.querySelector('.pulse-dot');
|
||||
if (connectedCount === RELAYS.length) {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-success/10 text-success';
|
||||
dot.className = 'pulse-dot bg-success';
|
||||
connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`;
|
||||
} else if (connectedCount > 0) {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-warning/10 text-warning';
|
||||
dot.className = 'pulse-dot bg-warning';
|
||||
connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`;
|
||||
} else {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-danger/10 text-danger';
|
||||
dot.className = 'pulse-dot bg-danger';
|
||||
connectionStatus.lastChild.textContent = ' Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
function connectToRelay(url) {
|
||||
if (!isAlive()) return;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
const subId = 'torch-dash-' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
if (!isAlive()) return;
|
||||
connectedCount++;
|
||||
updateConnectionBadge();
|
||||
|
||||
// NIP-01 REQ: subscribe to TORCH lock events
|
||||
// Request from 7 days ago to cover active locks created before midnight
|
||||
// and recent history for weekly cadence
|
||||
const since = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60;
|
||||
const filter = {
|
||||
kinds: [LOCK_EVENT_KIND],
|
||||
'#t': [HASHTAG],
|
||||
since: since,
|
||||
};
|
||||
ws.send(JSON.stringify(['REQ', subId, filter]));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (msg) => {
|
||||
if (!isAlive()) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(msg.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// NIP-01: ["EVENT", subId, event]
|
||||
if (data[0] === 'EVENT' && data[2]) {
|
||||
const event = data[2];
|
||||
const lock = parseLockEvent(event);
|
||||
|
||||
// De-duplicate by dTag: keep one lock per d-tag, preferring the
|
||||
// most recent created_at; if equal, replace deterministically by event id.
|
||||
const existing = lockStore.get(lock.dTag);
|
||||
if (shouldReplaceLock(existing, lock)) {
|
||||
lockStore.set(lock.dTag, lock);
|
||||
}
|
||||
|
||||
appendRawLog(event);
|
||||
renderLocks();
|
||||
}
|
||||
|
||||
// NIP-01: ["EOSE", subId] — end of stored events
|
||||
if (data[0] === 'EOSE') {
|
||||
renderLocks();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
// Clean up socket from tracking array
|
||||
const idx = sockets.indexOf(ws);
|
||||
if (idx !== -1) {
|
||||
sockets.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (isTeardown) return;
|
||||
if (ws.isManualClose) return;
|
||||
|
||||
connectedCount = Math.max(0, connectedCount - 1);
|
||||
updateConnectionBadge();
|
||||
|
||||
// Auto-reconnect after 5 seconds if still alive
|
||||
setTimeout(() => {
|
||||
if (isAlive()) {
|
||||
connectToRelay(url);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
// Error fires before close, so close handler will decrement
|
||||
});
|
||||
|
||||
sockets.push(ws);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Periodic TTL refresh
|
||||
// -----------------------------------------------------------------------
|
||||
refreshIntervalId = setInterval(() => {
|
||||
if (isAlive() && !document.hidden) {
|
||||
renderLocks();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && isAlive()) {
|
||||
renderLocks();
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
function setPage(p) {
|
||||
currentPage = p;
|
||||
renderLocks();
|
||||
}
|
||||
|
||||
if (cadenceFilter) cadenceFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); });
|
||||
if (statusFilter) statusFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); });
|
||||
|
||||
if (prevBtn) prevBtn.addEventListener('click', () => setPage(currentPage - 1));
|
||||
if (nextBtn) nextBtn.addEventListener('click', () => setPage(currentPage + 1));
|
||||
if (mobilePrevBtn) mobilePrevBtn.addEventListener('click', () => setPage(currentPage - 1));
|
||||
if (mobileNextBtn) mobileNextBtn.addEventListener('click', () => setPage(currentPage + 1));
|
||||
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', () => {
|
||||
// Close existing connections and reconnect
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.isManualClose = true;
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sockets.length = 0;
|
||||
connectedCount = 0;
|
||||
lockStore.clear();
|
||||
updateConnectionBadge();
|
||||
init();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
function init() {
|
||||
if (!isAlive()) return;
|
||||
|
||||
applyConfigToHelpText();
|
||||
|
||||
for (const url of RELAYS) {
|
||||
connectToRelay(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const torchConfig = await loadTorchConfigFile();
|
||||
DASHBOARD_CONFIG = parseDashboardConfig(torchConfig);
|
||||
RELAYS = DASHBOARD_CONFIG.relays;
|
||||
HASHTAG = DASHBOARD_CONFIG.hashtag;
|
||||
|
||||
cadenceFilter.value = DASHBOARD_CONFIG.defaultCadenceView;
|
||||
statusFilter.value = DASHBOARD_CONFIG.defaultStatusView;
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
+1
-809
@@ -230,814 +230,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { KIND_APP_DATA } from '../src/constants.mjs';
|
||||
import { createElement, escapeHtml } from './domUtils.js';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal Logic
|
||||
// -----------------------------------------------------------------------
|
||||
const docsModal = document.getElementById("docsModal");
|
||||
const settingsModal = document.getElementById("settingsModal");
|
||||
const openDocsBtn = document.getElementById("openDocs");
|
||||
const openSettingsBtn = document.getElementById("settingsBtn");
|
||||
const closeDocsBtn = document.getElementsByClassName("close-modal")[0];
|
||||
const closeSettingsBtn = document.getElementById("closeSettings");
|
||||
const contentDiv = document.getElementById("markdown-content");
|
||||
let docsLoaded = false;
|
||||
|
||||
// Docs Modal
|
||||
if (openDocsBtn) {
|
||||
openDocsBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
docsModal.classList.add("show");
|
||||
if (!docsLoaded) {
|
||||
fetchDocs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closeDocsBtn) {
|
||||
closeDocsBtn.onclick = function() {
|
||||
docsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Modal
|
||||
if (openSettingsBtn) {
|
||||
openSettingsBtn.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Pre-fill values
|
||||
document.getElementById('settingHashtag').value = HASHTAG;
|
||||
document.getElementById('settingNamespace').value = DASHBOARD_CONFIG.namespace;
|
||||
document.getElementById('settingRelays').value = RELAYS.join(', ');
|
||||
|
||||
settingsModal.classList.add("show");
|
||||
}
|
||||
}
|
||||
|
||||
if (closeSettingsBtn) {
|
||||
closeSettingsBtn.onclick = function() {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('cancelSettings').onclick = function() {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
|
||||
// Handle Settings Save
|
||||
document.getElementById('settingsForm').onsubmit = function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const newHashtag = document.getElementById('settingHashtag').value.trim();
|
||||
const newNamespace = document.getElementById('settingNamespace').value.trim();
|
||||
const newRelays = document.getElementById('settingRelays').value.split(',').map(r => r.trim()).filter(Boolean);
|
||||
|
||||
if (!newHashtag) {
|
||||
alert('Hashtag is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = {
|
||||
hashtag: newHashtag,
|
||||
namespace: newNamespace,
|
||||
relays: newRelays
|
||||
};
|
||||
|
||||
// Save to LocalStorage
|
||||
localStorage.setItem('torch_dashboard_prefs', JSON.stringify(prefs));
|
||||
|
||||
// Update URL to reflect new settings (so link is shareable)
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('hashtag', newHashtag);
|
||||
url.searchParams.set('namespace', newNamespace);
|
||||
url.searchParams.set('relays', newRelays.join(','));
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Reload to apply
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target == docsModal) {
|
||||
docsModal.classList.remove("show");
|
||||
}
|
||||
if (event.target == settingsModal) {
|
||||
settingsModal.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDocs() {
|
||||
try {
|
||||
const response = await fetch('../src/docs/TORCH.md');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load documentation');
|
||||
}
|
||||
const text = await response.text();
|
||||
contentDiv.innerHTML = marked.parse(text);
|
||||
docsLoaded = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
contentDiv.innerHTML = `<p class="text-danger">Error loading documentation: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Configuration — matches bin/torch-lock.mjs defaults
|
||||
// Optional URL params:
|
||||
// ?namespace=torch&hashtag=torch-agent-lock&relays=wss://relay.damus.io,wss://nos.lol
|
||||
// -----------------------------------------------------------------------
|
||||
const DASHBOARD_DEFAULTS = {
|
||||
namespace: 'torch',
|
||||
hashtag: '',
|
||||
defaultCadenceView: 'daily',
|
||||
defaultStatusView: 'active',
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
],
|
||||
};
|
||||
|
||||
function normalizeCadence(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'daily' || normalized === 'weekly' || normalized === 'all'
|
||||
? normalized
|
||||
: DASHBOARD_DEFAULTS.defaultCadenceView;
|
||||
}
|
||||
|
||||
function normalizeStatus(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return ['active', 'completed', 'all'].includes(normalized)
|
||||
? normalized
|
||||
: DASHBOARD_DEFAULTS.defaultStatusView;
|
||||
}
|
||||
|
||||
async function loadTorchConfigFile() {
|
||||
const configCandidates = ['../torch-config.json', './torch-config.json'];
|
||||
|
||||
for (const path of configCandidates) {
|
||||
try {
|
||||
const response = await fetch(path, { cache: 'no-store' });
|
||||
if (!response.ok) continue;
|
||||
return await response.json();
|
||||
} catch {
|
||||
// optional config, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseDashboardConfig(torchConfig = {}) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// 1. LocalStorage Preferences
|
||||
let localPrefs = {};
|
||||
try {
|
||||
localPrefs = JSON.parse(localStorage.getItem('torch_dashboard_prefs')) || {};
|
||||
} catch {}
|
||||
|
||||
const dashboardConfig = torchConfig.dashboard || {};
|
||||
const lockConfig = torchConfig.nostrLock || {};
|
||||
|
||||
// Hierarchy: URL Param > LocalStorage > Config File > Default
|
||||
|
||||
// Namespace
|
||||
const configNamespace = dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace;
|
||||
const namespace = (
|
||||
params.get('namespace') ||
|
||||
localPrefs.namespace ||
|
||||
configNamespace
|
||||
).trim() || DASHBOARD_DEFAULTS.namespace;
|
||||
|
||||
// Hashtag
|
||||
const configHashtag = dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag;
|
||||
const hashtag = (
|
||||
params.get('hashtag') ||
|
||||
localPrefs.hashtag ||
|
||||
configHashtag ||
|
||||
`${namespace}-agent-lock`
|
||||
).trim();
|
||||
|
||||
// Relays
|
||||
const paramsRelays = params.get('relays') ? params.get('relays').split(',') : null;
|
||||
const localRelays = localPrefs.relays;
|
||||
const configRelays = dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays;
|
||||
|
||||
let relays = (paramsRelays || localRelays || configRelays)
|
||||
.map(r => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (relays.length === 0) relays = DASHBOARD_DEFAULTS.relays;
|
||||
|
||||
const defaultCadenceView = normalizeCadence(dashboardConfig.defaultCadenceView);
|
||||
const defaultStatusView = normalizeStatus(dashboardConfig.defaultStatusView);
|
||||
|
||||
return {
|
||||
namespace,
|
||||
hashtag,
|
||||
relays,
|
||||
defaultCadenceView,
|
||||
defaultStatusView,
|
||||
};
|
||||
}
|
||||
|
||||
let DASHBOARD_CONFIG = parseDashboardConfig({});
|
||||
|
||||
let RELAYS = DASHBOARD_CONFIG.relays;
|
||||
let D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`;
|
||||
|
||||
const LOCK_EVENT_KIND = KIND_APP_DATA;
|
||||
let HASHTAG = DASHBOARD_CONFIG.hashtag;
|
||||
|
||||
function applyConfigToHelpText() {
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const hashtagDisplay = `#${HASHTAG}`;
|
||||
const filterExample = JSON.stringify({ kinds: [LOCK_EVENT_KIND], '#t': [HASHTAG] });
|
||||
|
||||
const introHashtag = byId('introHashtag');
|
||||
if (introHashtag) introHashtag.textContent = hashtagDisplay;
|
||||
|
||||
// These elements were in the footer which is now removed
|
||||
// byId('infoHashtag').textContent = hashtagDisplay;
|
||||
// byId('infoHashtagRepeat').textContent = hashtagDisplay;
|
||||
// byId('followHashtag').textContent = `#t = ${HASHTAG}`;
|
||||
// byId('dTagPrefix').textContent = D_TAG_PREFIX;
|
||||
// byId('filterExample').textContent = filterExample;
|
||||
// byId('relayList').textContent = RELAYS.join(', ');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// State
|
||||
// -----------------------------------------------------------------------
|
||||
/** @type {Map<string, object>} dTag -> latest parsed lock */
|
||||
const lockStore = new Map();
|
||||
/** @type {WebSocket[]} */
|
||||
const sockets = [];
|
||||
let connectedCount = 0;
|
||||
let refreshIntervalId = null;
|
||||
let isTeardown = false;
|
||||
|
||||
// DOM refs
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const lockGrid = document.getElementById('lockGrid');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const rawLog = document.getElementById('rawLog');
|
||||
const eventCount = document.getElementById('eventCount');
|
||||
const summaryBar = document.getElementById('summaryBar');
|
||||
const cadenceFilter = document.getElementById('cadenceFilter');
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
|
||||
// Pagination DOM refs
|
||||
const paginationControls = document.getElementById('paginationControls');
|
||||
const mobilePrevBtn = document.getElementById('mobilePrevBtn');
|
||||
const mobileNextBtn = document.getElementById('mobileNextBtn');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
const pageStartEl = document.getElementById('pageStart');
|
||||
const pageEndEl = document.getElementById('pageEnd');
|
||||
const totalItemsEl = document.getElementById('totalItems');
|
||||
|
||||
// Pagination State
|
||||
let currentPage = 1;
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cleanup check
|
||||
// -----------------------------------------------------------------------
|
||||
function isAlive() {
|
||||
if (isTeardown) return false;
|
||||
// Check if our root element is still in the DOM
|
||||
const stillInDom = document.body.contains(lockGrid);
|
||||
if (!stillInDom) {
|
||||
teardown();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (isTeardown) return;
|
||||
isTeardown = true;
|
||||
|
||||
if (refreshIntervalId) {
|
||||
clearInterval(refreshIntervalId);
|
||||
refreshIntervalId = null;
|
||||
}
|
||||
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sockets.length = 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
function nowUnix() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtTime(unixSec) {
|
||||
return new Date(unixSec * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDuration(seconds) {
|
||||
if (seconds < 0) return 'expired';
|
||||
const m = Math.floor(seconds / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function parseLockEvent(event) {
|
||||
const dTag = (event.tags || []).find((t) => t[0] === 'd')?.[1] ?? '';
|
||||
const expTag = (event.tags || []).find(
|
||||
(t) => t[0] === 'expiration',
|
||||
)?.[1];
|
||||
const expiresAt = expTag ? parseInt(expTag, 10) : null;
|
||||
|
||||
let content = {};
|
||||
try {
|
||||
content = JSON.parse(event.content);
|
||||
} catch {
|
||||
// non-JSON content
|
||||
}
|
||||
|
||||
return {
|
||||
eventId: event.id,
|
||||
pubkey: event.pubkey,
|
||||
createdAt: event.created_at,
|
||||
expiresAt,
|
||||
dTag,
|
||||
agent: content.agent ?? dTag.split('/')[2] ?? 'unknown',
|
||||
cadence: content.cadence ?? dTag.split('/')[1] ?? 'unknown',
|
||||
status: content.status ?? 'unknown',
|
||||
date: content.date ?? dTag.split('/')[3] ?? todayStr(),
|
||||
platform: content.platform ?? 'unknown',
|
||||
model: content.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function isExpired(lock) {
|
||||
return lock.expiresAt != null && lock.expiresAt <= nowUnix();
|
||||
}
|
||||
|
||||
function shouldReplaceLock(existingLock, incomingLock) {
|
||||
if (!existingLock) return true;
|
||||
if (incomingLock.createdAt !== existingLock.createdAt) {
|
||||
return incomingLock.createdAt > existingLock.createdAt;
|
||||
}
|
||||
|
||||
// Deterministic tie-break: prefer lexicographically higher event id.
|
||||
return (incomingLock.eventId || '') > (existingLock.eventId || '');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Platform styling
|
||||
// -----------------------------------------------------------------------
|
||||
const PLATFORM_COLORS = {
|
||||
jules: { bg: 'bg-info/10', text: 'text-info', label: 'Jules' },
|
||||
'claude-code': {
|
||||
bg: 'bg-warning/10',
|
||||
text: 'text-warning',
|
||||
label: 'Claude Code',
|
||||
},
|
||||
codex: {
|
||||
bg: 'bg-success/10',
|
||||
text: 'text-success',
|
||||
label: 'Codex',
|
||||
},
|
||||
unknown: { bg: 'bg-muted/10', text: 'text-muted', label: 'Unknown' },
|
||||
};
|
||||
|
||||
function platformStyle(platform) {
|
||||
const raw = String(platform || '').toLowerCase().trim();
|
||||
if (raw === 'claude' || raw.includes('claude')) return PLATFORM_COLORS['claude-code'];
|
||||
return PLATFORM_COLORS[raw] || PLATFORM_COLORS.unknown;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
function getFilteredLocks() {
|
||||
const cadence = cadenceFilter.value;
|
||||
const statusMode = statusFilter.value;
|
||||
const now = nowUnix();
|
||||
|
||||
return [...lockStore.values()]
|
||||
.filter((l) => {
|
||||
if (cadence !== 'all' && l.cadence !== cadence) return false;
|
||||
|
||||
if (statusMode === 'completed') {
|
||||
return l.status === 'completed';
|
||||
}
|
||||
|
||||
if (statusMode === 'active') {
|
||||
// Active means not expired AND not completed
|
||||
if (isExpired(l)) return false;
|
||||
if (l.status === 'completed') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If 'all', show everything (expired, completed, started)
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
function renderSummary(locks) {
|
||||
const now = nowUnix();
|
||||
const active = locks.filter((l) => !isExpired(l));
|
||||
const platforms = {};
|
||||
for (const l of active) {
|
||||
const label = platformStyle(l.platform).label;
|
||||
platforms[label] = (platforms[label] || 0) + 1;
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: 'Active locks',
|
||||
value: active.length,
|
||||
color: 'text-accent-strong',
|
||||
},
|
||||
{
|
||||
label: 'Total events',
|
||||
value: locks.length,
|
||||
color: 'text-text',
|
||||
},
|
||||
{
|
||||
label: 'Platforms',
|
||||
value: Object.keys(platforms).join(', ') || 'none',
|
||||
color: 'text-info',
|
||||
},
|
||||
{
|
||||
label: 'Date',
|
||||
value: todayStr(),
|
||||
color: 'text-muted',
|
||||
},
|
||||
];
|
||||
|
||||
summaryBar.replaceChildren();
|
||||
for (const c of cards) {
|
||||
const card = createElement('div', 'rounded-lg bg-surface-alt p-4');
|
||||
const label = createElement('div', 'text-xs text-muted mb-1', c.label);
|
||||
const value = createElement('div', `text-lg font-semibold ${c.color} mono`, String(c.value));
|
||||
card.appendChild(label);
|
||||
card.appendChild(value);
|
||||
summaryBar.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function createLockCard(lock, now) {
|
||||
const expired = isExpired(lock);
|
||||
const isCompleted = lock.status === 'completed';
|
||||
const ps = platformStyle(lock.platform);
|
||||
const age = now - lock.createdAt;
|
||||
const remaining = lock.expiresAt ? lock.expiresAt - now : 0;
|
||||
const ttlTotal = lock.expiresAt
|
||||
? lock.expiresAt - lock.createdAt
|
||||
: 7200;
|
||||
const ttlPct = expired
|
||||
? 0
|
||||
: Math.max(0, Math.min(100, (remaining / ttlTotal) * 100));
|
||||
|
||||
let cardBg = 'bg-surface-alt';
|
||||
let cardBorder = expired ? 'border-border/50 opacity-60' : 'border-border';
|
||||
|
||||
if (isCompleted) {
|
||||
cardBg = 'bg-success/5';
|
||||
cardBorder = 'border-success/30';
|
||||
}
|
||||
|
||||
const card = createElement('div', `rounded-lg border ${cardBorder} ${cardBg} p-4 flex flex-col gap-3`);
|
||||
|
||||
// Header
|
||||
const header = createElement('div', 'flex items-start justify-between');
|
||||
const left = createElement('div', 'flex items-center gap-2');
|
||||
if (!expired && !isCompleted) {
|
||||
left.appendChild(createElement('span', 'pulse-dot bg-success mt-1 shrink-0'));
|
||||
}
|
||||
const meta = createElement('div');
|
||||
meta.appendChild(createElement('div', 'font-medium text-text-strong text-sm', lock.agent));
|
||||
meta.appendChild(createElement('div', 'text-xs text-muted', `${lock.cadence} \u00B7 ${lock.date}`));
|
||||
left.appendChild(meta);
|
||||
header.appendChild(left);
|
||||
|
||||
const right = createElement('div', 'flex flex-col items-end gap-1');
|
||||
const badge = createElement('span', `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${ps.bg} ${ps.text}`, ps.label);
|
||||
right.appendChild(badge);
|
||||
|
||||
if (lock.model && lock.model !== 'unknown') {
|
||||
right.appendChild(createElement('span', 'text-xs text-muted mono opacity-75', lock.model));
|
||||
}
|
||||
header.appendChild(right);
|
||||
card.appendChild(header);
|
||||
|
||||
// Grid
|
||||
const grid = createElement('div', 'grid grid-cols-2 gap-x-4 gap-y-1 text-xs');
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Started'));
|
||||
grid.appendChild(createElement('div', 'mono text-text', fmtTime(lock.createdAt)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Age'));
|
||||
grid.appendChild(createElement('div', 'mono text-text', fmtDuration(age)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'TTL remaining'));
|
||||
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-text'}`, fmtDuration(remaining)));
|
||||
grid.appendChild(createElement('div', 'text-muted', 'Status'));
|
||||
grid.appendChild(createElement('div', `mono ${expired ? 'text-danger' : 'text-success'}`, expired ? 'expired' : lock.status));
|
||||
card.appendChild(grid);
|
||||
|
||||
// Progress Bar
|
||||
const barContainer = createElement('div', 'w-full bg-border/30 rounded-full overflow-hidden');
|
||||
const bar = createElement('div', `ttl-bar ${expired ? 'bg-danger' : 'bg-success'}`);
|
||||
bar.style.width = `${ttlPct}%`;
|
||||
barContainer.appendChild(bar);
|
||||
card.appendChild(barContainer);
|
||||
|
||||
// Footer (Event ID)
|
||||
const footer = createElement('div', 'text-xs text-muted mono truncate', lock.eventId ? lock.eventId.slice(0, 16) + '\u2026' : 'no id');
|
||||
footer.title = lock.eventId || '';
|
||||
card.appendChild(footer);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderLocks() {
|
||||
if (!isAlive()) return;
|
||||
|
||||
const allLocks = getFilteredLocks();
|
||||
const now = nowUnix();
|
||||
|
||||
eventCount.textContent = `${lockStore.size} events`;
|
||||
renderSummary(allLocks);
|
||||
|
||||
const totalItems = allLocks.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
// Adjust currentPage if out of bounds
|
||||
if (currentPage > totalPages && totalPages > 0) {
|
||||
currentPage = totalPages;
|
||||
}
|
||||
if (currentPage < 1) currentPage = 1;
|
||||
|
||||
if (totalItems === 0) {
|
||||
lockGrid.replaceChildren(emptyState);
|
||||
emptyState.textContent =
|
||||
lockStore.size === 0
|
||||
? 'Waiting for lock events from relays\u2026'
|
||||
: 'No locks match the current filters.';
|
||||
paginationControls.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice for pagination
|
||||
const startIdx = (currentPage - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
|
||||
const pageLocks = allLocks.slice(startIdx, endIdx);
|
||||
|
||||
const cards = pageLocks.map((lock) => createLockCard(lock, now));
|
||||
lockGrid.replaceChildren(...cards);
|
||||
|
||||
// Update Pagination Controls
|
||||
if (totalItems > itemsPerPage) {
|
||||
paginationControls.classList.remove('hidden');
|
||||
|
||||
// Update text
|
||||
pageStartEl.textContent = startIdx + 1;
|
||||
pageEndEl.textContent = endIdx;
|
||||
totalItemsEl.textContent = totalItems;
|
||||
|
||||
// Update buttons state
|
||||
const isFirst = currentPage === 1;
|
||||
const isLast = currentPage === totalPages;
|
||||
|
||||
prevBtn.disabled = isFirst;
|
||||
nextBtn.disabled = isLast;
|
||||
mobilePrevBtn.disabled = isFirst;
|
||||
mobileNextBtn.disabled = isLast;
|
||||
} else {
|
||||
paginationControls.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Raw log
|
||||
// -----------------------------------------------------------------------
|
||||
let rawLogInitialized = false;
|
||||
function appendRawLog(event) {
|
||||
if (!isAlive()) return;
|
||||
|
||||
if (!rawLogInitialized) {
|
||||
rawLog.replaceChildren();
|
||||
rawLogInitialized = true;
|
||||
}
|
||||
const ts = new Date(event.created_at * 1000).toISOString();
|
||||
const text = `[${ts}] ${JSON.stringify(event).slice(0, 300)}`;
|
||||
const line = createElement('div', 'mb-2 pb-2 border-b border-border/30', text);
|
||||
rawLog.prepend(line);
|
||||
|
||||
// Keep log bounded
|
||||
while (rawLog.children.length > 200) {
|
||||
rawLog.removeChild(rawLog.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebSocket relay connections
|
||||
// -----------------------------------------------------------------------
|
||||
function updateConnectionBadge() {
|
||||
if (!connectionStatus) return;
|
||||
const dot = connectionStatus.querySelector('.pulse-dot');
|
||||
if (connectedCount === RELAYS.length) {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-success/10 text-success';
|
||||
dot.className = 'pulse-dot bg-success';
|
||||
connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`;
|
||||
} else if (connectedCount > 0) {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-warning/10 text-warning';
|
||||
dot.className = 'pulse-dot bg-warning';
|
||||
connectionStatus.lastChild.textContent = ` ${connectedCount}/${RELAYS.length} relays`;
|
||||
} else {
|
||||
connectionStatus.className =
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium bg-danger/10 text-danger';
|
||||
dot.className = 'pulse-dot bg-danger';
|
||||
connectionStatus.lastChild.textContent = ' Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
function connectToRelay(url) {
|
||||
if (!isAlive()) return;
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
const subId = 'torch-dash-' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
if (!isAlive()) return;
|
||||
connectedCount++;
|
||||
updateConnectionBadge();
|
||||
|
||||
// NIP-01 REQ: subscribe to TORCH lock events
|
||||
// Request from 7 days ago to cover active locks created before midnight
|
||||
// and recent history for weekly cadence
|
||||
const since = Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60;
|
||||
const filter = {
|
||||
kinds: [LOCK_EVENT_KIND],
|
||||
'#t': [HASHTAG],
|
||||
since: since,
|
||||
};
|
||||
ws.send(JSON.stringify(['REQ', subId, filter]));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (msg) => {
|
||||
if (!isAlive()) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(msg.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// NIP-01: ["EVENT", subId, event]
|
||||
if (data[0] === 'EVENT' && data[2]) {
|
||||
const event = data[2];
|
||||
const lock = parseLockEvent(event);
|
||||
|
||||
// De-duplicate by dTag: keep one lock per d-tag, preferring the
|
||||
// most recent created_at; if equal, replace deterministically by event id.
|
||||
const existing = lockStore.get(lock.dTag);
|
||||
if (shouldReplaceLock(existing, lock)) {
|
||||
lockStore.set(lock.dTag, lock);
|
||||
}
|
||||
|
||||
appendRawLog(event);
|
||||
renderLocks();
|
||||
}
|
||||
|
||||
// NIP-01: ["EOSE", subId] — end of stored events
|
||||
if (data[0] === 'EOSE') {
|
||||
renderLocks();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
// Clean up socket from tracking array
|
||||
const idx = sockets.indexOf(ws);
|
||||
if (idx !== -1) {
|
||||
sockets.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (isTeardown) return;
|
||||
if (ws.isManualClose) return;
|
||||
|
||||
connectedCount = Math.max(0, connectedCount - 1);
|
||||
updateConnectionBadge();
|
||||
|
||||
// Auto-reconnect after 5 seconds if still alive
|
||||
setTimeout(() => {
|
||||
if (isAlive()) {
|
||||
connectToRelay(url);
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
// Error fires before close, so close handler will decrement
|
||||
});
|
||||
|
||||
sockets.push(ws);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Periodic TTL refresh
|
||||
// -----------------------------------------------------------------------
|
||||
refreshIntervalId = setInterval(() => {
|
||||
if (isAlive() && !document.hidden) {
|
||||
renderLocks();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && isAlive()) {
|
||||
renderLocks();
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// -----------------------------------------------------------------------
|
||||
function setPage(p) {
|
||||
currentPage = p;
|
||||
renderLocks();
|
||||
}
|
||||
|
||||
if (cadenceFilter) cadenceFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); });
|
||||
if (statusFilter) statusFilter.addEventListener('change', () => { currentPage = 1; renderLocks(); });
|
||||
|
||||
if (prevBtn) prevBtn.addEventListener('click', () => setPage(currentPage - 1));
|
||||
if (nextBtn) nextBtn.addEventListener('click', () => setPage(currentPage + 1));
|
||||
if (mobilePrevBtn) mobilePrevBtn.addEventListener('click', () => setPage(currentPage - 1));
|
||||
if (mobileNextBtn) mobileNextBtn.addEventListener('click', () => setPage(currentPage + 1));
|
||||
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', () => {
|
||||
// Close existing connections and reconnect
|
||||
for (const ws of sockets) {
|
||||
try {
|
||||
ws.isManualClose = true;
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
sockets.length = 0;
|
||||
connectedCount = 0;
|
||||
lockStore.clear();
|
||||
updateConnectionBadge();
|
||||
init();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Init
|
||||
// -----------------------------------------------------------------------
|
||||
function init() {
|
||||
if (!isAlive()) return;
|
||||
|
||||
applyConfigToHelpText();
|
||||
|
||||
for (const url of RELAYS) {
|
||||
connectToRelay(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const torchConfig = await loadTorchConfigFile();
|
||||
DASHBOARD_CONFIG = parseDashboardConfig(torchConfig);
|
||||
RELAYS = DASHBOARD_CONFIG.relays;
|
||||
D_TAG_PREFIX = `${DASHBOARD_CONFIG.namespace}-lock/`;
|
||||
HASHTAG = DASHBOARD_CONFIG.hashtag;
|
||||
|
||||
cadenceFilter.value = DASHBOARD_CONFIG.defaultCadenceView;
|
||||
statusFilter.value = DASHBOARD_CONFIG.defaultStatusView;
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
</script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
# TORCH System Architecture
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Agents["AI Agent Platforms"]
|
||||
Jules["Jules"]
|
||||
Claude["Claude Code"]
|
||||
Codex["Codex"]
|
||||
Goose["Goose"]
|
||||
end
|
||||
|
||||
subgraph TORCH["TORCH CLI / Scheduler"]
|
||||
CLI["bin/torch-lock.mjs"]
|
||||
Lib["src/lib.mjs<br/>Command Dispatch"]
|
||||
LockOps["src/lock-ops.mjs<br/>Lock Publisher / Query"]
|
||||
Health["src/relay-health.mjs<br/>Relay Health Monitor"]
|
||||
Scheduler["scripts/agent/<br/>run-scheduler-cycle.mjs"]
|
||||
end
|
||||
|
||||
subgraph Nostr["Nostr Network"]
|
||||
R1["Relay 1<br/>relay.damus.io"]
|
||||
R2["Relay 2<br/>nos.lol"]
|
||||
R3["Relay 3<br/>relay.primal.net"]
|
||||
end
|
||||
|
||||
subgraph Dashboard["Dashboard"]
|
||||
Server["src/dashboard.mjs<br/>HTTP Server"]
|
||||
UI["dashboard/index.html<br/>+ app.js"]
|
||||
end
|
||||
|
||||
subgraph Memory["Memory Subsystem"]
|
||||
Ingestor["Ingestor"]
|
||||
Retriever["Retriever"]
|
||||
Pruner["Pruner"]
|
||||
Store["memory-store.json"]
|
||||
end
|
||||
|
||||
Agents -->|"torch-lock lock/check/complete"| CLI
|
||||
CLI --> Lib
|
||||
Lib --> LockOps
|
||||
LockOps -->|"NIP-01 Events<br/>(kind 30078)"| R1 & R2 & R3
|
||||
Health -->|"Probe & Track"| R1 & R2 & R3
|
||||
Scheduler -->|"Orchestrate"| CLI
|
||||
|
||||
Server -->|"Serve Static"| UI
|
||||
UI -->|"WebSocket SUB"| R1 & R2 & R3
|
||||
|
||||
Lib --> Ingestor
|
||||
Lib --> Retriever
|
||||
Ingestor --> Store
|
||||
Retriever --> Store
|
||||
Pruner --> Store
|
||||
```
|
||||
|
||||
## Lock Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent
|
||||
participant TORCH as TORCH CLI
|
||||
participant Relays as Nostr Relays
|
||||
|
||||
Agent->>TORCH: torch-lock check --cadence daily
|
||||
TORCH->>Relays: Query active locks (REQ filter)
|
||||
Relays-->>TORCH: Existing lock events
|
||||
TORCH-->>Agent: Status: available / locked
|
||||
|
||||
Agent->>TORCH: torch-lock lock --agent my-agent
|
||||
TORCH->>TORCH: Generate NIP-01 event (kind 30078)
|
||||
TORCH->>Relays: Publish lock event (fanout)
|
||||
Relays-->>TORCH: ACK from N relays
|
||||
TORCH->>Relays: Race-check query (verify no conflicts)
|
||||
Relays-->>TORCH: Confirm lock acquired
|
||||
TORCH-->>Agent: Lock acquired (exit 0)
|
||||
|
||||
Note over Agent: Agent performs work...
|
||||
|
||||
Agent->>TORCH: torch-lock complete --agent my-agent
|
||||
TORCH->>Relays: Publish completion event
|
||||
TORCH-->>Agent: Completed (exit 0)
|
||||
```
|
||||
|
||||
## Scheduler Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["Scheduler Cycle Start"] --> LoadRoster["Load Roster<br/>(roster.json)"]
|
||||
LoadRoster --> Filter["Filter by cadence<br/>(daily/weekly)"]
|
||||
Filter --> CheckPaused{"Agent<br/>paused?"}
|
||||
CheckPaused -->|Yes| Skip["Skip agent"]
|
||||
CheckPaused -->|No| CheckCompleted{"Already<br/>completed<br/>today?"}
|
||||
CheckCompleted -->|Yes| Skip
|
||||
CheckCompleted -->|No| CheckLock{"Lock<br/>available?"}
|
||||
CheckLock -->|No| Skip
|
||||
CheckLock -->|Yes| Acquire["Acquire Lock"]
|
||||
Acquire --> RunAgent["Execute Agent Prompt"]
|
||||
RunAgent --> Complete["Publish Completion"]
|
||||
Complete --> LogResult["Write Task Log"]
|
||||
Skip --> LogResult
|
||||
LogResult --> Next{"More<br/>agents?"}
|
||||
Next -->|Yes| CheckPaused
|
||||
Next -->|No| End["Cycle Complete"]
|
||||
```
|
||||
|
||||
## Memory Subsystem
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Events["Runtime Events"] --> Ingestor
|
||||
Ingestor --> |"Chunk + Summarize"| Store["Memory Store<br/>(JSON file)"]
|
||||
Store --> Retriever
|
||||
Retriever --> |"Rank: semantic +<br/>importance + recency"| Results["Ranked Memories"]
|
||||
|
||||
Scheduler["Maintenance<br/>Scheduler"] --> Pruner
|
||||
Pruner --> |"TTL / merge /<br/>archive / delete"| Store
|
||||
|
||||
subgraph Jobs["Scheduled Jobs"]
|
||||
J1["Ingest (1-5 min)"]
|
||||
J2["Consolidate (1 hr)"]
|
||||
J3["Prune (daily)"]
|
||||
J4["Deep Merge (weekly)"]
|
||||
end
|
||||
Scheduler --> Jobs
|
||||
```
|
||||
|
||||
## Key Data Flows
|
||||
|
||||
| Flow | Protocol | Event Kind | Key Fields |
|
||||
|------|----------|-----------|------------|
|
||||
| Lock acquire | NIP-01 publish | 30078 | d-tag, expiration, agent, cadence, status |
|
||||
| Lock query | NIP-01 REQ | 30078 | #t filter on hashtag |
|
||||
| Lock complete | NIP-01 publish | 30078 | status: "completed" |
|
||||
| Dashboard feed | NIP-01 WebSocket | 30078 | Real-time SUB with since filter |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
TORCH/
|
||||
├── bin/ # CLI entry point
|
||||
├── src/
|
||||
│ ├── lib.mjs # Command dispatch
|
||||
│ ├── lock-ops.mjs # Nostr relay operations
|
||||
│ ├── relay-health.mjs # Relay health monitoring
|
||||
│ ├── dashboard.mjs # Dashboard HTTP server
|
||||
│ ├── ops.mjs # Install/update operations
|
||||
│ ├── services/
|
||||
│ │ ├── memory/ # Memory subsystem
|
||||
│ │ └── governance/ # Governance service
|
||||
│ └── prompts/ # Agent prompt definitions
|
||||
├── dashboard/ # Dashboard frontend
|
||||
├── scripts/agent/ # Scheduler and agent scripts
|
||||
├── test/ # Test suite
|
||||
├── docs/ # Architecture documentation
|
||||
└── migrations/ # Database schemas (future)
|
||||
```
|
||||
+1
-1
@@ -16,7 +16,7 @@ export default [
|
||||
sourceType: "module",
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "warn",
|
||||
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
|
||||
Generated
-64
@@ -17,7 +17,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"eslint": "^10.0.0",
|
||||
"globals": "^17.3.0"
|
||||
},
|
||||
@@ -260,22 +259,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
@@ -691,21 +674,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -988,38 +956,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
+1
-2
@@ -48,7 +48,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "npm run validate:scheduler && npm run test:integration:e2e && node --test test/*.test.mjs test/*.test.js",
|
||||
"test": "npm run validate:scheduler && npm run test:integration:e2e && node --test --test-timeout=30000 test/*.test.mjs test/*.test.js",
|
||||
"test:unit:lock-backend": "node --test test/lock-ops.test.mjs test/lock-ops-publish-retry.test.mjs",
|
||||
"lock:check:daily": "node bin/torch-lock.mjs check --cadence daily",
|
||||
"lock:check:weekly": "node bin/torch-lock.mjs check --cadence weekly",
|
||||
@@ -76,7 +76,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"eslint": "^10.0.0",
|
||||
"globals": "^17.3.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -16,11 +16,8 @@ const getArg = (key) => {
|
||||
if (flagIdx !== -1 && ARGS[flagIdx + 1] && !ARGS[flagIdx + 1].startsWith('--')) return ARGS[flagIdx + 1];
|
||||
return null;
|
||||
};
|
||||
const hasArg = (key) => ARGS.includes(`--${key}`);
|
||||
|
||||
const RELAYS = (getArg('relays') || process.env.RELAY_URLS || '').split(',').filter(Boolean);
|
||||
const SERVE_CMD = getArg('serve') || 'npx';
|
||||
const CONFIRM_PUBLIC = hasArg('confirm-public');
|
||||
const OUT_DIR = getArg('out') || 'artifacts';
|
||||
|
||||
// --- Logging ---
|
||||
@@ -45,7 +42,7 @@ async function waitForServer(url, timeoutMs = 10000) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return true;
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
await sleep(500);
|
||||
|
||||
@@ -22,7 +22,7 @@ async function waitForServer() {
|
||||
req.end();
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,8 @@ async function benchmark() {
|
||||
if (current >= REQUESTS) break;
|
||||
try {
|
||||
await makeRequest();
|
||||
} catch (e) {
|
||||
// console.error(e);
|
||||
} catch (_e) {
|
||||
// console.error(_e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ async function runBenchmark() {
|
||||
duplicateWindowMs: 1000 * 60 * 60, // 1 hour
|
||||
similarityThreshold: 0.99,
|
||||
getEmbedding: getEmbeddingWrapper,
|
||||
generateSummary: async (prompt) => {
|
||||
generateSummary: async (_prompt) => {
|
||||
await delay(LATENCY_MS);
|
||||
return JSON.stringify({
|
||||
summary: 'Condensed summary based on prompt.',
|
||||
|
||||
@@ -31,7 +31,7 @@ async function runTests(i) {
|
||||
|
||||
child.stdout.on('data', (d) => stdout += d.toString());
|
||||
|
||||
child.on('close', (code) => {
|
||||
child.on('close', (_code) => {
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('not ok ') && !line.includes('# skip') && !line.includes('# todo')) {
|
||||
|
||||
@@ -12,7 +12,7 @@ function getFiles(dir, files = []) {
|
||||
files.push(name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// ignore if dir doesn't exist
|
||||
}
|
||||
return files;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createProposal, listProposals, applyProposal, rejectProposal, getProposal } from './services/governance/index.js';
|
||||
import { ExitError } from './errors.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export async function cmdProposal(subcommand, args = {}) {
|
||||
switch (subcommand) {
|
||||
@@ -30,7 +29,7 @@ async function handleCreate({ agent, target, contentFile, reason }) {
|
||||
let newContent;
|
||||
try {
|
||||
newContent = await fs.readFile(contentFile, 'utf8');
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
console.error(`Failed to read content file: ${contentFile}`);
|
||||
throw new ExitError(1, 'File read error');
|
||||
}
|
||||
|
||||
+10
-3
@@ -8,7 +8,7 @@ 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';",
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self' 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'
|
||||
@@ -18,8 +18,15 @@ function timingSafeCompare(a, b) {
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) {
|
||||
// Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely
|
||||
crypto.timingSafeEqual(bufA, bufA);
|
||||
// Pad both buffers to equal length so the comparison takes constant time
|
||||
// regardless of input lengths. The length difference is still observable,
|
||||
// but the content timing channel is closed.
|
||||
const maxLen = Math.max(bufA.length, bufB.length);
|
||||
const paddedA = Buffer.alloc(maxLen);
|
||||
const paddedB = Buffer.alloc(maxLen);
|
||||
bufA.copy(paddedA);
|
||||
bufB.copy(paddedB);
|
||||
crypto.timingSafeEqual(paddedA, paddedB);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
|
||||
+1
-11
@@ -9,19 +9,13 @@ import {
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
KIND_APP_DATA,
|
||||
DEFAULT_RETRY_ATTEMPTS,
|
||||
DEFAULT_RETRY_BASE_DELAY_MS,
|
||||
DEFAULT_RETRY_CAP_DELAY_MS,
|
||||
DEFAULT_ROLLING_WINDOW_SIZE,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_SNAPSHOT_INTERVAL_MS,
|
||||
} from './constants.mjs';
|
||||
|
||||
function nowUnix() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
import { nowUnix } from './utils.mjs';
|
||||
|
||||
/**
|
||||
* Parses a raw Nostr event into a structured lock object.
|
||||
@@ -630,10 +624,6 @@ export class LockPublisher {
|
||||
async publish() {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
getPublishTimeoutMsFn = getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacksFn = getRelayFallbacks,
|
||||
getMinActiveRelayPoolFn = getMinActiveRelayPool,
|
||||
retryAttempts = 4,
|
||||
retryBaseDelayMs = 500,
|
||||
retryCapDelayMs = 8_000,
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export async function getCompletedAgents(cadence, logDir, deps) {
|
||||
const match = file.match(/^(\d{4}-\d{2}-\d{2})T.*__([a-zA-Z0-9-_]+)__(.*)\.md$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [_, datePart, agent, status] = match;
|
||||
const [, datePart, agent, status] = match;
|
||||
|
||||
if (status === 'completed') {
|
||||
if (cadence === 'daily' && datePart === today) {
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ export function cmdUpdate(force = false, cwd = process.cwd()) {
|
||||
if (pkg.name === 'torch-lock') {
|
||||
installDirName = '.';
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Ignore error if package.json is missing or invalid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ async function run() {
|
||||
const res = await loadTorchConfig();
|
||||
// Use res to prevent dead code elimination if JIT is smart (though side effects exist)
|
||||
if (!res) throw new Error("Failed");
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function listProposals() {
|
||||
id: entry.name,
|
||||
...meta
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Ignore invalid proposals
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export async function createProposal({ agent, target, newContent, reason }) {
|
||||
console.error('Diff generation failed:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
diff = '(New File)';
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function applyProposal(id) {
|
||||
// User requested <path>/<hash>.md.
|
||||
const archivePath = path.join(archiveDir, `${path.basename(meta.target, '.md')}_${hash}.md`);
|
||||
await fs.writeFile(archivePath, oldContent);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// If file didn't exist, nothing to archive
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ export async function rollbackPrompt(target, hashOrStrategy = 'latest') {
|
||||
sourceContent = await fs.readFile(path.join(archiveDir, match), 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
// Archive lookup failed
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND';
|
||||
* @param {string} text
|
||||
* @returns {number[]}
|
||||
*/
|
||||
function fallbackEmbedding(text) {
|
||||
function fallbackEmbedding(_text) {
|
||||
// Disabled as per configuration: Jules uses text-based memory, not vector embeddings.
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -7,7 +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');
|
||||
|
||||
@@ -5,7 +5,7 @@ import assert from 'node:assert';
|
||||
|
||||
const DIST_DIR = path.resolve('dist');
|
||||
|
||||
test('Build artifacts verification', async (t) => {
|
||||
test('Build artifacts verification', { skip: !fs.existsSync(DIST_DIR) && 'dist/ not found — run npm run build first' }, async (t) => {
|
||||
await t.test('dist directory exists', () => {
|
||||
assert.strictEqual(fs.existsSync(DIST_DIR), true, 'dist directory should exist');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, describe, it } from 'node:test';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { cmdList } from '../src/cmd-list.mjs';
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ test('Dashboard Configuration Exposure Security', async (t) => {
|
||||
|
||||
// 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.
|
||||
@@ -93,7 +91,7 @@ test('Dashboard Configuration Exposure Security', async (t) => {
|
||||
let exposedConfig;
|
||||
try {
|
||||
exposedConfig = JSON.parse(responseBody);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
assert.fail(`Failed to parse JSON response: ${responseBody}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ test('Dashboard File Access Security', async (t) => {
|
||||
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, /script-src 'self' https:\/\/cdn\.jsdelivr\.net/, 'CSP: missing script-src');
|
||||
assert.match(csp, /object-src 'none'/, 'CSP: missing object-src none');
|
||||
|
||||
// X-Content-Type-Options
|
||||
|
||||
@@ -99,7 +99,6 @@ describe('src/lib.mjs', () => {
|
||||
});
|
||||
|
||||
describe('cmdLock', () => {
|
||||
const mockPublishLock = async () => {};
|
||||
const mockGenerateSecretKey = () => new Uint8Array(32);
|
||||
const mockGetPublicKey = () => 'mock-pubkey';
|
||||
const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id', sig: 'mock-sig' });
|
||||
@@ -353,7 +352,6 @@ describe('src/lib.mjs', () => {
|
||||
});
|
||||
|
||||
describe('cmdComplete', () => {
|
||||
const mockPublishLock = async () => {};
|
||||
const mockGenerateSecretKey = () => new Uint8Array(32);
|
||||
const mockGetPublicKey = () => 'mock-pubkey';
|
||||
const mockFinalizeEvent = (t) => ({ ...t, id: 'mock-id-complete' });
|
||||
|
||||
@@ -138,7 +138,7 @@ test('runPruneCycle respects prune feature-flag modes', async () => {
|
||||
test('markMemoryMerged delegates to repository and clears cache', async () => {
|
||||
let callCount = 0;
|
||||
const repository = {
|
||||
async markMerged(id, mergedInto) {
|
||||
async markMerged(id, _mergedInto) {
|
||||
callCount++;
|
||||
return id === 'exists';
|
||||
}
|
||||
|
||||
@@ -23,15 +23,24 @@ test('stores runtime events in signer/session namespace and clears scope', () =>
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's2' }).length, 1);
|
||||
});
|
||||
|
||||
test('expires runtime events and records expiration metrics', async () => {
|
||||
const cache = createMemoryCache();
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }, 1);
|
||||
test('expires runtime events and records expiration metrics', () => {
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
const start = originalNow.call(Date);
|
||||
Date.now = () => start;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const cache = createMemoryCache();
|
||||
cache.setRuntimeEvent({ signer_id: 'alice', session_id: 's1' }, { id: 1 }, 1);
|
||||
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0);
|
||||
const metrics = cache.getMetrics();
|
||||
assert.equal(metrics.runtime_events_expired, 1);
|
||||
// Advance mocked clock past the 1ms TTL
|
||||
Date.now = () => start + 10;
|
||||
|
||||
assert.equal(cache.getRecentRuntimeEvents({ signer_id: 'alice', session_id: 's1' }).length, 0);
|
||||
const metrics = cache.getMetrics();
|
||||
assert.equal(metrics.runtime_events_expired, 1);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test('enforces LRU bounds and tracks eviction metrics', () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
formatMemoryBlock,
|
||||
formatMemoriesForPrompt,
|
||||
renderPromptWithMemoryContext,
|
||||
isMemoryRetrievalEnabled
|
||||
} from '../src/services/memory/formatter.js';
|
||||
|
||||
function createMemory(overrides = {}) {
|
||||
|
||||
@@ -110,7 +110,7 @@ test('getRelevantMemories passes correct parameters to ranker', async (t) => {
|
||||
await getRelevantMemories(params);
|
||||
|
||||
assert.equal(ranker.mock.callCount(), 1);
|
||||
const [_, queryParams] = ranker.mock.calls[0].arguments;
|
||||
const [, queryParams] = ranker.mock.calls[0].arguments;
|
||||
|
||||
assert.equal(queryParams.agent_id, 'agent-1');
|
||||
assert.equal(queryParams.query, 'my query');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||
import { test, describe, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ingestEvents, getRelevantMemories } from '../src/services/memory/index.js';
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('ingestEvents', () => {
|
||||
let linkedEmbeddings = new Map();
|
||||
|
||||
const mockOptions = {
|
||||
embedText: async (text) => Array(128).fill(0.1), // Deterministic embedding
|
||||
embedText: async (_text) => Array(128).fill(0.1), // Deterministic embedding
|
||||
emitTelemetry: (event, payload) => telemetryEvents.push({ event, payload }),
|
||||
metrics: {
|
||||
emit: (metric, payload) => metricsEvents.push({ metric, payload }),
|
||||
|
||||
@@ -52,7 +52,7 @@ test('pinMemory returns null for non-existent ID but still clears cache', async
|
||||
listMemoriesCallCount++;
|
||||
return records;
|
||||
},
|
||||
async setPinned(id, pinned) {
|
||||
async setPinned(_id, _pinned) {
|
||||
return null;
|
||||
},
|
||||
async updateMemoryUsage() { return null; }
|
||||
@@ -121,7 +121,7 @@ test('unpinMemory returns null for non-existent ID but still clears cache', asyn
|
||||
listMemoriesCallCount++;
|
||||
return records;
|
||||
},
|
||||
async setPinned(id, pinned) {
|
||||
async setPinned(_id, _pinned) {
|
||||
return null;
|
||||
},
|
||||
async updateMemoryUsage() { return null; }
|
||||
|
||||
@@ -42,8 +42,7 @@ test.before(async () => {
|
||||
await fs.mkdir(memDir, { recursive: true });
|
||||
|
||||
// Create initial state
|
||||
const now = 200_000;
|
||||
// retentionMs will be 100,000
|
||||
// now = 200_000, retentionMs will be 100,000
|
||||
// cutoff = 100,000
|
||||
|
||||
const memories = [
|
||||
|
||||
+7
-2
@@ -15,7 +15,12 @@ import {
|
||||
SYNTHETIC_EVENTS,
|
||||
} from './fixtures/memory-fixtures.js';
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
/** Drain the async job queue without wall-clock dependency. */
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
const withMockedNow = async (now, run) => {
|
||||
const originalNow = Date.now;
|
||||
@@ -265,7 +270,7 @@ test('scheduler smoke test handles mocked clock and lock contention', async () =
|
||||
},
|
||||
});
|
||||
|
||||
await wait(30);
|
||||
await flushAsync();
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, describe, it } from 'node:test';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
|
||||
Reference in new issue
Block a user