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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +185 -1
- package/README.md +289 -20
- package/bin/k0-board.js +51 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +69 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/idle.js +56 -0
- package/server/index.js +177 -36
- package/server/launcher.js +5 -1
- package/server/machine.js +9 -0
- package/server/paths.js +5 -4
- package/server/servers.js +481 -0
- package/server/settings.js +121 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +137 -7
- package/web/board.js +285 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { servers as os, capabilities } from '../platform/index.js'
|
|
6
|
+
import { alive } from '../platform/shared/run.js'
|
|
7
|
+
import { LOG_DIR, ensureDirs } from './paths.js'
|
|
8
|
+
import * as db from './db.js'
|
|
9
|
+
import { tree, subtree } from './machine.js'
|
|
10
|
+
|
|
11
|
+
export { parseListeners, parseCwds, parseSs, parseNetTcp, portOf } from '../platform/shared/listeners.js'
|
|
12
|
+
|
|
13
|
+
// ── The dev server a project runs while you work on it ────────────────────────
|
|
14
|
+
// The globe in a column heading. It answers one question — is this repository's server up, and
|
|
15
|
+
// on what — and takes one instruction: turn it on, turn it off, turn it over.
|
|
16
|
+
//
|
|
17
|
+
// Three things make it different from everything else k0 starts.
|
|
18
|
+
//
|
|
19
|
+
// It OUTLIVES THE SESSION. The old way round was a skill in each repository that ran `npm run
|
|
20
|
+
// dev` as a background task inside a Claude Code session, and a background task belongs to its
|
|
21
|
+
// session: close the session and the server goes with it. Here the process is detached, owned by
|
|
22
|
+
// nobody, and it survives k0 restarting as readily as it survives a session closing. That is the
|
|
23
|
+
// whole point of the feature, and it is why this module is deliberately absent from the shutdown
|
|
24
|
+
// handlers in index.js.
|
|
25
|
+
//
|
|
26
|
+
// It is NEVER ASKED whether it is up. k0 makes no network requests — the rule is in CLAUDE.md and
|
|
27
|
+
// it is not negotiable — so "up" is not a 200 to a request nobody should be making. It is: the
|
|
28
|
+
// process is alive AND the operating system says it is holding a TCP port open. That is stronger
|
|
29
|
+
// evidence anyway. A server that has bound its port but is still compiling answers a request with
|
|
30
|
+
// an error and this test with the truth.
|
|
31
|
+
//
|
|
32
|
+
// And it is HONEST ABOUT WHAT IT DID NOT START. A server somebody started by hand in a terminal
|
|
33
|
+
// is recognised, shown green and governed like the rest, because a globe that only knew about its
|
|
34
|
+
// own servers would be grey next to a site that is plainly running. Where a platform cannot tell
|
|
35
|
+
// whose repository an outside process belongs to — Windows — the interface says so rather than
|
|
36
|
+
// drawing it as off.
|
|
37
|
+
|
|
38
|
+
/** How long a server gets to open a port before k0 stops calling it "starting". */
|
|
39
|
+
const GRACE = 90_000
|
|
40
|
+
|
|
41
|
+
/** Ports the kernel hands out for outgoing connections: never what a dev server was asked for. */
|
|
42
|
+
const EPHEMERAL = 49152
|
|
43
|
+
|
|
44
|
+
/** How long a reading of the machine's sockets stays good for. The globe is not a stopwatch. */
|
|
45
|
+
const TTL = 4000
|
|
46
|
+
|
|
47
|
+
/** How long a glance at the board counts for before the sampling goes quiet again. */
|
|
48
|
+
const ATTENTION = 15000
|
|
49
|
+
|
|
50
|
+
/** How far up a process's ancestry to look for the top of a dev server. */
|
|
51
|
+
const MAX_DEPTH = 12
|
|
52
|
+
|
|
53
|
+
// ── Deciding whether a repository has a server at all ─────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which script starts this project, out of its package.json.
|
|
57
|
+
*
|
|
58
|
+
* `start` is deliberately not in the list. By convention it runs what has been BUILT, which is
|
|
59
|
+
* not what a globe on a working board means — and there is a concrete trap behind the
|
|
60
|
+
* convention: k0's own package.json has only `start`, so accepting it would put a globe on k0
|
|
61
|
+
* that tried to launch a second k0 on a port the first one is already holding.
|
|
62
|
+
*
|
|
63
|
+
* The command is always `npm run <script>` and never the script's own text. Scripts chain
|
|
64
|
+
* (`velite && node scripts/build-posts-index.mjs && vite`) and rely on node_modules/.bin being
|
|
65
|
+
* on PATH, which is something only `npm run` arranges.
|
|
66
|
+
*/
|
|
67
|
+
export function detectCommand(pkg) {
|
|
68
|
+
const scripts = pkg && typeof pkg === 'object' ? pkg.scripts : null
|
|
69
|
+
if (!scripts || typeof scripts !== 'object') return null
|
|
70
|
+
for (const script of ['dev', 'serve']) {
|
|
71
|
+
if (typeof scripts[script] === 'string' && scripts[script].trim()) {
|
|
72
|
+
return { script, command: `npm run ${script}` }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Reading a package.json is cheap, but board() asks about every repository every second and
|
|
79
|
+
// there is no reason to pay for it that often. A repository that grows a `dev` script waits at
|
|
80
|
+
// most this long for its globe.
|
|
81
|
+
const DETECT_TTL = 30_000
|
|
82
|
+
const detected = new Map() // path -> { at, command }
|
|
83
|
+
|
|
84
|
+
export function commandFor(repo) {
|
|
85
|
+
const hit = detected.get(repo)
|
|
86
|
+
if (hit && Date.now() - hit.at < DETECT_TTL) return hit.command
|
|
87
|
+
let command = null
|
|
88
|
+
try {
|
|
89
|
+
command = detectCommand(JSON.parse(fs.readFileSync(path.join(repo, 'package.json'), 'utf8')))
|
|
90
|
+
} catch {
|
|
91
|
+
// No package.json, or one that is being written to right now. Either way: no globe, and
|
|
92
|
+
// the next glance will ask again.
|
|
93
|
+
}
|
|
94
|
+
detected.set(repo, { at: Date.now(), command })
|
|
95
|
+
return command
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── The pure decisions ───────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which port to show, out of everything a server's processes hold open. The lowest wins: a
|
|
102
|
+
* framework that opens a second socket opens it above the one it was asked for. Ports the
|
|
103
|
+
* kernel handed out for outgoing connections are not offers and are dropped.
|
|
104
|
+
*/
|
|
105
|
+
export function pickPort(ports) {
|
|
106
|
+
const usable = [...(ports ?? [])].filter((p) => p > 0 && p < EPHEMERAL).sort((a, b) => a - b)
|
|
107
|
+
return usable[0] ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const urlFor = (port) => (port ? `http://localhost:${port}` : null)
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The four states, from facts only.
|
|
114
|
+
*
|
|
115
|
+
* off nothing is running, and nothing tried
|
|
116
|
+
* starting a process is alive but has not opened a port yet, and is still within its grace
|
|
117
|
+
* up a process is alive and holding a port
|
|
118
|
+
* failed it was started and it is not up: either the process is gone, or the grace ran out
|
|
119
|
+
*
|
|
120
|
+
* The row in the database is what separates "we stopped it" from "it died": stopping deletes
|
|
121
|
+
* the row, so a row whose process is gone is a server that fell over.
|
|
122
|
+
*/
|
|
123
|
+
export function phaseOf({ started, running, port, startedAt, now = Date.now(), sampled = true }) {
|
|
124
|
+
if (port) return 'up'
|
|
125
|
+
if (running) {
|
|
126
|
+
// Nothing has been read off the machine yet — the first glance after k0 itself restarted,
|
|
127
|
+
// say, with a dev server that has been up for hours. "We do not know" wears the same face as
|
|
128
|
+
// "coming up", because a red globe on no evidence at all is an accusation, not a report.
|
|
129
|
+
if (!sampled) return 'starting'
|
|
130
|
+
return now - (startedAt ?? now) > GRACE ? 'failed' : 'starting'
|
|
131
|
+
}
|
|
132
|
+
// Nothing is running. Whether that is a quiet globe or a red one turns entirely on `started`:
|
|
133
|
+
// k0 was told to start this and there is no process, so it fell over — and saying so is the
|
|
134
|
+
// difference between a globe that reports and one that shrugs. A server nobody asked for is
|
|
135
|
+
// simply off.
|
|
136
|
+
return started ? 'failed' : 'off'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Where a repository's output goes. Two repositories never share a file, whatever they are called. */
|
|
140
|
+
export function logPathFor(repo) {
|
|
141
|
+
const short = crypto.createHash('sha1').update(String(repo)).digest('hex').slice(0, 8)
|
|
142
|
+
const name = path.basename(String(repo)).replace(/[^a-zA-Z0-9._-]/g, '-') || 'repo'
|
|
143
|
+
return path.join(LOG_DIR, `dev-${name}-${short}.log`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Is `dir` the repository, or inside it?
|
|
148
|
+
*
|
|
149
|
+
* Compared a whole segment at a time, so `/a/bc` is not inside `/a/b` — without that, a
|
|
150
|
+
* neighbouring directory whose name merely starts the same would be adopted as if it were this
|
|
151
|
+
* repository, and the globe would then govern somebody else's server.
|
|
152
|
+
*
|
|
153
|
+
* Both separators count, rather than whichever one this machine happens to use. The paths on
|
|
154
|
+
* either side come from different places — one from the operating system, one from a card or from
|
|
155
|
+
* Claude Code's own files — and on Windows those two do not reliably agree on which slash they
|
|
156
|
+
* write. Asking `path.sep` would make the answer depend on the machine reading it, which is how a
|
|
157
|
+
* comparison starts being right in the tests and wrong in the field.
|
|
158
|
+
*/
|
|
159
|
+
export function isInside(dir, repo) {
|
|
160
|
+
if (!dir || !repo) return false
|
|
161
|
+
if (dir === repo) return true
|
|
162
|
+
const base = String(repo).replace(/[\\/]+$/, '')
|
|
163
|
+
return dir.startsWith(base) && (dir[base.length] === '/' || dir[base.length] === '\\')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The last thing the log has to say, which is what a failure owes the person looking at it. */
|
|
167
|
+
export function lastLine(text, limit = 200) {
|
|
168
|
+
const lines = String(text ?? '')
|
|
169
|
+
.split('\n')
|
|
170
|
+
.map((l) => l.replace(/\[[0-9;]*m/g, '').trim()) // colour codes say nothing in a tooltip
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
const line = lines[lines.length - 1] ?? ''
|
|
173
|
+
return line.length > limit ? `${line.slice(0, limit - 1)}…` : line
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The tail of a log file without reading the whole thing: a dev server writes a lot. */
|
|
177
|
+
export function logTail(file, bytes = 4096) {
|
|
178
|
+
try {
|
|
179
|
+
const { size } = fs.statSync(file)
|
|
180
|
+
const from = Math.max(0, size - bytes)
|
|
181
|
+
const fd = fs.openSync(file, 'r')
|
|
182
|
+
try {
|
|
183
|
+
const buf = Buffer.alloc(Math.min(bytes, size - from))
|
|
184
|
+
fs.readSync(fd, buf, 0, buf.length, from)
|
|
185
|
+
return buf.toString('utf8')
|
|
186
|
+
} finally {
|
|
187
|
+
fs.closeSync(fd)
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
return ''
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Reading the machine ──────────────────────────────────────────────────────
|
|
195
|
+
// The same discipline machine.js already lives by, and for the same reason: asking the whole
|
|
196
|
+
// system who is listening costs real time on a busy laptop, and a board nobody is looking at
|
|
197
|
+
// has nothing to ask for.
|
|
198
|
+
|
|
199
|
+
let watching = 0
|
|
200
|
+
let watched = [] // the repositories currently on the board
|
|
201
|
+
let snap = null // { at, byRepo: Map<path, {root, pids, port}> }
|
|
202
|
+
// The reading in flight, if there is one. A second asker WAITS for it rather than skipping: the
|
|
203
|
+
// obvious version of this — a boolean, and return early if it is set — would let `stop` sail past
|
|
204
|
+
// a reading that had not finished and go on to signal whatever the last one happened to say.
|
|
205
|
+
let sampling = null
|
|
206
|
+
|
|
207
|
+
/** Somebody is looking at the board, and at these repositories. */
|
|
208
|
+
export function touch(repos = []) {
|
|
209
|
+
watching = Date.now()
|
|
210
|
+
watched = repos
|
|
211
|
+
if (!snap) cycle()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Every process between `pid` and the top, itself included. It stops at k0's own process for
|
|
216
|
+
* the same reason it stops at init: whatever is above there is not part of a dev server.
|
|
217
|
+
*/
|
|
218
|
+
export function ancestry(procs, pid, self = process.pid) {
|
|
219
|
+
const chain = []
|
|
220
|
+
let cur = pid
|
|
221
|
+
for (let i = 0; i < MAX_DEPTH && cur > 1 && cur !== self; i++) {
|
|
222
|
+
chain.push(cur)
|
|
223
|
+
const parent = procs.get(cur)?.ppid
|
|
224
|
+
if (!parent || parent === cur) break
|
|
225
|
+
cur = parent
|
|
226
|
+
}
|
|
227
|
+
return chain
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Which pids to ask the operating system about, when looking for servers k0 did not start. */
|
|
231
|
+
export function strayPids(ports, procs, kids, self = process.pid) {
|
|
232
|
+
const mine = procs.size ? subtree(kids, self) : new Set([self])
|
|
233
|
+
return [...ports.keys()].filter((pid) => !mine.has(pid))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Which server belongs to which repository — the whole of the deciding, with none of the asking.
|
|
238
|
+
*
|
|
239
|
+
* It is separated from `read` below for the reason every parser in k0 is separated from the
|
|
240
|
+
* command that feeds it: this is the part that can be wrong quietly. Everything it needs arrives
|
|
241
|
+
* as a value — who is listening on what, who descends from whom, who is working from where, and
|
|
242
|
+
* what k0 remembers starting — and what comes back is the answer, so it can be read and proved
|
|
243
|
+
* without a single process being spawned.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} facts
|
|
246
|
+
* @param {Map<number, Set<number>>} facts.ports pid -> the ports it holds open
|
|
247
|
+
* @param {Map<number, {ppid: number, cmd: string}>} facts.procs
|
|
248
|
+
* @param {Map<number, number[]>} facts.kids who descends from whom
|
|
249
|
+
* @param {Map<number, string>} facts.cwds pid -> where it is working from
|
|
250
|
+
* @param {{project_path: string, pid: number, command: string}[]} facts.rows what k0 started
|
|
251
|
+
* @param {string[]} facts.repos the repositories on the board right now
|
|
252
|
+
* @param {(pid: number) => boolean} facts.isAlive
|
|
253
|
+
* @param {boolean} facts.canAdopt
|
|
254
|
+
*/
|
|
255
|
+
export function attribute({ ports, procs, kids, cwds, rows, repos, isAlive = alive, canAdopt = true, self = process.pid }) {
|
|
256
|
+
const byRepo = new Map()
|
|
257
|
+
|
|
258
|
+
// Servers k0 started: the root is known, so the only question is which of its children got the
|
|
259
|
+
// port.
|
|
260
|
+
//
|
|
261
|
+
// Except that a remembered pid is only a number, and the operating system hands numbers out
|
|
262
|
+
// again. A row written before a reboot can point at somebody else's process entirely — and this
|
|
263
|
+
// is the map that `stop` later kills by, so guessing here would be a way to kill the wrong
|
|
264
|
+
// thing. Where the process table can be read, the command it was started with has to still be
|
|
265
|
+
// the command it is running.
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
if (!isAlive(row.pid)) continue
|
|
268
|
+
if (procs.size && !(procs.get(row.pid)?.cmd ?? '').includes(row.command)) continue
|
|
269
|
+
const pids = procs.has(row.pid) ? [...subtree(kids, row.pid)] : [row.pid]
|
|
270
|
+
const held = new Set()
|
|
271
|
+
for (const pid of pids) for (const port of ports.get(pid) ?? []) held.add(port)
|
|
272
|
+
byRepo.set(row.project_path, { root: row.pid, pids, port: pickPort(held), adopted: false })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!canAdopt) return byRepo
|
|
276
|
+
|
|
277
|
+
// And the ones somebody started by hand. The listening process's own working directory is what
|
|
278
|
+
// ties it to a repository — `vite` and `next` both inherit it from the `npm run` that started
|
|
279
|
+
// them — and the top of the server is the highest ancestor still working from inside that same
|
|
280
|
+
// repository.
|
|
281
|
+
const strays = strayPids(ports, procs, kids, self)
|
|
282
|
+
for (const repo of repos.filter((r) => !byRepo.has(r))) {
|
|
283
|
+
let best = null
|
|
284
|
+
for (const pid of strays) {
|
|
285
|
+
if (!isInside(cwds.get(pid), repo)) continue
|
|
286
|
+
const port = pickPort(ports.get(pid))
|
|
287
|
+
if (port === null) continue
|
|
288
|
+
if (best && best.port <= port) continue
|
|
289
|
+
const chain = ancestry(procs, pid, self)
|
|
290
|
+
// The highest ancestor still inside the repository is the `npm run` that was typed, and
|
|
291
|
+
// stopping that is what stops the whole thing.
|
|
292
|
+
const root = [...chain].reverse().find((p) => isInside(cwds.get(p), repo)) ?? pid
|
|
293
|
+
best = { root, pids: procs.size ? [...subtree(kids, root)] : chain, port, adopted: true }
|
|
294
|
+
}
|
|
295
|
+
if (best) byRepo.set(repo, best)
|
|
296
|
+
}
|
|
297
|
+
return byRepo
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The reading itself, with no regard for whether anybody asked. `cycle` is the polite one. */
|
|
301
|
+
function sample() {
|
|
302
|
+
if (!sampling) {
|
|
303
|
+
sampling = read().finally(() => {
|
|
304
|
+
sampling = null
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
return sampling
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The asking, which is all this does. The deciding is `attribute`, above. */
|
|
311
|
+
async function read() {
|
|
312
|
+
try {
|
|
313
|
+
const ports = capabilities.servers.ports ? await os.listeners() : new Map()
|
|
314
|
+
const t = tree()
|
|
315
|
+
const procs = t?.procs ?? new Map()
|
|
316
|
+
const kids = t?.kids ?? new Map()
|
|
317
|
+
const rows = db.listDevServers()
|
|
318
|
+
const canAdopt = capabilities.servers.adopt
|
|
319
|
+
|
|
320
|
+
// Where a process is working from is a second question to the operating system, and it is
|
|
321
|
+
// only worth asking about the processes that could still turn out to be somebody's dev
|
|
322
|
+
// server: the ones listening that k0 did not start itself, and everything above them.
|
|
323
|
+
let cwds = new Map()
|
|
324
|
+
if (canAdopt) {
|
|
325
|
+
const strays = strayPids(ports, procs, kids)
|
|
326
|
+
const claimed = new Set(rows.map((r) => r.project_path))
|
|
327
|
+
if (strays.length && watched.some((repo) => !claimed.has(repo))) {
|
|
328
|
+
cwds = await os.cwds([...new Set(strays.flatMap((pid) => ancestry(procs, pid)))])
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
snap = { at: Date.now(), byRepo: attribute({ ports, procs, kids, cwds, rows, repos: watched, canAdopt }) }
|
|
333
|
+
} catch {
|
|
334
|
+
/* one bad reading does not throw away what was already known */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A reading, but only while somebody is looking. See machine.js, which lives by the same rule. */
|
|
339
|
+
async function cycle() {
|
|
340
|
+
if (Date.now() - watching > ATTENTION) return
|
|
341
|
+
await sample()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
setInterval(cycle, TTL).unref()
|
|
345
|
+
|
|
346
|
+
// ── What the board is told ───────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Everything the globe needs for one repository, or null when this repository has no way to
|
|
350
|
+
* start a server and should not have a globe at all.
|
|
351
|
+
*/
|
|
352
|
+
export function stateOf(repo) {
|
|
353
|
+
const command = commandFor(repo)
|
|
354
|
+
if (!command) return null
|
|
355
|
+
|
|
356
|
+
const found = snap?.byRepo.get(repo) ?? null
|
|
357
|
+
const row = db.getDevServer(repo)
|
|
358
|
+
// A row whose process is gone is a server that fell over: stopping it would have removed the
|
|
359
|
+
// row. Without a row and without a sighting there is simply nothing running.
|
|
360
|
+
const up = !!found || (!!row && alive(row.pid))
|
|
361
|
+
const port = found?.port ?? null
|
|
362
|
+
const phase = phaseOf({ started: !!row, running: up, port, startedAt: row?.started_at, sampled: !!snap })
|
|
363
|
+
|
|
364
|
+
const state = {
|
|
365
|
+
state: phase,
|
|
366
|
+
port,
|
|
367
|
+
url: urlFor(port),
|
|
368
|
+
command: row?.command ?? command.command,
|
|
369
|
+
script: command.script,
|
|
370
|
+
adopted: !!found?.adopted,
|
|
371
|
+
pid: found?.root ?? row?.pid ?? null,
|
|
372
|
+
log: logPathFor(repo),
|
|
373
|
+
error: null,
|
|
374
|
+
// What this machine can do about it, so the interface greys out rather than pretends.
|
|
375
|
+
can: { run: capabilities.servers.run, ports: capabilities.servers.ports, adopt: capabilities.servers.adopt },
|
|
376
|
+
}
|
|
377
|
+
if (phase === 'failed') state.error = lastLine(logTail(state.log))
|
|
378
|
+
return state
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Turning it on and off ────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Start the project's dev server, detached.
|
|
385
|
+
*
|
|
386
|
+
* `detached` is what gives the process a group of its own, which is both how it survives k0 and
|
|
387
|
+
* how stopping it can take its children with it. `unref` is what lets k0 exit while it runs. The
|
|
388
|
+
* output goes to a file rather than to a pipe nobody is reading: a pipe with a full buffer and
|
|
389
|
+
* no reader stops the writer, which would hang the server the moment it got chatty.
|
|
390
|
+
*/
|
|
391
|
+
export async function start(repo) {
|
|
392
|
+
if (!capabilities.servers.run) throw new Error('k0 cannot start dev servers on this machine.')
|
|
393
|
+
const command = commandFor(repo)
|
|
394
|
+
if (!command) throw new Error('This repository has no `dev` or `serve` script to run.')
|
|
395
|
+
if (!fs.existsSync(repo)) throw new Error('That directory is not there any more.')
|
|
396
|
+
|
|
397
|
+
const existing = stateOf(repo)
|
|
398
|
+
if (existing && (existing.state === 'up' || existing.state === 'starting')) return existing
|
|
399
|
+
|
|
400
|
+
ensureDirs()
|
|
401
|
+
const log = logPathFor(repo)
|
|
402
|
+
// Truncated, not appended: what matters is why THIS run failed, not the ten before it.
|
|
403
|
+
const fd = fs.openSync(log, 'w')
|
|
404
|
+
try {
|
|
405
|
+
fs.writeSync(fd, `$ ${command.command}\n in ${repo}\n at ${new Date().toISOString()}\n\n`)
|
|
406
|
+
const { file, args } = os.shell(command.command)
|
|
407
|
+
const child = spawn(file, args, {
|
|
408
|
+
cwd: repo,
|
|
409
|
+
detached: true,
|
|
410
|
+
stdio: ['ignore', fd, fd],
|
|
411
|
+
windowsHide: true,
|
|
412
|
+
})
|
|
413
|
+
child.unref()
|
|
414
|
+
// A spawn that fails does so asynchronously, and there is no one left to hear it: the log
|
|
415
|
+
// is where the reason goes, and `stateOf` reads it back out when the globe turns red.
|
|
416
|
+
child.on('error', (err) => {
|
|
417
|
+
try {
|
|
418
|
+
fs.appendFileSync(log, `\nk0 could not start it: ${err.message}\n`)
|
|
419
|
+
} catch {
|
|
420
|
+
/* the log is a courtesy, not a contract */
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
db.setDevServer(repo, { pid: child.pid, command: command.command, startedAt: Date.now() })
|
|
424
|
+
} finally {
|
|
425
|
+
fs.closeSync(fd)
|
|
426
|
+
}
|
|
427
|
+
// The next reading will find the port; until then the globe spins, which is the truth.
|
|
428
|
+
snap = null
|
|
429
|
+
touch(watched.includes(repo) ? watched : [...watched, repo])
|
|
430
|
+
return stateOf(repo)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Stop it, and everything it started. SIGTERM first — a dev server given the chance will clean
|
|
435
|
+
* up after itself — then SIGKILL for whatever is still standing, on the same three-second
|
|
436
|
+
* patience `terminal.close` already uses.
|
|
437
|
+
*/
|
|
438
|
+
export async function stop(repo) {
|
|
439
|
+
// Read the machine FIRST, and kill only what that reading found. The stored pid on its own is
|
|
440
|
+
// not enough to act on: it is a number the operating system is free to hand out again, and
|
|
441
|
+
// acting on a stale one would mean killing whatever now happens to wear it. The sample checks
|
|
442
|
+
// that the process is still running the command it was started with — see the guard in
|
|
443
|
+
// `sample` — so by the time anything is signalled here, it has been identified twice.
|
|
444
|
+
watching = Date.now()
|
|
445
|
+
if (!watched.includes(repo)) watched = [...watched, repo]
|
|
446
|
+
await sample()
|
|
447
|
+
const found = snap?.byRepo.get(repo) ?? null
|
|
448
|
+
|
|
449
|
+
// The row goes unconditionally: it is what says "k0 started this and it is meant to be
|
|
450
|
+
// running", and leaving it behind would turn a stop into a failure the next time anybody
|
|
451
|
+
// looked at the globe.
|
|
452
|
+
db.clearDevServer(repo)
|
|
453
|
+
if (!found) {
|
|
454
|
+
snap = null
|
|
455
|
+
return stateOf(repo)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await os.stop(found.root, found.pids, 'SIGTERM')
|
|
459
|
+
// The same three seconds of patience `terminal.close` gives a session: long enough for a dev
|
|
460
|
+
// server to put its own things away, short enough that nobody thinks the click was missed.
|
|
461
|
+
const deadline = Date.now() + 3000
|
|
462
|
+
while (Date.now() < deadline && alive(found.root)) await new Promise((r) => setTimeout(r, 150))
|
|
463
|
+
if (alive(found.root)) await os.stop(found.root, found.pids, 'SIGKILL')
|
|
464
|
+
snap = null
|
|
465
|
+
await sample()
|
|
466
|
+
return stateOf(repo)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Off, then on. There is no third way to restart a process that does not know it can be. */
|
|
470
|
+
export async function restart(repo) {
|
|
471
|
+
await stop(repo)
|
|
472
|
+
return start(repo)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** For the tests, which must not inherit a previous file's sampling. */
|
|
476
|
+
export function forget() {
|
|
477
|
+
snap = null
|
|
478
|
+
watching = 0
|
|
479
|
+
watched = []
|
|
480
|
+
detected.clear()
|
|
481
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { HOME } from './paths.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The few things about k0 you are allowed to change, and the file they live in.
|
|
7
|
+
*
|
|
8
|
+
* k0 has never had one of these, and this is deliberately not the first room of a settings
|
|
9
|
+
* panel. The board shows what changes during a working day; a number you set once and then never
|
|
10
|
+
* look at again is the opposite of that, and putting it on screen would cost the one page that
|
|
11
|
+
* has to stay readable in exchange for nothing.
|
|
12
|
+
*
|
|
13
|
+
* So it is a file, and the file IS the list: k0 writes it out with every setting already in it at
|
|
14
|
+
* its default, so that opening it is how you find out what there is to change. `k0-board doctor`
|
|
15
|
+
* prints the same thing, for anyone who would rather ask than look.
|
|
16
|
+
*
|
|
17
|
+
* It lives beside the database and not inside the app, because the app directory is disposable —
|
|
18
|
+
* `npx` unpacks into a cache npm may wipe, and `k0-board install` overwrites it — and a setting
|
|
19
|
+
* an update throws away is not a setting. And it is read again whenever it changes, so editing
|
|
20
|
+
* it takes effect on the next round rather than after a restart.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** `K0_CONFIG` is how the tests stay away from the real file, the way `K0_DB` does for the board. */
|
|
24
|
+
export const PATH = process.env.K0_CONFIG || path.join(HOME, 'config.json')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every setting there is, and what it is worth if you never touch the file. No default is written
|
|
28
|
+
* down anywhere else: this object is the whole answer to "and if I leave it alone?".
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULTS = {
|
|
31
|
+
closeIdleTerminalsAfterHours: 12,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shortest timeout that is still a timeout. The sweep goes round once a minute, and a
|
|
36
|
+
* terminal you were typing into ten minutes ago is not one you have forgotten: below an hour this
|
|
37
|
+
* stops being a tidy-up and becomes a machine closing windows while you are using them. A number
|
|
38
|
+
* under it is raised rather than refused — the intent was clearly "be aggressive about it", and an
|
|
39
|
+
* hour is as aggressive as this is willing to be.
|
|
40
|
+
*/
|
|
41
|
+
export const MIN_HOURS = 1
|
|
42
|
+
|
|
43
|
+
/** The line k0 leaves at the top of the file, since JSON has nowhere else to put a word. */
|
|
44
|
+
const NOTE =
|
|
45
|
+
'k0 wrote this file and reads it again by itself whenever it changes — nothing to restart. ' +
|
|
46
|
+
'Delete a line to go back to its default. Hours at 0 switch that closing off entirely. ' +
|
|
47
|
+
'See Settings in the README.'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a terminal may sit there. Anything that is not a number falls back to the default
|
|
51
|
+
* rather than blowing up: this arrives from a file somebody edits by hand, and from there
|
|
52
|
+
* anything can arrive. Zero and below mean off, which is how the whole thing is switched off
|
|
53
|
+
* without a second switch to be kept in step with the first.
|
|
54
|
+
*/
|
|
55
|
+
export function idleHours(raw) {
|
|
56
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULTS.closeIdleTerminalsAfterHours
|
|
57
|
+
const n = Number(raw)
|
|
58
|
+
if (!Number.isFinite(n)) return DEFAULTS.closeIdleTerminalsAfterHours
|
|
59
|
+
if (n <= 0) return 0
|
|
60
|
+
return Math.max(MIN_HOURS, n)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file's worth of anything, turned into settings that can be trusted. Pure: the tests live here. */
|
|
64
|
+
export function normalise(raw) {
|
|
65
|
+
const o = raw && typeof raw === 'object' ? raw : {}
|
|
66
|
+
return { closeIdleTerminalsAfterHours: idleHours(o.closeIdleTerminalsAfterHours) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cached = null
|
|
70
|
+
let stamp = null // the mtime the cache was built from, or null for "there was no file"
|
|
71
|
+
let broken = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The settings in force. The file is only re-read when its mtime moves, so this can be called
|
|
75
|
+
* from the watching loop without costing a read a second, and changing the file still lands
|
|
76
|
+
* without a restart.
|
|
77
|
+
*/
|
|
78
|
+
export function read() {
|
|
79
|
+
let mtime = null
|
|
80
|
+
try {
|
|
81
|
+
mtime = fs.statSync(PATH).mtimeMs
|
|
82
|
+
} catch {
|
|
83
|
+
/* no file: the defaults hold, and there is nothing to remember */
|
|
84
|
+
}
|
|
85
|
+
if (cached && stamp === mtime) return cached
|
|
86
|
+
stamp = mtime
|
|
87
|
+
if (mtime === null) broken = false // a file that is not there is not a file that is broken
|
|
88
|
+
cached = normalise(mtime === null ? null : parse())
|
|
89
|
+
return cached
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parse() {
|
|
93
|
+
try {
|
|
94
|
+
broken = false
|
|
95
|
+
return JSON.parse(fs.readFileSync(PATH, 'utf8'))
|
|
96
|
+
} catch {
|
|
97
|
+
// Half-saved by an editor, or one comma too many. k0 goes on with the defaults rather than
|
|
98
|
+
// refusing to start, and `doctor` is where you find out the file is being ignored — silence
|
|
99
|
+
// would be the worst of both: a setting you believe is in force and is not.
|
|
100
|
+
broken = true
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Writes the file out, with everything in it, if it is not there yet. Called explicitly at
|
|
107
|
+
* startup rather than from an import: a module that writes to your disk merely because somebody
|
|
108
|
+
* mentioned it is a module nobody can reason about.
|
|
109
|
+
*/
|
|
110
|
+
export function ensure() {
|
|
111
|
+
if (fs.existsSync(PATH)) return false
|
|
112
|
+
fs.mkdirSync(path.dirname(PATH), { recursive: true })
|
|
113
|
+
fs.writeFileSync(PATH, JSON.stringify({ '//': NOTE, ...DEFAULTS }, null, 2) + '\n')
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the doctor prints: where the file is, whether it could be read, and what is in force. */
|
|
118
|
+
export function status() {
|
|
119
|
+
const values = read()
|
|
120
|
+
return { path: PATH, exists: stamp !== null, broken, values }
|
|
121
|
+
}
|
package/server/watcher.js
CHANGED
|
@@ -162,6 +162,13 @@ export function forgetSession(sessionId) {
|
|
|
162
162
|
scans.delete(sessionId)
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Something is really running behind this status. It is the one question two different places
|
|
167
|
+
* ask: whether a status that survives the death of a process would be a lie, and whether a
|
|
168
|
+
* session may be closed from the board — a session that is grinding away is not.
|
|
169
|
+
*/
|
|
170
|
+
export const busy = (status) => status === 'WORKING' || status === 'PLANNING'
|
|
171
|
+
|
|
165
172
|
/**
|
|
166
173
|
* Translates Claude Code's signals into the board's statuses.
|
|
167
174
|
* Returns { status, alive }.
|
|
@@ -175,7 +182,7 @@ export function deriveStatus(card, live) {
|
|
|
175
182
|
// Process dead: the session can still be resumed. We keep the last status only if it said
|
|
176
183
|
// something about you (an open question, a plan left sitting there); WORKING and PLANNING
|
|
177
184
|
// would be a lie, nothing is grinding away any more.
|
|
178
|
-
const stale = card.status
|
|
185
|
+
const stale = busy(card.status) || card.status === 'BACKLOG'
|
|
179
186
|
return { status: stale ? 'IDLE' : card.status, alive: false }
|
|
180
187
|
}
|
|
181
188
|
|