@volter/supercode-orchestrator 0.5.84 → 0.5.86
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/board/git-document.mjs +476 -115
- package/board/ztrack.mjs +11 -3
- package/package.json +1 -1
package/board/git-document.mjs
CHANGED
|
@@ -7,55 +7,250 @@
|
|
|
7
7
|
// either side wrote is dropped; a merge that cannot be made stops the push and says why, leaving both texts in the
|
|
8
8
|
// checkout's history.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
// on
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// Git is publication, not durability: the board's store and its documents are the record, and a write answers once they
|
|
11
|
+
// are written. Nothing here runs on the caller's loop or waits on it (t_00f071f4: a busy index once held the board
|
|
12
|
+
// owner's loop 5 s). Every git step of a checkout runs beside the process, one at a time on that checkout's chain
|
|
13
|
+
// (`git` through execFile): a write's commit, a fetch's merge, a push check. Those are still git processes, one set per
|
|
14
|
+
// write, off the loop (gap A41). What is owed survives in git's own state: board documents that differ from HEAD are a
|
|
15
|
+
// commit owed, and a HEAD ahead of its upstream is a push owed. In memory is only each owed write's own text, in order,
|
|
16
|
+
// so that each commit holds its write and no other. A commit needs no lock but HEAD's: HEAD moves by compare-and-swap,
|
|
17
|
+
// and the checkout's index follows it after (never ahead), a busy index tried again on a timer and said once it has
|
|
18
|
+
// held the entries a minute. A process that ends with anything owed leaves it in the checkout, and the next one to open
|
|
19
|
+
// each board derives it again and does it (`derive`: documents that differ from HEAD committed, the board documents'
|
|
20
|
+
// index entries moved to HEAD, an unpushed HEAD pushed).
|
|
21
|
+
//
|
|
22
|
+
// Only the board writes its documents. A write's commit is made from that write's own text, through an index of its
|
|
23
|
+
// own (never the checkout's staged files, never a later write's text). What a fetch brings is merged without touching
|
|
24
|
+
// the working tree (`git merge-tree`), a board document both sides changed merged by the board's own document merge;
|
|
25
|
+
// the merged documents are handed to their boards (every board this process holds in the checkout), each of which
|
|
26
|
+
// writes its own as an edit in its next transaction only if each is still what HEAD holds (a write owed goes first,
|
|
27
|
+
// and the next fetch takes what was pushed again); then git's HEAD and index move to the merge. A write that lands
|
|
28
|
+
// between stays a commit owed, never lost.
|
|
29
|
+
// Where the checkout is, its branch and its per-worktree git paths (what `git rev-parse --git-path` names: FETCH_HEAD
|
|
30
|
+
// and the push error live in a linked worktree's own git dir) are read from `.git` itself, so a board open starts no
|
|
31
|
+
// process. A write does start its commit's git processes, beside the loop (A41).
|
|
16
32
|
import { current } from './call.mjs';
|
|
17
33
|
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
18
|
-
import { readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { basename, dirname, relative } from 'node:path';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
import { readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
36
|
+
|
|
37
|
+
/** git beside the process: its stdout, or a rejection carrying git's stdout and stderr. `input` is written to its stdin;
|
|
38
|
+
* `env` is added to this process's. */
|
|
39
|
+
const git = (cwd, args, timeout = 120_000, { input = null, env = null } = {}) => new Promise((done, fail) => {
|
|
40
|
+
const child = execFile('git', args, { cwd, encoding: 'utf8', timeout, maxBuffer: 64 * 1024 * 1024, windowsHide: true, ...(env ? { env: { ...process.env, ...env } } : {}) }, (error, stdout, stderr) => {
|
|
41
|
+
if (error) fail(Object.assign(error, { stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })); else done(String(stdout));
|
|
42
|
+
});
|
|
43
|
+
if (input !== null) child.stdin.end(input); else child.stdin.end();
|
|
44
|
+
});
|
|
45
|
+
const quiet = (cwd, args, timeout) => git(cwd, args, timeout).catch(() => null);
|
|
46
|
+
const busyIndex = (error) => /index\.lock/.test(String(error?.stderr ?? error?.message ?? ''));
|
|
47
|
+
// a ref git would not move: its lock held by another git step, or HEAD moving under the commit on every try
|
|
48
|
+
const refHeld = (error) => Boolean(error?.refHeld) || /\.lock\b|cannot lock ref|unable to lock/i.test(`${error?.stderr ?? ''} ${error?.message ?? ''}`);
|
|
49
|
+
|
|
50
|
+
/** The checkout `dir` is in, read from its `.git` (a folder, or a linked worktree's `gitdir:` file): its work tree root,
|
|
51
|
+
* its git dir and its common dir. Null outside a checkout. */
|
|
52
|
+
function checkoutOf(dir) {
|
|
53
|
+
let at; try { at = realpathSync(dir); } catch { return null; }
|
|
54
|
+
for (;;) {
|
|
55
|
+
const dotgit = join(at, '.git');
|
|
56
|
+
let stat = null; try { stat = statSync(dotgit); } catch { /* not here */ }
|
|
57
|
+
if (stat) {
|
|
58
|
+
let gitDir = dotgit;
|
|
59
|
+
if (stat.isFile()) { const named = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotgit, 'utf8')); if (!named) return null; gitDir = resolve(at, named[1].trim()); }
|
|
60
|
+
let common = gitDir; try { common = resolve(gitDir, readFileSync(join(gitDir, 'commondir'), 'utf8').trim()); } catch { /* its own */ }
|
|
61
|
+
return { root: at, gitDir, common };
|
|
30
62
|
}
|
|
63
|
+
const up = dirname(at); if (up === at) return null; at = up;
|
|
31
64
|
}
|
|
32
|
-
}
|
|
65
|
+
}
|
|
33
66
|
|
|
34
67
|
const FETCH_EVERY_MS = 15_000;
|
|
35
|
-
// A fetch beside the process, the network's part: its result is merged (
|
|
36
|
-
const fetchAside = (cwd, branch, timeout = 120_000) => new Promise((
|
|
68
|
+
// A fetch beside the process, the network's part: its result is merged (on the checkout's chain) when it lands.
|
|
69
|
+
const fetchAside = (cwd, branch, timeout = 120_000) => new Promise((done) => execFile('git', ['fetch', '--quiet', 'origin', branch], { cwd, timeout }, (error) => done(!error)));
|
|
37
70
|
const fetching = new Map(); // checkout root → the fetch in flight (one at a time per checkout, in this process)
|
|
71
|
+
const chains = new Map(); // checkout root → its git steps, one at a time
|
|
38
72
|
const derived = new Set(); // boards (by their open document) whose owed commit this process has derived from git's state
|
|
39
73
|
const pushDerived = new Set(); // checkouts whose owed push this process has derived
|
|
74
|
+
const boardsAt = new Map(); // checkout root → the boards (GitDocument) this process holds in it
|
|
75
|
+
const takings = new Map(); // checkout root → what a fetch brought, with its boards until each has written its part
|
|
40
76
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
77
|
+
/** One git step of a checkout, after the ones before it; its failure is said, never thrown into the caller. */
|
|
78
|
+
function onChain(root, step) {
|
|
79
|
+
const previous = chains.get(root) ?? Promise.resolve();
|
|
80
|
+
const next = previous.then(step).catch((error) => { current().err(`board: ${error.message}\n`); });
|
|
81
|
+
chains.set(root, next);
|
|
82
|
+
next.finally(() => { if (chains.get(root) === next) chains.delete(root); });
|
|
83
|
+
return next;
|
|
45
84
|
}
|
|
85
|
+
|
|
86
|
+
/** How long a process that is ending waits for its git steps to land. What is still owed then stays in git's state (a
|
|
87
|
+
* document that differs from HEAD, a HEAD ahead of its upstream) for the next process to derive. */
|
|
88
|
+
const SETTLE_MS = 10_000;
|
|
89
|
+
|
|
90
|
+
/** Every fetch and git step in flight, landed, until none is left or SETTLE_MS has passed: a process that exits after a
|
|
91
|
+
* write awaits this first. A commit waiting out a busy index is not waited for: it stays owed, said. */
|
|
92
|
+
export async function settleDocuments(within = SETTLE_MS) {
|
|
93
|
+
const deadline = Date.now() + within;
|
|
94
|
+
// until none is left: a step that ran may have queued another (a commit its push follows); a retry whose timer fires
|
|
95
|
+
// meanwhile joins the chain and is waited for too
|
|
96
|
+
while (fetching.size || chains.size) {
|
|
97
|
+
const left = deadline - Date.now();
|
|
98
|
+
if (left <= 0) { current().err(`board: ${chains.size + fetching.size} git step(s) of the board documents were still running as this process ended; what they owed stays in the checkout for the next process\n`); break; }
|
|
99
|
+
await Promise.race([Promise.all([...fetching.values(), ...chains.values()]), new Promise((done) => setTimeout(done, left).unref?.())]);
|
|
100
|
+
}
|
|
101
|
+
// then what still waits on a timer, said: after the chains, so a retry a step set while settling is said too
|
|
102
|
+
for (const boards of boardsAt.values()) for (const board of boards) if (board.retryTimer) {
|
|
103
|
+
clearTimeout(board.retryTimer); board.retryTimer = null;
|
|
104
|
+
current().err(`board: ${board.owed.length} write${board.owed.length === 1 ? '' : 's'} of the board documents not committed as this process ends (git would not move HEAD); ${board.owed.length === 1 ? 'it stays' : 'they stay'} owed in the checkout (documents that differ from HEAD) for the next process\n`);
|
|
105
|
+
}
|
|
106
|
+
for (const [root, moves] of filesOwed) if (moves.timer) { clearTimeout(moves.timer); moves.timer = null; current().err(`board: ${moves.files.size} file(s) in ${root} still differ from HEAD as this process ends (what was pushed, not yet written there); the next process to open a board there writes them\n`); }
|
|
107
|
+
for (const [root, sync] of indexOwed) if (sync.timer) {
|
|
108
|
+
clearTimeout(sync.timer); sync.timer = null;
|
|
109
|
+
current().err(`board: git's index in ${root} is still locked as this process ends; its board documents' index entries stay behind HEAD until the next process opens the board\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** How long a busy index may hold an index sync before it is said (with its age), once per stretch. */
|
|
113
|
+
const BUSY_SAID_MS = 60_000;
|
|
114
|
+
// checkout root → { paths, since, said, delay, timer }: board documents whose entries in the checkout's index are to be
|
|
115
|
+
// moved to HEAD (after a commit or a take moved HEAD), waiting out a busy index.
|
|
116
|
+
const indexOwed = new Map();
|
|
117
|
+
|
|
118
|
+
/** The checkout's index told these paths are as HEAD has them (a path HEAD lacks leaves the index), on the chain. HEAD
|
|
119
|
+
* moves first, by compare-and-swap; the index follows, so it is never ahead of HEAD. A busy index is tried again on a
|
|
120
|
+
* timer, from 250 ms doubling to 30 s, and said with its age once it has held the sync a minute. */
|
|
121
|
+
function syncIndex(root, paths) {
|
|
122
|
+
const sync = indexOwed.get(root) ?? { paths: new Set(), since: null, said: false, delay: 0, timer: null };
|
|
123
|
+
indexOwed.set(root, sync);
|
|
124
|
+
for (const path of paths) sync.paths.add(path);
|
|
125
|
+
if (!sync.timer) onChain(root, () => syncNow(root));
|
|
126
|
+
}
|
|
127
|
+
async function syncNow(root) {
|
|
128
|
+
const sync = indexOwed.get(root);
|
|
129
|
+
if (!sync || !sync.paths.size) return;
|
|
130
|
+
const paths = [...sync.paths];
|
|
131
|
+
try { await git(root, ['reset', '-q', 'HEAD', '--', ...paths]); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!busyIndex(error)) { indexOwed.delete(root); throw new Error(`the index entries of ${paths.slice(0, 3).join(', ')} could not be moved to HEAD: ${error.message.split('\n')[0]}`); }
|
|
134
|
+
sync.since ??= Date.now();
|
|
135
|
+
if (!sync.said && Date.now() - sync.since >= BUSY_SAID_MS) {
|
|
136
|
+
sync.said = true;
|
|
137
|
+
current().err(`board: git's index in ${root} has been locked for ${Math.round((Date.now() - sync.since) / 1000)} s (its index.lock: another git process, or one that ended holding it); ${paths.length} board document index entr${paths.length === 1 ? 'y stays' : 'ies stay'} behind HEAD until it frees\n`);
|
|
138
|
+
}
|
|
139
|
+
sync.delay = Math.min(30_000, Math.max(250, (sync.delay || 125) * 2));
|
|
140
|
+
sync.timer = setTimeout(() => { sync.timer = null; onChain(root, () => syncNow(root)); }, sync.delay);
|
|
141
|
+
sync.timer.unref?.();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const path of paths) sync.paths.delete(path);
|
|
145
|
+
if (!sync.paths.size) indexOwed.delete(root);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// checkout root → { files: Map(path → { from, to }), since, said, delay, timer }: working files a take moved HEAD past,
|
|
149
|
+
// to be written from `to` (or removed, when `to` lacks them), each only while it is still `from`'s text.
|
|
150
|
+
const filesOwed = new Map();
|
|
151
|
+
|
|
152
|
+
/** The working files a take changed, brought to what HEAD now holds, on the chain: each only while the file is still
|
|
153
|
+
* what the commit before the take held (a file changed here since is left, said). One that fails is tried again on a
|
|
154
|
+
* timer, said with its age after a minute. */
|
|
155
|
+
function moveFiles(root, files, from, to) {
|
|
156
|
+
const moves = filesOwed.get(root) ?? { files: new Map(), since: null, said: false, delay: 0, timer: null };
|
|
157
|
+
filesOwed.set(root, moves);
|
|
158
|
+
// a file a second take moves again keeps the base of the first: its working text is still that one's until written
|
|
159
|
+
for (const file of files) moves.files.set(file, { from: moves.files.get(file)?.from ?? from, to });
|
|
160
|
+
keepMoves(root);
|
|
161
|
+
if (!moves.timer) onChain(root, () => moveNow(root));
|
|
162
|
+
}
|
|
163
|
+
/** The moves still owed, kept in the checkout's git dir until each is written: a process that ends first leaves them for
|
|
164
|
+
* the next one to open a board there (`derive`). */
|
|
165
|
+
const movesPath = (root) => { const checkout = checkoutOf(root); return checkout ? join(checkout.gitDir, 'supercode-board-moves.json') : null; };
|
|
166
|
+
function keepMoves(root) {
|
|
167
|
+
const path = movesPath(root); if (!path) return;
|
|
168
|
+
const moves = filesOwed.get(root);
|
|
169
|
+
try {
|
|
170
|
+
if (!moves?.files.size) rmSync(path, { force: true });
|
|
171
|
+
else writeAtomically(path, JSON.stringify(Object.fromEntries(moves.files)));
|
|
172
|
+
} catch (error) { current().err(`board: the moves owed in ${root} could not be kept (${error.message}); a process that ends before they are written leaves those files differing from HEAD\n`); }
|
|
173
|
+
}
|
|
174
|
+
/** Moves an earlier process left owed, queued again (once per checkout per process). */
|
|
175
|
+
function deriveMoves(root) {
|
|
176
|
+
const path = movesPath(root); if (!path) return;
|
|
177
|
+
let kept = null; try { kept = JSON.parse(readFileSync(path, 'utf8')); } catch { return; }
|
|
178
|
+
for (const [file, { from, to }] of Object.entries(kept ?? {})) if (from && to) moveFiles(root, [file], from, to);
|
|
179
|
+
}
|
|
180
|
+
async function moveNow(root) {
|
|
181
|
+
const moves = filesOwed.get(root);
|
|
182
|
+
if (!moves) return;
|
|
183
|
+
let failed = null;
|
|
184
|
+
for (const [file, { from, to }] of [...moves.files]) {
|
|
185
|
+
try {
|
|
186
|
+
const was = (await quiet(root, ['rev-parse', '-q', '--verify', `${from}:${file}`]))?.trim() || null;
|
|
187
|
+
let exists = true; try { statSync(join(root, file)); } catch { exists = false; }
|
|
188
|
+
const now = exists ? (await git(root, ['hash-object', '--', file])).trim() : null;
|
|
189
|
+
if (now !== was) {
|
|
190
|
+
// already what the take brought (written before a process ended): done; else changed here since, left and said
|
|
191
|
+
const brought = (await quiet(root, ['rev-parse', '-q', '--verify', `${to}:${file}`]))?.trim() || null;
|
|
192
|
+
if (now !== brought) current().err(`board: ${file} in ${root} changed here since what was pushed was merged; it is left as it is (HEAD has the pushed text)\n`);
|
|
193
|
+
// written before a process ended whose index sync was still owed: the index entry follows HEAD now (a file HEAD
|
|
194
|
+
// lacks leaves the index, so it is not left staged against HEAD)
|
|
195
|
+
else await git(root, ['reset', '-q', 'HEAD', '--', file]);
|
|
196
|
+
moves.files.delete(file); continue;
|
|
197
|
+
}
|
|
198
|
+
const kept = Boolean((await quiet(root, ['rev-parse', '-q', '--verify', `${to}:${file}`]))?.trim());
|
|
199
|
+
if (kept) await git(root, ['checkout', to, '--', file]);
|
|
200
|
+
else {
|
|
201
|
+
// the index entry first (a busy index retries with the file still in place), then the file
|
|
202
|
+
await git(root, ['rm', '--cached', '-q', '--ignore-unmatch', '--', file]);
|
|
203
|
+
rmSync(join(root, file), { force: true });
|
|
204
|
+
}
|
|
205
|
+
moves.files.delete(file);
|
|
206
|
+
} catch (error) { failed ??= error; }
|
|
207
|
+
}
|
|
208
|
+
keepMoves(root);
|
|
209
|
+
if (!moves.files.size) { filesOwed.delete(root); return; }
|
|
210
|
+
moves.since ??= Date.now();
|
|
211
|
+
if (!moves.said && Date.now() - moves.since >= BUSY_SAID_MS) {
|
|
212
|
+
moves.said = true;
|
|
213
|
+
current().err(`board: ${moves.files.size} file(s) in ${root} have differed from HEAD for ${Math.round((Date.now() - moves.since) / 1000)} s since what was pushed was taken (${failed?.message.split('\n')[0]}); tried again until written\n`);
|
|
214
|
+
}
|
|
215
|
+
moves.delay = Math.min(30_000, Math.max(250, (moves.delay || 125) * 2));
|
|
216
|
+
moves.timer = setTimeout(() => { moves.timer = null; onChain(root, () => moveNow(root)); }, moves.delay);
|
|
217
|
+
moves.timer.unref?.();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Whether this git can merge without a working tree (`merge-tree --write-tree`, git 2.38): asked once per process,
|
|
221
|
+
* beside the loop, on the first take; an older git is said once and nothing pushed is taken. */
|
|
222
|
+
let mergeTreeAble = null;
|
|
223
|
+
async function canMergeTree(root) {
|
|
224
|
+
if (mergeTreeAble === null) {
|
|
225
|
+
const version = /(\d+)\.(\d+)/.exec((await quiet(root, ['version'])) ?? '');
|
|
226
|
+
mergeTreeAble = Boolean(version) && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 38));
|
|
227
|
+
if (!mergeTreeAble) current().err(`board: this git (${version ? version[0] : 'its version unread'}) is older than 2.38 and cannot merge without a working tree; what others pushed to the board documents is not taken until git is updated\n`);
|
|
228
|
+
}
|
|
229
|
+
return mergeTreeAble;
|
|
230
|
+
}
|
|
231
|
+
|
|
46
232
|
/** The background push: `git push`, then its error kept for `behind()` (or the last one cleared when it landed). */
|
|
47
233
|
const PUSH = `const {spawnSync}=require('node:child_process'),fs=require('node:fs');const [branch,failure,temporary]=process.argv.slice(1);
|
|
48
234
|
const r=spawnSync('git',['push','--quiet','origin','HEAD:'+branch],{encoding:'utf8',windowsHide:true});
|
|
49
235
|
try{if(r.status===0)fs.rmSync(failure,{force:true});else{fs.writeFileSync(temporary,String(r.stderr||r.error||'push failed'));fs.renameSync(temporary,failure);}}catch{}`;
|
|
50
|
-
/** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
|
|
51
|
-
const fetchedAt = (root) => { try { return statSync(git(root, ['rev-parse', '--git-path', 'FETCH_HEAD']).trim().replace(/^(?!\/)/, `${root}/`)).mtimeMs; } catch { return 0; } };
|
|
52
236
|
|
|
53
237
|
export class GitDocument {
|
|
54
238
|
constructor(paths, merge) {
|
|
55
239
|
this.paths = paths;
|
|
56
240
|
this.cwd = dirname(paths[0]);
|
|
57
|
-
this.
|
|
241
|
+
this.checkout = checkoutOf(this.cwd);
|
|
242
|
+
this.root = this.checkout?.root ?? null;
|
|
58
243
|
this.merge = merge; // (base, theirs, ours, path) => text | null, ztrack's mergeBoardDocument
|
|
244
|
+
this.owed = []; // the writes whose commit is owed: { doer, message, files }
|
|
245
|
+
if (this.root !== null) { if (!boardsAt.has(this.root)) boardsAt.set(this.root, new Set()); boardsAt.get(this.root).add(this); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The board closed: it is no longer one of its checkout's boards, and a take still waiting on it is given up. */
|
|
249
|
+
release() {
|
|
250
|
+
this.onTaken = null;
|
|
251
|
+
boardsAt.get(this.root)?.delete(this);
|
|
252
|
+
const taking = takings.get(this.root);
|
|
253
|
+
if (taking?.handed.has(this) && !taking.applied.has(this)) abandon(taking);
|
|
59
254
|
}
|
|
60
255
|
|
|
61
256
|
/** Whether this board's owed commit is still to be derived in this process (its board calls `derive` after its first
|
|
@@ -69,25 +264,36 @@ export class GitDocument {
|
|
|
69
264
|
if (!this.usable) return;
|
|
70
265
|
derived.add(this.paths[0]);
|
|
71
266
|
this.commitDirty('board: documents written before this process started');
|
|
267
|
+
// an earlier process that moved HEAD and ended before the index followed: the entries are moved now
|
|
268
|
+
syncIndex(this.root, this.paths.map((path) => this.rel(path)));
|
|
72
269
|
if (pushDerived.has(this.root)) return;
|
|
73
270
|
pushDerived.add(this.root);
|
|
271
|
+
deriveMoves(this.root);
|
|
74
272
|
const branch = this.branch();
|
|
75
|
-
if (branch
|
|
273
|
+
if (branch) onChain(this.root, async () => { if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch); });
|
|
76
274
|
}
|
|
77
275
|
|
|
78
|
-
/** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's)
|
|
276
|
+
/** This board's documents that differ from HEAD, committed as the board (a recovery's writes are no doer's), with
|
|
277
|
+
* their text as it is now: a later write's text is that write's own commit, never this one's. */
|
|
79
278
|
commitDirty(message) {
|
|
80
279
|
if (!this.usable) return;
|
|
81
|
-
|
|
280
|
+
this.commit(null, message, this.paths.map((path) => ({ path, text: readText(path) })));
|
|
82
281
|
}
|
|
83
282
|
|
|
84
283
|
get usable() { return this.root !== null; }
|
|
85
|
-
rel(path) {
|
|
86
|
-
|
|
284
|
+
rel(path) {
|
|
285
|
+
let real = path; try { real = join(realpathSync(dirname(path)), basename(path)); } catch { /* not written yet */ }
|
|
286
|
+
return relative(this.root, real);
|
|
287
|
+
}
|
|
288
|
+
/** The checkout's branch, or null on no branch (a detached HEAD has nowhere to push and nothing to pull): its HEAD file. */
|
|
87
289
|
branch() {
|
|
88
|
-
const
|
|
89
|
-
return name && name !== 'HEAD' ? name : null;
|
|
290
|
+
try { const head = readFileSync(join(this.checkout.gitDir, 'HEAD'), 'utf8').trim(); return /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null; } catch { return null; }
|
|
90
291
|
}
|
|
292
|
+
/** When this checkout last asked its remote (git writes FETCH_HEAD on every fetch, whichever process ran it). */
|
|
293
|
+
// FETCH_HEAD is the checkout's own (a linked worktree's in its git dir, as `git rev-parse --git-path` says), and so is
|
|
294
|
+
// the push error this keeps beside it.
|
|
295
|
+
fetchedAt() { try { return statSync(join(this.checkout.gitDir, 'FETCH_HEAD')).mtimeMs; } catch { return 0; } }
|
|
296
|
+
failurePath() { return join(this.checkout.gitDir, 'supercode-board-push.err'); }
|
|
91
297
|
|
|
92
298
|
/** Said once per process: a checkout on no branch keeps its commits here (every write tried to push `HEAD:HEAD`
|
|
93
299
|
* three times, with a fetch and a merge between, and failed: seconds of network on each write, t_23e47b73). */
|
|
@@ -96,67 +302,127 @@ export class GitDocument {
|
|
|
96
302
|
this.warnedDetached = true;
|
|
97
303
|
}
|
|
98
304
|
|
|
99
|
-
// Before a write: take what was pushed, so a merged pull request's edit
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// not land (refused: something was pushed meanwhile) takes what was pushed now and pushes before writing again.
|
|
305
|
+
// Before a write: take what was pushed, so a merged pull request's edit reaches the document. The remote is asked at
|
|
306
|
+
// most every FETCH_EVERY_MS (git's own FETCH_HEAD is the stamp, so every board process shares it), beside the write:
|
|
307
|
+
// what it brings is merged on the checkout's chain when it lands, and the board's next read takes the document.
|
|
103
308
|
pull() {
|
|
104
309
|
if (!this.usable) return;
|
|
105
|
-
|
|
106
|
-
// not even the branch, so a round no longer spends seconds on git subprocesses that decide nothing.
|
|
107
|
-
if (Date.now() - fetchedAt(this.root) < FETCH_EVERY_MS) return;
|
|
310
|
+
if (Date.now() - this.fetchedAt() < FETCH_EVERY_MS) return;
|
|
108
311
|
const branch = this.branch();
|
|
109
312
|
if (!branch) return this.detached();
|
|
110
313
|
this.fetchThenTake(branch);
|
|
111
314
|
}
|
|
112
315
|
|
|
113
|
-
/** The fetch beside the process; when it lands, what was pushed is taken (take)
|
|
316
|
+
/** The fetch beside the process; when it lands, what was pushed is taken (take) on the chain. One at a time. */
|
|
114
317
|
fetchThenTake(branch, timeout) {
|
|
115
318
|
if (fetching.has(this.root)) return fetching.get(this.root);
|
|
116
319
|
const root = this.root;
|
|
117
320
|
const landing = fetchAside(root, branch, timeout).then((fetched) => {
|
|
118
321
|
if (!fetched) return; // offline: a later write asks again
|
|
119
|
-
|
|
322
|
+
return onChain(root, () => this.take(branch));
|
|
120
323
|
}).finally(() => fetching.delete(root));
|
|
121
324
|
fetching.set(root, landing);
|
|
122
325
|
return landing;
|
|
123
326
|
}
|
|
124
327
|
|
|
125
|
-
/** After a fetch: what was pushed
|
|
126
|
-
*
|
|
127
|
-
take(branch) {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
if (this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
|
|
328
|
+
/** After a fetch: what was pushed taken (integrate), then a push of what this checkout holds that the remote lacks,
|
|
329
|
+
* beside the process. */
|
|
330
|
+
async take(branch) {
|
|
331
|
+
const head = (await git(this.root, ['rev-parse', 'HEAD'])).trim();
|
|
332
|
+
const theirs = (await git(this.root, ['rev-parse', `origin/${branch}`])).trim();
|
|
333
|
+
const behind = Number((await git(this.root, ['rev-list', '--count', `${head}..${theirs}`])).trim());
|
|
334
|
+
if (behind > 0) await this.integrate(branch, head, theirs);
|
|
335
|
+
if (await this.unpushed(branch) && !this.pushing) this.pushInBackground(branch);
|
|
135
336
|
this.refreshLateness();
|
|
136
337
|
}
|
|
137
338
|
|
|
138
|
-
// After a write: commit the
|
|
139
|
-
|
|
339
|
+
// After a write: its commit is owed, made from the write's own text (`files`, its rendered documents; a recovery's or
|
|
340
|
+
// an earlier process's has none, and commits the documents as they are), as its doer, then pushed, beside the process.
|
|
341
|
+
commit(doer, message, files = null) {
|
|
140
342
|
if (!this.usable) return;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
343
|
+
this.owed.push({ doer: doer || null, message, files: files ? files.map((file) => ({ path: file.path, text: file.text })) : null });
|
|
344
|
+
if (this.owed.length === 1) this.owedSince = Date.now();
|
|
345
|
+
if (this.owed.length === 1 && !this.retryTimer) onChain(this.root, () => this.commitOwed());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Each owed write committed in order, each as its own commit (none needs the checkout's index: a busy one holds
|
|
349
|
+
* only the index's following, syncIndex). */
|
|
350
|
+
async commitOwed() {
|
|
351
|
+
let made = 0, held = null;
|
|
352
|
+
while (this.owed.length) {
|
|
353
|
+
try { if (await this.commitOne(this.owed[0])) made += 1; }
|
|
354
|
+
catch (error) {
|
|
355
|
+
// a ref git holds locked (HEAD.lock, a branch's lock), or a HEAD that kept moving: the write stays owed, in order
|
|
356
|
+
if (refHeld(error)) { held = error; break; }
|
|
357
|
+
// said, and the writes after it still committed: its documents stay owed in git's state (they differ from HEAD)
|
|
358
|
+
current().err(`board: a write of the board documents could not be committed (${error.message.split('\n')[0]}); its documents stay owed in the checkout\n`);
|
|
359
|
+
}
|
|
360
|
+
this.owed.shift();
|
|
361
|
+
}
|
|
362
|
+
if (held) this.waitOutRef(held);
|
|
363
|
+
else { this.owedSince = null; this.refSince = null; this.refSaid = false; this.refDelay = 0; }
|
|
149
364
|
this.refreshLateness();
|
|
365
|
+
if (!made) return;
|
|
150
366
|
const branch = this.branch();
|
|
151
367
|
if (!branch) return this.detached();
|
|
152
|
-
// The push leaves the write's path: the commit is the write, and a push the remote refuses is taken
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
// Writes that land while it runs are one more push after it (a burst publishes in two pushes, not one per write).
|
|
368
|
+
// The push leaves the write's path: the commit is the write's publication, and a push the remote refuses is taken
|
|
369
|
+
// up by the next fetch (unpushed commits), which merges what was pushed and pushes. While a push runs, the next
|
|
370
|
+
// commit does not push the same commits beside it: one more push after it (a burst publishes in two pushes).
|
|
156
371
|
if (this.pushing) { this.again = true; return; }
|
|
157
372
|
this.pushInBackground(branch);
|
|
158
373
|
}
|
|
159
374
|
|
|
375
|
+
/** git would not move HEAD (its ref locked, or moving under the commit): the owed writes are tried again on a timer,
|
|
376
|
+
* from 250 ms doubling to 30 s, and said with how long once they have waited a minute. */
|
|
377
|
+
waitOutRef(error) {
|
|
378
|
+
this.refSince ??= Date.now();
|
|
379
|
+
if (!this.refSaid && Date.now() - this.refSince >= BUSY_SAID_MS) {
|
|
380
|
+
this.refSaid = true;
|
|
381
|
+
current().err(`board: ${this.owed.length} write${this.owed.length === 1 ? '' : 's'} of the board documents not committed for ${Math.round((Date.now() - this.refSince) / 1000)} s: git would not move HEAD in ${this.root} (${error.message.split('\n')[0]}); ${this.owed.length === 1 ? 'it stays' : 'they stay'} owed, tried again until it does\n`);
|
|
382
|
+
}
|
|
383
|
+
this.refDelay = Math.min(30_000, Math.max(250, (this.refDelay || 125) * 2));
|
|
384
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = null; onChain(this.root, () => this.commitOwed()); }, this.refDelay);
|
|
385
|
+
this.retryTimer.unref?.();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One write's commit: its documents' text written as blobs, through an index of this process's own read from HEAD
|
|
389
|
+
* (the checkout's staged files never join it), committed as its doer; HEAD moved only if it is where the commit was
|
|
390
|
+
* made (another commit came first: made again on the new HEAD), and then the checkout's index told these documents
|
|
391
|
+
* are as committed. Answers whether a commit was made. */
|
|
392
|
+
async commitOne(write, tries = 3) {
|
|
393
|
+
const { doer, message, files } = write;
|
|
394
|
+
const who = doer || 'board';
|
|
395
|
+
const texts = (files ?? this.paths.map((path) => ({ path, text: readText(path) }))).filter((file) => file.text !== null);
|
|
396
|
+
if (!texts.length) return false;
|
|
397
|
+
const head = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
|
|
398
|
+
const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
|
|
399
|
+
let moved = false;
|
|
400
|
+
try {
|
|
401
|
+
const own = { GIT_INDEX_FILE: index };
|
|
402
|
+
await git(this.root, head ? ['read-tree', head] : ['read-tree', '--empty'], 120_000, { env: own });
|
|
403
|
+
const entries = [];
|
|
404
|
+
for (const { path, text } of texts) {
|
|
405
|
+
const rel = this.rel(path);
|
|
406
|
+
const blob = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
|
|
407
|
+
entries.push({ rel, blob });
|
|
408
|
+
await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${blob},${rel}`], 120_000, { env: own });
|
|
409
|
+
}
|
|
410
|
+
const tree = (await git(this.root, ['write-tree'], 120_000, { env: own })).trim();
|
|
411
|
+
if (head && tree === (await git(this.root, ['rev-parse', `${head}^{tree}`])).trim()) return false;
|
|
412
|
+
const commit = (await git(this.root, ['commit-tree', tree, ...(head ? ['-p', head] : []), '-m', message], 120_000, { env: author(who) })).trim();
|
|
413
|
+
try { await git(this.root, ['update-ref', '-m', `board: ${message.split('\n')[0]}`, 'HEAD', commit, head ?? '0'.repeat(40)]); }
|
|
414
|
+
catch (error) {
|
|
415
|
+
const now = (await quiet(this.root, ['rev-parse', '--verify', '-q', 'HEAD']))?.trim() || null;
|
|
416
|
+
if (now === head) throw error; // HEAD where it was: its ref was refused (a lock), said by the caller
|
|
417
|
+
if (tries <= 1) throw Object.assign(new Error('HEAD moved under the commit on every try'), { refHeld: true });
|
|
418
|
+
moved = true;
|
|
419
|
+
}
|
|
420
|
+
if (!moved) syncIndex(this.root, entries.map(({ rel }) => rel));
|
|
421
|
+
} finally { rmSync(index, { force: true }); }
|
|
422
|
+
// HEAD moved under it (another commit, or a take): the same write made again on the new HEAD
|
|
423
|
+
return moved ? this.commitOne(write, tries - 1) : true;
|
|
424
|
+
}
|
|
425
|
+
|
|
160
426
|
/** `git push` beside the process, outliving it. Its refusal is kept where `behind()` reads it, and cleared by a push
|
|
161
427
|
* that lands. */
|
|
162
428
|
pushInBackground(branch) {
|
|
@@ -175,86 +441,181 @@ export class GitDocument {
|
|
|
175
441
|
pushing.unref();
|
|
176
442
|
}
|
|
177
443
|
|
|
178
|
-
failurePath() {
|
|
179
|
-
return git(this.root, ['rev-parse', '--git-path', 'supercode-board-push.err']).trim().replace(/^(?!\/)/, `${this.root}/`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** The board documents' publish when it is behind: commits the remote lacks, the oldest one's time and the last push's
|
|
183
|
-
* error; null when the remote has everything (or this checkout pushes nowhere). Local reads only, no network. */
|
|
184
444
|
/** A held board's lateness: read once beside the owner's loop, then again after each of its own commits, pushes and
|
|
185
445
|
* takes (refreshLateness), never by spawning git on an open. `lateness` is that reading (null while none is owed). */
|
|
186
446
|
holdLateness() { this.heldLate = true; this.lateness = null; this.refreshLateness(); }
|
|
187
447
|
refreshLateness() {
|
|
188
448
|
if (!this.heldLate || !this.usable) return;
|
|
189
449
|
if (this.lateReading) { this.lateAgain = true; return; }
|
|
190
|
-
const root = this.root;
|
|
191
|
-
const run = (args) => new Promise((resolve) => execFile('git', args, { cwd: root, timeout: 30_000 }, (error, out) => resolve(error ? null : String(out))));
|
|
192
450
|
this.lateReading = (async () => {
|
|
193
|
-
const name = (
|
|
194
|
-
if (!name
|
|
195
|
-
const times = ((await
|
|
196
|
-
|
|
197
|
-
const
|
|
451
|
+
const name = this.branch();
|
|
452
|
+
if (!name) return null;
|
|
453
|
+
const times = ((await quiet(this.root, ['log', '--format=%ct', `origin/${name}..HEAD`], 30_000)) ?? '').trim().split('\n').filter(Boolean).map(Number);
|
|
454
|
+
// writes not committed yet (a busy index holding them), as well as commits not pushed
|
|
455
|
+
const uncommitted = this.owed.length ? { writes: this.owed.length, since: this.owedSince ?? Date.now() } : null;
|
|
456
|
+
if (!times.length && !uncommitted) return null;
|
|
198
457
|
let error = null;
|
|
199
|
-
try { error =
|
|
200
|
-
return { commits: times.length, since: Math.min(...times) * 1000, error };
|
|
458
|
+
try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
|
|
459
|
+
return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted };
|
|
201
460
|
})().then((late) => { this.lateness = late; }, () => {}).finally(() => {
|
|
202
461
|
this.lateReading = null;
|
|
203
462
|
if (this.lateAgain) { this.lateAgain = false; this.refreshLateness(); }
|
|
204
463
|
});
|
|
205
464
|
}
|
|
206
465
|
|
|
466
|
+
/** The board documents' publish when it is behind, for a one-off board open (a CLI process, never the owner, whose
|
|
467
|
+
* boards are held and read `lateness`): commits the remote lacks, the oldest one's time and the last push's error, and
|
|
468
|
+
* documents that differ from HEAD (a commit owed: an earlier process ended, or a busy index held it, before it was
|
|
469
|
+
* made); null when nothing is owed (or this checkout pushes nowhere). Local reads only, no network. */
|
|
207
470
|
behind() {
|
|
208
471
|
if (!this.usable) return null;
|
|
209
472
|
const branch = this.branch();
|
|
210
473
|
if (!branch) return null;
|
|
211
|
-
const
|
|
212
|
-
|
|
474
|
+
const run = (args) => { try { return execFileSync('git', args, { cwd: this.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }); } catch { return ''; } };
|
|
475
|
+
const times = run(['log', '--format=%ct', `origin/${branch}..HEAD`]).trim().split('\n').filter(Boolean).map(Number);
|
|
476
|
+
const owed = run(['status', '--porcelain', '--', ...this.paths.map((path) => this.rel(path))]).trim().split('\n').filter(Boolean).length;
|
|
477
|
+
if (!times.length && !owed) return null;
|
|
213
478
|
let error = null;
|
|
214
479
|
try { error = readFileSync(this.failurePath(), 'utf8').split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).find(Boolean) ?? null; } catch { /* none recorded */ }
|
|
215
|
-
return { commits: times.length, since: Math.min(...times) * 1000, error };
|
|
480
|
+
return { commits: times.length, since: times.length ? Math.min(...times) * 1000 : null, error, uncommitted: owed ? { documents: owed, since: null } : null };
|
|
216
481
|
}
|
|
217
482
|
|
|
218
483
|
/** The board's round: publish what an earlier write committed and the remote has not taken (a refused or failed
|
|
219
|
-
* background push), with the same merge
|
|
484
|
+
* background push), with the same merge a write's pull uses; beside the round. */
|
|
220
485
|
publishPending() {
|
|
221
486
|
if (!this.usable || this.pushing) return;
|
|
222
487
|
const branch = this.branch();
|
|
223
|
-
if (!branch
|
|
224
|
-
|
|
225
|
-
|
|
488
|
+
if (!branch) return;
|
|
489
|
+
onChain(this.root, async () => {
|
|
490
|
+
// A remote that hangs costs this fetch 15 s, beside the round, never the round.
|
|
491
|
+
if (await this.unpushed(branch)) this.fetchThenTake(branch, 15_000);
|
|
492
|
+
});
|
|
226
493
|
}
|
|
227
494
|
|
|
228
495
|
/** Whether this checkout holds board commits the remote does not have. */
|
|
229
|
-
unpushed(branch) {
|
|
230
|
-
return Number(quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`])?.trim() || 0) > 0;
|
|
496
|
+
async unpushed(branch) {
|
|
497
|
+
return Number((await quiet(this.root, ['rev-list', '--count', `origin/${branch}..HEAD`]))?.trim() || 0) > 0;
|
|
231
498
|
}
|
|
232
499
|
|
|
233
|
-
// Take what was pushed
|
|
234
|
-
//
|
|
235
|
-
// conflict leaves the checkout as it was and says so.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
500
|
+
// Take what was pushed: merged without touching the working tree (`git merge-tree`), so a board write never meets a
|
|
501
|
+
// half-merged document; a board document both sides changed is merged by the board's own document merge, and any other
|
|
502
|
+
// conflict leaves the checkout as it was and says so. What changes a board document is handed to the board
|
|
503
|
+
// (applyTaken, in its next transaction); what changes only other files moves git at once (finishTake).
|
|
504
|
+
async integrate(branch, head, theirs) {
|
|
505
|
+
if (takings.has(this.root)) return; // one is with the boards already
|
|
506
|
+
if (!(await canMergeTree(this.root))) return;
|
|
507
|
+
// every board document this process holds in this checkout, by its path in the checkout, with its board
|
|
508
|
+
const own = new Map();
|
|
509
|
+
for (const board of boardsAt.get(this.root) ?? []) for (const path of board.paths) own.set(board.rel(path), { path, board });
|
|
510
|
+
const merged = await git(this.root, ['merge-tree', '--write-tree', '-z', head, theirs]).then(
|
|
511
|
+
(out) => ({ out, clean: true }),
|
|
512
|
+
(error) => (error.code === 1 && error.stdout ? { out: error.stdout, clean: false } : Promise.reject(error)));
|
|
513
|
+
const fields = merged.out.split('\0');
|
|
514
|
+
let tree = fields[0].trim();
|
|
515
|
+
const stages = new Map();
|
|
516
|
+
for (let i = 1; i < fields.length && fields[i] !== ''; i++) {
|
|
517
|
+
const entry = /^(\d+) ([0-9a-f]+) (\d)\t(.*)$/s.exec(fields[i]);
|
|
518
|
+
if (!entry) continue;
|
|
519
|
+
if (!stages.has(entry[4])) stages.set(entry[4], {});
|
|
520
|
+
stages.get(entry[4])[entry[3]] = entry[2];
|
|
243
521
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
522
|
+
if (!merged.clean) {
|
|
523
|
+
if (!stages.size) throw new Error(`what was pushed to ${branch} could not be taken: a conflict git names by no file; nothing was taken, and this checkout is as it was`);
|
|
524
|
+
const blob = async (oid) => (oid ? git(this.root, ['cat-file', 'blob', oid]) : '');
|
|
525
|
+
const index = join(this.checkout.gitDir, `supercode-board-index.${process.pid}`);
|
|
526
|
+
try {
|
|
527
|
+
const env = { GIT_INDEX_FILE: index };
|
|
528
|
+
await git(this.root, ['read-tree', tree], 120_000, { env });
|
|
529
|
+
for (const [file, stage] of stages) {
|
|
530
|
+
// stage 2 is this checkout's text and stage 3 the pushed one
|
|
531
|
+
const text = own.has(file) ? own.get(file).board.merge(await blob(stage[1]), await blob(stage[3]), await blob(stage[2]), own.get(file).path) : null;
|
|
532
|
+
if (text === null) throw new Error(`${basename(file)} could not be merged with what was pushed to ${branch}; nothing was taken, and this checkout is as it was`);
|
|
533
|
+
const oid = (await git(this.root, ['hash-object', '-w', '--stdin'], 120_000, { input: text })).trim();
|
|
534
|
+
await git(this.root, ['update-index', '--add', '--cacheinfo', `100644,${oid},${file}`], 120_000, { env });
|
|
535
|
+
}
|
|
536
|
+
tree = (await git(this.root, ['write-tree'], 120_000, { env })).trim();
|
|
537
|
+
} finally { rmSync(index, { force: true }); }
|
|
538
|
+
}
|
|
539
|
+
const commit = (await git(this.root, ['commit-tree', tree, '-p', head, '-p', theirs, '-m', `board: took what was pushed to ${branch}`], 120_000, { env: author('board') })).trim();
|
|
540
|
+
// without rename detection: a renamed file is its old path deleted and its new one added, both moved
|
|
541
|
+
const changed = (await git(this.root, ['diff', '--no-renames', '--name-only', '-z', head, commit])).split('\0').filter(Boolean);
|
|
542
|
+
const docs = changed.filter((file) => own.has(file));
|
|
543
|
+
const others = changed.filter((file) => !own.has(file));
|
|
544
|
+
if (others.length && (await git(this.root, ['status', '--porcelain', '--', ...others])).trim()) {
|
|
545
|
+
throw new Error(`what was pushed to ${branch} changes files this checkout has changed too (${others.slice(0, 3).join(', ')}); nothing was taken`);
|
|
546
|
+
}
|
|
547
|
+
const taking = { root: this.root, head, commit, others, docs, handed: new Map(), applied: new Set() };
|
|
548
|
+
if (!docs.length) return finishTake(taking);
|
|
549
|
+
for (const file of docs) {
|
|
550
|
+
const { path, board } = own.get(file);
|
|
551
|
+
if (!taking.handed.has(board)) taking.handed.set(board, []);
|
|
552
|
+
taking.handed.get(board).push({ path, base: (await quiet(this.root, ['show', `${head}:${file}`])) ?? '', text: (await quiet(this.root, ['show', `${commit}:${file}`])) ?? '' });
|
|
255
553
|
}
|
|
256
|
-
|
|
554
|
+
takings.set(this.root, taking);
|
|
555
|
+
for (const board of taking.handed.keys()) board.onTaken?.();
|
|
257
556
|
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* In the board's transaction, on its loop: what a fetch brought, written as the board's own edit (its stale read then
|
|
560
|
+
* takes it into the store), each document only if it is still what HEAD held when the merge was made. A write owed
|
|
561
|
+
* first drops it: that commit goes first, and the next fetch takes what was pushed again. Then git moves (finishTake).
|
|
562
|
+
*/
|
|
563
|
+
applyTaken() {
|
|
564
|
+
const taking = takings.get(this.root);
|
|
565
|
+
const mine = taking?.handed.get(this);
|
|
566
|
+
if (!mine || taking.applied.has(this)) return false;
|
|
567
|
+
// compared without line endings: a checkout with core.autocrlf holds CRLF where git's blob holds LF
|
|
568
|
+
if (mine.some(({ path, base }) => lf(readText(path) ?? '') !== lf(base))) { abandon(taking); return false; }
|
|
569
|
+
for (const { path, text } of mine) writeAtomically(path, text);
|
|
570
|
+
taking.applied.add(this);
|
|
571
|
+
// git moves once every board the merge changes has written its part
|
|
572
|
+
if (taking.applied.size === taking.handed.size) onChain(this.root, () => finishTake(taking));
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** A take given up (a board's document is no longer what HEAD held, or the board closed): what the boards already
|
|
578
|
+
* wrote of it is their edit, committed as owed; the next fetch merges what was pushed again, cleanly. */
|
|
579
|
+
function abandon(taking) {
|
|
580
|
+
if (takings.get(taking.root) === taking) takings.delete(taking.root);
|
|
581
|
+
for (const board of taking.applied) board.commitDirty('board: documents taken in part from what was pushed');
|
|
258
582
|
}
|
|
259
583
|
|
|
260
|
-
|
|
584
|
+
/** git moved to the merge. HEAD first, by compare-and-swap from where the merge was made; then the checkout's index
|
|
585
|
+
* follows for every path the merge changed (syncIndex), and the other files the merge changed are written (a file it
|
|
586
|
+
* deleted removed), only while unchanged here. Whatever fails before HEAD moves gives the take up (abandon: what the
|
|
587
|
+
* boards wrote is committed as owed); after, what is left is said and its index entries stay owed. */
|
|
588
|
+
async function finishTake(taking) {
|
|
589
|
+
const { root, head, commit, others, docs } = taking;
|
|
590
|
+
let moved = false;
|
|
591
|
+
try {
|
|
592
|
+
if ((await git(root, ['rev-parse', 'HEAD'])).trim() !== head) return abandon(taking); // a commit came first
|
|
593
|
+
if (others.length && (await git(root, ['status', '--porcelain', '--', ...others])).trim()) {
|
|
594
|
+
throw new Error(`what was pushed changes files this checkout has changed too (${others.slice(0, 3).join(', ')}); it is taken by a later fetch`);
|
|
595
|
+
}
|
|
596
|
+
await git(root, ['update-ref', '-m', 'board: took what was pushed', 'HEAD', commit, head]);
|
|
597
|
+
moved = true;
|
|
598
|
+
syncIndex(root, [...docs, ...others]);
|
|
599
|
+
if (others.length) moveFiles(root, others, head, commit);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
if (!moved) abandon(taking);
|
|
602
|
+
throw error;
|
|
603
|
+
} finally { if (takings.get(root) === taking) takings.delete(root); }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Text without CR before LF. */
|
|
607
|
+
const lf = (text) => text.replace(/\r\n/g, '\n');
|
|
608
|
+
|
|
609
|
+
/** A document's text, or null when it does not exist. */
|
|
610
|
+
function readText(path) { try { return readFileSync(path, 'utf8'); } catch { return null; } }
|
|
611
|
+
|
|
612
|
+
/** A document written whole, never half: beside it, then renamed over it. */
|
|
613
|
+
function writeAtomically(path, text) {
|
|
614
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
615
|
+
writeFileSync(temporary, text);
|
|
616
|
+
renameSync(temporary, path);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The board's commits are made as `who`. */
|
|
620
|
+
const author = (who) => ({ GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: 'board@supercode.invalid', GIT_COMMITTER_NAME: who, GIT_COMMITTER_EMAIL: 'board@supercode.invalid' });
|
|
621
|
+
|
package/board/ztrack.mjs
CHANGED
|
@@ -101,6 +101,8 @@ class ZtrackBoard {
|
|
|
101
101
|
// own work; the backing commits only the writes the manager does not make.
|
|
102
102
|
this.manager = manager;
|
|
103
103
|
this.git = commit ? new GitDocument(this.baseDocs.map((doc) => doc.path), (base, theirs, ours, path) => { try { return this.codec.mergeBoardDocument(base, theirs, ours, path)?.text ?? null; } catch { return null; } }) : null;
|
|
104
|
+
// what a fetch brought is written in the board's next transaction: one soon, on this loop, if none comes
|
|
105
|
+
if (this.git) this.git.onTaken = () => setImmediate(() => { try { if (!this.closed) this.tx(() => {}); } catch (error) { current().err(`board: ${error.message}\n`); } });
|
|
104
106
|
// A publish that is behind is said once per call, on whichever workflow verb opened the board: a refused push
|
|
105
107
|
// surfaces on the next call instead of only in the dispatcher's report, and stops being said once the push lands.
|
|
106
108
|
// A held board reads git's state once, beside the owner's loop, and again after its own commits and pushes; an
|
|
@@ -145,7 +147,11 @@ class ZtrackBoard {
|
|
|
145
147
|
if (!this.git || said.has(`behind:${this.path}`)) return;
|
|
146
148
|
said.add(`behind:${this.path}`);
|
|
147
149
|
// A push younger than a minute is still on its way.
|
|
148
|
-
if (late && Date.now() - late.since > 60_000) current().err(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
|
|
150
|
+
if (late?.commits && Date.now() - late.since > 60_000) current().err(`board: warning: ${late.commits} board document commit${late.commits === 1 ? '' : 's'} not published since ${new Date(late.since).toISOString().slice(0, 16)}Z${late.error ? ` (last push: ${late.error})` : ''}; the dispatcher's next round retries\n`);
|
|
151
|
+
// A commit not made: writes still waiting on the checkout's git steps past a minute, or documents an earlier process
|
|
152
|
+
// left owed.
|
|
153
|
+
const owed = late?.uncommitted;
|
|
154
|
+
if (owed && (owed.since === null || Date.now() - owed.since > 60_000)) current().err(`board: warning: ${owed.writes ? `${owed.writes} board document write${owed.writes === 1 ? '' : 's'}` : `${owed.documents} board document${owed.documents === 1 ? '' : 's'}`} not committed${owed.since ? ` since ${new Date(owed.since).toISOString().slice(0, 16)}Z (waiting on the checkout's git steps)` : ''}; ${owed.writes ? 'committed when they land' : 'committed by the board owner when it next opens the board'}\n`);
|
|
149
155
|
}
|
|
150
156
|
/** The owner's watch saw this board's documents change: their stamps are read on the next read, and only then. */
|
|
151
157
|
documentsChanged() { this.docsChanged = true; this.importDue = true; }
|
|
@@ -160,7 +166,7 @@ class ZtrackBoard {
|
|
|
160
166
|
get codec() { return this._codec ??= require('@volter/ztrack/board-document'); }
|
|
161
167
|
get preset() { return this._preset ??= require('@volter/ztrack/kanban-preset'); }
|
|
162
168
|
board() { return { root: this.root, slug: this.slug, label: this.slug, db: this.db }; }
|
|
163
|
-
close() { this.dropOverlay(); this.conn.close(); }
|
|
169
|
+
close() { this.closed = true; this.git?.release(); this.dropOverlay(); this.conn.close(); }
|
|
164
170
|
|
|
165
171
|
statement(sql) {
|
|
166
172
|
let statement = this.statements.get(sql);
|
|
@@ -424,6 +430,8 @@ class ZtrackBoard {
|
|
|
424
430
|
this.refused = new Set();
|
|
425
431
|
timedStep('board tx errors', () => this.loadErrors());
|
|
426
432
|
const recovered = timedStep('board tx recover', () => this.recover());
|
|
433
|
+
// what a fetch brought, merged beside the loop: written here as the board's own edit, read by the stale check
|
|
434
|
+
if (this.git && !this.seed) timedStep('board tx take what was pushed', () => this.git.applyTaken());
|
|
427
435
|
if (this.seed) this.indexForSeed();
|
|
428
436
|
else {
|
|
429
437
|
const stale = timedStep('board tx stale check', () => this.staleDocuments({ pending: false }));
|
|
@@ -468,7 +476,7 @@ class ZtrackBoard {
|
|
|
468
476
|
// Committed as the session (or person) whose act this write was, and pushed.
|
|
469
477
|
const byManager = this.manager !== null && (actorOf() === this.manager || principalOf() === this.manager);
|
|
470
478
|
if (this.git) this.git.paths = this.docs.map((doc) => doc.path);
|
|
471
|
-
if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}
|
|
479
|
+
if (!byManager) try { timedStep('board git commit and push', () => this.git?.commit(actorOf(), `board: ${outcome.files.map((file) => basename(file.path)).join(', ')} written for ${actorOf() ?? 'the board'}`, outcome.files)); }
|
|
472
480
|
catch (error) { current().err(`board: ${error.message}\n`); }
|
|
473
481
|
}
|
|
474
482
|
return outcome.result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.86",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|