mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 07:51:52 +00:00
Merge branch 'main' of https://github.com/PR0M3TH3AN/TORCH
This commit is contained in:
commit
0cfb40a410
34 files changed
+1464
-789
No files matched your search
@@ -20,7 +20,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
matrix:
|
||||
node-version: [22, 23]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
+83
-6
@@ -518,6 +518,40 @@
|
||||
/* Define danger color if not exists in dashboard/styles.css */
|
||||
:root {
|
||||
--danger: #ef4444;
|
||||
/* Warm Accent Colors */
|
||||
--warm-primary: #ff6b6b;
|
||||
--warm-secondary: #fbbf24;
|
||||
--warm-glow: rgba(255, 107, 107, 0.15);
|
||||
}
|
||||
|
||||
/* Ambient Background */
|
||||
.background-decoration {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.background-decoration svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Scroll Animations */
|
||||
.scroll-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.8s cubic-bezier(0.5, 0, 0, 1);
|
||||
}
|
||||
|
||||
.scroll-reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Core Capabilities Section */
|
||||
@@ -767,6 +801,38 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="background-decoration">
|
||||
<svg viewBox="0 0 1440 1024" fill="none" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<linearGradient id="warmGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0" />
|
||||
<stop offset="50%" stop-color="var(--warm-primary)" stop-opacity="0.6" />
|
||||
<stop offset="100%" stop-color="var(--warm-secondary)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M-100 600 C 200 400, 600 800, 1540 500" stroke="url(#warmGradient)" stroke-width="2" fill="none" opacity="0.5">
|
||||
<animate attributeName="d"
|
||||
values="M-100 600 C 200 400, 600 800, 1540 500; M-100 600 C 200 500, 600 700, 1540 500; M-100 600 C 200 400, 600 800, 1540 500"
|
||||
dur="20s" repeatCount="indefinite" />
|
||||
</path>
|
||||
<path d="M-100 300 C 400 500, 800 100, 1540 400" stroke="url(#warmGradient)" stroke-width="1.5" fill="none" opacity="0.3">
|
||||
<animate attributeName="d"
|
||||
values="M-100 300 C 400 500, 800 100, 1540 400; M-100 300 C 400 400, 800 200, 1540 400; M-100 300 C 400 500, 800 100, 1540 400"
|
||||
dur="25s" repeatCount="indefinite" />
|
||||
</path>
|
||||
<circle cx="200" cy="400" r="4" fill="var(--warm-primary)" opacity="0.2">
|
||||
<animate attributeName="opacity" values="0.2;0.8;0.2" dur="4s" repeatCount="indefinite" />
|
||||
<animate attributeName="cx" values="200;220;200" dur="10s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="800" cy="100" r="3" fill="var(--info)" opacity="0.3">
|
||||
<animate attributeName="opacity" values="0.2;0.6;0.2" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="1200" cy="700" r="2" fill="var(--warm-secondary)" opacity="0.4">
|
||||
<animate attributeName="cy" values="700;720;700" dur="8s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<div class="logo-container">
|
||||
<img src="../assets/logo.svg" alt="TORCH Logo" width="32" height="32">
|
||||
@@ -778,7 +844,7 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<section class="hero scroll-reveal">
|
||||
<h1>A Simple Round-Robin Agent Scheduler.</h1>
|
||||
<p>
|
||||
A lightweight, drop-in folder that rotates through autonomous agents to maintain and improve your codebase. No complex pipelines—just simple, effective orchestration.
|
||||
@@ -789,7 +855,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="install-container">
|
||||
<div class="install-container scroll-reveal">
|
||||
<h2 class="mb-8 text-text-strong" style="font-size: 2rem;">Choose Your Agent</h2>
|
||||
|
||||
<div class="agent-selector">
|
||||
@@ -874,7 +940,7 @@ npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz &
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="features">
|
||||
<section class="features scroll-reveal">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<div class="feature-title">Instant Setup</div>
|
||||
@@ -892,7 +958,7 @@ npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz &
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="core-capabilities">
|
||||
<section class="core-capabilities scroll-reveal">
|
||||
<h2>Why Run TORCH?</h2>
|
||||
<div class="capability-grid">
|
||||
<div class="capability-card">
|
||||
@@ -918,7 +984,7 @@ npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz &
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta-prompts">
|
||||
<section class="meta-prompts scroll-reveal">
|
||||
<h2>Scheduler Meta Prompts</h2>
|
||||
<div class="prompts-grid">
|
||||
<div class="prompt-card">
|
||||
@@ -942,7 +1008,7 @@ npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz &
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="agent-showcase" id="agent-showcase">
|
||||
<section class="agent-showcase scroll-reveal" id="agent-showcase">
|
||||
<h2>Functional Agents</h2>
|
||||
<div id="functional-agent-list">
|
||||
<!-- Populated by JS -->
|
||||
@@ -1007,6 +1073,17 @@ npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz &
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Scroll Reveal
|
||||
const scrollObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
|
||||
document.querySelectorAll('.scroll-reveal').forEach(el => scrollObserver.observe(el));
|
||||
|
||||
// Modal Logic
|
||||
const modal = document.getElementById("docsModal");
|
||||
const btn = document.getElementById("openDocs");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Decomposed src/lock-ops.mjs into src/relay-health-manager.mjs and src/lock-publisher.mjs. Reduced size from 880 to 152 lines.
|
||||
Generated
+42
-63
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nostr-tools": "2.19.4",
|
||||
"nostr-tools": "2.23.1",
|
||||
"ws": "8.19.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -215,96 +215,75 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
|
||||
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "~1.3.0",
|
||||
"@scure/base": "~1.1.0"
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@@ -858,17 +837,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"version": "2.19.4",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
|
||||
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
|
||||
"version": "2.23.1",
|
||||
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.1.tgz",
|
||||
"integrity": "sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"@noble/ciphers": "2.1.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/bip32": "2.0.1",
|
||||
"@scure/bip39": "2.0.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"torch:memory:inspect": "node bin/torch-lock.mjs inspect-memory"
|
||||
},
|
||||
"dependencies": {
|
||||
"nostr-tools": "2.19.4",
|
||||
"nostr-tools": "2.23.1",
|
||||
"ws": "8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Dependency Security Report
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Run ID:** 2026-02-20T01-13-24Z
|
||||
**Agent:** deps-security-agent
|
||||
|
||||
## Summary
|
||||
- **Vulnerabilities:** 5 (1 High, 4 Moderate)
|
||||
- **Outdated Packages:** 0
|
||||
|
||||
## Vulnerabilities
|
||||
|
||||
### High Severity
|
||||
- **minimatch** (<10.2.1)
|
||||
- **Path:** eslint > minimatch
|
||||
- **Issue:** ReDoS via repeated wildcards.
|
||||
- **Status:** Transitive dependency of `[email protected]`.
|
||||
|
||||
### Moderate Severity
|
||||
- **ajv** (<8.18.0)
|
||||
- **Path:** eslint > ajv
|
||||
- **Issue:** ReDoS when using `$data` option.
|
||||
- **eslint**, **@eslint/js**, **@eslint-community/eslint-utils**
|
||||
- **Issue:** Various moderate issues related to internal dependencies.
|
||||
|
||||
## Outdated Packages
|
||||
- None (all dependencies up to date with package.json specifiers).
|
||||
|
||||
## Recommendations
|
||||
- Monitor `eslint` for updates that bump `minimatch` and `ajv`.
|
||||
- Consider using `overrides` in package.json to force `[email protected]` if `eslint` is compatible.
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"auditReportVersion": 2,
|
||||
"vulnerabilities": {
|
||||
"@eslint-community/eslint-utils": {
|
||||
"name": "@eslint-community/eslint-utils",
|
||||
"severity": "moderate",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
"eslint"
|
||||
],
|
||||
"effects": [
|
||||
"eslint"
|
||||
],
|
||||
"range": "*",
|
||||
"nodes": [
|
||||
"node_modules/@eslint-community/eslint-utils"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint",
|
||||
"version": "4.1.1",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"@eslint/js": {
|
||||
"name": "@eslint/js",
|
||||
"severity": "moderate",
|
||||
"isDirect": true,
|
||||
"via": [
|
||||
"eslint"
|
||||
],
|
||||
"effects": [],
|
||||
"range": ">=10.0.1",
|
||||
"nodes": [
|
||||
"node_modules/@eslint/js"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "@eslint/js",
|
||||
"version": "9.39.2",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"ajv": {
|
||||
"name": "ajv",
|
||||
"severity": "moderate",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1113214,
|
||||
"name": "ajv",
|
||||
"dependency": "ajv",
|
||||
"title": "ajv has ReDoS when using `$data` option",
|
||||
"url": "https://github.com/advisories/GHSA-2g4f-4pwh-qvx6",
|
||||
"severity": "moderate",
|
||||
"cwe": [
|
||||
"CWE-400"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 0,
|
||||
"vectorString": null
|
||||
},
|
||||
"range": "<8.18.0"
|
||||
}
|
||||
],
|
||||
"effects": [
|
||||
"eslint"
|
||||
],
|
||||
"range": "<8.18.0",
|
||||
"nodes": [
|
||||
"node_modules/ajv"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint",
|
||||
"version": "4.1.1",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"eslint": {
|
||||
"name": "eslint",
|
||||
"severity": "moderate",
|
||||
"isDirect": true,
|
||||
"via": [
|
||||
"@eslint-community/eslint-utils",
|
||||
"ajv"
|
||||
],
|
||||
"effects": [
|
||||
"@eslint-community/eslint-utils",
|
||||
"@eslint/js"
|
||||
],
|
||||
"range": ">=4.2.0",
|
||||
"nodes": [
|
||||
"node_modules/eslint"
|
||||
],
|
||||
"fixAvailable": {
|
||||
"name": "eslint",
|
||||
"version": "4.1.1",
|
||||
"isSemVerMajor": true
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"name": "minimatch",
|
||||
"severity": "high",
|
||||
"isDirect": false,
|
||||
"via": [
|
||||
{
|
||||
"source": 1113296,
|
||||
"name": "minimatch",
|
||||
"dependency": "minimatch",
|
||||
"title": "minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern",
|
||||
"url": "https://github.com/advisories/GHSA-3ppc-4f35-3m26",
|
||||
"severity": "high",
|
||||
"cwe": [
|
||||
"CWE-1333"
|
||||
],
|
||||
"cvss": {
|
||||
"score": 0,
|
||||
"vectorString": null
|
||||
},
|
||||
"range": "<10.2.1"
|
||||
}
|
||||
],
|
||||
"effects": [],
|
||||
"range": "<10.2.1",
|
||||
"nodes": [
|
||||
"node_modules/minimatch"
|
||||
],
|
||||
"fixAvailable": true
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"vulnerabilities": {
|
||||
"info": 0,
|
||||
"low": 0,
|
||||
"moderate": 4,
|
||||
"high": 1,
|
||||
"critical": 0,
|
||||
"total": 5
|
||||
},
|
||||
"dependencies": {
|
||||
"prod": 10,
|
||||
"dev": 72,
|
||||
"optional": 0,
|
||||
"peer": 0,
|
||||
"peerOptional": 0,
|
||||
"total": 81
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,18 @@
|
||||
Run Start: 2026-02-19T10-00-00Z
|
||||
Cadence: daily
|
||||
Agent: decompose-agent
|
||||
Prompt: src/prompts/daily/decompose-agent.md
|
||||
|
||||
# Context: Decompose src/lock-ops.mjs
|
||||
|
||||
**Target File:** `src/lock-ops.mjs` (880 lines)
|
||||
**Reason:** Largest JS file in `src/` exceeding the size limit.
|
||||
**Commit SHA:** e10528141db40674c793884007255fb3c46ed795
|
||||
**Node Version:** v22.22.0
|
||||
**Extraction Plan:**
|
||||
1. Extract `RelayHealthManager` and related functions (`summarizeRelayMetrics`, `computeRelayScore`, `buildRelayHealthConfig`, `defaultHealthManager`, `_resetRelayHealthState`) into `src/relay-health-manager.mjs`.
|
||||
2. Extract `LockPublisher` and related functions (`classifyPublishError`, `isTransientPublishCategory`, `PUBLISH_ERROR_CODES`, `PUBLISH_FAILURE_CATEGORIES`, `secureRandom`, `calculateBackoffDelayMs`, `publishToRelays`, `publishLock`) into `src/lock-publisher.mjs`.
|
||||
3. Move shared utilities (`withTimeout`, `relayListLabel`, `mergeRelayList`) to `src/lock-utils.mjs` or ensure correct imports.
|
||||
4. Update `src/lock-ops.mjs` to import these dependencies.
|
||||
|
||||
**Goal:** Reduce `src/lock-ops.mjs` by at least 200 lines.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Context: Content Audit Agent Run
|
||||
|
||||
Agent: content-audit-agent
|
||||
Prompt: src/prompts/daily/content-audit-agent.md
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-19T22-29-56Z
|
||||
Run ID: session-1771540169
|
||||
|
||||
## Goal
|
||||
Audit `docs/` for user-facing upload, contribution, and media claims. Verify against code/runtime and update docs where necessary.
|
||||
|
||||
## Scope
|
||||
- Directory: `docs/`
|
||||
- Target: User-facing documentation (guides, API references).
|
||||
|
||||
## Constraints
|
||||
- Do not invent APIs.
|
||||
- Prefer docs changes over code changes.
|
||||
|
||||
This run is executed by the **content-audit-agent** using the prompt at **src/prompts/daily/content-audit-agent.md**.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Context
|
||||
Agent: deps-security-agent
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-20T01-13-24Z
|
||||
Prompt: src/prompts/daily/deps-security-agent.md
|
||||
|
||||
## Goal
|
||||
Perform daily security audit of dependencies.
|
||||
|
||||
## Scope
|
||||
- Production and Development dependencies in `package.json`.
|
||||
- `package-lock.json` analysis.
|
||||
|
||||
## Constraints
|
||||
- No breaking changes.
|
||||
- Safe upgrades only.
|
||||
- Manual review for crypto/protocol libraries.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Context: Decompose src/lock-ops.mjs
|
||||
|
||||
## File Selection
|
||||
- **File:** `src/lock-ops.mjs`
|
||||
- **Size:** 881 lines (Excess: 581 lines)
|
||||
- **Selection Reason:** Single largest grandfathered source file available for decomposition.
|
||||
- **Recent History:** Last significant commit was a merge (`e1052814`). No recent decomposition or major refactor found in `git log`.
|
||||
|
||||
## Decomposition Plan
|
||||
The file contains three major cohesive blocks:
|
||||
1. **RelayHealthManager**: A class for tracking relay health, scoring, and metrics.
|
||||
2. **LockPublisher**: A class for publishing lock events with retries and fallback logic.
|
||||
3. **Lock Operations**: The core `queryLocks` and `parseLockEvent` logic.
|
||||
|
||||
### Extraction Strategy
|
||||
1. **Extract `RelayHealthManager`** into `src/relay-health-manager.mjs`.
|
||||
- Includes: `RelayHealthManager` class, `defaultHealthManager` instance, `summarizeRelayMetrics`, `computeRelayScore`, `buildRelayHealthConfig`.
|
||||
- Exports: `RelayHealthManager`, `defaultHealthManager`, `buildRelayHealthConfig`.
|
||||
|
||||
2. **Extract `LockPublisher`** into `src/lock-publisher.mjs`.
|
||||
- Includes: `LockPublisher` class, `publishLock`, `publishToRelays`.
|
||||
- Includes: `classifyPublishError` and related constants (`PUBLISH_ERROR_CODES`, `PUBLISH_FAILURE_CATEGORIES`).
|
||||
- Includes: `secureRandom`, `calculateBackoffDelayMs`.
|
||||
- Internal Helpers (duplicated): `withTimeout`, `relayListLabel`.
|
||||
- Exports: `publishLock`, `LockPublisher`.
|
||||
|
||||
3. **Refactor `src/lock-ops.mjs`**.
|
||||
- Import `defaultHealthManager`, `buildRelayHealthConfig`, `publishLock`.
|
||||
- Remove extracted code.
|
||||
- Retain `queryLocks`, `parseLockEvent`, `filterActiveLocks`.
|
||||
- Retain `withTimeout` and `relayListLabel` (for local use).
|
||||
|
||||
## Goal
|
||||
Reduce `src/lock-ops.mjs` by > 200 lines.
|
||||
@@ -0,0 +1,20 @@
|
||||
Run Start: 2026-02-19T10-00-00Z
|
||||
Cadence: daily
|
||||
Agent: decompose-agent
|
||||
Prompt: src/prompts/daily/decompose-agent.md
|
||||
|
||||
# Decisions: Decompose src/lock-ops.mjs
|
||||
|
||||
## Decision 1: Extract `RelayHealthManager`
|
||||
- **Rationale:** The `RelayHealthManager` class manages complex state and logic for relay health metrics, scoring, and prioritization. It is a distinct cohesive block that can be reused and tested independently.
|
||||
- **Target File:** `src/relay-health-manager.mjs`.
|
||||
|
||||
## Decision 2: Extract `LockPublisher`
|
||||
- **Rationale:** The `LockPublisher` class encapsulates the retry logic, backoff strategy, and publishing workflow. Separating it clarifies the distinction between "publishing a lock" and "managing locks generally".
|
||||
- **Target File:** `src/lock-publisher.mjs`.
|
||||
|
||||
## Decision 3: Move utilities
|
||||
- **Rationale:** Utilities like `withTimeout` and `mergeRelayList` are used by both the extraction targets and the original file. Moving them to `src/lock-utils.mjs` (or similar) avoids circular dependencies.
|
||||
|
||||
## Decision 4: Skip baseline update in script
|
||||
- **Rationale:** The `scripts/check-file-size.mjs` script does not support `--update` or maintain a `BASELINE` constant as described in the prompt. I manually verified that `src/lock-ops.mjs` is now under the limit (152 lines).
|
||||
@@ -0,0 +1,16 @@
|
||||
# Decisions: Content Audit Agent Run
|
||||
|
||||
Agent: content-audit-agent
|
||||
Prompt: src/prompts/daily/content-audit-agent.md
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-19T22-29-56Z
|
||||
Run ID: session-1771540169
|
||||
|
||||
## Decision: No Changes Made
|
||||
- **Rationale**: The audit of `docs/` revealed that the directory currently houses internal documentation (`agent-handoffs`, `architecture`, `proposals`, etc.) rather than user-facing guides about file uploads or contributions.
|
||||
- **Action**: Created inventory artifact documenting the state of `docs/` but did not modify any files.
|
||||
|
||||
## Decision: Skip Verification/Validation Steps
|
||||
- **Rationale**: Since no relevant claims were found in the documentation, there was nothing to verify against the codebase or validate end-to-end.
|
||||
|
||||
This run is executed by the **content-audit-agent** using the prompt at **src/prompts/daily/content-audit-agent.md**.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Decisions
|
||||
Agent: deps-security-agent
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-20T01-13-24Z
|
||||
Prompt: src/prompts/daily/deps-security-agent.md
|
||||
|
||||
## Decision: Defer Upgrades
|
||||
- **Rationale:** The identified High severity vulnerability (`minimatch`) is a transitive dependency of `eslint`. Automatic upgrading of transitive dependencies is disabled to prevent instability. `npm outdated` shows `eslint` is up to date with the registry for the specified range.
|
||||
- **Action:** Logged as pending task for manual review or future automated check when `eslint` updates.
|
||||
|
||||
## Decision: No Safe Upgrades Available
|
||||
- **Rationale:** `npm outdated` returned no results, indicating all direct dependencies are at their latest satisfying versions.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Decisions: Decompose src/lock-ops.mjs
|
||||
|
||||
## 1. Duplicate Internal Helpers (`withTimeout`, `relayListLabel`)
|
||||
- **Context:** Both `src/lock-ops.mjs` and the new `src/lock-publisher.mjs` need `withTimeout` and `relayListLabel`.
|
||||
- **Option A:** Move them to `src/utils.mjs`.
|
||||
- **Option B:** Duplicate them in the new file.
|
||||
- **Decision:** **Option B (Duplicate)**.
|
||||
- **Rationale:** The prompt emphasizes "One file per PR only" and "Do not decompose multiple files". Modifying `src/utils.mjs` would touch a second existing file, potentially expanding the scope and risk. Duplicating these small helpers keeps the changes isolated to the decomposition of `src/lock-ops.mjs` and its new children.
|
||||
|
||||
## 2. Shared Constants
|
||||
- **Context:** Extracted files need constants from `src/constants.mjs` and `src/torch-config.mjs`.
|
||||
- **Decision:** Import them directly in the new files.
|
||||
- **Rationale:** Standard module practice.
|
||||
|
||||
## 3. Class Extraction
|
||||
- **Context:** `RelayHealthManager` and `LockPublisher` are large, distinct classes.
|
||||
- **Decision:** Extract each into its own file (`src/relay-health-manager.mjs`, `src/lock-publisher.mjs`).
|
||||
- **Rationale:** Improves modularity and reduces the size of `src/lock-ops.mjs` significantly.
|
||||
+9
-716
@@ -1,22 +1,16 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import {
|
||||
getQueryTimeoutMs,
|
||||
getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacks,
|
||||
getMinActiveRelayPool,
|
||||
} from './torch-config.mjs';
|
||||
import {
|
||||
KIND_APP_DATA,
|
||||
DEFAULT_ROLLING_WINDOW_SIZE,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_SNAPSHOT_INTERVAL_MS,
|
||||
MS_PER_SECOND,
|
||||
} from './constants.mjs';
|
||||
import { nowUnix } from './utils.mjs';
|
||||
import { defaultHealthManager, buildRelayHealthConfig, RelayHealthManager } from './relay-health-manager.mjs';
|
||||
import { publishLock, LockPublisher, secureRandom } from './lock-publisher.mjs';
|
||||
|
||||
/**
|
||||
* Parses a raw Nostr event into a structured lock object.
|
||||
@@ -75,387 +69,6 @@ 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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies a raw error message into a standardized publication error code.
|
||||
* Used to determine if a failure is transient (retryable) or permanent.
|
||||
*
|
||||
* @param {string|Error} message - The error message or object to classify.
|
||||
* @returns {string} One of the PUBLISH_ERROR_CODES constants.
|
||||
*/
|
||||
function classifyPublishError(message) {
|
||||
const normalized = String(message || '').toLowerCase();
|
||||
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
|
||||
return PUBLISH_ERROR_CODES.TIMEOUT;
|
||||
}
|
||||
if (
|
||||
normalized.includes('enotfound')
|
||||
|| normalized.includes('eai_again')
|
||||
|| normalized.includes('getaddrinfo')
|
||||
|| (normalized.includes('dns') && normalized.includes('websocket'))
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.DNS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('connect etimedout')
|
||||
|| normalized.includes('tcp connect timed out')
|
||||
|| normalized.includes('connect timeout')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TCP;
|
||||
}
|
||||
if (
|
||||
normalized.includes('tls')
|
||||
|| normalized.includes('ssl')
|
||||
|| normalized.includes('certificate')
|
||||
|| normalized.includes('handshake')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TLS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('websocket')
|
||||
|| normalized.includes('bad response')
|
||||
|| normalized.includes('unexpected server response')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.WEBSOCKET;
|
||||
}
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
|
||||
return PUBLISH_ERROR_CODES.NETWORK;
|
||||
}
|
||||
if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) {
|
||||
return PUBLISH_ERROR_CODES.CONNECTION_RESET;
|
||||
}
|
||||
if (
|
||||
normalized.includes('unavailable')
|
||||
|| normalized.includes('offline')
|
||||
|| normalized.includes('econnrefused')
|
||||
|| normalized.includes('connection refused')
|
||||
|| normalized.includes('enotfound')
|
||||
|| normalized.includes('503')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE;
|
||||
}
|
||||
return PUBLISH_ERROR_CODES.PERMANENT;
|
||||
}
|
||||
|
||||
function isTransientPublishCategory(category) {
|
||||
return [
|
||||
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);
|
||||
}
|
||||
|
||||
const MAX_RANDOM = 281474976710655; // 2**48 - 1
|
||||
|
||||
function secureRandom() {
|
||||
return randomInt(0, MAX_RANDOM) / MAX_RANDOM;
|
||||
}
|
||||
|
||||
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) {
|
||||
const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1)));
|
||||
return Math.floor(randomFn() * maxDelay);
|
||||
}
|
||||
|
||||
function summarizeRelayMetrics(metrics, nowMs) {
|
||||
const total = metrics.recentOutcomes.length;
|
||||
const successCount = metrics.recentOutcomes.filter((entry) => entry.success).length;
|
||||
const timeoutCount = metrics.recentOutcomes.filter((entry) => entry.timedOut).length;
|
||||
const latencyEntries = metrics.recentOutcomes.map((entry) => entry.latencyMs).filter((v) => Number.isFinite(v));
|
||||
const averageLatencyMs = latencyEntries.length
|
||||
? Math.round(latencyEntries.reduce((sum, value) => sum + value, 0) / latencyEntries.length)
|
||||
: null;
|
||||
const successRate = total > 0 ? successCount / total : 0.5;
|
||||
const timeoutRate = total > 0 ? timeoutCount / total : 0;
|
||||
const quarantineRemainingMs = metrics.quarantineUntil > nowMs ? metrics.quarantineUntil - nowMs : 0;
|
||||
return {
|
||||
relay: metrics.relay,
|
||||
sampleSize: total,
|
||||
successRate,
|
||||
timeoutRate,
|
||||
averageLatencyMs,
|
||||
failureStreak: metrics.failureStreak,
|
||||
quarantined: quarantineRemainingMs > 0,
|
||||
quarantineRemainingMs,
|
||||
cooldownMs: metrics.cooldownMs,
|
||||
lastResultAt: metrics.lastResultAt,
|
||||
};
|
||||
}
|
||||
|
||||
function computeRelayScore(summary) {
|
||||
const latencyPenalty = summary.averageLatencyMs === null
|
||||
? 0.1
|
||||
: Math.min(0.35, summary.averageLatencyMs / 10_000);
|
||||
const quarantinePenalty = summary.quarantined ? 1 : 0;
|
||||
return (summary.successRate * 1.4) - (summary.timeoutRate * 0.9) - latencyPenalty - quarantinePenalty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages health metrics, scoring, and prioritization for Nostr relays.
|
||||
* Tracks success rates, timeouts, and latency to optimize relay selection.
|
||||
* Implements a quarantine mechanism for failing relays.
|
||||
*/
|
||||
export class RelayHealthManager {
|
||||
constructor() {
|
||||
this.metricsByRelay = new Map();
|
||||
this.lastSnapshotAt = 0;
|
||||
this._metricsVersion = 0;
|
||||
this._sortedCache = null;
|
||||
}
|
||||
|
||||
ensureMetrics(relay, config) {
|
||||
let metrics = this.metricsByRelay.get(relay);
|
||||
if (!metrics) {
|
||||
metrics = {
|
||||
relay,
|
||||
recentOutcomes: [],
|
||||
failureStreak: 0,
|
||||
quarantineUntil: 0,
|
||||
cooldownMs: config.quarantineCooldownMs,
|
||||
lastLatencyMs: null,
|
||||
lastResultAt: null,
|
||||
};
|
||||
this.metricsByRelay.set(relay, metrics);
|
||||
this._metricsVersion += 1;
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks a list of relays based on their health scores.
|
||||
* Utilizes a cached sort order to avoid re-sorting on every call if metrics haven't changed.
|
||||
*
|
||||
* @param {string[]} relays - List of relay URLs to rank.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
* @returns {Array} List of ranked relay entries with scores and summaries.
|
||||
*/
|
||||
rankRelays(relays, config, nowMs = Date.now()) {
|
||||
for (const relay of relays) {
|
||||
this.ensureMetrics(relay, config);
|
||||
}
|
||||
|
||||
// Check if the cached sort order is still valid
|
||||
if (
|
||||
!this._sortedCache
|
||||
|| this._sortedCache.version !== this._metricsVersion
|
||||
|| nowMs >= this._sortedCache.validUntil
|
||||
) {
|
||||
let minQuarantineUntil = Infinity;
|
||||
const entries = [];
|
||||
|
||||
// Re-evaluate scores for all tracked relays
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score (desc), then quarantine status, then latency (asc), then name
|
||||
const comparator = (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);
|
||||
};
|
||||
entries.sort(comparator);
|
||||
|
||||
this._sortedCache = {
|
||||
version: this._metricsVersion,
|
||||
validUntil: minQuarantineUntil,
|
||||
entries,
|
||||
byRelay: new Map(entries.map((e) => [e.relay, e])),
|
||||
comparator,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimization: if requesting a small subset of total known relays,
|
||||
// look them up directly instead of filtering the entire list.
|
||||
// Threshold: if requested set is smaller than 50% of cached entries.
|
||||
if (relays.length > 0 && relays.length * 2 < this._sortedCache.entries.length) {
|
||||
const subset = [];
|
||||
for (const relay of relays) {
|
||||
const entry = this._sortedCache.byRelay.get(relay);
|
||||
if (entry) {
|
||||
subset.push(entry);
|
||||
}
|
||||
}
|
||||
// Re-sort the subset to maintain ranking order
|
||||
subset.sort(this._sortedCache.comparator);
|
||||
|
||||
return subset.map((entry) => {
|
||||
const quarantineRemainingMs = entry.metrics.quarantineUntil > nowMs
|
||||
? entry.metrics.quarantineUntil - nowMs
|
||||
: 0;
|
||||
return {
|
||||
relay: entry.relay,
|
||||
score: entry.score,
|
||||
summary: {
|
||||
...entry.summary,
|
||||
quarantineRemainingMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a subset of relays for immediate use, prioritizing healthy ones.
|
||||
* May include quarantined relays if the number of healthy relays is below `minActiveRelayPool`.
|
||||
*
|
||||
* @param {string[]} relays - Candidate relays.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
* @returns {Object} An object containing `prioritized` (list of selected relay URLs) and `ranked` (full ranking details).
|
||||
*/
|
||||
prioritizeRelays(relays, config, nowMs = Date.now()) {
|
||||
const minActive = Math.max(1, Math.min(config.minActiveRelayPool, relays.length || 1));
|
||||
const ranked = this.rankRelays([...new Set(relays)], config, nowMs);
|
||||
const active = ranked.filter((entry) => !entry.summary.quarantined);
|
||||
const quarantined = ranked
|
||||
.filter((entry) => entry.summary.quarantined)
|
||||
.sort((a, b) => a.summary.quarantineRemainingMs - b.summary.quarantineRemainingMs);
|
||||
|
||||
const selected = [...active];
|
||||
if (selected.length < minActive) {
|
||||
const additionalNeeded = Math.min(minActive - selected.length, quarantined.length);
|
||||
selected.push(...quarantined.slice(0, additionalNeeded));
|
||||
}
|
||||
|
||||
return {
|
||||
prioritized: selected.map((entry) => entry.relay),
|
||||
ranked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates health metrics for a relay based on the outcome of an operation.
|
||||
*
|
||||
* @param {string} relay - The relay URL.
|
||||
* @param {boolean} success - Whether the operation succeeded.
|
||||
* @param {string} errorMessage - Error message if failed.
|
||||
* @param {number} latencyMs - Duration of the operation.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
*/
|
||||
recordOutcome(relay, success, errorMessage, latencyMs, config, nowMs = Date.now()) {
|
||||
const metrics = this.ensureMetrics(relay, config);
|
||||
const timedOut = String(errorMessage || '').toLowerCase().includes('timeout')
|
||||
|| String(errorMessage || '').toLowerCase().includes('timed out');
|
||||
metrics.recentOutcomes.push({ success, timedOut, latencyMs, atMs: nowMs });
|
||||
if (metrics.recentOutcomes.length > config.rollingWindowSize) {
|
||||
metrics.recentOutcomes.splice(0, metrics.recentOutcomes.length - config.rollingWindowSize);
|
||||
}
|
||||
metrics.lastResultAt = nowMs;
|
||||
metrics.lastLatencyMs = Number.isFinite(latencyMs) ? latencyMs : null;
|
||||
|
||||
if (success) {
|
||||
metrics.failureStreak = 0;
|
||||
if (metrics.quarantineUntil > nowMs) {
|
||||
metrics.quarantineUntil = 0;
|
||||
metrics.cooldownMs = config.quarantineCooldownMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.failureStreak += 1;
|
||||
if (metrics.failureStreak >= config.failureThreshold) {
|
||||
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()) {
|
||||
const uniqueRelays = [...new Set(relays)];
|
||||
return this.rankRelays(uniqueRelays, config, nowMs).map((entry) => ({
|
||||
relay: entry.relay,
|
||||
score: Number(entry.score.toFixed(4)),
|
||||
...entry.summary,
|
||||
}));
|
||||
}
|
||||
|
||||
maybeLogSnapshot(relays, config, logger, reason, force = false, nowMs = Date.now()) {
|
||||
const intervalReached = nowMs - this.lastSnapshotAt >= config.snapshotIntervalMs;
|
||||
if (!force && !intervalReached) return;
|
||||
this.lastSnapshotAt = nowMs;
|
||||
logger(JSON.stringify({
|
||||
event: 'relay_health_snapshot',
|
||||
reason,
|
||||
relays: this.collectSnapshot(relays, config, nowMs),
|
||||
}));
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metricsByRelay.clear();
|
||||
this.lastSnapshotAt = 0;
|
||||
this._metricsVersion = 0;
|
||||
this._sortedCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultHealthManager = new RelayHealthManager();
|
||||
|
||||
function buildRelayHealthConfig(deps) {
|
||||
return {
|
||||
rollingWindowSize: deps.rollingWindowSize ?? DEFAULT_ROLLING_WINDOW_SIZE,
|
||||
failureThreshold: deps.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD,
|
||||
quarantineCooldownMs: deps.quarantineCooldownMs ?? DEFAULT_QUARANTINE_COOLDOWN_MS,
|
||||
maxQuarantineCooldownMs: deps.maxQuarantineCooldownMs ?? DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
|
||||
snapshotIntervalMs: deps.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS,
|
||||
minActiveRelayPool: Math.max(1, deps.minActiveRelayPool ?? 1),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRelayList(primaryRelays, fallbackRelays) {
|
||||
return [...new Set([...primaryRelays, ...fallbackRelays])];
|
||||
}
|
||||
@@ -546,335 +159,15 @@ export async function queryLocks(relays, cadence, dateStr, namespace, deps = {})
|
||||
}
|
||||
}
|
||||
|
||||
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
|
||||
const publishPromises = pool.publish(relays, event);
|
||||
const settled = await Promise.allSettled(
|
||||
publishPromises.map((publishPromise, index) => withTimeout(
|
||||
publishPromise,
|
||||
publishTimeoutMs,
|
||||
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||
)),
|
||||
);
|
||||
|
||||
return settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return {
|
||||
relay: relays[index],
|
||||
success: true,
|
||||
phase,
|
||||
latencyMs: null,
|
||||
};
|
||||
}
|
||||
const reason = result.reason;
|
||||
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
||||
return {
|
||||
relay: relays[index],
|
||||
success: false,
|
||||
phase,
|
||||
message,
|
||||
latencyMs: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the publication of a lock event to multiple relays.
|
||||
* Handles retries, fallback relays, and health-based relay prioritization.
|
||||
*
|
||||
* Cycle:
|
||||
* 1. Attempt to publish to primary relays.
|
||||
* 2. If quorum not met, attempt fallback relays.
|
||||
* 3. If still failing, retry with backoff (if errors are transient).
|
||||
* 4. Report final success or failure.
|
||||
*/
|
||||
export class LockPublisher {
|
||||
/**
|
||||
* @param {string[]} relays - Target primary relays.
|
||||
* @param {Object} event - The lock event to publish.
|
||||
* @param {Object} [deps] - Dependencies and configuration.
|
||||
*/
|
||||
constructor(relays, event, deps = {}) {
|
||||
this.relays = relays;
|
||||
this.event = event;
|
||||
this.deps = deps;
|
||||
this.pool = null;
|
||||
this.healthConfig = null;
|
||||
this.allRelays = [];
|
||||
this.fallbackRelays = [];
|
||||
this.publishTimeoutMs = 0;
|
||||
this.minSuccesses = 0;
|
||||
this.minActiveRelayPool = 0;
|
||||
this.maxAttempts = 0;
|
||||
this.retryBaseDelayMs = 0;
|
||||
this.retryCapDelayMs = 0;
|
||||
this.sleepFn = null;
|
||||
this.randomFn = null;
|
||||
this.telemetryLogger = null;
|
||||
this.healthLogger = null;
|
||||
this.healthManager = null;
|
||||
this.correlationId = null;
|
||||
this.attemptId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the publication process with retries.
|
||||
*
|
||||
* @returns {Promise<Object>} The published event if successful.
|
||||
* @throws {Error} If publication quorum is not met after all attempts.
|
||||
*/
|
||||
async publish() {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
retryAttempts = 4,
|
||||
retryBaseDelayMs = 500,
|
||||
retryCapDelayMs = 8_000,
|
||||
sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
randomFn = Math.random,
|
||||
telemetryLogger = console.error,
|
||||
healthLogger = console.error,
|
||||
diagnostics = {},
|
||||
healthManager = defaultHealthManager,
|
||||
} = this.deps;
|
||||
|
||||
this.healthManager = healthManager;
|
||||
this.pool = poolFactory();
|
||||
this.publishTimeoutMs = this.deps.resolvedConfig?.publishTimeoutMs;
|
||||
this.minSuccesses = this.deps.resolvedConfig?.minSuccesses;
|
||||
this.fallbackRelays = (this.deps.resolvedConfig?.fallbackRelays || []).filter((relay) => !this.relays.includes(relay));
|
||||
this.maxAttempts = Math.max(1, Math.floor(retryAttempts));
|
||||
this.healthConfig = buildRelayHealthConfig({
|
||||
...this.deps,
|
||||
minActiveRelayPool: this.deps.resolvedConfig?.minActiveRelayPool,
|
||||
});
|
||||
|
||||
this.retryBaseDelayMs = retryBaseDelayMs;
|
||||
this.retryCapDelayMs = retryCapDelayMs;
|
||||
this.sleepFn = sleepFn;
|
||||
this.randomFn = randomFn;
|
||||
this.telemetryLogger = telemetryLogger;
|
||||
this.healthLogger = healthLogger;
|
||||
this.healthManager = healthManager;
|
||||
this.correlationId = diagnostics.correlationId || randomUUID();
|
||||
this.attemptId = diagnostics.attemptId || randomUUID();
|
||||
|
||||
this.allRelays = mergeRelayList(this.relays, this.fallbackRelays);
|
||||
|
||||
try {
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
|
||||
let lastAttemptState = null;
|
||||
const retryTimeline = [];
|
||||
const overallStartedAt = Date.now();
|
||||
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
|
||||
// Retry loop: attempts publication until success or max attempts reached
|
||||
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
|
||||
const startedAtMs = Date.now();
|
||||
lastAttemptState = await this.executePublishCycle();
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
retryTimeline.push({
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
elapsedMs,
|
||||
});
|
||||
|
||||
if (lastAttemptState.successCount >= this.minSuccesses) {
|
||||
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
|
||||
return this.event;
|
||||
}
|
||||
|
||||
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
|
||||
const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
|
||||
|
||||
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
|
||||
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
|
||||
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
|
||||
await this.sleepFn(nextDelayMs);
|
||||
}
|
||||
|
||||
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
|
||||
} finally {
|
||||
this.pool.close(this.allRelays);
|
||||
}
|
||||
}
|
||||
|
||||
async executePublishCycle() {
|
||||
const attempted = new Set();
|
||||
const publishResults = [];
|
||||
|
||||
await this.attemptPhase(this.relays, 'publish:primary', attempted, publishResults);
|
||||
|
||||
let successCount = publishResults.filter((result) => result.success).length;
|
||||
if (successCount < this.minSuccesses && this.fallbackRelays.length > 0) {
|
||||
await this.attemptPhase(this.fallbackRelays, 'publish:fallback', attempted, publishResults);
|
||||
successCount = publishResults.filter((result) => result.success).length;
|
||||
}
|
||||
|
||||
const failures = publishResults
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => ({
|
||||
...result,
|
||||
category: classifyPublishError(result.message),
|
||||
}));
|
||||
|
||||
return { attempted, publishResults, successCount, failures };
|
||||
}
|
||||
|
||||
async attemptPhase(phaseRelays, phaseName, attempted, publishResults) {
|
||||
if (!phaseRelays.length) return;
|
||||
const { prioritized } = this.healthManager.prioritizeRelays(phaseRelays, this.healthConfig);
|
||||
if (!prioritized.length) return;
|
||||
|
||||
console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${prioritized.join(', ')})...`);
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
for (const relay of prioritized) {
|
||||
attempted.add(relay);
|
||||
}
|
||||
const phaseResults = await publishToRelays(this.pool, prioritized, this.event, this.publishTimeoutMs, phaseName);
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
for (const result of phaseResults) {
|
||||
result.latencyMs = elapsedMs;
|
||||
this.healthManager.recordOutcome(result.relay, result.success, result.message, elapsedMs, this.healthConfig);
|
||||
}
|
||||
publishResults.push(...phaseResults);
|
||||
}
|
||||
|
||||
analyzeFailures(failures) {
|
||||
const hasTransientFailure = failures.some((failure) => isTransientPublishCategory(failure.category));
|
||||
const hasPermanentFailure = failures.some((failure) => !isTransientPublishCategory(failure.category));
|
||||
return { hasTransientFailure, hasPermanentFailure };
|
||||
}
|
||||
|
||||
determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) {
|
||||
if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) {
|
||||
return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE;
|
||||
} else if (hasPermanentFailure) {
|
||||
return PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
} else {
|
||||
return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt) {
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_met',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: this.minSuccesses,
|
||||
timeoutMs: this.publishTimeoutMs,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
console.error(
|
||||
` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays `
|
||||
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
logRetry(attemptNumber, failures, elapsedMs, nextDelayMs) {
|
||||
for (const failure of failures) {
|
||||
if (!isTransientPublishCategory(failure.category)) continue;
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
relayUrl: failure.relay,
|
||||
errorCategory: failure.category,
|
||||
elapsedMs,
|
||||
nextDelayMs,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt) {
|
||||
const reasonDistribution = {};
|
||||
const failureLines = lastAttemptState.failures
|
||||
.map((result) => {
|
||||
reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1;
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_failure',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
relayUrl: result.relay,
|
||||
phase: result.phase,
|
||||
reason: result.category,
|
||||
message: result.message,
|
||||
}));
|
||||
return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`;
|
||||
});
|
||||
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_failed',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
errorCategory: terminalFailureCategory,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: this.minSuccesses,
|
||||
timeoutMs: this.publishTimeoutMs,
|
||||
attempts: this.maxAttempts,
|
||||
reasonDistribution,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:failure', true);
|
||||
throw new Error(
|
||||
`Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful `
|
||||
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms, attempts=${this.maxAttempts}, attempt_id=${this.attemptId}, correlation_id=${this.correlationId}, error_category=${terminalFailureCategory}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n`
|
||||
+ ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n`
|
||||
+ ` ${failureLines.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level function to publish a lock event.
|
||||
* Initializes a LockPublisher and triggers the publication process.
|
||||
*
|
||||
* @param {string[]} relays - Target relays.
|
||||
* @param {Object} event - Lock event to publish.
|
||||
* @param {Object} [deps] - Dependencies.
|
||||
* @returns {Promise<Object>} The published event.
|
||||
*/
|
||||
export async function publishLock(relays, event, deps = {}) {
|
||||
const {
|
||||
getPublishTimeoutMsFn = getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacksFn = getRelayFallbacks,
|
||||
getMinActiveRelayPoolFn = getMinActiveRelayPool,
|
||||
} = deps;
|
||||
|
||||
const [publishTimeoutMs, minSuccesses, fallbackRelays, minActiveRelayPool] = await Promise.all([
|
||||
getPublishTimeoutMsFn(),
|
||||
getMinSuccessfulRelayPublishesFn(),
|
||||
getRelayFallbacksFn(),
|
||||
getMinActiveRelayPoolFn(),
|
||||
]);
|
||||
|
||||
return new LockPublisher(relays, event, {
|
||||
...deps,
|
||||
resolvedConfig: {
|
||||
publishTimeoutMs,
|
||||
minSuccesses,
|
||||
fallbackRelays,
|
||||
minActiveRelayPool,
|
||||
},
|
||||
}).publish();
|
||||
}
|
||||
|
||||
export function _resetRelayHealthState() {
|
||||
defaultHealthManager.reset();
|
||||
}
|
||||
|
||||
export {
|
||||
RelayHealthManager,
|
||||
defaultHealthManager,
|
||||
LockPublisher,
|
||||
publishLock,
|
||||
};
|
||||
|
||||
export const _secureRandom = secureRandom;
|
||||
@@ -0,0 +1,456 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import {
|
||||
getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacks,
|
||||
getMinActiveRelayPool,
|
||||
} from './torch-config.mjs';
|
||||
import { defaultHealthManager, buildRelayHealthConfig } from './relay-health-manager.mjs';
|
||||
|
||||
function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||
let timeoutHandle;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
});
|
||||
}
|
||||
|
||||
function relayListLabel(relays) {
|
||||
return relays.join(', ');
|
||||
}
|
||||
|
||||
function mergeRelayList(primaryRelays, fallbackRelays) {
|
||||
return [...new Set([...primaryRelays, ...fallbackRelays])];
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
/**
|
||||
* Classifies a raw error message into a standardized publication error code.
|
||||
* Used to determine if a failure is transient (retryable) or permanent.
|
||||
*
|
||||
* @param {string|Error} message - The error message or object to classify.
|
||||
* @returns {string} One of the PUBLISH_ERROR_CODES constants.
|
||||
*/
|
||||
function classifyPublishError(message) {
|
||||
const normalized = String(message || '').toLowerCase();
|
||||
if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) {
|
||||
return PUBLISH_ERROR_CODES.TIMEOUT;
|
||||
}
|
||||
if (
|
||||
normalized.includes('enotfound')
|
||||
|| normalized.includes('eai_again')
|
||||
|| normalized.includes('getaddrinfo')
|
||||
|| (normalized.includes('dns') && normalized.includes('websocket'))
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.DNS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('connect etimedout')
|
||||
|| normalized.includes('tcp connect timed out')
|
||||
|| normalized.includes('connect timeout')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TCP;
|
||||
}
|
||||
if (
|
||||
normalized.includes('tls')
|
||||
|| normalized.includes('ssl')
|
||||
|| normalized.includes('certificate')
|
||||
|| normalized.includes('handshake')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.TLS;
|
||||
}
|
||||
if (
|
||||
normalized.includes('websocket')
|
||||
|| normalized.includes('bad response')
|
||||
|| normalized.includes('unexpected server response')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.WEBSOCKET;
|
||||
}
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) {
|
||||
return PUBLISH_ERROR_CODES.NETWORK;
|
||||
}
|
||||
if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) {
|
||||
return PUBLISH_ERROR_CODES.CONNECTION_RESET;
|
||||
}
|
||||
if (
|
||||
normalized.includes('unavailable')
|
||||
|| normalized.includes('offline')
|
||||
|| normalized.includes('econnrefused')
|
||||
|| normalized.includes('connection refused')
|
||||
|| normalized.includes('enotfound')
|
||||
|| normalized.includes('503')
|
||||
) {
|
||||
return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE;
|
||||
}
|
||||
return PUBLISH_ERROR_CODES.PERMANENT;
|
||||
}
|
||||
|
||||
function isTransientPublishCategory(category) {
|
||||
return [
|
||||
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);
|
||||
}
|
||||
|
||||
const MAX_RANDOM = 281474976710655; // 2**48 - 1
|
||||
|
||||
export function secureRandom() {
|
||||
return randomInt(0, MAX_RANDOM) / MAX_RANDOM;
|
||||
}
|
||||
|
||||
function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) {
|
||||
const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1)));
|
||||
return Math.floor(randomFn() * maxDelay);
|
||||
}
|
||||
|
||||
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
|
||||
const publishPromises = pool.publish(relays, event);
|
||||
const settled = await Promise.allSettled(
|
||||
publishPromises.map((publishPromise, index) => withTimeout(
|
||||
publishPromise,
|
||||
publishTimeoutMs,
|
||||
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||
)),
|
||||
);
|
||||
|
||||
return settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return {
|
||||
relay: relays[index],
|
||||
success: true,
|
||||
phase,
|
||||
latencyMs: null,
|
||||
};
|
||||
}
|
||||
const reason = result.reason;
|
||||
const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown');
|
||||
return {
|
||||
relay: relays[index],
|
||||
success: false,
|
||||
phase,
|
||||
message,
|
||||
latencyMs: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the publication of a lock event to multiple relays.
|
||||
* Handles retries, fallback relays, and health-based relay prioritization.
|
||||
*
|
||||
* Cycle:
|
||||
* 1. Attempt to publish to primary relays.
|
||||
* 2. If quorum not met, attempt fallback relays.
|
||||
* 3. If still failing, retry with backoff (if errors are transient).
|
||||
* 4. Report final success or failure.
|
||||
*/
|
||||
export class LockPublisher {
|
||||
/**
|
||||
* @param {string[]} relays - Target primary relays.
|
||||
* @param {Object} event - The lock event to publish.
|
||||
* @param {Object} [deps] - Dependencies and configuration.
|
||||
*/
|
||||
constructor(relays, event, deps = {}) {
|
||||
this.relays = relays;
|
||||
this.event = event;
|
||||
this.deps = deps;
|
||||
this.pool = null;
|
||||
this.healthConfig = null;
|
||||
this.allRelays = [];
|
||||
this.fallbackRelays = [];
|
||||
this.publishTimeoutMs = 0;
|
||||
this.minSuccesses = 0;
|
||||
this.minActiveRelayPool = 0;
|
||||
this.maxAttempts = 0;
|
||||
this.retryBaseDelayMs = 0;
|
||||
this.retryCapDelayMs = 0;
|
||||
this.sleepFn = null;
|
||||
this.randomFn = null;
|
||||
this.telemetryLogger = null;
|
||||
this.healthLogger = null;
|
||||
this.healthManager = null;
|
||||
this.correlationId = null;
|
||||
this.attemptId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the publication process with retries.
|
||||
*
|
||||
* @returns {Promise<Object>} The published event if successful.
|
||||
* @throws {Error} If publication quorum is not met after all attempts.
|
||||
*/
|
||||
async publish() {
|
||||
const {
|
||||
poolFactory = () => new SimplePool(),
|
||||
retryAttempts = 4,
|
||||
retryBaseDelayMs = 500,
|
||||
retryCapDelayMs = 8_000,
|
||||
sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
randomFn = Math.random,
|
||||
telemetryLogger = console.error,
|
||||
healthLogger = console.error,
|
||||
diagnostics = {},
|
||||
healthManager = defaultHealthManager,
|
||||
} = this.deps;
|
||||
|
||||
this.healthManager = healthManager;
|
||||
this.pool = poolFactory();
|
||||
this.publishTimeoutMs = this.deps.resolvedConfig?.publishTimeoutMs;
|
||||
this.minSuccesses = this.deps.resolvedConfig?.minSuccesses;
|
||||
this.fallbackRelays = (this.deps.resolvedConfig?.fallbackRelays || []).filter((relay) => !this.relays.includes(relay));
|
||||
this.maxAttempts = Math.max(1, Math.floor(retryAttempts));
|
||||
this.healthConfig = buildRelayHealthConfig({
|
||||
...this.deps,
|
||||
minActiveRelayPool: this.deps.resolvedConfig?.minActiveRelayPool,
|
||||
});
|
||||
|
||||
this.retryBaseDelayMs = retryBaseDelayMs;
|
||||
this.retryCapDelayMs = retryCapDelayMs;
|
||||
this.sleepFn = sleepFn;
|
||||
this.randomFn = randomFn;
|
||||
this.telemetryLogger = telemetryLogger;
|
||||
this.healthLogger = healthLogger;
|
||||
this.healthManager = healthManager;
|
||||
this.correlationId = diagnostics.correlationId || randomUUID();
|
||||
this.attemptId = diagnostics.attemptId || randomUUID();
|
||||
|
||||
this.allRelays = mergeRelayList(this.relays, this.fallbackRelays);
|
||||
|
||||
try {
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic');
|
||||
let lastAttemptState = null;
|
||||
const retryTimeline = [];
|
||||
const overallStartedAt = Date.now();
|
||||
let terminalFailureCategory = PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
|
||||
// Retry loop: attempts publication until success or max attempts reached
|
||||
for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) {
|
||||
const startedAtMs = Date.now();
|
||||
lastAttemptState = await this.executePublishCycle();
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
retryTimeline.push({
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
elapsedMs,
|
||||
});
|
||||
|
||||
if (lastAttemptState.successCount >= this.minSuccesses) {
|
||||
this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt);
|
||||
return this.event;
|
||||
}
|
||||
|
||||
const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures);
|
||||
const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure;
|
||||
|
||||
terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber);
|
||||
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
|
||||
const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn);
|
||||
this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs);
|
||||
await this.sleepFn(nextDelayMs);
|
||||
}
|
||||
|
||||
this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt);
|
||||
} finally {
|
||||
this.pool.close(this.allRelays);
|
||||
}
|
||||
}
|
||||
|
||||
async executePublishCycle() {
|
||||
const attempted = new Set();
|
||||
const publishResults = [];
|
||||
|
||||
await this.attemptPhase(this.relays, 'publish:primary', attempted, publishResults);
|
||||
|
||||
let successCount = publishResults.filter((result) => result.success).length;
|
||||
if (successCount < this.minSuccesses && this.fallbackRelays.length > 0) {
|
||||
await this.attemptPhase(this.fallbackRelays, 'publish:fallback', attempted, publishResults);
|
||||
successCount = publishResults.filter((result) => result.success).length;
|
||||
}
|
||||
|
||||
const failures = publishResults
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => ({
|
||||
...result,
|
||||
category: classifyPublishError(result.message),
|
||||
}));
|
||||
|
||||
return { attempted, publishResults, successCount, failures };
|
||||
}
|
||||
|
||||
async attemptPhase(phaseRelays, phaseName, attempted, publishResults) {
|
||||
if (!phaseRelays.length) return;
|
||||
const { prioritized } = this.healthManager.prioritizeRelays(phaseRelays, this.healthConfig);
|
||||
if (!prioritized.length) return;
|
||||
|
||||
console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${relayListLabel(prioritized)})...`);
|
||||
|
||||
const startedAtMs = Date.now();
|
||||
for (const relay of prioritized) {
|
||||
attempted.add(relay);
|
||||
}
|
||||
const phaseResults = await publishToRelays(this.pool, prioritized, this.event, this.publishTimeoutMs, phaseName);
|
||||
const elapsedMs = Date.now() - startedAtMs;
|
||||
for (const result of phaseResults) {
|
||||
result.latencyMs = elapsedMs;
|
||||
this.healthManager.recordOutcome(result.relay, result.success, result.message, elapsedMs, this.healthConfig);
|
||||
}
|
||||
publishResults.push(...phaseResults);
|
||||
}
|
||||
|
||||
analyzeFailures(failures) {
|
||||
const hasTransientFailure = failures.some((failure) => isTransientPublishCategory(failure.category));
|
||||
const hasPermanentFailure = failures.some((failure) => !isTransientPublishCategory(failure.category));
|
||||
return { hasTransientFailure, hasPermanentFailure };
|
||||
}
|
||||
|
||||
determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) {
|
||||
if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) {
|
||||
return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE;
|
||||
} else if (hasPermanentFailure) {
|
||||
return PUBLISH_FAILURE_CATEGORIES.NON_RETRYABLE;
|
||||
} else {
|
||||
return PUBLISH_FAILURE_CATEGORIES.QUORUM_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt) {
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_met',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: this.minSuccesses,
|
||||
timeoutMs: this.publishTimeoutMs,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
console.error(
|
||||
` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays `
|
||||
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms)`,
|
||||
);
|
||||
}
|
||||
|
||||
logRetry(attemptNumber, failures, elapsedMs, nextDelayMs) {
|
||||
for (const failure of failures) {
|
||||
if (!isTransientPublishCategory(failure.category)) continue;
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_retry',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
publishAttempt: attemptNumber,
|
||||
relayUrl: failure.relay,
|
||||
errorCategory: failure.category,
|
||||
elapsedMs,
|
||||
nextDelayMs,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt) {
|
||||
const reasonDistribution = {};
|
||||
const failureLines = lastAttemptState.failures
|
||||
.map((result) => {
|
||||
reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1;
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_failure',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
relayUrl: result.relay,
|
||||
phase: result.phase,
|
||||
reason: result.category,
|
||||
message: result.message,
|
||||
}));
|
||||
return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`;
|
||||
});
|
||||
|
||||
this.telemetryLogger(JSON.stringify({
|
||||
event: 'lock_publish_quorum_failed',
|
||||
correlationId: this.correlationId,
|
||||
attemptId: this.attemptId,
|
||||
errorCategory: terminalFailureCategory,
|
||||
successCount: lastAttemptState.successCount,
|
||||
relayAttemptedCount: lastAttemptState.attempted.size,
|
||||
requiredSuccesses: this.minSuccesses,
|
||||
timeoutMs: this.publishTimeoutMs,
|
||||
attempts: this.maxAttempts,
|
||||
reasonDistribution,
|
||||
retryTimeline,
|
||||
totalElapsedMs: Date.now() - overallStartedAt,
|
||||
}));
|
||||
|
||||
this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:failure', true);
|
||||
throw new Error(
|
||||
`Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful `
|
||||
+ `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms, attempts=${this.maxAttempts}, attempt_id=${this.attemptId}, correlation_id=${this.correlationId}, error_category=${terminalFailureCategory}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n`
|
||||
+ ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n`
|
||||
+ ` ${failureLines.join('\n ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level function to publish a lock event.
|
||||
* Initializes a LockPublisher and triggers the publication process.
|
||||
*
|
||||
* @param {string[]} relays - Target relays.
|
||||
* @param {Object} event - Lock event to publish.
|
||||
* @param {Object} [deps] - Dependencies.
|
||||
* @returns {Promise<Object>} The published event.
|
||||
*/
|
||||
export async function publishLock(relays, event, deps = {}) {
|
||||
const {
|
||||
getPublishTimeoutMsFn = getPublishTimeoutMs,
|
||||
getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes,
|
||||
getRelayFallbacksFn = getRelayFallbacks,
|
||||
getMinActiveRelayPoolFn = getMinActiveRelayPool,
|
||||
} = deps;
|
||||
|
||||
const [publishTimeoutMs, minSuccesses, fallbackRelays, minActiveRelayPool] = await Promise.all([
|
||||
getPublishTimeoutMsFn(),
|
||||
getMinSuccessfulRelayPublishesFn(),
|
||||
getRelayFallbacksFn(),
|
||||
getMinActiveRelayPoolFn(),
|
||||
]);
|
||||
|
||||
return new LockPublisher(relays, event, {
|
||||
...deps,
|
||||
resolvedConfig: {
|
||||
publishTimeoutMs,
|
||||
minSuccesses,
|
||||
fallbackRelays,
|
||||
minActiveRelayPool,
|
||||
},
|
||||
}).publish();
|
||||
}
|
||||
@@ -47,3 +47,21 @@ export async function getCompletedAgents(cadence, logDir, deps) {
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
export function withTimeout(promise, timeoutMs, timeoutMessage) {
|
||||
let timeoutHandle;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||
});
|
||||
}
|
||||
|
||||
export function relayListLabel(relays) {
|
||||
return relays.join(', ');
|
||||
}
|
||||
|
||||
export function mergeRelayList(primaryRelays, fallbackRelays) {
|
||||
return [...new Set([...primaryRelays, ...fallbackRelays])];
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
DEFAULT_ROLLING_WINDOW_SIZE,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
|
||||
DEFAULT_SNAPSHOT_INTERVAL_MS,
|
||||
} from './constants.mjs';
|
||||
|
||||
function summarizeRelayMetrics(metrics, nowMs) {
|
||||
const total = metrics.recentOutcomes.length;
|
||||
const successCount = metrics.recentOutcomes.filter((entry) => entry.success).length;
|
||||
const timeoutCount = metrics.recentOutcomes.filter((entry) => entry.timedOut).length;
|
||||
const latencyEntries = metrics.recentOutcomes.map((entry) => entry.latencyMs).filter((v) => Number.isFinite(v));
|
||||
const averageLatencyMs = latencyEntries.length
|
||||
? Math.round(latencyEntries.reduce((sum, value) => sum + value, 0) / latencyEntries.length)
|
||||
: null;
|
||||
const successRate = total > 0 ? successCount / total : 0.5;
|
||||
const timeoutRate = total > 0 ? timeoutCount / total : 0;
|
||||
const quarantineRemainingMs = metrics.quarantineUntil > nowMs ? metrics.quarantineUntil - nowMs : 0;
|
||||
return {
|
||||
relay: metrics.relay,
|
||||
sampleSize: total,
|
||||
successRate,
|
||||
timeoutRate,
|
||||
averageLatencyMs,
|
||||
failureStreak: metrics.failureStreak,
|
||||
quarantined: quarantineRemainingMs > 0,
|
||||
quarantineRemainingMs,
|
||||
cooldownMs: metrics.cooldownMs,
|
||||
lastResultAt: metrics.lastResultAt,
|
||||
};
|
||||
}
|
||||
|
||||
function computeRelayScore(summary) {
|
||||
const latencyPenalty = summary.averageLatencyMs === null
|
||||
? 0.1
|
||||
: Math.min(0.35, summary.averageLatencyMs / 10_000);
|
||||
const quarantinePenalty = summary.quarantined ? 1 : 0;
|
||||
return (summary.successRate * 1.4) - (summary.timeoutRate * 0.9) - latencyPenalty - quarantinePenalty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages health metrics, scoring, and prioritization for Nostr relays.
|
||||
* Tracks success rates, timeouts, and latency to optimize relay selection.
|
||||
* Implements a quarantine mechanism for failing relays.
|
||||
*/
|
||||
export class RelayHealthManager {
|
||||
constructor() {
|
||||
this.metricsByRelay = new Map();
|
||||
this.lastSnapshotAt = 0;
|
||||
this._metricsVersion = 0;
|
||||
this._sortedCache = null;
|
||||
}
|
||||
|
||||
ensureMetrics(relay, config) {
|
||||
let metrics = this.metricsByRelay.get(relay);
|
||||
if (!metrics) {
|
||||
metrics = {
|
||||
relay,
|
||||
recentOutcomes: [],
|
||||
failureStreak: 0,
|
||||
quarantineUntil: 0,
|
||||
cooldownMs: config.quarantineCooldownMs,
|
||||
lastLatencyMs: null,
|
||||
lastResultAt: null,
|
||||
};
|
||||
this.metricsByRelay.set(relay, metrics);
|
||||
this._metricsVersion += 1;
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks a list of relays based on their health scores.
|
||||
* Utilizes a cached sort order to avoid re-sorting on every call if metrics haven't changed.
|
||||
*
|
||||
* @param {string[]} relays - List of relay URLs to rank.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
* @returns {Array} List of ranked relay entries with scores and summaries.
|
||||
*/
|
||||
rankRelays(relays, config, nowMs = Date.now()) {
|
||||
for (const relay of relays) {
|
||||
this.ensureMetrics(relay, config);
|
||||
}
|
||||
|
||||
// Check if the cached sort order is still valid
|
||||
if (
|
||||
!this._sortedCache
|
||||
|| this._sortedCache.version !== this._metricsVersion
|
||||
|| nowMs >= this._sortedCache.validUntil
|
||||
) {
|
||||
let minQuarantineUntil = Infinity;
|
||||
const entries = [];
|
||||
|
||||
// Re-evaluate scores for all tracked relays
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by score (desc), then quarantine status, then latency (asc), then name
|
||||
const comparator = (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);
|
||||
};
|
||||
entries.sort(comparator);
|
||||
|
||||
this._sortedCache = {
|
||||
version: this._metricsVersion,
|
||||
validUntil: minQuarantineUntil,
|
||||
entries,
|
||||
byRelay: new Map(entries.map((e) => [e.relay, e])),
|
||||
comparator,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimization: if requesting a small subset of total known relays,
|
||||
// look them up directly instead of filtering the entire list.
|
||||
// Threshold: if requested set is smaller than 50% of cached entries.
|
||||
if (relays.length > 0 && relays.length * 2 < this._sortedCache.entries.length) {
|
||||
const subset = [];
|
||||
for (const relay of relays) {
|
||||
const entry = this._sortedCache.byRelay.get(relay);
|
||||
if (entry) {
|
||||
subset.push(entry);
|
||||
}
|
||||
}
|
||||
// Re-sort the subset to maintain ranking order
|
||||
subset.sort(this._sortedCache.comparator);
|
||||
|
||||
return subset.map((entry) => {
|
||||
const quarantineRemainingMs = entry.metrics.quarantineUntil > nowMs
|
||||
? entry.metrics.quarantineUntil - nowMs
|
||||
: 0;
|
||||
return {
|
||||
relay: entry.relay,
|
||||
score: entry.score,
|
||||
summary: {
|
||||
...entry.summary,
|
||||
quarantineRemainingMs,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a subset of relays for immediate use, prioritizing healthy ones.
|
||||
* May include quarantined relays if the number of healthy relays is below `minActiveRelayPool`.
|
||||
*
|
||||
* @param {string[]} relays - Candidate relays.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
* @returns {Object} An object containing `prioritized` (list of selected relay URLs) and `ranked` (full ranking details).
|
||||
*/
|
||||
prioritizeRelays(relays, config, nowMs = Date.now()) {
|
||||
const minActive = Math.max(1, Math.min(config.minActiveRelayPool, relays.length || 1));
|
||||
const ranked = this.rankRelays([...new Set(relays)], config, nowMs);
|
||||
const active = ranked.filter((entry) => !entry.summary.quarantined);
|
||||
const quarantined = ranked
|
||||
.filter((entry) => entry.summary.quarantined)
|
||||
.sort((a, b) => a.summary.quarantineRemainingMs - b.summary.quarantineRemainingMs);
|
||||
|
||||
const selected = [...active];
|
||||
if (selected.length < minActive) {
|
||||
const additionalNeeded = Math.min(minActive - selected.length, quarantined.length);
|
||||
selected.push(...quarantined.slice(0, additionalNeeded));
|
||||
}
|
||||
|
||||
return {
|
||||
prioritized: selected.map((entry) => entry.relay),
|
||||
ranked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates health metrics for a relay based on the outcome of an operation.
|
||||
*
|
||||
* @param {string} relay - The relay URL.
|
||||
* @param {boolean} success - Whether the operation succeeded.
|
||||
* @param {string} errorMessage - Error message if failed.
|
||||
* @param {number} latencyMs - Duration of the operation.
|
||||
* @param {Object} config - Health configuration.
|
||||
* @param {number} [nowMs] - Current timestamp.
|
||||
*/
|
||||
recordOutcome(relay, success, errorMessage, latencyMs, config, nowMs = Date.now()) {
|
||||
const metrics = this.ensureMetrics(relay, config);
|
||||
const timedOut = String(errorMessage || '').toLowerCase().includes('timeout')
|
||||
|| String(errorMessage || '').toLowerCase().includes('timed out');
|
||||
metrics.recentOutcomes.push({ success, timedOut, latencyMs, atMs: nowMs });
|
||||
if (metrics.recentOutcomes.length > config.rollingWindowSize) {
|
||||
metrics.recentOutcomes.splice(0, metrics.recentOutcomes.length - config.rollingWindowSize);
|
||||
}
|
||||
metrics.lastResultAt = nowMs;
|
||||
metrics.lastLatencyMs = Number.isFinite(latencyMs) ? latencyMs : null;
|
||||
|
||||
if (success) {
|
||||
metrics.failureStreak = 0;
|
||||
if (metrics.quarantineUntil > nowMs) {
|
||||
metrics.quarantineUntil = 0;
|
||||
metrics.cooldownMs = config.quarantineCooldownMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
metrics.failureStreak += 1;
|
||||
if (metrics.failureStreak >= config.failureThreshold) {
|
||||
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()) {
|
||||
const uniqueRelays = [...new Set(relays)];
|
||||
return this.rankRelays(uniqueRelays, config, nowMs).map((entry) => ({
|
||||
relay: entry.relay,
|
||||
score: Number(entry.score.toFixed(4)),
|
||||
...entry.summary,
|
||||
}));
|
||||
}
|
||||
|
||||
maybeLogSnapshot(relays, config, logger, reason, force = false, nowMs = Date.now()) {
|
||||
const intervalReached = nowMs - this.lastSnapshotAt >= config.snapshotIntervalMs;
|
||||
if (!force && !intervalReached) return;
|
||||
this.lastSnapshotAt = nowMs;
|
||||
logger(JSON.stringify({
|
||||
event: 'relay_health_snapshot',
|
||||
reason,
|
||||
relays: this.collectSnapshot(relays, config, nowMs),
|
||||
}));
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metricsByRelay.clear();
|
||||
this.lastSnapshotAt = 0;
|
||||
this._metricsVersion = 0;
|
||||
this._sortedCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultHealthManager = new RelayHealthManager();
|
||||
|
||||
export function buildRelayHealthConfig(deps) {
|
||||
return {
|
||||
rollingWindowSize: deps.rollingWindowSize ?? DEFAULT_ROLLING_WINDOW_SIZE,
|
||||
failureThreshold: deps.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD,
|
||||
quarantineCooldownMs: deps.quarantineCooldownMs ?? DEFAULT_QUARANTINE_COOLDOWN_MS,
|
||||
maxQuarantineCooldownMs: deps.maxQuarantineCooldownMs ?? DEFAULT_MAX_QUARANTINE_COOLDOWN_MS,
|
||||
snapshotIntervalMs: deps.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS,
|
||||
minActiveRelayPool: Math.max(1, deps.minActiveRelayPool ?? 1),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
Run Start: 2026-02-19T10-00-00Z
|
||||
Cadence: daily
|
||||
Agent: decompose-agent
|
||||
Prompt: src/prompts/daily/decompose-agent.md
|
||||
|
||||
# Test Log: Decompose src/lock-ops.mjs
|
||||
|
||||
**Timestamp:** 2026-02-19T10-00-00Z
|
||||
**Agent:** decompose-agent
|
||||
|
||||
## Commands
|
||||
|
||||
### Lint
|
||||
- **Command:** `npm run lint`
|
||||
- **Result:** Passed (No errors)
|
||||
|
||||
### Unit Tests
|
||||
- **Command:** `npm run test`
|
||||
- **Result:** Passed
|
||||
- **Summary:**
|
||||
- Tests: 295
|
||||
- Passed: 294
|
||||
- Skipped: 1
|
||||
|
||||
## Observations
|
||||
- Decomposed `src/lock-ops.mjs` into `src/relay-health-manager.mjs` and `src/lock-publisher.mjs`.
|
||||
- Moved shared utilities to `src/lock-utils.mjs`.
|
||||
- All tests passed, confirming no regression in functionality.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Test Log: Content Audit Agent Run
|
||||
|
||||
Agent: content-audit-agent
|
||||
Prompt: src/prompts/daily/content-audit-agent.md
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-19T22-29-56Z
|
||||
Run ID: session-1771540169
|
||||
|
||||
- Command: ls -R docs/
|
||||
- Result: Listed directory contents (internal docs).
|
||||
- Status: Success
|
||||
|
||||
- Command: npm run lint
|
||||
- Result: Passed.
|
||||
- Status: Success
|
||||
|
||||
This run is executed by the **content-audit-agent** using the prompt at **src/prompts/daily/content-audit-agent.md**.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Test Log
|
||||
Agent: deps-security-agent
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-20T01-13-24Z
|
||||
Prompt: src/prompts/daily/deps-security-agent.md
|
||||
|
||||
## Environment
|
||||
- Command: node -v
|
||||
- Result: v22.22.0
|
||||
- Command: npm -v
|
||||
- Result: 11.7.0
|
||||
|
||||
## Bootstrap
|
||||
- Command: npm ci
|
||||
|
||||
added 81 packages, and audited 82 packages in 2s
|
||||
|
||||
25 packages are looking for funding
|
||||
run `npm fund` for details
|
||||
|
||||
5 vulnerabilities (4 moderate, 1 high)
|
||||
|
||||
To address issues that do not require attention, run:
|
||||
npm audit fix
|
||||
|
||||
To address all issues (including breaking changes), run:
|
||||
npm audit fix --force
|
||||
|
||||
Run `npm audit` for details.
|
||||
- Result: (see above)
|
||||
## Audit
|
||||
- Command: npm audit
|
||||
- Result: reports/security/npm-audit-2026-02-20T01-13-24Z.json created
|
||||
## Outdated
|
||||
- Command: npm outdated
|
||||
- Result: reports/security/npm-outdated-2026-02-20T01-13-24Z.json created
|
||||
## Dependency Tree Investigation
|
||||
- Command: npm ls minimatch
|
||||
torch-lock@0.1.0 /app
|
||||
└─┬ eslint@10.0.0
|
||||
├─┬ @eslint/config-array@0.23.1
|
||||
│ └── minimatch@10.2.0 deduped
|
||||
└── minimatch@10.2.0
|
||||
|
||||
- Command: npm ls ajv
|
||||
torch-lock@0.1.0 /app
|
||||
└─┬ eslint@10.0.0
|
||||
└── ajv@6.12.6
|
||||
|
||||
- Command: npm ls eslint
|
||||
torch-lock@0.1.0 /app
|
||||
├─┬ @eslint/js@10.0.1
|
||||
│ └── eslint@10.0.0 deduped
|
||||
└─┬ eslint@10.0.0
|
||||
└─┬ @eslint-community/eslint-utils@4.9.1
|
||||
└── eslint@10.0.0 deduped
|
||||
@@ -0,0 +1,29 @@
|
||||
# Test Log: Decompose src/lock-ops.mjs
|
||||
|
||||
## Date
|
||||
2026-02-20
|
||||
|
||||
## Verification Steps
|
||||
|
||||
1. **Linting**
|
||||
- Command: `npm run lint`
|
||||
- Result: Passed (after fixing unused `relayListLabel` in `src/lock-publisher.mjs`).
|
||||
|
||||
2. **Unit Tests**
|
||||
- Command: `npm test`
|
||||
- Result: Passed (294 tests).
|
||||
- Key Tests Covered:
|
||||
- `test/lock-ops.test.mjs` (implicitly covered by keeping API compatible and general suite passing).
|
||||
- `test/run-scheduler-cycle.mjs` (integration).
|
||||
|
||||
3. **File Size Check**
|
||||
- Command: `node scripts/check-file-size.mjs --report`
|
||||
- Result: `src/lock-ops.mjs` is no longer in the report (meaning it is < 300 lines).
|
||||
- Previous Size: 881 lines.
|
||||
- New Size: < 300 lines.
|
||||
- Reduction: > 581 lines.
|
||||
|
||||
## Artifact Verification
|
||||
- `src/relay-health-manager.mjs` created and verified.
|
||||
- `src/lock-publisher.mjs` created and verified.
|
||||
- `src/lock-ops.mjs` refactored and verified.
|
||||
@@ -0,0 +1,22 @@
|
||||
Run Start: 2026-02-19T10-00-00Z
|
||||
Cadence: daily
|
||||
Agent: decompose-agent
|
||||
Prompt: src/prompts/daily/decompose-agent.md
|
||||
|
||||
# Todo: Decompose src/lock-ops.mjs
|
||||
|
||||
## Pending
|
||||
- [x] Create `src/relay-health-manager.mjs` with `RelayHealthManager` logic.
|
||||
- [x] Create `src/lock-publisher.mjs` with `LockPublisher` logic.
|
||||
- [x] Update `src/lock-ops.mjs` to import new modules.
|
||||
- [x] Verify linting passes (`npm run lint`).
|
||||
- [x] Verify tests pass (`npm run test`).
|
||||
- [x] Update file size baseline (skipped script update, manually verified).
|
||||
|
||||
## Completed
|
||||
- [x] Decomposed `src/lock-ops.mjs`.
|
||||
- [x] Created `src/relay-health-manager.mjs`.
|
||||
- [x] Created `src/lock-publisher.mjs`.
|
||||
- [x] Updated `src/lock-utils.mjs`.
|
||||
- [x] Updated `src/lock-ops.mjs`.
|
||||
- [x] Passed lint and tests.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Todo: Content Audit Agent Run
|
||||
|
||||
Agent: content-audit-agent
|
||||
Prompt: src/prompts/daily/content-audit-agent.md
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-19T22-29-56Z
|
||||
Run ID: session-1771540169
|
||||
|
||||
## Pending
|
||||
- [ ] Verify claims against code (N/A - no claims found).
|
||||
- [ ] Update documentation (N/A - no claims found).
|
||||
- [ ] Validate changes (N/A - no changes made).
|
||||
|
||||
## Completed
|
||||
- [x] Inventory `docs/` for relevant claims.
|
||||
- [x] Check for user-facing upload/contribution guides.
|
||||
|
||||
This run is executed by the **content-audit-agent** using the prompt at **src/prompts/daily/content-audit-agent.md**.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Todo
|
||||
Agent: deps-security-agent
|
||||
Cadence: daily
|
||||
Run Start: 2026-02-20T01-13-24Z
|
||||
Prompt: src/prompts/daily/deps-security-agent.md
|
||||
|
||||
## Pending Tasks
|
||||
- [ ] Investigate `eslint` upgrade path to resolve `minimatch` (High) vulnerability.
|
||||
- [ ] Investigate `ajv` vulnerability in `eslint` dependency tree.
|
||||
|
||||
## Completed Tasks
|
||||
- [x] Run npm audit.
|
||||
- [x] Run npm outdated.
|
||||
- [x] Generate security report.
|
||||
@@ -0,0 +1,8 @@
|
||||
# TODO: Decompose src/lock-ops.mjs
|
||||
|
||||
- [ ] Extract `RelayHealthManager` and helpers to `src/relay-health-manager.mjs`
|
||||
- [ ] Extract `LockPublisher` and helpers to `src/lock-publisher.mjs`
|
||||
- [ ] Refactor `src/lock-ops.mjs` to import new modules
|
||||
- [ ] Verify linting passes
|
||||
- [ ] Verify tests pass (`npm test`)
|
||||
- [ ] Verify file size reduction
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
agent: decompose-agent
|
||||
cadence: daily
|
||||
date: 2026-02-19
|
||||
platform: jules
|
||||
status: completed
|
||||
run_start: 2026-02-19T10-00-00Z
|
||||
---
|
||||
|
||||
Scheduler cycle completed successfully.
|
||||
Decomposed src/lock-ops.mjs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
agent: content-audit-agent
|
||||
platform: jules
|
||||
---
|
||||
Run completed successfully.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
agent: deps-security-agent
|
||||
platform: jules
|
||||
---
|
||||
|
||||
Run completed successfully.
|
||||
Security report: reports/security/deps-report-2026-02-20T01-13-24Z.md
|
||||
Vulnerabilities found: 5 (1 High, 4 Moderate).
|
||||
No safe upgrades identified.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
agent: decompose-agent
|
||||
cadence: daily
|
||||
date: 2026-02-20
|
||||
platform: jules
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Decompose Agent Run
|
||||
|
||||
- **Target:** `src/lock-ops.mjs`
|
||||
- **Result:** Successfully decomposed into `src/relay-health-manager.mjs` and `src/lock-publisher.mjs`.
|
||||
- **Reduction:** Original 881 lines -> New < 300 lines.
|
||||
- **Verification:** Lint and Tests passed.
|
||||
@@ -1 +1 @@
|
||||
{"ok":false,"cadence":"daily","namespace":"torch","timestamp":"2026-02-20T01:58:50.496Z","relays":["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net"],"relayResults":[{"relay":"wss://relay.damus.io","websocketReachable":false,"publishReadOk":false,"healthy":false,"checks":{"websocket":{"ok":false,"error":"getaddrinfo ENOTFOUND relay.damus.io"}}},{"relay":"wss://nos.lol","websocketReachable":false,"publishReadOk":false,"healthy":false,"checks":{"websocket":{"ok":false,"error":"getaddrinfo ENOTFOUND nos.lol"}}},{"relay":"wss://relay.primal.net","websocketReachable":false,"publishReadOk":false,"healthy":false,"checks":{"websocket":{"ok":false,"error":"getaddrinfo ENOTFOUND relay.primal.net"}}}],"summary":{"totalRelays":3,"healthyRelays":0,"unhealthyRelays":3,"allRelaysUnhealthy":true},"incidentSignal":{"id":"relay-health-daily-1771552730497","reason":"All relays unhealthy before lock acquisition","severity":"critical","suggestedAction":"Defer scheduler run and investigate relay availability before retrying lock acquisition."},"alerts":[{"type":"relay_success_rate_below_threshold","threshold":0.7,"actual":0,"windowMinutes":60,"severity":"warning"}],"alertStats":{"windowMinutes":60,"sampleCount":1,"successRate":0,"allDownDurationMinutes":null},"historyPath":"/home/user/Documents/GitHub/TORCH/task-logs/relay-health/daily.jsonl"}
|
||||
{"ok":true,"cadence":"daily","namespace":"torch","timestamp":"2026-02-20T01:13:27.775Z","relays":["wss://relay.damus.io","wss://nos.lol","wss://relay.primal.net"],"relayResults":[{"relay":"wss://relay.damus.io","websocketReachable":true,"publishReadOk":true,"healthy":true,"checks":{"websocket":{"ok":true,"latencyMs":186},"publishRead":{"ok":true,"eventId":"7d217815c782e1dac6557a8b616f30713820872c3e2f13e49c82fc6f787269e2"}}},{"relay":"wss://nos.lol","websocketReachable":true,"publishReadOk":true,"healthy":true,"checks":{"websocket":{"ok":true,"latencyMs":358},"publishRead":{"ok":true,"eventId":"ce6e9fe40d4365b0a36ee90d15aad91fe28ddafc25b1b2d2887bbd54a5e6cf90"}}},{"relay":"wss://relay.primal.net","websocketReachable":true,"publishReadOk":true,"healthy":true,"checks":{"websocket":{"ok":true,"latencyMs":520},"publishRead":{"ok":true,"eventId":"0bc4ad0f2ed9d580d0147b9af847dffd3009ec9419e516c0105d29837c9bcd71"}}}],"summary":{"totalRelays":3,"healthyRelays":3,"unhealthyRelays":0,"allRelaysUnhealthy":false},"incidentSignal":null,"alerts":[],"alertStats":{"windowMinutes":60,"sampleCount":1,"successRate":1,"allDownDurationMinutes":0},"historyPath":"/app/task-logs/relay-health/daily.jsonl"}
|
||||
Reference in new issue
Block a user