k0-board 0.5.0 → 0.6.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
@@ -36,6 +36,8 @@ import * as mirror from './mirror.js'
36
36
  import * as worktree from './worktree.js'
37
37
  import * as update from './update.js'
38
38
  import * as usage from './usage.js'
39
+ import * as cloud from './cloud.js'
40
+ import * as sync from './sync.js'
39
41
  import { report as platformReport, capabilities, why } from '../platform/index.js'
40
42
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
41
43
  // called, and two different readings of the same header would give two different names.
@@ -1004,7 +1006,12 @@ async function backlogApi(req, res, url, seg) {
1004
1006
  // checkout would make an epic nothing could ever write down.
1005
1007
  const repo = backlog.backlogRepo(body.project_path)
1006
1008
  if (!body.title?.trim() || !repo) return send(res, 400, { error: 'Title and a known repository are required' })
1007
- const epic = db.createEpic({ ...only(NEW_EPIC, body), title: body.title.trim(), project_path: repo })
1009
+ const epic = db.createEpic({
1010
+ ...only(NEW_EPIC, body),
1011
+ title: body.title.trim(),
1012
+ project_path: repo,
1013
+ cloud_key: await cloud.nextKey(),
1014
+ })
1008
1015
  return wrote(res, backlog.publicEpic(epic), mirrorEpic(epic.id))
1009
1016
  }
1010
1017
 
@@ -1107,6 +1114,7 @@ async function backlogApi(req, res, url, seg) {
1107
1114
  ...only(NEW_STORY, body),
1108
1115
  title: body.title.trim(),
1109
1116
  project_path: repo,
1117
+ cloud_key: await cloud.nextKey(),
1110
1118
  // A task takes its parent's epic unless it was given one of its own. Not tidiness: a
1111
1119
  // story's effective decisions are its own plus its EPIC's, so a task left outside the epic
1112
1120
  // its parent sits in would be planned and counter-checked blind to every rule that shaped
@@ -1361,6 +1369,9 @@ async function api(req, res, url) {
1361
1369
  if (resource === 'board' && req.method === 'GET')
1362
1370
  return send(res, 200, boardAnswer(url.searchParams.get('since')))
1363
1371
  if (resource === 'status' && req.method === 'GET') return send(res, 200, status())
1372
+
1373
+ // ── k0 Cloud: who is signed in, and the way in and out ─────────────────────
1374
+ if (resource === 'cloud') return cloudApi(req, res, url, idRaw)
1364
1375
  if (resource === 'projects' && req.method === 'GET') return send(res, 200, listProjects())
1365
1376
 
1366
1377
  // ── What you have been doing ───────────────────────────────────────────────
@@ -1528,6 +1539,7 @@ async function api(req, res, url) {
1528
1539
  session_id: sessionId,
1529
1540
  started_at: it.started_at,
1530
1541
  ended_at: it.ended_at,
1542
+ cloud_key: await cloud.nextKey(),
1531
1543
  })
1532
1544
  stories.push(made)
1533
1545
  // The readable copy is written here as it is at every other door. An imported story that
@@ -1681,7 +1693,12 @@ async function api(req, res, url) {
1681
1693
  if (resource === 'story' && req.method === 'POST' && !idRaw) {
1682
1694
  const b = await readBody(req)
1683
1695
  if (!b.title?.trim() || !b.project_path) return send(res, 400, { error: 'Title and repository are required' })
1684
- const story = db.createStory({ ...only(NEW_STORY, b), title: b.title.trim(), project_path: b.project_path })
1696
+ const story = db.createStory({
1697
+ ...only(NEW_STORY, b),
1698
+ title: b.title.trim(),
1699
+ project_path: b.project_path,
1700
+ cloud_key: await cloud.nextKey(),
1701
+ })
1685
1702
  return wrote(res, story, mirrorStory(story.id))
1686
1703
  }
1687
1704
 
@@ -1781,6 +1798,74 @@ async function api(req, res, url) {
1781
1798
  return send(res, 404, { error: 'No such endpoint' })
1782
1799
  }
1783
1800
 
1801
+ // ── k0 Cloud ─────────────────────────────────────────────────────────────────
1802
+
1803
+ /** Where the browser comes back to after signing in: this machine, on the name it used to get here. */
1804
+ const callbackFor = (req) => `http://${req.headers.host}/auth/callback`
1805
+
1806
+ async function cloudApi(req, res, url, action) {
1807
+ if (!action && req.method === 'GET') {
1808
+ return send(res, 200, { ...sync.status(), providers: cloud.enabled() ? await cloud.providers() : [] })
1809
+ }
1810
+ if (action === 'login' && req.method === 'POST') {
1811
+ const body = await readBody(req)
1812
+ if (!cloud.enabled()) return send(res, 400, { error: 'k0 Cloud is switched off on this machine.' })
1813
+ try {
1814
+ return send(res, 200, { url: cloud.loginUrl(String(body.provider || ''), callbackFor(req)) })
1815
+ } catch (err) {
1816
+ return send(res, 400, { error: err.message })
1817
+ }
1818
+ }
1819
+ if (action === 'logout' && req.method === 'POST') {
1820
+ sync.detach()
1821
+ return send(res, 200, await cloud.logout())
1822
+ }
1823
+ if (action === 'sync' && req.method === 'POST') return send(res, 200, await sync.round())
1824
+ // Moving a board that existed before its owner signed in: the preview, and then the move.
1825
+ if (action === 'move' && req.method === 'GET') {
1826
+ try {
1827
+ return send(res, 200, await sync.movePlan())
1828
+ } catch (err) {
1829
+ return send(res, 503, { error: err.message })
1830
+ }
1831
+ }
1832
+ if (action === 'move' && req.method === 'POST') {
1833
+ try {
1834
+ return send(res, 200, await sync.move())
1835
+ } catch (err) {
1836
+ return send(res, 400, { error: err.message })
1837
+ }
1838
+ }
1839
+ return send(res, 404, { error: 'No such endpoint' })
1840
+ }
1841
+
1842
+ /**
1843
+ * The page the browser lands on after GitHub or Google. It is not under `/api/` because it is a
1844
+ * page somebody sees, for a moment: it trades the code for a session and sends them to the board,
1845
+ * or says in a sentence what went wrong.
1846
+ */
1847
+ async function authCallback(req, res, url) {
1848
+ const page = (title, line) => {
1849
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
1850
+ res.end(`<!doctype html><meta charset="utf-8"><title>${title}</title>
1851
+ <link rel="stylesheet" href="/base.css"><body class="auth-page"><main><h1>${title}</h1><p>${line}</p>
1852
+ <p><a href="/">Back to the board</a></p></main></body>`)
1853
+ }
1854
+ const escape = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
1855
+ const failed = url.searchParams.get('error_description') || url.searchParams.get('error')
1856
+ if (failed) return page('Not signed in', escape(failed))
1857
+ const code = url.searchParams.get('code')
1858
+ if (!code) return page('Not signed in', 'The sign-in came back without anything to sign in with. Start it again from the board.')
1859
+ try {
1860
+ await cloud.finishLogin(code)
1861
+ sync.attach()
1862
+ sync.round()
1863
+ res.writeHead(302, { location: '/' }).end()
1864
+ } catch (err) {
1865
+ page('Not signed in', escape(err.message || err))
1866
+ }
1867
+ }
1868
+
1784
1869
  // ── Static files ─────────────────────────────────────────────────────────────
1785
1870
  function serveStatic(res, pathname) {
1786
1871
  const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
@@ -1818,6 +1903,7 @@ http
1818
1903
  res.mayZip = /\bgzip\b/.test(req.headers['accept-encoding'] || '')
1819
1904
  try {
1820
1905
  if (url.pathname.startsWith('/api/')) return await api(req, res, url)
1906
+ if (url.pathname === '/auth/callback' && req.method === 'GET') return await authCallback(req, res, url)
1821
1907
  serveStatic(res, url.pathname)
1822
1908
  } catch (err) {
1823
1909
  send(res, 500, { error: String(err.message || err) })
@@ -1839,6 +1925,9 @@ http
1839
1925
  // What the sessions consumed, read out of their transcripts at its own pace: quickly while
1840
1926
  // there is history to catch up on, once a minute after that.
1841
1927
  usage.start()
1928
+ // The cloud's copy of the board, kept up to date every few seconds while somebody is signed
1929
+ // in and let in. Nothing happens here for a board that is not linked to an account.
1930
+ sync.start()
1842
1931
  // The first round, and the one that sets the loop going: `round` is `tick` plus the decision
1843
1932
  // of when to come back. Deliberately here and not at import time — the first round reads the
1844
1933
  // settings file, which the line above has only just made sure of.
@@ -1869,6 +1958,7 @@ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
1869
1958
  mode.stopNow()
1870
1959
  // A summary being written when the server goes down has nobody left to give it to.
1871
1960
  writer.stop()
1961
+ sync.stop()
1872
1962
  process.exit(0)
1873
1963
  })
1874
1964
  }
package/server/mirror.js CHANGED
@@ -622,6 +622,27 @@ function drop(folder, key, repoPath) {
622
622
  })
623
623
  }
624
624
 
625
+ /**
626
+ * A story or an epic that changed its key — a provisional `K~3` that got its real number, or the
627
+ * one-time move to numbers counted across the whole account. The file under the old key goes and
628
+ * the file under the new one is written; `place` alone would leave both, because it only ever
629
+ * looks for files under the key it is about to write.
630
+ */
631
+ export function renameKey(kind, id, oldKey) {
632
+ const row = kind === 'epic' ? db.getEpic(id) : db.getStory(id)
633
+ if (!row || !there(row.project_path)) return { file: null, error: null }
634
+ const folder = kind === 'epic' ? 'epics' : 'stories'
635
+ attempt(null, () => {
636
+ const dir = inside(row.project_path, folder)
637
+ if (!fs.existsSync(dir)) return null
638
+ const escaped = String(oldKey).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
639
+ const mine = new RegExp(`^${escaped}(-.*)?\\.md$`)
640
+ for (const name of fs.readdirSync(dir)) if (mine.test(name)) fs.rmSync(inside(row.project_path, folder, name))
641
+ return null
642
+ })
643
+ return kind === 'epic' ? writeEpic(id) : writeStory(id)
644
+ }
645
+
625
646
  export const removeStory = (key, repoPath) => drop('stories', key, repoPath)
626
647
 
627
648
  export const removeEpic = (key, repoPath) => drop('epics', key, repoPath)
package/server/sync.js ADDED
@@ -0,0 +1,383 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import fs from 'node:fs'
5
+ import * as db from './db.js'
6
+ import { HOME } from './paths.js'
7
+ import * as cloud from './cloud.js'
8
+ import * as mirror from './mirror.js'
9
+ import { listProjects } from './projects.js'
10
+
11
+ // ── Keeping the cloud's copy of the board ────────────────────────────────────
12
+ // The board lives on this machine and keeps working with no network at all; the cloud holds a
13
+ // copy of every row so it survives a lost laptop, reaches a second machine and can be read from a
14
+ // browser. This file moves rows between the two. It never decides what the board means — that is
15
+ // all in `db.js` — it only carries rows across, which is why it can afford to be dull.
16
+ //
17
+ // Three things happen on every round, in this order:
18
+ //
19
+ // 1. stories made offline get their real K number, oldest first;
20
+ // 2. what changed here goes up (the `outbox` the triggers in `db.js` fill);
21
+ // 3. what other machines changed comes down.
22
+ //
23
+ // A row travels as a document. Its references to other rows go by uid, because an integer id is
24
+ // only meaningful in the database that handed it out.
25
+
26
+ /** Which columns point at another row, and at which table. They travel as `<column>_uid`. */
27
+ const REFS = {
28
+ epic: {},
29
+ story: { epic_id: 'epic', parent_story_id: 'story' },
30
+ session: { story_id: 'story' },
31
+ decision: { story_id: 'story', epic_id: 'epic', superseded_by: 'decision' },
32
+ decision_check: { story_id: 'story', decision_id: 'decision' },
33
+ round: { story_id: 'story', epic_id: 'epic' },
34
+ check_item: { story_id: 'story' },
35
+ dependency: { story_id: 'story', depends_on: 'story' },
36
+ story_log: { story_id: 'story' },
37
+ }
38
+
39
+ /** What stays on this machine: which terminal window, which folder, which commit it started at. */
40
+ const LOCAL_ONLY = {
41
+ session: ['terminal_window_id', 'work_path', 'head_at_start'],
42
+ }
43
+
44
+ /**
45
+ * The order rows are written in on the way down, so that what a row points at is usually there
46
+ * already. Sessions do not come down at all: a session is a terminal on the machine that opened
47
+ * it, and the other machine has nothing to attach it to. Their status still goes up, for the web.
48
+ */
49
+ const APPLY_ORDER = ['epic', 'story', 'decision', 'round', 'check_item', 'dependency', 'story_log', 'decision_check']
50
+
51
+ // ── Which project a row is in ────────────────────────────────────────────────
52
+ // A path means nothing on another machine. A project is named in the cloud by where its code
53
+ // lives — the `origin` remote — so the same repository checked out in two places is one project.
54
+
55
+ const projectIds = new Map()
56
+
57
+ /** A repository's `origin`, cleaned of credentials and of the difference between ssh and https. */
58
+ export function normaliseRemote(url) {
59
+ const raw = String(url || '').trim()
60
+ if (!raw) return ''
61
+ let host = ''
62
+ let repo = ''
63
+ const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(raw)
64
+ if (scp) {
65
+ host = scp[1]
66
+ repo = scp[2]
67
+ } else {
68
+ try {
69
+ const u = new URL(raw)
70
+ host = u.hostname
71
+ repo = u.pathname
72
+ } catch {
73
+ return raw.replace(/\.git$/, '')
74
+ }
75
+ }
76
+ return `${host.toLowerCase()}/${repo.replace(/^\/+/, '').replace(/\.git$/, '').replace(/\/+$/, '')}`
77
+ }
78
+
79
+ function remoteOf(dir) {
80
+ try {
81
+ return execFileSync('git', ['-C', dir, 'remote', 'get-url', 'origin'], {
82
+ encoding: 'utf8',
83
+ timeout: 3000,
84
+ stdio: ['ignore', 'pipe', 'ignore'],
85
+ }).trim()
86
+ } catch {
87
+ return ''
88
+ }
89
+ }
90
+
91
+ /** A uid that is the same on every machine for the same repository: derived, not stored. */
92
+ const derivedUid = (text) => {
93
+ const h = crypto.createHash('sha256').update(text).digest('hex')
94
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-5${h.slice(13, 16)}-${'89ab'[parseInt(h[16], 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`
95
+ }
96
+
97
+ export function projectOf(projectPath) {
98
+ if (projectIds.has(projectPath)) return projectIds.get(projectPath)
99
+ const remote = normaliseRemote(remoteOf(projectPath))
100
+ // A repository with no remote is only ever on this machine, so its path is as good a name as any.
101
+ const uid = derivedUid(remote ? `remote:${remote}` : `path:${cloud.machine()}:${projectPath}`)
102
+ const p = { uid, remote, name: path.basename(projectPath || '') || projectPath }
103
+ projectIds.set(projectPath, p)
104
+ return p
105
+ }
106
+
107
+ // ── Up ───────────────────────────────────────────────────────────────────────
108
+
109
+ /** One outbox line as the document the cloud keeps. */
110
+ export function documentFor(entry) {
111
+ const row = entry.op === 'delete' ? null : db.syncRow(entry.tbl, entry.row_id)
112
+ if (!row) return { kind: entry.tbl, uid: entry.uid, deleted: true, data: {}, changed_at: entry.at }
113
+ const data = {}
114
+ const refs = REFS[entry.tbl] || {}
115
+ const skip = new Set(['_rowid', 'id', 'uid', ...(LOCAL_ONLY[entry.tbl] || [])])
116
+ for (const [k, v] of Object.entries(row)) {
117
+ if (skip.has(k)) continue
118
+ if (k in refs) data[`${k}_uid`] = db.uidOf(refs[k], v)
119
+ else data[k] = v
120
+ }
121
+ if ('project_path' in row) {
122
+ const p = projectOf(row.project_path)
123
+ data.project_uid = p.uid
124
+ data.project_name = p.name
125
+ data.project_remote = p.remote
126
+ }
127
+ return { kind: entry.tbl, uid: row.uid, deleted: false, data, changed_at: entry.at }
128
+ }
129
+
130
+ // ── Down ─────────────────────────────────────────────────────────────────────
131
+
132
+ /** The local folder a project lives in here, if this machine has it; otherwise the path it came with. */
133
+ function localPath(data) {
134
+ if (data.project_uid) {
135
+ for (const [p, info] of projectIds) if (info.uid === data.project_uid) return p
136
+ }
137
+ return data.project_path
138
+ }
139
+
140
+ /** Writes what came down. Returns how many rows were applied and how many were left for later. */
141
+ export function apply(records) {
142
+ const waiting = new Set(db.outboxBatch(100000).map((e) => e.uid))
143
+ const order = (r) => {
144
+ const i = APPLY_ORDER.indexOf(r.kind)
145
+ return i < 0 ? APPLY_ORDER.length : i
146
+ }
147
+ const todo = records.filter((r) => r.kind in REFS && r.kind !== 'session').sort((a, b) => order(a) - order(b))
148
+ let applied = 0
149
+ let skipped = 0
150
+ db.applyRemote((put, drop) => {
151
+ for (const r of todo) {
152
+ // A change made here and not delivered yet is newer than anything the cloud can send back
153
+ // about the same row: it is what the person in front of this machine just did.
154
+ if (waiting.has(r.uid)) {
155
+ skipped++
156
+ continue
157
+ }
158
+ if (r.deleted) {
159
+ drop(r.kind, r.uid)
160
+ applied++
161
+ continue
162
+ }
163
+ const row = { uid: r.uid }
164
+ const refs = REFS[r.kind]
165
+ let missing = false
166
+ for (const [k, v] of Object.entries(r.data || {})) {
167
+ if (k.endsWith('_uid') && k.slice(0, -4) in refs) {
168
+ const col = k.slice(0, -4)
169
+ const id = db.idOfUid(refs[col], v)
170
+ if (v && id == null) missing = true
171
+ row[col] = id
172
+ } else if (!k.startsWith('project_') || k === 'project_path') {
173
+ row[k] = v
174
+ }
175
+ }
176
+ if ('project_path' in row) row.project_path = localPath(r.data)
177
+ // A row whose owner has not arrived is left for a later round rather than written pointing at
178
+ // nothing; the ones whose reference may be empty (an epic, a parent) are written without it.
179
+ const required = { session: 'story_id', decision_check: 'story_id', check_item: 'story_id', story_log: 'story_id', dependency: 'story_id' }
180
+ if (missing && required[r.kind] && row[required[r.kind]] == null) {
181
+ skipped++
182
+ continue
183
+ }
184
+ put(r.kind, row)
185
+ applied++
186
+ }
187
+ })
188
+ return { applied, skipped }
189
+ }
190
+
191
+ // ── The round ────────────────────────────────────────────────────────────────
192
+
193
+ const state = { running: false, last: null, error: null, pushed: 0, pulled: 0, askedMe: 0 }
194
+
195
+ export function status() {
196
+ return {
197
+ ...cloud.status(),
198
+ recording: db.syncRecording(),
199
+ pending: db.outboxSize(),
200
+ provisional: db.provisionalKeys().length,
201
+ needs_move: needsMove(),
202
+ last_sync: state.last,
203
+ sync_error: state.error,
204
+ }
205
+ }
206
+
207
+ // ── The first time ───────────────────────────────────────────────────────────
208
+ // A board that existed before its owner signed in has numbers counted on this machine — per
209
+ // repository, if it is older than k0 Cloud — and the cloud's counter starts somewhere else. Using
210
+ // the cloud's numbers straight away would give a new story a K1 that the old board already has.
211
+ // So nothing is recorded and no number comes from the cloud until the board has MOVED: renumbered
212
+ // once, in the order its stories were made, starting where the cloud says, with a copy of the
213
+ // database kept first. The person whose board it is sees every old number beside its new one and
214
+ // says yes before any of it happens.
215
+
216
+ const MOVED = 'cloud.moved'
217
+
218
+ /** Signed in, let in, and the board on this machine still has to be moved to the account. */
219
+ export function needsMove() {
220
+ return cloud.linked() && cloud.approved() && db.getPref(MOVED, '') !== String(cloud.me()?.id || '') && !db.boardIsEmpty()
221
+ }
222
+
223
+ /** What the move would do: every story and epic, its old key and its new one. Writes nothing. */
224
+ export async function movePlan() {
225
+ const start = await peekNext()
226
+ const rows = db.renumberAll({ start, apply: false })
227
+ return { start, rows, texts: rows.textChanges || 0 }
228
+ }
229
+
230
+ /**
231
+ * The cloud's next number without using it up. There is no such call in the cloud on purpose — a
232
+ * number asked for is a number handed out — so this asks for one and the move then starts from
233
+ * exactly it; the preview is computed from the same number, and the counter is pushed past the
234
+ * whole board at the end.
235
+ */
236
+ let peeked = null
237
+ async function peekNext() {
238
+ if (peeked && Date.now() - peeked.at < 30 * 60_000 && peeked.who === cloud.me()?.id) return peeked.n
239
+ const n = await cloud.nextKey()
240
+ if (!n) throw new Error('k0 Cloud cannot be reached, so the board cannot be moved yet.')
241
+ peeked = { n, at: Date.now(), who: cloud.me()?.id }
242
+ return n
243
+ }
244
+
245
+ export const BACKUPS = path.join(HOME, 'backups')
246
+
247
+ /** The move itself. A copy first, then the numbers, then the `.k0/` files, then the first sync. */
248
+ export async function move() {
249
+ if (!cloud.linked() || !cloud.approved()) throw new Error('Sign in, and be let in, before moving the board.')
250
+ const start = await peekNext()
251
+ fs.mkdirSync(BACKUPS, { recursive: true })
252
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
253
+ const backup = db.backupTo(path.join(BACKUPS, `k0-before-cloud-${stamp}.db`))
254
+ const rows = db.renumberAll({ start, apply: true })
255
+ const map = path.join(BACKUPS, `k0-before-cloud-${stamp}.keys.json`)
256
+ fs.writeFileSync(map, JSON.stringify(rows.map((r) => ({ kind: r.kind, project: r.project_path, title: r.title, old: r.old, new: r.new })), null, 2))
257
+ for (const r of rows) {
258
+ if (r.old === r.new) continue
259
+ try {
260
+ mirror.renameKey(r.kind, r.id, r.old)
261
+ } catch {
262
+ // `.k0/` reports its own errors where it is written; the numbers are moved regardless.
263
+ }
264
+ }
265
+ if (rows.length) await cloud.reserveKeys(start + rows.length - 1)
266
+ peeked = null
267
+ db.setPref(MOVED, String(cloud.me()?.id || ''))
268
+ attach()
269
+ await round()
270
+ return { moved: rows.length, start, backup, map }
271
+ }
272
+
273
+ /** Links the board to the account that just signed in: keys from the cloud, changes recorded. */
274
+ export function attach() {
275
+ if (!cloud.linked() || !cloud.approved()) return false
276
+ if (db.boardIsEmpty() && db.getPref(MOVED, '') !== String(cloud.me()?.id || '')) db.setPref(MOVED, String(cloud.me()?.id || ''))
277
+ if (needsMove()) return false
278
+ db.useCloudKeys(true)
279
+ if (!db.syncRecording()) db.setSyncRecording(true, { snapshot: true })
280
+ return true
281
+ }
282
+
283
+ /** Signed out: keys are counted here again and nothing more is queued. */
284
+ export function detach() {
285
+ db.useCloudKeys(false)
286
+ db.setSyncRecording(false)
287
+ }
288
+
289
+ async function giveRealKeys() {
290
+ for (const row of db.provisionalKeys()) {
291
+ const n = await cloud.nextKey()
292
+ if (!n) return
293
+ const was = db.assignKey(row.kind, row.id, n)
294
+ if (was) {
295
+ try {
296
+ mirror.renameKey(row.kind, row.id, was)
297
+ } catch {
298
+ // `.k0/` is a courtesy copy; its own errors are reported where it is written.
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ async function sendUp() {
305
+ for (let i = 0; i < 20; i++) {
306
+ const batch = db.outboxBatch(200)
307
+ if (!batch.length) return
308
+ await cloud.push(batch.map(documentFor))
309
+ db.outboxDone(batch)
310
+ state.pushed += batch.length
311
+ }
312
+ }
313
+
314
+ /** Every repository on this machine, named the way the cloud names it, once per run. */
315
+ let known = false
316
+ function learnProjects() {
317
+ if (known) return
318
+ known = true
319
+ try {
320
+ for (const p of listProjects()) projectOf(p.path)
321
+ } catch {
322
+ // Without the list a row from another machine keeps the path it came with: still on the
323
+ // board, just not openable here until the repository is.
324
+ }
325
+ }
326
+
327
+ async function bringDown() {
328
+ learnProjects()
329
+ for (let i = 0; i < 20; i++) {
330
+ const rows = await cloud.pull(cloud.pulledTo())
331
+ if (!rows.length) return
332
+ apply(rows)
333
+ cloud.setPulledTo(rows[rows.length - 1].seq)
334
+ state.pulled += rows.length
335
+ if (rows.length < 500) return
336
+ }
337
+ }
338
+
339
+ /** One round. Never throws: a sync that failed says so in `status()` and tries again next time. */
340
+ export async function round() {
341
+ if (state.running || !cloud.enabled() || !cloud.linked()) return status()
342
+ state.running = true
343
+ try {
344
+ // Who this is is asked again every ten minutes, not only once: an account can be let in, or
345
+ // shut out, from the other side, and the board should notice without a restart.
346
+ if (!cloud.me() || Date.now() - state.askedMe > 10 * 60_000) {
347
+ await cloud.refreshMe()
348
+ state.askedMe = Date.now()
349
+ }
350
+ if (!cloud.approved()) {
351
+ // Signed in, waiting to be let in: ask again now and then, and do nothing else.
352
+ await cloud.refreshMe()
353
+ if (!cloud.approved()) return status()
354
+ }
355
+ if (!attach()) return status()
356
+ await giveRealKeys()
357
+ await sendUp()
358
+ await bringDown()
359
+ state.last = Date.now()
360
+ state.error = null
361
+ } catch (err) {
362
+ state.error = err.offline ? 'Offline — the board carries on and catches up when k0 Cloud is back.' : String(err.message || err)
363
+ } finally {
364
+ state.running = false
365
+ }
366
+ return status()
367
+ }
368
+
369
+ let timer = null
370
+
371
+ /** Every few seconds while the server runs. Unref'd: the sync never keeps k0 alive by itself. */
372
+ export function start(every = 5000) {
373
+ if (timer || !cloud.enabled()) return
374
+ if (cloud.linked() && cloud.approved()) attach()
375
+ timer = setInterval(() => round(), every)
376
+ timer.unref()
377
+ round()
378
+ }
379
+
380
+ export function stop() {
381
+ if (timer) clearInterval(timer)
382
+ timer = null
383
+ }
package/server/update.js CHANGED
@@ -4,9 +4,10 @@ import { getPref, setPref } from './db.js'
4
4
  import { ROOT } from './paths.js'
5
5
  import * as settings from './settings.js'
6
6
 
7
- // ── The one request k0 makes ─────────────────────────────────────────────────
7
+ // ── One of the two places k0 reaches outside ─────────────────────────────────
8
8
  // Everywhere else in k0 the answer to "shall we ask the internet" is no: no fonts fetched, no
9
- // telemetry, no `git fetch`. This file is the single exception, and what it does is written out
9
+ // telemetry, no `git fetch`. There are two exceptions — this file, and `server/cloud.js`, which
10
+ // keeps the board in sync with k0 Cloud — and what this one does is written out
10
11
  // here rather than left in a commit message, because it is a promise a sceptical reader should
11
12
  // be able to check by reading one screen.
12
13
  //