k0-board 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.js CHANGED
@@ -3,8 +3,9 @@ import fs from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import crypto from 'node:crypto'
5
5
  import * as db from './db.js'
6
+ import { allowed } from './guard.js'
6
7
  import { ROOT } from './paths.js'
7
- import { readLiveSessions, deriveStatus, forgetSession, renameSession } from './watcher.js'
8
+ import { readLiveSessions, deriveStatus, forgetSession, renameSession, busy } from './watcher.js'
8
9
  import {
9
10
  launch,
10
11
  focusWindow,
@@ -15,12 +16,17 @@ import {
15
16
  relayoutWindows,
16
17
  } from './launcher.js'
17
18
  import * as mode from './mode.js'
19
+ import * as settings from './settings.js'
20
+ import { dueForClose } from './idle.js'
18
21
  import { listProjects, onDisk, projectName } from './projects.js'
19
22
  import { scanSessions } from './sessions.js'
20
23
  import * as git from './git.js'
24
+ import * as changelog from './changelog.js'
25
+ import * as writer from './writer.js'
21
26
  import * as files from './files.js'
22
27
  import * as pdf from './pdf.js'
23
28
  import * as machine from './machine.js'
29
+ import * as servers from './servers.js'
24
30
  import { report as platformReport } from '../platform/index.js'
25
31
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
26
32
  // called, and two different readings of the same header would give two different names.
@@ -61,44 +67,56 @@ function tick() {
61
67
  // The power levers are checked once a minute rather than every round: reading the power
62
68
  // state costs processes, and the things it has to follow — the mains plugged in or out, the
63
69
  // battery going down — do not change from one second to the next.
64
- if (rounds++ % 60 === 0) mode.guard()
70
+ if (rounds % 60 === 0) mode.guard()
71
+ // The forgotten terminals, on the same cadence but half a minute off it, so the two jobs that
72
+ // only run once a minute never land in the same second as each other.
73
+ if (rounds % 60 === 30) sweepIdle(live)
74
+ rounds++
75
+ }
76
+
77
+ /**
78
+ * Gives back the memory of the windows you have stopped using.
79
+ *
80
+ * It does exactly what the `Close` link on a card does, and on purpose the same three moves and
81
+ * not a path of its own: stop the session, shut the window, forget the window. The card stays
82
+ * where it is with the status it had, `session_id` is untouched, and `Resume` picks the
83
+ * conversation up where it was — a closed terminal is not a lost session.
84
+ *
85
+ * Deliberately not awaited by `tick`: closing a terminal waits up to two seconds for the process
86
+ * to go, and the watching loop must not be held up by it. Hence the latch, the same one
87
+ * `machine.js` and `servers.js` keep for their own sampling — `setInterval` will happily start a
88
+ * second round on top of the first.
89
+ */
90
+ let sweeping = false
91
+ async function sweepIdle(live) {
92
+ if (sweeping) return
93
+ const hours = settings.read().closeIdleTerminalsAfterHours
94
+ const due = dueForClose({ cards: db.listCards(), live, hours })
95
+ if (!due.length) return
96
+ sweeping = true
97
+ try {
98
+ for (const card of due) {
99
+ try {
100
+ await closeTerminal({ winId: card.terminal_window_id, pid: live.get(card.session_id)?.pid })
101
+ db.setTerminalWindow(card.id, null) // that window is gone; Resume opens a new one
102
+ db.setAutoClosed(card.id, true)
103
+ console.log(`k0 — "${card.title}" sat still for ${hours}h: its terminal is closed, Resume picks it up`)
104
+ } catch (err) {
105
+ // One window that refuses to go must not take the others with it, and must certainly not
106
+ // take the server: nothing is awaiting this, so a rejection escaping here would end the
107
+ // process outright. It runs unattended — the next round is the retry.
108
+ console.error(`k0 — could not close the terminal of "${card.title}": ${String(err?.message || err)}`)
109
+ }
110
+ }
111
+ } finally {
112
+ sweeping = false
113
+ }
65
114
  }
66
115
 
67
116
  /** When somebody last looked at the board. */
68
117
  let lastBoard = 0
69
118
  setInterval(tick, 1000)
70
119
 
71
- // ── Who is allowed to talk to this server ────────────────────────────────────
72
- // k0 listens on the loopback interface only, which keeps the rest of the network out. What it
73
- // does not keep out is a web page open in your browser: any site can make requests to
74
- // 127.0.0.1, and this API opens terminals and starts sessions.
75
- //
76
- // Two checks close that door.
77
- //
78
- // The `Host` header must be one we recognise. A page cannot forge it, and requiring it stops
79
- // DNS rebinding — the trick where a hostile domain resolves to 127.0.0.1 so the browser thinks
80
- // it is talking to its own site.
81
- //
82
- // The `Origin` header, when there is one, must be ours. Browsers attach it to every
83
- // cross-origin request and to every same-origin POST, so a hostile page is always identified.
84
- // Things that are not browsers — the menu bar icon, curl, the import skill — send none, and
85
- // they are let through: they are already running as you, on your machine, and a header would
86
- // not tell us anything a hostile process could not also write.
87
- const ALLOWED_HOSTS = new Set(
88
- ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `${h}:${PORT}`)
89
- )
90
- const ALLOWED_ORIGINS = new Set(
91
- ['127.0.0.1', 'localhost', 'k0.localhost', '[::1]'].map((h) => `http://${h}:${PORT}`)
92
- )
93
-
94
- function allowed(req) {
95
- const host = String(req.headers.host || '')
96
- if (!ALLOWED_HOSTS.has(host)) return false
97
- const origin = req.headers.origin
98
- if (origin && !ALLOWED_ORIGINS.has(origin)) return false
99
- return true
100
- }
101
-
102
120
  // ── API ──────────────────────────────────────────────────────────────────────
103
121
  const MIME = {
104
122
  '.html': 'text/html; charset=utf-8',
@@ -170,6 +188,9 @@ function board() {
170
188
  load: machine.loadOf(live.get(c.session_id)?.pid),
171
189
  }))
172
190
  const paths = [...new Set(cards.map((c) => c.project_path))]
191
+ // And the same for the dev servers: looking at the board is what makes k0 ask the machine
192
+ // who is listening, and it asks only about the repositories in front of you.
193
+ servers.touch(paths)
173
194
 
174
195
  // Live sessions no card claims: they weigh the same, and if the computer is struggling it is
175
196
  // fair to know there are three of them open outside here.
@@ -180,7 +201,14 @@ function board() {
180
201
  // The columns are the projects with at least one card — and that still exist: a card is
181
202
  // kept alive by its directory (see `onDisk` in projects.js).
182
203
  columns: paths
183
- .map((p) => ({ path: p, name: projectName(p), git: publicGit(git.stateOf(p)) }))
204
+ .map((p) => ({
205
+ path: p,
206
+ name: projectName(p),
207
+ git: publicGit(git.stateOf(p)),
208
+ // Null where the repository has no way to start a server: then there is no globe at
209
+ // all, rather than one that would do nothing.
210
+ server: servers.stateOf(p),
211
+ }))
184
212
  .sort((a, b) => a.name.localeCompare(b.name)),
185
213
  cards,
186
214
  // The repositories for the last column, the one you go through to reach the files of a
@@ -203,6 +231,10 @@ function board() {
203
231
  mode: mode.current(),
204
232
  lid: mode.lid(),
205
233
  reason: mode.reason(),
234
+ // After how long a forgotten terminal is closed, so the machine chip can say that it is
235
+ // watching and after how long. Zero means it is switched off, and then the chip says nothing
236
+ // rather than saying "0h" — a number that would read as "closes immediately".
237
+ closeIdleAfterHours: settings.read().closeIdleTerminalsAfterHours,
206
238
  // What this machine can and cannot do, so the board greys out the rest and says why
207
239
  // instead of offering a button that quietly does nothing.
208
240
  platform: platformReport(),
@@ -309,6 +341,50 @@ function site(req, res, seg) {
309
341
  // is a much smaller loss than that, so the header is gone.
310
342
  const SANDBOX = 'sandbox'
311
343
 
344
+ // ── The recap of a window ────────────────────────────────────────────────────
345
+ // Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
346
+ // write-up to start, then asks every second whether it is done. Reading every repository's git
347
+ // history once a second to answer "not yet" would be absurd. Nothing is written down, and a
348
+ // minute later the same question is asked of git again from scratch.
349
+ const RECAP_TTL = 60000
350
+ let recap = null
351
+
352
+ async function recapFor(raw, { fresh = true } = {}) {
353
+ const period = changelog.PERIODS.includes(raw) ? raw : 'last'
354
+ // The page asks about the write-up once a second until it is done, and a model can take
355
+ // longer than a minute: those questions must reuse whatever window is already open, or a
356
+ // slow answer would mean a full sweep of every repository's git, every second, while waiting.
357
+ if (recap && recap.period === period && (!fresh || Date.now() - recap.at < RECAP_TTL)) return recap
358
+ const data = await changelog.facts(period)
359
+ recap = {
360
+ period,
361
+ at: Date.now(),
362
+ // The window is part of the name: at midnight "yesterday" becomes a different day, and a
363
+ // summary written for the old one must not be handed over as if it were the new one.
364
+ key: `${data.period}:${data.from}:${data.to}`,
365
+ public: data,
366
+ payload: JSON.stringify(
367
+ {
368
+ period: data.period,
369
+ from: new Date(data.from).toISOString(),
370
+ to: new Date(data.to).toISOString(),
371
+ totals: data.totals,
372
+ repositories: data.repos.map((r) => ({
373
+ name: r.name,
374
+ commits: r.commits.map(({ at, subject, body, online }) => ({ at, subject, body, online })),
375
+ unpushed_total: r.unpushedTotal,
376
+ uncommitted_files: r.dirty.map((f) => f.path),
377
+ unreleased_changelog: r.unreleased,
378
+ cards: r.cards,
379
+ })),
380
+ },
381
+ null,
382
+ 2
383
+ ),
384
+ }
385
+ return recap
386
+ }
387
+
312
388
  async function api(req, res, url) {
313
389
  const seg = url.pathname.split('/').filter(Boolean) // ['api', 'card', '3', 'start']
314
390
  const [, resource, idRaw, action] = seg
@@ -318,6 +394,20 @@ async function api(req, res, url) {
318
394
  if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
319
395
  if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
320
396
 
397
+ // ── What you have been doing ───────────────────────────────────────────────
398
+ // Two halves, deliberately apart. The facts come back straight away and the page can already
399
+ // show them; the write-up takes a model half a minute, so it is started and then asked
400
+ // about, the same once-a-second poll the board and the viewer already live on.
401
+ if (resource === 'changelog' && !idRaw && req.method === 'GET') {
402
+ const data = await recapFor(url.searchParams.get('period'))
403
+ return send(res, 200, { ...data.public, writer: writer.capability() })
404
+ }
405
+ if (resource === 'changelog' && idRaw === 'write') {
406
+ const data = await recapFor(url.searchParams.get('period'), { fresh: false })
407
+ if (req.method === 'POST') return send(res, 200, writer.write(data.key, data.payload))
408
+ if (req.method === 'GET') return send(res, 200, writer.state(data.key))
409
+ }
410
+
321
411
  // ── The four modes ─────────────────────────────────────────────────────────
322
412
  // One control, with two handles: the row of buttons on the board and the four entries in the
323
413
  // menu bar. The text in terminals that are already open changes STRAIGHT AWAY, not only in
@@ -333,6 +423,25 @@ async function api(req, res, url) {
333
423
  return send(res, 200, { mode: mode.current(), lid: mode.lid(), reason: mode.reason() })
334
424
  }
335
425
 
426
+ // ── The dev server of a repository ─────────────────────────────────────────
427
+ // The globe in the column heading. One address for the three gestures, because they are the
428
+ // same gesture with different endings — and `restart` really is stop-then-start, not a third
429
+ // path through the same code.
430
+ //
431
+ // The repository is not taken on trust. It arrives from the page as a plain path and it is a
432
+ // path k0 is about to RUN something in, so it goes through the same allowlist the file viewer
433
+ // uses: a directory k0 already knows as a project, or nothing at all.
434
+ if (resource === 'server' && req.method === 'POST') {
435
+ const b = await readBody(req)
436
+ const repo = rootOf(b.path)
437
+ if (!repo) return send(res, 404, { error: 'k0 does not know that repository.' })
438
+ const action = b.action
439
+ if (action === 'start') return send(res, 200, await servers.start(repo))
440
+ if (action === 'stop') return send(res, 200, await servers.stop(repo))
441
+ if (action === 'restart') return send(res, 200, await servers.restart(repo))
442
+ return send(res, 400, { error: 'That is not something a server can be asked to do.' })
443
+ }
444
+
336
445
  // The screen changed under the windows: a monitor plugged in or unplugged, a different
337
446
  // resolution. This puts them all back in the middle of what is there now, as they were the
338
447
  // day they were born. The menu bar icon calls it, being the one piece of k0 that sees that
@@ -552,6 +661,22 @@ async function api(req, res, url) {
552
661
  return send(res, 200, await focusWindow(card.terminal_window_id))
553
662
  }
554
663
 
664
+ // Closing the terminal without closing the work: the same two moves as Done — stop the
665
+ // session, shut its window — and none of the marking. The card stays where it is, with the
666
+ // status it had, and Resume picks the conversation up where it was.
667
+ if (req.method === 'POST' && action === 'close') {
668
+ if (!card.session_id) return send(res, 400, { error: 'This card has no session' })
669
+ // The board is up to a second behind, so a card that was idle when it was clicked may have
670
+ // started thinking since. Nothing that is working gets stopped from here.
671
+ if (busy(card.status)) return send(res, 409, { error: 'It is working: let it finish' })
672
+ const pid = readLiveSessions().get(card.session_id)?.pid
673
+ const out = await closeTerminal({ winId: card.terminal_window_id, pid })
674
+ db.setTerminalWindow(id, null) // that window is gone; Resume opens a new one
675
+ db.setAutoClosed(id, false) // this one was you: the card must not say otherwise
676
+ tick() // so the answer already carries the closed card, without waiting for the loop
677
+ return send(res, 200, { ...out, card: db.getCard(id) })
678
+ }
679
+
555
680
  if (req.method === 'POST' && (action === 'start' || action === 'resume')) {
556
681
  const resuming = action === 'resume' && card.session_id
557
682
  const sessionId = resuming ? card.session_id : crypto.randomUUID()
@@ -567,6 +692,7 @@ async function api(req, res, url) {
567
692
  try {
568
693
  const out = await launch({ card: db.getCard(id), sessionId, mode: resuming ? 'resume' : 'start' })
569
694
  db.setTerminalWindow(id, out.winId)
695
+ db.setAutoClosed(id, false) // there is a window again: whoever shut the last one is history
570
696
  tick()
571
697
  return send(res, 200, { ...out, card: db.getCard(id) })
572
698
  } catch (err) {
@@ -589,13 +715,23 @@ function serveStatic(res, pathname) {
589
715
  res.writeHead(404).end('Not found')
590
716
  return
591
717
  }
592
- res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' })
718
+ // Nothing here is cached, and that is deliberate. These files have no version in their names
719
+ // and nothing rebuilds them, so with no instruction at all the browser decides for itself how
720
+ // long to keep them — and a tab left open all day keeps the stylesheet it loaded that morning.
721
+ // That turns "change a file, reload the page" into a promise k0 does not keep, and it does the
722
+ // same after an update: the new app on disk, the old interface on screen. They are a few
723
+ // kilobytes over loopback; there is nothing to save here and a whole class of confusion to
724
+ // avoid.
725
+ res.writeHead(200, {
726
+ 'content-type': MIME[path.extname(file)] || 'application/octet-stream',
727
+ 'cache-control': 'no-cache, no-store, must-revalidate',
728
+ })
593
729
  fs.createReadStream(file).pipe(res)
594
730
  }
595
731
 
596
732
  http
597
733
  .createServer(async (req, res) => {
598
- if (!allowed(req)) {
734
+ if (!allowed(req.headers, PORT)) {
599
735
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
600
736
  res.end('k0 only answers to its own address on this machine.')
601
737
  return
@@ -609,6 +745,9 @@ http
609
745
  }
610
746
  })
611
747
  .listen(PORT, '127.0.0.1', () => {
748
+ // The settings file, written out with everything in it the first time k0 runs: it is the only
749
+ // list of what can be changed, so it has to exist before anybody goes looking for it.
750
+ settings.ensure()
612
751
  tick()
613
752
  // The mode is remembered, and at startup it puts the machine back as it was: the sleep
614
753
  // levers and, where needed, the font size of terminals left open. The server restarts often
@@ -631,6 +770,8 @@ process.on('exit', mode.stopNow)
631
770
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
632
771
  process.on(signal, () => {
633
772
  mode.stopNow()
773
+ // A summary being written when the server goes down has nobody left to give it to.
774
+ writer.stop()
634
775
  process.exit(0)
635
776
  })
636
777
  }
@@ -105,7 +105,11 @@ export async function launch({ card, sessionId, mode = 'start' }) {
105
105
  const up = await waitForSession(sessionId)
106
106
 
107
107
  if (autoSend) return { name, up, winId, pasted: true, autoSent: true }
108
- if (!up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
108
+ // The prompt belongs to the beginning. Resuming picks up a conversation that answered it hours
109
+ // ago, and putting it back under the cursor is at best a stale instruction in the way — at
110
+ // worst it is sent, because where pasting is unavailable the fallback below types it. This
111
+ // matters more now that k0 closes forgotten terminals by itself: a Resume is no longer rare.
112
+ if (resume || !up || !card.prompt?.trim()) return { name, up, winId, pasted: false }
109
113
 
110
114
  await waitForPrompt(winId)
111
115
 
package/server/machine.js CHANGED
@@ -123,6 +123,15 @@ async function cycle() {
123
123
  setInterval(cycle, TTL).unref()
124
124
 
125
125
  // ── What is known right now ───────────────────────────────────────────────────
126
+ /**
127
+ * The process table as it was last sampled, for whoever needs to walk it rather than weigh it —
128
+ * today that is `servers.js`, which follows a dev server down to whichever of its children is
129
+ * actually holding the port. Null until the first cycle has run: the caller decides what a
130
+ * missing sample means, and re-reading the table here to fill the gap would undo the whole
131
+ * point of only sampling while somebody is looking.
132
+ */
133
+ export const tree = () => (snap ? { procs: snap.procs, kids: snap.kids } : null)
134
+
126
135
  /**
127
136
  * What a session weighs: its whole subtree. `top` is the three biggest processes inside it,
128
137
  * and that is the answer to "why is this terminal eating so much".
package/server/paths.js CHANGED
@@ -12,10 +12,11 @@ import { fileURLToPath } from 'node:url'
12
12
  * there is on borrowed time. Data now lives in a directory owned by the user, and the
13
13
  * installed copy of the app is disposable.
14
14
  *
15
- * ~/.k0/k0.db the board
16
- * ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
17
- * ~/.k0/logs/ service stdout/stderr
18
- * ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
15
+ * ~/.k0/k0.db the board
16
+ * ~/.k0/config.json the settings, written out with everything in it on the first run
17
+ * ~/.k0/app/ the installed copy of k0 (written by `k0-board install`)
18
+ * ~/.k0/logs/ service stdout/stderr
19
+ * ~/.k0/cache/ pasted images, the Chrome profile used for PDF export
19
20
  *
20
21
  * On Windows the same tree goes under %LOCALAPPDATA%\k0, which is the local-only,
21
22
  * not-roamed location Windows expects for this kind of state.