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/CHANGELOG.md +66 -1
- package/LICENSE +83 -18
- package/README.md +238 -36
- package/bin/k0-board.js +48 -1
- package/package.json +2 -2
- package/server/cloud.js +355 -0
- package/server/db.js +375 -58
- package/server/index.js +92 -2
- package/server/mirror.js +21 -0
- package/server/sync.js +383 -0
- package/server/update.js +3 -2
- package/web/account.js +265 -0
- package/web/base.css +93 -0
- package/web/index.html +1 -0
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({
|
|
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({
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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
|
-
// ──
|
|
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`.
|
|
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
|
//
|