src/publish.js (9424 bytes)
1 // The dashboard: a static page (dashboard/index.html) and a snapshot of the archive (data.json). 2 // `ideamine publish` uploads both with HTTP PUT, for example to nginx on a WireGuard address, or 3 // writes them to a folder. Like the watcher, no process stays alive: the hook calls kick() for 4 // each prompt, and kick() starts a background publish when the archive changed since the last one. 5 6 import { spawn } from 'node:child_process'; 7 import crypto from 'node:crypto'; 8 import fs from 'node:fs'; 9 import path from 'node:path'; 10 import { fileURLToPath } from 'node:url'; 11 import * as config from './config.js'; 12 import * as embed from './embed.js'; 13 import { baseName, counts, dbPath, home, lane, listIdeas, load } from './store.js'; 14 15 const PAGE = new URL('../dashboard/index.html', import.meta.url); 16 const WAIT_AFTER_ERROR_MS = 10 * 60 * 1000; // after a failed publish, before kick() tries again 17 const LOCK_STALE_MS = 10 * 60 * 1000; 18 19 const statePath = () => path.join(home(), 'publish.json'); 20 const lockPath = () => path.join(home(), '.publish'); 21 22 /** The earliest of the given times; null when none is set. */ 23 const earliest = (...times) => times.filter(Boolean).sort()[0] || null; 24 25 /** 26 * Bars for the timeline: the time an idea waited in the inbox, waited in the queue, and was in work. 27 * `to: null` means "until now". Ideas that started before ideamine recorded start times get an 28 * `estimated` work bar from the triage (or the creation) to the end. 29 */ 30 export function phases(idea) { 31 const triaged = idea.triage?.at || null; 32 const started = idea.started || null; 33 const closed = idea.closed || null; 34 const out = []; 35 const add = (phase, from, to, estimated = false) => { 36 if (from && (!to || to >= from)) out.push(estimated ? { phase, from, to, estimated } : { phase, from, to }); 37 }; 38 if (!started && (idea.status === 'doing' || idea.status === 'done')) { 39 if (triaged) add('inbox', idea.created, triaged); 40 add('doing', triaged || idea.created, closed, true); 41 return out; 42 } 43 add('inbox', idea.created, earliest(triaged, started, closed)); 44 if (triaged && (!started || triaged < started)) add('queued', triaged, started || closed); 45 if (started) add('doing', started, closed); 46 return out; 47 } 48 49 /** data.json (version 1). The dashboard page reads it; its format is in the README. */ 50 export function snapshot(db, { vectors = null, groups = [], generated = new Date().toISOString() } = {}) { 51 const model = config.get('embed_model'); 52 const groupThreshold = config.get('group_threshold'); 53 const rank = new Map(listIdeas(db, { filter: 'all' }).map((idea, n) => [idea.id, n])); 54 const groupOf = new Map(); 55 groups.forEach((g, n) => g.ids.forEach((id) => groupOf.set(id, n))); 56 const ids = db.ideas.map((i) => i.id); 57 return { 58 version: 1, 59 generated, 60 embed: { 61 model, 62 dim: vectors?.size ? vectors.values().next().value.length : 0, 63 query_prefix: embed.queryText(model, ''), 64 search_threshold: config.get('search_threshold'), 65 group_threshold: groupThreshold, 66 available: !!vectors, 67 }, 68 counts: counts(db), 69 ideas: db.ideas.map((i) => { 70 const t = i.triage; 71 return { 72 id: i.id, 73 key: `IDEA-${i.id}`, 74 title: i.title, 75 text: i.text, 76 lane: lane(i), 77 status: i.status, 78 rank: rank.get(i.id), 79 tags: i.tags || [], 80 project: i.project ? baseName(i.project) : null, 81 host: i.host || null, 82 created: i.created, 83 updated: i.updated, 84 triaged: t?.at || null, 85 started: i.started || null, 86 closed: i.closed || null, 87 verdict: t?.verdict || null, 88 impact: t?.impact ?? null, 89 size: t?.size || null, 90 model: t?.model || null, 91 why: t?.why || '', 92 brief: t?.brief || '', 93 notes: i.notes || [], 94 dup_of: i.dup_of || null, 95 group: groupOf.has(i.id) ? groupOf.get(i.id) : null, 96 related: vectors ? embed.nearest(i.id, ids, vectors, { k: 3, threshold: groupThreshold }) : [], 97 phases: phases(i), 98 vec: vectors?.has(i.id) ? embed.encodeVec(vectors.get(i.id)) : null, 99 }; 100 }), 101 groups: groups.map((g, n) => ({ id: n, label: g.label, ids: g.ids })), 102 }; 103 } 104 105 /** The snapshot, with groups when the embedding server answers. `note` says why there are none. */ 106 export async function build(db, { timeoutMs = 60000 } = {}) { 107 let vectors = null; 108 let groups = []; 109 let note = ''; 110 try { 111 vectors = await embed.vectorsFor(db.ideas, { timeoutMs }); 112 groups = await embed.groupIdeas(db.ideas, { vectors }); 113 } catch (e) { 114 if (!(e instanceof embed.EmbedError)) throw e; 115 vectors = null; 116 note = `published without search by meaning or groups: ${e.message}`; 117 } 118 return { data: snapshot(db, { vectors, groups }), note }; 119 } 120 121 async function put(url, body, type) { 122 let res; 123 try { 124 res = await fetch(url, { method: 'PUT', body, headers: { 'content-type': type }, signal: AbortSignal.timeout(30000) }); 125 } catch (e) { 126 throw new Error(`cannot reach ${url} (${e.name === 'TimeoutError' ? 'no answer in 30 s' : e.cause?.code || e.cause?.message || e.message})`); 127 } 128 if (!res.ok) throw new Error(`${url} answered ${res.status} ${res.statusText}`); 129 } 130 131 const pageHash = () => crypto.createHash('sha256').update(fs.readFileSync(PAGE)).digest('hex').slice(0, 16); 132 133 function archiveTime() { 134 try { 135 return fs.statSync(dbPath()).mtimeMs; 136 } catch { 137 return 0; 138 } 139 } 140 141 export function readState() { 142 try { 143 return JSON.parse(fs.readFileSync(statePath(), 'utf8')); 144 } catch { 145 return {}; 146 } 147 } 148 149 function writeState(patch) { 150 fs.mkdirSync(home(), { recursive: true }); 151 fs.writeFileSync(statePath(), JSON.stringify({ ...readState(), ...patch }, null, 2) + '\n'); 152 } 153 154 /** 155 * Upload index.html and data.json to `url` (a folder URL on the dashboard server), or write them 156 * to the folder `dir`. Returns what it did, for the caller to show. 157 */ 158 export async function publish({ url = config.get('publish_url'), dir = null } = {}) { 159 if (!url && !dir) throw new Error('no dashboard server is set. Run `ideamine publish <url>` once, or pass --dir <folder>'); 160 const mtime = archiveTime(); 161 const { data, note } = await build(load()); 162 const files = [ 163 ['data.json', JSON.stringify(data), 'application/json'], 164 ['index.html', fs.readFileSync(PAGE), 'text/html; charset=utf-8'], 165 ]; 166 if (dir) { 167 fs.mkdirSync(dir, { recursive: true }); 168 for (const [name, body] of files) fs.writeFileSync(path.join(dir, name), body); 169 } else { 170 const base = url.endsWith('/') ? url : `${url}/`; 171 for (const [name, body, type] of files) await put(new URL(name, base), body, type); 172 writeState({ at: new Date().toISOString(), mtime, page: pageHash(), url, error: null, errorAt: null }); 173 } 174 return { where: dir || url, ideas: data.ideas.length, groups: data.groups.length, note }; 175 } 176 177 function isRunning(now) { 178 try { 179 return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS; 180 } catch { 181 return false; 182 } 183 } 184 185 /** One background publish, from kick(). Only one runs at a time, and a failure waits 10 minutes. */ 186 export async function backgroundPublish() { 187 fs.mkdirSync(home(), { recursive: true }); 188 try { 189 fs.mkdirSync(lockPath()); 190 } catch (e) { 191 if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy'; 192 fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed publish 193 } 194 try { 195 await publish(); 196 return 'done'; 197 } catch (e) { 198 writeState({ error: e.message, errorAt: new Date().toISOString() }); 199 return 'error'; 200 } finally { 201 fs.rmSync(lockPath(), { recursive: true, force: true }); 202 } 203 } 204 205 function startProcess() { 206 const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 207 const child = spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }); 208 // A publish that cannot start must not stop the caller, for example the server of the dashboard. 209 child.on('error', (e) => writeState({ error: `cannot start a publish: ${e.message}`, errorAt: new Date().toISOString() })); 210 child.unref(); 211 } 212 213 /** 214 * Start a background publish when a dashboard server is set, the archive or the page changed since 215 * the last publish, no publish runs, and no publish failed a short time ago. 216 */ 217 export function kick({ start = startProcess, now = Date.now() } = {}) { 218 const url = config.get('publish_url'); 219 // With sync on, the ideamine server shows the live archive itself: nothing to upload. 220 if (!url || config.get('sync_url')) return false; 221 const state = readState(); 222 if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false; 223 if (isRunning(now)) return false; 224 const current = state.url === url && archiveTime() <= (state.mtime || 0) && state.page === pageHash(); 225 if (current) return false; 226 start(); 227 return true; 228 } 229 230 /** Where the dashboard goes and how the last publish went. */ 231 export function status() { 232 const url = config.get('publish_url'); 233 if (!url) return 'ideamine publish: off. `ideamine publish <url>` sets the dashboard server.'; 234 const s = readState(); 235 const lines = [`ideamine publish: ${url}`]; 236 lines.push(s.at ? `last publish ${s.at}` : 'not published yet'); 237 if (s.error) lines.push(`the last background publish failed at ${s.errorAt}: ${s.error}`); 238 return lines.join('\n'); 239 }