Add the miner log intake, Windows uploader and Mac reader
POST /api/log stores a log snapshot in Neon table miner_logs over the HTTP SQL endpoint with no dependencies. upload-log.bat posts the last 256 KB of a log from Windows with the curl.exe that ships with it. tools/logs.mjs lists runs and prints the latest lines on the Mac. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
33f7b331b2
commit
78df75703a
4 changed files with 165 additions and 0 deletions
7
proto-cuda/windows-miner/UPLOAD.md
Normal file
7
proto-cuda/windows-miner/UPLOAD.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Sending miner logs to Claude
|
||||
|
||||
1. Run `upload-log.bat <logfile> <label>` from this folder, for example `upload-log.bat miner.log rtx4090-run1`. Windows 10 and 11 need nothing installed (curl.exe and PowerShell ship with Windows).
|
||||
2. It posts the last 256 KB of the file to `https://igneum-six.vercel.app/api/log` with the key baked into the bat, which only authorises log uploads.
|
||||
3. Run it as often as you like during a test; every call stores a fresh snapshot. Pass a third argument or set `IGNEUM_RUN_ID` to keep one run_id across calls, otherwise the run_id is `<label>-<date>-<time>`.
|
||||
4. On the Mac, `node tools/logs.mjs` lists runs and `node tools/logs.mjs <run_id>` prints the latest snapshot (`--all` prints every one).
|
||||
5. The log never contains secrets; the intake stores label, machine name, run_id, the lines and the byte count.
|
||||
41
proto-cuda/windows-miner/upload-log.bat
Normal file
41
proto-cuda/windows-miner/upload-log.bat
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
@echo off
|
||||
rem Igneum miner log uploader. Sends the last 256 KB of a log file to the Igneum log intake
|
||||
rem so Claude on the Mac can read it (node tools/logs.mjs). Needs Windows 10 or 11 (curl.exe, PowerShell).
|
||||
rem Usage: upload-log.bat <logfile> <label> [run_id]
|
||||
rem run_id falls back to the IGNEUM_RUN_ID environment variable, then to <label>-<date>-<time>.
|
||||
rem The key below only authorises log uploads. It is meant to ship inside this package.
|
||||
setlocal
|
||||
set "IGNEUM_LOG_URL=https://igneum-six.vercel.app/api/log"
|
||||
set "IGNEUM_LOG_KEY=okLO0nuvYzKkgNCWL5WNW37e"
|
||||
|
||||
if "%~1"=="" goto usage
|
||||
if "%~2"=="" goto usage
|
||||
if not exist "%~1" (
|
||||
echo upload-log: file not found: %~1
|
||||
exit /b 2
|
||||
)
|
||||
set "LOGFILE=%~f1"
|
||||
set "LABEL=%~2"
|
||||
set "RUNID=%~3"
|
||||
if "%RUNID%"=="" set "RUNID=%IGNEUM_RUN_ID%"
|
||||
set "OUT=%TEMP%\igneum-upload-%RANDOM%.json"
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $b=[IO.File]::ReadAllBytes($env:LOGFILE); $n=[Math]::Min($b.Length,262144); $t=[Text.Encoding]::UTF8.GetString($b,$b.Length-$n,$n); $r=$env:RUNID; if (-not $r) { $r=$env:LABEL + '-' + (Get-Date -Format 'yyyyMMdd-HHmm') }; $o=@{label=$env:LABEL;machine=$env:COMPUTERNAME;run_id=$r;lines=$t}; [IO.File]::WriteAllText($env:OUT,(ConvertTo-Json $o -Compress),(New-Object Text.UTF8Encoding $false)); Write-Host ('upload-log: run_id ' + $r + ', ' + $n + ' bytes')"
|
||||
if errorlevel 1 (
|
||||
echo upload-log: could not read or encode %LOGFILE%
|
||||
exit /b 3
|
||||
)
|
||||
|
||||
curl.exe -sS --max-time 60 -X POST "%IGNEUM_LOG_URL%" -H "Content-Type: application/json" -H "x-igneum-key: %IGNEUM_LOG_KEY%" --data-binary "@%OUT%"
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
del "%OUT%" >nul 2>&1
|
||||
if not "%RC%"=="0" (
|
||||
echo upload-log: curl failed with code %RC%
|
||||
exit /b %RC%
|
||||
)
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo Usage: upload-log.bat ^<logfile^> ^<label^> [run_id]
|
||||
exit /b 1
|
||||
64
site/api/log.mjs
Normal file
64
site/api/log.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Igneum miner log intake. POST /api/log with header x-igneum-key and a JSON body
|
||||
// {label, machine, run_id, lines}. Stores one row in Neon table miner_logs.
|
||||
// Zero dependencies: Neon's HTTP SQL endpoint over Node's built-in fetch.
|
||||
// Read back on the Mac with `node tools/logs.mjs`.
|
||||
|
||||
const MAX_LINES = 256 * 1024;
|
||||
|
||||
function neon() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
const host = new URL(url).hostname.replace('-pooler', '');
|
||||
return async (query, params = []) => {
|
||||
const r = await fetch(`https://${host}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||
return j;
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
if (req.body !== undefined && req.body !== null) {
|
||||
if (typeof req.body === 'string') return JSON.parse(req.body);
|
||||
if (Buffer.isBuffer(req.body)) return JSON.parse(req.body.toString('utf8'));
|
||||
return req.body;
|
||||
}
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
}
|
||||
|
||||
const str = (v, max) => (v === undefined || v === null ? '' : String(v)).slice(0, max);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', 'POST');
|
||||
return res.status(405).json({ ok: false, error: 'method not allowed' });
|
||||
}
|
||||
const key = process.env.LOG_INTAKE_KEY;
|
||||
const given = req.headers['x-igneum-key'];
|
||||
if (!key || typeof given !== 'string' || given !== key) {
|
||||
return res.status(401).json({ ok: false, error: 'bad key' });
|
||||
}
|
||||
let body;
|
||||
try { body = await readBody(req); } catch { return res.status(400).json({ ok: false, error: 'bad json' }); }
|
||||
let lines = str(body.lines, Infinity);
|
||||
const buf = Buffer.from(lines, 'utf8');
|
||||
if (buf.length > MAX_LINES) lines = buf.subarray(buf.length - MAX_LINES).toString('utf8');
|
||||
const bytes = Buffer.byteLength(lines, 'utf8');
|
||||
try {
|
||||
const sql = neon();
|
||||
const r = await sql(
|
||||
'INSERT INTO miner_logs (label, machine, run_id, lines, bytes) VALUES ($1, $2, $3, $4, $5) RETURNING id',
|
||||
[str(body.label, 200), str(body.machine, 200), str(body.run_id, 200), lines, bytes],
|
||||
);
|
||||
return res.status(200).json({ ok: true, id: Number(r.rows[0].id), bytes });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
53
tools/logs.mjs
Executable file
53
tools/logs.mjs
Executable file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env node
|
||||
// Reader for the miner log intake (site/api/log.mjs). Runs on the Mac.
|
||||
// node tools/logs.mjs list runs: label, machine, run_id, last received, total bytes
|
||||
// node tools/logs.mjs <run_id> print the latest upload for that run
|
||||
// node tools/logs.mjs <run_id> --all print every upload for that run, oldest first
|
||||
// Reads DATABASE_URL from ~/.config/igneum/env. No dependencies: Neon HTTP SQL over fetch.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
process.stdout.on('error', e => { if (e.code === 'EPIPE') process.exit(0); throw e; });
|
||||
|
||||
const env = readFileSync(`${homedir()}/.config/igneum/env`, 'utf8');
|
||||
const m = /^DATABASE_URL=(.*)$/m.exec(env);
|
||||
if (!m) { console.error('DATABASE_URL not found in ~/.config/igneum/env'); process.exit(1); }
|
||||
const url = m[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
const host = new URL(url).hostname.replace('-pooler', '');
|
||||
|
||||
async function sql(query, params = []) {
|
||||
const r = await fetch(`https://${host}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||
return j.rows;
|
||||
}
|
||||
|
||||
const [runId, flag] = process.argv.slice(2);
|
||||
|
||||
if (!runId) {
|
||||
const rows = await sql(`
|
||||
SELECT run_id, max(label) AS label, max(machine) AS machine, count(*)::int AS uploads,
|
||||
max(received_at) AS last_received, sum(bytes)::bigint AS total_bytes
|
||||
FROM miner_logs GROUP BY run_id ORDER BY max(received_at) DESC LIMIT 100`);
|
||||
if (!rows.length) { console.log('No runs yet.'); process.exit(0); }
|
||||
const fmt = rows.map(r => ({
|
||||
run_id: r.run_id, label: r.label, machine: r.machine, uploads: r.uploads,
|
||||
last_received: new Date(r.last_received).toISOString().replace('T', ' ').slice(0, 19) + ' UTC',
|
||||
total_bytes: Number(r.total_bytes),
|
||||
}));
|
||||
console.table(fmt);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rows = flag === '--all'
|
||||
? await sql('SELECT id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id = $1 ORDER BY received_at ASC', [runId])
|
||||
: await sql('SELECT id, received_at, label, machine, bytes, lines FROM miner_logs WHERE run_id = $1 ORDER BY received_at DESC LIMIT 1', [runId]);
|
||||
if (!rows.length) { console.error(`No uploads for run_id ${runId}`); process.exit(1); }
|
||||
for (const r of rows) {
|
||||
console.log(`===== id ${r.id} | ${r.label} | ${r.machine} | ${new Date(r.received_at).toISOString()} | ${r.bytes} bytes =====`);
|
||||
process.stdout.write(r.lines.endsWith('\n') ? r.lines : r.lines + '\n');
|
||||
}
|
||||
Loading…
Reference in a new issue