src/publish.js (12986 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, LANES, listIdeas, load, MODEL_ALIASES, SIZES } 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 const DAY = 86400000; 50 const OPEN_LANES = ['inbox', 'do', 'maybe', 'doing']; 51 52 /** 53 * The lane of an idea at time `t` (ms), with the same rules as phases(): null before the idea 54 * exists. A done idea without a recorded close time closed at its last update. 55 */ 56 export function laneAt(idea, t) { 57 const at = (time) => (time ? Date.parse(time) : NaN); 58 if (!(t >= at(idea.created))) return null; 59 const closedStatus = idea.status === 'done' || idea.status === 'dropped'; 60 if (closedStatus && t >= at(idea.closed || idea.updated)) return idea.status; 61 if (t >= at(idea.started)) return 'doing'; 62 const triaged = at(idea.triage?.at); 63 if (!idea.started && idea.status === 'doing' && t >= (triaged || at(idea.created))) return 'doing'; 64 if (t >= triaged) return idea.triage?.verdict || 'maybe'; 65 return 'inbox'; 66 } 67 68 /** 69 * Cumulative flow: `times` (ISO, up to `samples` evenly spaced from the first idea to now) and 70 * `series`, a count for each lane at each time. At every time, the lanes add up to the ideas that 71 * existed then. 72 */ 73 export function flow(ideas, { samples = 60, now = Date.now() } = {}) { 74 const series = Object.fromEntries(LANES.map((l) => [l, []])); 75 const starts = ideas.map((i) => Date.parse(i.created)).filter(Number.isFinite); 76 if (!starts.length) return { times: [], series }; 77 const first = Math.min(...starts, now); 78 const span = Math.max(now - first, 1); 79 const n = Math.max(2, Math.min(samples, span + 1)); // a step of 1 ms at least, so the times differ 80 const times = []; 81 for (let k = 0; k < n; k++) { 82 const t = first + Math.round((span * k) / (n - 1)); 83 times.push(new Date(t).toISOString()); 84 for (const l of LANES) series[l].push(0); 85 for (const idea of ideas) { 86 const l = laneAt(idea, t); 87 if (l && series[l]) series[l][k]++; 88 } 89 } 90 return { times, series }; 91 } 92 93 function median(values) { 94 if (!values.length) return null; 95 const sorted = [...values].sort((a, b) => a - b); 96 const mid = sorted.length >> 1; 97 return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; 98 } 99 100 /** The tiles of the Flow view: what is open, what closed in the last 7 days, and how long ideas take. */ 101 export function stats(ideas, { now = Date.now() } = {}) { 102 const week = now - 7 * DAY; 103 const open = ideas.filter((i) => OPEN_LANES.includes(lane(i))); 104 const closedSince = (status) => ideas.filter((i) => i.status === status && Date.parse(i.closed) >= week).length; 105 const done = ideas.filter((i) => i.status === 'done' && i.closed); 106 const count = (keys, pick) => Object.fromEntries(keys.map((k) => [k, open.filter((i) => pick(i) === k).length])); 107 return { 108 open: open.length, 109 done_7d: closedSince('done'), 110 dropped_7d: closedSince('dropped'), 111 lead_median_ms: median(done.map((i) => Date.parse(i.closed) - Date.parse(i.created))), 112 cycle_median_ms: median(done.filter((i) => i.started).map((i) => Date.parse(i.closed) - Date.parse(i.started))), 113 oldest_open_ms: open.length ? Math.max(...open.map((i) => now - Date.parse(i.created))) : null, 114 models: count(MODEL_ALIASES, (i) => i.triage?.model), 115 sizes: count(SIZES, (i) => i.triage?.size), 116 }; 117 } 118 119 /** data.json (version 1). The dashboard page reads it; its format is in the README. */ 120 export function snapshot(db, { vectors = null, groups = [], generated = new Date().toISOString() } = {}) { 121 const model = config.get('embed_model'); 122 const groupThreshold = config.get('group_threshold'); 123 const rank = new Map(listIdeas(db, { filter: 'all' }).map((idea, n) => [idea.id, n])); 124 const groupOf = new Map(); 125 groups.forEach((g, n) => g.ids.forEach((id) => groupOf.set(id, n))); 126 const ids = db.ideas.map((i) => i.id); 127 return { 128 version: 1, 129 generated, 130 embed: { 131 model, 132 dim: vectors?.size ? vectors.values().next().value.length : 0, 133 query_prefix: embed.queryText(model, ''), 134 search_threshold: config.get('search_threshold'), 135 group_threshold: groupThreshold, 136 available: !!vectors, 137 }, 138 counts: counts(db), 139 ideas: db.ideas.map((i) => { 140 const t = i.triage; 141 return { 142 id: i.id, 143 key: `IDEA-${i.id}`, 144 title: i.title, 145 text: i.text, 146 lane: lane(i), 147 status: i.status, 148 rank: rank.get(i.id), 149 tags: i.tags || [], 150 project: i.project ? baseName(i.project) : null, 151 host: i.host || null, 152 created: i.created, 153 updated: i.updated, 154 triaged: t?.at || null, 155 started: i.started || null, 156 closed: i.closed || null, 157 verdict: t?.verdict || null, 158 impact: t?.impact ?? null, 159 size: t?.size || null, 160 model: t?.model || null, 161 why: t?.why || '', 162 brief: t?.brief || '', 163 notes: i.notes || [], 164 dup_of: i.dup_of || null, 165 group: groupOf.has(i.id) ? groupOf.get(i.id) : null, 166 related: vectors ? embed.nearest(i.id, ids, vectors, { k: 3, threshold: groupThreshold }) : [], 167 phases: phases(i), 168 vec: vectors?.has(i.id) ? embed.encodeVec(vectors.get(i.id)) : null, 169 }; 170 }), 171 groups: groups.map((g, n) => ({ id: n, label: g.label, ids: g.ids })), 172 flow: flow(db.ideas, { now: Date.parse(generated) }), 173 stats: stats(db.ideas, { now: Date.parse(generated) }), 174 }; 175 } 176 177 /** The snapshot, with groups when the embedding server answers. `note` says why there are none. */ 178 export async function build(db, { timeoutMs = 60000 } = {}) { 179 let vectors = null; 180 let groups = []; 181 let note = ''; 182 try { 183 vectors = await embed.vectorsFor(db.ideas, { timeoutMs }); 184 groups = await embed.groupIdeas(db.ideas, { vectors }); 185 } catch (e) { 186 if (!(e instanceof embed.EmbedError)) throw e; 187 vectors = null; 188 note = `published without search by meaning or groups: ${e.message}`; 189 } 190 return { data: snapshot(db, { vectors, groups }), note }; 191 } 192 193 async function put(url, body, type) { 194 let res; 195 try { 196 res = await fetch(url, { method: 'PUT', body, headers: { 'content-type': type }, signal: AbortSignal.timeout(30000) }); 197 } catch (e) { 198 throw new Error(`cannot reach ${url} (${e.name === 'TimeoutError' ? 'no answer in 30 s' : e.cause?.code || e.cause?.message || e.message})`); 199 } 200 if (!res.ok) throw new Error(`${url} answered ${res.status} ${res.statusText}`); 201 } 202 203 const pageHash = () => crypto.createHash('sha256').update(fs.readFileSync(PAGE)).digest('hex').slice(0, 16); 204 205 function archiveTime() { 206 try { 207 return fs.statSync(dbPath()).mtimeMs; 208 } catch { 209 return 0; 210 } 211 } 212 213 export function readState() { 214 try { 215 return JSON.parse(fs.readFileSync(statePath(), 'utf8')); 216 } catch { 217 return {}; 218 } 219 } 220 221 function writeState(patch) { 222 fs.mkdirSync(home(), { recursive: true }); 223 fs.writeFileSync(statePath(), JSON.stringify({ ...readState(), ...patch }, null, 2) + '\n'); 224 } 225 226 /** 227 * Upload index.html and data.json to `url` (a folder URL on the dashboard server), or write them 228 * to the folder `dir`. Returns what it did, for the caller to show. 229 */ 230 export async function publish({ url = config.get('publish_url'), dir = null } = {}) { 231 if (!url && !dir) throw new Error('no dashboard server is set. Run `ideamine publish <url>` once, or pass --dir <folder>'); 232 const mtime = archiveTime(); 233 const { data, note } = await build(load()); 234 const { ASSETS, assetPath } = await import('./serve.js'); 235 const files = [ 236 ['data.json', JSON.stringify(data), 'application/json'], 237 ['index.html', fs.readFileSync(PAGE), 'text/html; charset=utf-8'], 238 // The manifest, the icons, and the service worker: the page is an app on a phone with them. 239 ...Object.entries(ASSETS).map(([name, type]) => [name, fs.readFileSync(assetPath(name)), type]), 240 ]; 241 if (dir) { 242 fs.mkdirSync(dir, { recursive: true }); 243 for (const [name, body] of files) fs.writeFileSync(path.join(dir, name), body); 244 } else { 245 const base = url.endsWith('/') ? url : `${url}/`; 246 for (const [name, body, type] of files) await put(new URL(name, base), body, type); 247 writeState({ at: new Date().toISOString(), mtime, page: pageHash(), url, error: null, errorAt: null }); 248 } 249 return { where: dir || url, ideas: data.ideas.length, groups: data.groups.length, note }; 250 } 251 252 function isRunning(now) { 253 try { 254 return now - fs.statSync(lockPath()).mtimeMs < LOCK_STALE_MS; 255 } catch { 256 return false; 257 } 258 } 259 260 /** One background publish, from kick(). Only one runs at a time, and a failure waits 10 minutes. */ 261 export async function backgroundPublish() { 262 fs.mkdirSync(home(), { recursive: true }); 263 try { 264 fs.mkdirSync(lockPath()); 265 } catch (e) { 266 if (e.code !== 'EEXIST' || isRunning(Date.now())) return 'busy'; 267 fs.utimesSync(lockPath(), new Date(), new Date()); // take over the lock of a crashed publish 268 } 269 try { 270 await publish(); 271 return 'done'; 272 } catch (e) { 273 writeState({ error: e.message, errorAt: new Date().toISOString() }); 274 return 'error'; 275 } finally { 276 fs.rmSync(lockPath(), { recursive: true, force: true }); 277 } 278 } 279 280 function startProcess() { 281 const bin = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 282 const child = spawn(process.execPath, [bin, 'publish', '--background'], { cwd: home(), detached: true, stdio: 'ignore', windowsHide: true }); 283 // A publish that cannot start must not stop the caller, for example the server of the dashboard. 284 child.on('error', (e) => writeState({ error: `cannot start a publish: ${e.message}`, errorAt: new Date().toISOString() })); 285 child.unref(); 286 } 287 288 /** 289 * Start a background publish when a dashboard server is set, the archive or the page changed since 290 * the last publish, no publish runs, and no publish failed a short time ago. 291 */ 292 export function kick({ start = startProcess, now = Date.now() } = {}) { 293 const url = config.get('publish_url'); 294 // With sync on, the ideamine server shows the live archive itself: nothing to upload. 295 if (!url || config.get('sync_url')) return false; 296 const state = readState(); 297 if (state.errorAt && now - Date.parse(state.errorAt) < WAIT_AFTER_ERROR_MS) return false; 298 if (isRunning(now)) return false; 299 const current = state.url === url && archiveTime() <= (state.mtime || 0) && state.page === pageHash(); 300 if (current) return false; 301 start(); 302 return true; 303 } 304 305 /** Where the dashboard goes and how the last publish went. */ 306 export function status() { 307 const url = config.get('publish_url'); 308 if (!url) return 'ideamine publish: off. `ideamine publish <url>` sets the dashboard server.'; 309 const s = readState(); 310 const lines = [`ideamine publish: ${url}`]; 311 lines.push(s.at ? `last publish ${s.at}` : 'not published yet'); 312 if (s.error) lines.push(`the last background publish failed at ${s.errorAt}: ${s.error}`); 313 return lines.join('\n'); 314 }