mainflow 0.42.0 → 0.43.0-alpha.2
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/dist/cli.js +767 -155
- package/dist/{dist-zSabglPq.js → dist-D5rZ0MQH.js} +71 -23
- package/dist/facts.d.ts +67 -12
- package/dist/facts.js +62 -13
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +5 -540
- package/dist/hooks.js +126 -92
- package/dist/{host-BAMZmRy8.js → host-BJvqZy-i.js} +1374 -489
- package/dist/host.d.ts +3 -2
- package/dist/host.js +1 -1
- package/dist/{index-Dk4fCI7q.d.ts → index-BqPQp6TF.d.ts} +2 -8
- package/dist/index-DRpamPge.d.ts +550 -0
- package/dist/launch.d.ts +6 -5
- package/dist/launch.js +323 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation-2wx2ex6x.js → operation-BhBbGauo.js} +395 -337
- package/dist/{ui-D1dg19Jw.js → ui-xxzmhAqF.js} +107 -98
- package/dist/version-Dvv4jwF1.js +6 -0
- package/dist/web/assets/{index-DvYj_6IC.js → index-B4w2iRCQ.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/launch-DosYhbwc.js +0 -327
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { $ as
|
|
1
|
+
import { $ as removedUntil, A as SURVEY_LINES, B as groupSchema, C as PRIORITIES, D as SESSION_ID, E as SERVES_REF, F as WORK_STATUS, G as mainlineSchema, H as isMainflowRange, I as configSchema, J as parseOpsLine, K as opsEntrySchema, L as decisionSchema, M as TASK_REQUIRED_SECTIONS, N as TASK_SECTIONS, O as SESSION_STATUS, P as UNTIL_FORMS, Q as refMainline, R as findingSchema, S as MERGE_STATES, T as RESERVED_HUB_NAMES, U as issueSchema, V as hubRegistrySchema, W as listedRepos, X as parseUntil, Y as parseRef, Z as refExample, a as DECISION_SECTIONS, at as waitingForAction, b as MAINLINE_SECTIONS, c as FINDING_SECTIONS, ct as stampMs, d as GROUP_SECTIONS, et as repoPath, f as GROUP_STATUS, ft as describeRequest, g as ISSUE_SECTIONS, gt as parseGrantTarget, ht as grantRepo, i as DECISION_BASES, j as SURVEY_SECTION, k as SLUG, l as FINDING_STATUS, m as HUB_NAME, mt as grantForm, n as CLOSED_STATUS, nt as sessionSchema, ot as waitingForRef, pt as grantCovers, q as parseActionRef, r as DECIDERS, rt as taskSchema, s as DEFAULT_CATEGORIES, st as OFFSET, w as REF_EXAMPLES, x as MAINLINE_STATUS, y as MAINFLOW_RANGE, z as formatOpsEntry } from "./dist-D5rZ0MQH.js";
|
|
2
2
|
import { a as LABEL_ROLES, i as FINDING_SEVERITIES, n as FINDING_CLASSES, r as FINDING_FIELDS, t as FINDINGS_FENCE } from "./retro-formats-ECy0gGOL.js";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
4
|
+
import { appendFileSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, matchesGlob, parse, posix, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
7
7
|
import { parse as parse$1, stringify } from "yaml";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
+
import satisfies from "semver/functions/satisfies.js";
|
|
9
10
|
import { performance } from "node:perf_hooks";
|
|
10
11
|
import { gunzipSync, inflateRawSync } from "node:zlib";
|
|
11
12
|
//#region ../core/dist/ops/document.js
|
|
13
|
+
/**
|
|
14
|
+
* How messages and the interface name the hub's data directory, whichever directory holds it on a machine: a file of
|
|
15
|
+
* the hub is `.mainflow/{path}`, its path in the data directory. Also the name of the data directory inside a hub
|
|
16
|
+
* repository before the data moved to the user's mainflow directory (`mainflow hub migrate-data`).
|
|
17
|
+
*/
|
|
12
18
|
const DATA_DIR = ".mainflow";
|
|
13
19
|
const ENTRY_DIR = /^([mtdifw]\d{3})-(.+)$/;
|
|
14
20
|
var WriteError = class extends Error {};
|
|
@@ -45,57 +51,92 @@ function stamps(ctx) {
|
|
|
45
51
|
}
|
|
46
52
|
//#endregion
|
|
47
53
|
//#region ../core/dist/paths.js
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
};
|
|
57
|
-
/** The main working tree and the current working tree of the repository containing `cwd`, or null outside git. */
|
|
58
|
-
function worktreesOf(cwd) {
|
|
59
|
-
const common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
60
|
-
const current = git(cwd, "rev-parse", "--show-toplevel");
|
|
61
|
-
if (!common.endsWith(`${sep}.git`) || !current) return null;
|
|
62
|
-
return {
|
|
63
|
-
main: dirname(common),
|
|
64
|
-
current
|
|
65
|
-
};
|
|
54
|
+
/**
|
|
55
|
+
* The absolute path of a file of the hub that messages and the interface name by its path in the data directory,
|
|
56
|
+
* written `.mainflow/{path}` (hubPath). Any other path is taken as relative to the hub repository.
|
|
57
|
+
*/
|
|
58
|
+
function dataFile(dirs, named) {
|
|
59
|
+
if (named === ".mainflow") return dirs.data;
|
|
60
|
+
if (named.startsWith(`.mainflow/`)) return join(dirs.data, named.slice(10));
|
|
61
|
+
return resolve(dirs.root, named);
|
|
66
62
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
/**
|
|
64
|
+
* How messages and the interface name `file`, an absolute path in the hub's data directory: `.mainflow/{path}`, with
|
|
65
|
+
* `/` between names whatever the platform. The name is the same in every environment of the hub, wherever its data
|
|
66
|
+
* directory is on each machine. A file outside the data directory keeps its absolute path.
|
|
67
|
+
*/
|
|
68
|
+
function hubPath(dirs, file) {
|
|
69
|
+
const path = relative(dirs.data, file);
|
|
70
|
+
if (path === "") return DATA_DIR;
|
|
71
|
+
if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return file;
|
|
72
|
+
return `${DATA_DIR}/${path.split(sep).join("/")}`;
|
|
70
73
|
}
|
|
71
74
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* own copy of the hub and the copies would conflict on merge. A hub below the root (a sample hub under
|
|
76
|
-
* examples/, a scratch hub under tmp/) stays as found: it is data the worktree's own branch owns, and
|
|
77
|
-
* mapping it would let tests and migrations in the worktree write the main tree's copy.
|
|
75
|
+
* `path` as the file system spells it, symbolic links resolved, or null when it does not exist. The native call also
|
|
76
|
+
* takes each name's case from the disk, so on a file system that ignores case (macOS and Windows by default) two
|
|
77
|
+
* spellings of one directory come out the same, and on one that keeps case apart (Linux) they stay two directories.
|
|
78
78
|
*/
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
function realPath(path) {
|
|
80
|
+
try {
|
|
81
|
+
return realpathSync.native(path);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The git checkout holding `dir`, read from the `.git` entries on disk without running git. `current` is the
|
|
88
|
+
* checkout's root. `main` is the main working tree of the repository: the checkout itself, or, for a linked
|
|
89
|
+
* worktree, the directory holding the `.git` directory its `gitdir` points into (through `commondir`); null
|
|
90
|
+
* for a worktree of a bare repository, which has no main working tree. A submodule is a checkout of its own.
|
|
91
|
+
*/
|
|
92
|
+
function gitCheckout(dir) {
|
|
93
|
+
for (let at = resolve(dir);; at = dirname(at)) {
|
|
94
|
+
const dotGit = join(at, ".git");
|
|
95
|
+
const entry = statSync(dotGit, { throwIfNoEntry: false });
|
|
96
|
+
if (entry?.isDirectory()) return {
|
|
97
|
+
current: at,
|
|
98
|
+
main: at
|
|
99
|
+
};
|
|
100
|
+
if (entry?.isFile()) {
|
|
101
|
+
const gitdir = /^gitdir:\s*(.+?)\s*$/m.exec(readText$1(dotGit) ?? "")?.[1];
|
|
102
|
+
const commondir = gitdir ? readText$1(join(resolve(at, gitdir), "commondir"))?.trim() : void 0;
|
|
103
|
+
if (!gitdir || !commondir) return {
|
|
104
|
+
current: at,
|
|
105
|
+
main: at
|
|
106
|
+
};
|
|
107
|
+
const common = realPath(resolve(at, gitdir, commondir)) ?? resolve(at, gitdir, commondir);
|
|
108
|
+
return {
|
|
109
|
+
current: at,
|
|
110
|
+
main: basename(common) === ".git" ? dirname(common) : null
|
|
111
|
+
};
|
|
85
112
|
}
|
|
86
|
-
if (dirname(
|
|
113
|
+
if (dirname(at) === at) return null;
|
|
87
114
|
}
|
|
88
|
-
const trees = worktreesOf(cwd);
|
|
89
|
-
if (!trees || trees.main === trees.current) return found;
|
|
90
|
-
if (found && found !== trees.current) return found;
|
|
91
|
-
return isHub(trees.main) ? trees.main : found;
|
|
92
115
|
}
|
|
93
|
-
|
|
116
|
+
/** The main working tree and the current working tree of the repository containing `cwd`, or null outside git or in a worktree of a bare repository. */
|
|
117
|
+
function worktreesOf(cwd) {
|
|
118
|
+
const checkout = gitCheckout(realPath(cwd) ?? cwd);
|
|
119
|
+
return checkout?.main ? {
|
|
120
|
+
main: checkout.main,
|
|
121
|
+
current: checkout.current
|
|
122
|
+
} : null;
|
|
123
|
+
}
|
|
124
|
+
/** The branch checked out at `cwd`, or null outside git or on a detached HEAD. */
|
|
125
|
+
function currentBranch(cwd) {
|
|
126
|
+
const result = spawnSync("git", [
|
|
127
|
+
"-C",
|
|
128
|
+
cwd,
|
|
129
|
+
"branch",
|
|
130
|
+
"--show-current"
|
|
131
|
+
], { encoding: "utf8" });
|
|
132
|
+
return (result.status === 0 ? result.stdout.trim() : "") || null;
|
|
133
|
+
}
|
|
134
|
+
function subdirs$1(dir) {
|
|
94
135
|
if (!existsSync(dir)) return [];
|
|
95
136
|
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(dir, entry.name)).sort();
|
|
96
137
|
}
|
|
97
138
|
/** Read a UTF-8 file, or return null when it does not exist. */
|
|
98
|
-
function readText(file) {
|
|
139
|
+
function readText$1(file) {
|
|
99
140
|
return existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
100
141
|
}
|
|
101
142
|
/** List every file below `dir`, sorted, skipping dot files. */
|
|
@@ -107,6 +148,586 @@ function listFiles(dir) {
|
|
|
107
148
|
}).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => join(entry.parentPath, entry.name)).sort();
|
|
108
149
|
}
|
|
109
150
|
//#endregion
|
|
151
|
+
//#region ../core/dist/storage/node.js
|
|
152
|
+
const LOCK_WAIT_MS = 5e3;
|
|
153
|
+
const LOCK_STALE_MS = 3e4;
|
|
154
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
155
|
+
/**
|
|
156
|
+
* Run `fn` while holding the hub-wide write lock in `local/` of the data directory `data`. When the lock had to create
|
|
157
|
+
* that directory and `fn` refuses the write, the directory goes again, so a refused write leaves the hub as it was.
|
|
158
|
+
*/
|
|
159
|
+
function withLock(data, fn) {
|
|
160
|
+
const dir = join(data, "local");
|
|
161
|
+
const created = lockDir(data);
|
|
162
|
+
try {
|
|
163
|
+
return holdLock(join(dir, "write.lock"), fn);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (created) removeIfEmpty(dir);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** withLock for an `fn` that waits on something, such as a request: the lock is held until its promise settles. */
|
|
170
|
+
async function withLockAsync(data, fn) {
|
|
171
|
+
const dir = join(data, "local");
|
|
172
|
+
const lock = join(dir, "write.lock");
|
|
173
|
+
const created = lockDir(data);
|
|
174
|
+
try {
|
|
175
|
+
acquireLock(lock);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (created) removeIfEmpty(dir);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return await fn();
|
|
182
|
+
} catch (error) {
|
|
183
|
+
rmSync(lock, { force: true });
|
|
184
|
+
if (created) removeIfEmpty(dir);
|
|
185
|
+
throw error;
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(lock, { force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Make `local/` of the data directory `data` for its lock, and say whether it had to. The data directory itself is never
|
|
192
|
+
* made: a writer still holding a path the hub's data moved away from (mainflow hub migrate-data) fails here instead
|
|
193
|
+
* of making an empty data directory there.
|
|
194
|
+
*/
|
|
195
|
+
function lockDir(data) {
|
|
196
|
+
try {
|
|
197
|
+
mkdirSync(join(data, "local"));
|
|
198
|
+
return true;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error.code === "EEXIST") return false;
|
|
201
|
+
if (error.code === "ENOENT") throw missingData(data);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const missingData = (data) => new WriteError(`the hub's data directory ${data} does not exist; when the hub's data moved, run the command again, which reads where it is now from the hub registry`);
|
|
206
|
+
function removeIfEmpty(dir) {
|
|
207
|
+
try {
|
|
208
|
+
rmdirSync(dir);
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Run `fn` while holding the lock file `lock`, created exclusively and removed afterwards. A lock older than
|
|
213
|
+
* LOCK_STALE_MS belongs to a writer that died and is taken over; past `waitMs` (LOCK_WAIT_MS by default) the wait
|
|
214
|
+
* ends with a WriteError saying `what` is locked. With `waitMs` 0 the lock is tried once and a held one fails at once.
|
|
215
|
+
*/
|
|
216
|
+
function holdLock(lock, fn, options = {}) {
|
|
217
|
+
acquireLock(lock, options);
|
|
218
|
+
try {
|
|
219
|
+
return fn();
|
|
220
|
+
} finally {
|
|
221
|
+
rmSync(lock, { force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Take the lock file `lock` as holdLock does; the caller removes it. When the lock's directory is gone, it is made
|
|
226
|
+
* again, but not its parent: a lock whose whole directory moved away, as a hub's data directory does, fails instead.
|
|
227
|
+
*/
|
|
228
|
+
function acquireLock(lock, { what = "hub", waitMs = LOCK_WAIT_MS } = {}) {
|
|
229
|
+
const dir = dirname(lock);
|
|
230
|
+
const deadline = Date.now() + waitMs;
|
|
231
|
+
let vanished = 0;
|
|
232
|
+
for (;;) try {
|
|
233
|
+
const fd = openSync(lock, "wx");
|
|
234
|
+
writeSync(fd, String(process.pid));
|
|
235
|
+
closeSync(fd);
|
|
236
|
+
return;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if (error.code === "ENOENT") {
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(dir);
|
|
241
|
+
} catch (made) {
|
|
242
|
+
if (made.code === "ENOENT") throw what === "hub" ? missingData(dirname(dir)) : new WriteError(`${dirname(dir)}, which holds the lock of ${what}, does not exist`);
|
|
243
|
+
if (made.code !== "EEXIST") throw made;
|
|
244
|
+
}
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (error.code !== "EEXIST") throw error;
|
|
248
|
+
const held = statSync(lock, { throwIfNoEntry: false });
|
|
249
|
+
if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
|
|
250
|
+
rmSync(lock, { force: true });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (Date.now() >= deadline) throw new WriteError(`${what} is locked by another writer (${lock}); retry in a moment`);
|
|
254
|
+
sleep(held ? 50 : Math.min(2 ** vanished++, 50));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Write through a temporary file and a rename, so a reader never sees half a file. */
|
|
258
|
+
function writeAtomic(file, text) {
|
|
259
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
260
|
+
writeFileSync(temp, text);
|
|
261
|
+
renameSync(temp, file);
|
|
262
|
+
}
|
|
263
|
+
/** The hub at `dirs`, its data directory read and written on the local file system. */
|
|
264
|
+
function nodeStorage(dirs) {
|
|
265
|
+
const file = (path) => join(dirs.data, path);
|
|
266
|
+
return {
|
|
267
|
+
read: (path) => {
|
|
268
|
+
const target = file(path);
|
|
269
|
+
return existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
270
|
+
},
|
|
271
|
+
list: (dir) => {
|
|
272
|
+
const target = file(dir);
|
|
273
|
+
if (!existsSync(target)) return [];
|
|
274
|
+
return readdirSync(target, { withFileTypes: true }).map((entry) => ({
|
|
275
|
+
name: entry.name,
|
|
276
|
+
dir: entry.isDirectory()
|
|
277
|
+
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
278
|
+
},
|
|
279
|
+
where: file,
|
|
280
|
+
write: (path, text) => {
|
|
281
|
+
mkdirSync(dirname(file(path)), { recursive: true });
|
|
282
|
+
writeAtomic(file(path), text);
|
|
283
|
+
},
|
|
284
|
+
append: (path, text) => {
|
|
285
|
+
mkdirSync(dirname(file(path)), { recursive: true });
|
|
286
|
+
appendFileSync(file(path), text);
|
|
287
|
+
},
|
|
288
|
+
makeDir: (path) => {
|
|
289
|
+
mkdirSync(file(path), { recursive: true });
|
|
290
|
+
},
|
|
291
|
+
remove: (path) => {
|
|
292
|
+
rmSync(file(path), { force: true });
|
|
293
|
+
},
|
|
294
|
+
lock: (fn) => withLock(dirs.data, fn)
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region ../core/dist/registry.js
|
|
299
|
+
/** The registry's file name inside MAINFLOW_HOME. */
|
|
300
|
+
const REGISTRY_FILE = "hubs.json";
|
|
301
|
+
/** How a hub is registered, for the messages that ask for it. */
|
|
302
|
+
const REGISTER_USAGE = "mainflow hub register <name> <path>";
|
|
303
|
+
/** The user-level mainflow directory: MAINFLOW_HOME when set, ~/.mainflow otherwise. */
|
|
304
|
+
const mainflowHome = (env = process.env) => resolve(env.MAINFLOW_HOME || join(homedir(), ".mainflow"));
|
|
305
|
+
/** The registry file of the user-level mainflow directory. */
|
|
306
|
+
const registryFile = (env = process.env) => join(mainflowHome(env), REGISTRY_FILE);
|
|
307
|
+
/** The data directory of hub `name` in the user-level mainflow directory: $MAINFLOW_HOME/<name>. */
|
|
308
|
+
const userDataDir = (name, env = process.env) => join(mainflowHome(env), name);
|
|
309
|
+
/**
|
|
310
|
+
* The data directory a hub kept inside its repository before its data moved to MAINFLOW_HOME, `<root>/.mainflow`, when
|
|
311
|
+
* it holds a hub: what `mainflow hub migrate-data` moves.
|
|
312
|
+
*/
|
|
313
|
+
function inRepositoryData(root) {
|
|
314
|
+
const data = join(root, DATA_DIR);
|
|
315
|
+
const name = /^hub:\s*["']?([a-z0-9-]+)/m.exec(readText(join(data, "config.yaml")) ?? "")?.[1];
|
|
316
|
+
return name ? {
|
|
317
|
+
name,
|
|
318
|
+
data
|
|
319
|
+
} : null;
|
|
320
|
+
}
|
|
321
|
+
const readText = (file) => {
|
|
322
|
+
try {
|
|
323
|
+
return readFileSync(file, "utf8");
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const empty = () => ({
|
|
329
|
+
version: 1,
|
|
330
|
+
hubs: {}
|
|
331
|
+
});
|
|
332
|
+
/** The registry as the file holds it; empty when there is no file yet. */
|
|
333
|
+
function readRegistry(env = process.env) {
|
|
334
|
+
const file = registryFile(env);
|
|
335
|
+
const text = readText(file);
|
|
336
|
+
if (text === null) return empty();
|
|
337
|
+
const redo = `fix it, or delete it and run ${REGISTER_USAGE} for each hub again`;
|
|
338
|
+
let data;
|
|
339
|
+
try {
|
|
340
|
+
data = JSON.parse(text);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new WriteError(`${file} is not JSON (${error.message}); ${redo}`);
|
|
343
|
+
}
|
|
344
|
+
const version = data?.version;
|
|
345
|
+
if (typeof version === "number" && version > 1) throw new WriteError(`${file} is format ${version}, written by a newer mainflow; this one reads format 1, so install the newer mainflow`);
|
|
346
|
+
const parsed = hubRegistrySchema.safeParse(data);
|
|
347
|
+
if (!parsed.success) throw new WriteError(`${file} is not a hub registry: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(top)"}: ${i.message}`).join("; ")}; ${redo}`);
|
|
348
|
+
return parsed.data;
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Change the registry under its lock: read it, let `change` edit it, and write it back when it changed. The lock is
|
|
352
|
+
* waited for as holdLock does, or with `wait` false tried once, so a hook never waits on another writer.
|
|
353
|
+
*/
|
|
354
|
+
function updateRegistry(env, change, wait = true) {
|
|
355
|
+
const file = registryFile(env);
|
|
356
|
+
mkdirSync(mainflowHome(env), { recursive: true });
|
|
357
|
+
return holdLock(`${file}.lock`, () => {
|
|
358
|
+
const registry = readRegistry(env);
|
|
359
|
+
const before = JSON.stringify(registry);
|
|
360
|
+
const result = change(registry);
|
|
361
|
+
const after = JSON.stringify(registry, null, 2);
|
|
362
|
+
if (JSON.stringify(registry) !== before) writeAtomic(file, `${after}\n`);
|
|
363
|
+
return result;
|
|
364
|
+
}, {
|
|
365
|
+
what: "the hub registry",
|
|
366
|
+
...wait ? {} : { waitMs: 0 }
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/** The repositories a hub is the primary hub of, with the names config.yaml gives them; the hub repository first. */
|
|
370
|
+
const primaries = (hub) => [["(the hub repository)", hub.root], ...Object.entries(hub.repos).filter(([, path]) => path !== hub.root)];
|
|
371
|
+
/** Why the hub repository `root` cannot be registered with the data directory `data`, which holds no config.yaml. */
|
|
372
|
+
function noDataMessage(root, data, env) {
|
|
373
|
+
const inRepository = inRepositoryData(root);
|
|
374
|
+
if (inRepository) return `hub "${inRepository.name}" still keeps its data inside its repository (${inRepository.data}); move it to ${mainflowHome(env)} and register the hub with: mainflow hub migrate-data ${root}`;
|
|
375
|
+
return `no hub data at ${data}: ${join(data, "config.yaml")} does not exist`;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Whether the hub registered from `root` with the data directory `data` is a new environment: neither holds a
|
|
379
|
+
* config.yaml. A repository that still keeps a hub's data inside it is not, since that data moves with
|
|
380
|
+
* `mainflow hub migrate-data`.
|
|
381
|
+
*/
|
|
382
|
+
function awaitsData(root, data) {
|
|
383
|
+
const real = realPath(root);
|
|
384
|
+
if (!real) return false;
|
|
385
|
+
const dir = realPath(data) ?? resolve(data);
|
|
386
|
+
return readText(join(dir, "config.yaml")) === null && inRepositoryData(real) === null;
|
|
387
|
+
}
|
|
388
|
+
/** The entry of hub `name` whose files are not on this machine yet. */
|
|
389
|
+
function pendingEntry(name, root, data) {
|
|
390
|
+
if (!HUB_NAME.test(name) || RESERVED_HUB_NAMES.includes(name)) throw new WriteError(`"${name}" is not a hub name: use lowercase letters, digits and hyphens, and not ${RESERVED_HUB_NAMES.join(" or ")}`);
|
|
391
|
+
const real = realPath(root);
|
|
392
|
+
if (!real) throw new WriteError(`${root} does not exist`);
|
|
393
|
+
mkdirSync(data, { recursive: true });
|
|
394
|
+
return {
|
|
395
|
+
name,
|
|
396
|
+
entry: {
|
|
397
|
+
root: real,
|
|
398
|
+
data: realPath(data) ?? resolve(data),
|
|
399
|
+
config: "",
|
|
400
|
+
repos: {},
|
|
401
|
+
references: {}
|
|
402
|
+
},
|
|
403
|
+
missing: []
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/** The registry entry of the hub whose repository is `root` and whose data directory is `data`, derived from its config.yaml. */
|
|
407
|
+
function derive(root, data, env) {
|
|
408
|
+
const real = realPath(root);
|
|
409
|
+
if (!real) throw new WriteError(`${root} does not exist`);
|
|
410
|
+
const dir = realPath(data) ?? resolve(data);
|
|
411
|
+
const file = join(dir, "config.yaml");
|
|
412
|
+
const text = readText(file);
|
|
413
|
+
if (text === null) throw new WriteError(noDataMessage(real, dir, env));
|
|
414
|
+
let raw;
|
|
415
|
+
try {
|
|
416
|
+
raw = parse$1(text);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
throw new WriteError(`${file} is not valid YAML: ${error.message}`);
|
|
419
|
+
}
|
|
420
|
+
const parsed = configSchema.pick({
|
|
421
|
+
hub: true,
|
|
422
|
+
repos: true
|
|
423
|
+
}).safeParse(raw);
|
|
424
|
+
if (!parsed.success) throw new WriteError(`${file} is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
425
|
+
const entry = {
|
|
426
|
+
root: real,
|
|
427
|
+
data: dir,
|
|
428
|
+
config: contentHash(text),
|
|
429
|
+
repos: {},
|
|
430
|
+
references: {}
|
|
431
|
+
};
|
|
432
|
+
const missing = [];
|
|
433
|
+
for (const repo of listedRepos(parsed.data.repos)) {
|
|
434
|
+
const listed = resolve(real, repo.path);
|
|
435
|
+
const path = realPath(listed);
|
|
436
|
+
if (!path) missing.push(`${repo.name} (${repo.path})`);
|
|
437
|
+
(repo.role === "primary" ? entry.repos : entry.references)[repo.name] = path ?? listed;
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
name: parsed.data.hub,
|
|
441
|
+
entry,
|
|
442
|
+
missing
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/** Each primary repository of `entry`, to be hub `name`, that another registered hub already has, one line each. */
|
|
446
|
+
function claimConflicts(registry, name, entry) {
|
|
447
|
+
const out = [];
|
|
448
|
+
for (const [other, hub] of Object.entries(registry.hubs)) {
|
|
449
|
+
if (other === name || hub.root === entry.root) continue;
|
|
450
|
+
const theirs = new Set(primaries(hub).map(([, path]) => path));
|
|
451
|
+
for (const [repo, path] of primaries(entry)) {
|
|
452
|
+
if (!theirs.has(path)) continue;
|
|
453
|
+
out.push(`${repo === "(the hub repository)" ? "the hub repository" : `repo "${repo}"`} ${path} is already the primary repository of hub "${other}" (registered from ${hub.root})`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
/** The message for the conflicts claimConflicts found, opening with `lead`, and what to do about them. */
|
|
459
|
+
const conflictMessage = (lead, conflicts) => [
|
|
460
|
+
lead,
|
|
461
|
+
...conflicts.map((c) => ` ${c}`),
|
|
462
|
+
"A repository has one primary hub. In the config.yaml of the hub that should only reference it, write it under repos as { path: ..., role: reference }, then run mainflow hub register again."
|
|
463
|
+
].join("\n");
|
|
464
|
+
/**
|
|
465
|
+
* The data directory of hub `name` registered from the repository `root`: the one `options` names, else the one the
|
|
466
|
+
* hub is registered with already, under this name or, when the hub was renamed, from this repository; else its
|
|
467
|
+
* directory in MAINFLOW_HOME.
|
|
468
|
+
*/
|
|
469
|
+
function dataDirFor(registry, name, root, env) {
|
|
470
|
+
const real = realPath(root);
|
|
471
|
+
return registry.hubs[name]?.data ?? Object.values(registry.hubs).find((hub) => hub.root === real)?.data ?? userDataDir(name, env);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Register the hub whose repository is `path`, or bring its entry up to date. `name` must be the name its config.yaml
|
|
475
|
+
* gives it. Its data directory is `options.data` when given, as when its data has just moved there; otherwise the one
|
|
476
|
+
* it is registered with already, or $MAINFLOW_HOME/<name> for a hub not registered yet. Refused when that directory
|
|
477
|
+
* holds no hub, or when a repository the hub is the primary hub of is already another hub's primary repository. A new
|
|
478
|
+
* environment (no entry yet, or one registered without data), where neither the repository nor that directory holds any hub data, is registered with only the name,
|
|
479
|
+
* the repository and the data directory (`pending`); its repositories are matched, and checked against the other
|
|
480
|
+
* hubs, once `mainflow sync` has downloaded config.yaml.
|
|
481
|
+
*/
|
|
482
|
+
function registerHub(name, path, env = process.env, options = {}) {
|
|
483
|
+
const data = options.data ?? dataDirFor(readRegistry(env), name, path, env);
|
|
484
|
+
let derived;
|
|
485
|
+
if (awaitsData(path, data)) {
|
|
486
|
+
const registry = readRegistry(env);
|
|
487
|
+
const real = realPath(path);
|
|
488
|
+
const [knownName, known] = registry.hubs[name] ? [name, registry.hubs[name]] : Object.entries(registry.hubs).find(([, hub]) => hub.root === real) ?? [];
|
|
489
|
+
if (known && known.config !== "") throw new WriteError(`${join(realPath(data) ?? resolve(data), "config.yaml")} does not exist, but hub "${knownName}" is registered with its data in ${known.data}; move the data back there, or run mainflow hub unregister ${knownName} and register it again`);
|
|
490
|
+
derived = pendingEntry(name, path, data);
|
|
491
|
+
} else derived = derivedAs(name, path, data, env);
|
|
492
|
+
return updateRegistry(env, (registry) => {
|
|
493
|
+
const registration = planRegistration(registry, name, derived);
|
|
494
|
+
if (registration.renamedFrom) delete registry.hubs[registration.renamedFrom];
|
|
495
|
+
registry.hubs[name] = derived.entry;
|
|
496
|
+
return registration;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* What registerHub would do with the same arguments, without writing the registry: it throws what registerHub would
|
|
501
|
+
* refuse. `options.data` is required here, since the hub's data may not be in its directory yet.
|
|
502
|
+
*/
|
|
503
|
+
function previewRegistration(name, path, env, options) {
|
|
504
|
+
return planRegistration(readRegistry(env), name, derivedAs(name, path, options.data, env));
|
|
505
|
+
}
|
|
506
|
+
/** The entry of the hub at `path` with its data in `data`, which must be hub `name`. */
|
|
507
|
+
function derivedAs(name, path, data, env) {
|
|
508
|
+
const derived = derive(path, data, env);
|
|
509
|
+
if (derived.name !== name) throw new WriteError(`${derived.entry.data} holds hub "${derived.name}"; register it as "${derived.name}"`);
|
|
510
|
+
return derived;
|
|
511
|
+
}
|
|
512
|
+
/** What registering `derived` as hub `name` changes in `registry`; refused when another hub has its repositories. */
|
|
513
|
+
function planRegistration(registry, name, derived) {
|
|
514
|
+
const conflicts = claimConflicts(registry, name, derived.entry);
|
|
515
|
+
if (conflicts.length) throw new WriteError(conflictMessage(`hub "${name}" is not registered, since another hub already has its repositories:`, conflicts));
|
|
516
|
+
const previous = registry.hubs[name] ?? null;
|
|
517
|
+
const renamedFrom = Object.keys(registry.hubs).find((other) => other !== name && registry.hubs[other].root === derived.entry.root) ?? null;
|
|
518
|
+
return {
|
|
519
|
+
name,
|
|
520
|
+
entry: derived.entry,
|
|
521
|
+
previous,
|
|
522
|
+
renamedFrom,
|
|
523
|
+
missing: derived.missing,
|
|
524
|
+
pending: derived.entry.config === ""
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/** Remove a hub from the registry; its files stay as they are. */
|
|
528
|
+
function unregisterHub(name, env = process.env) {
|
|
529
|
+
return updateRegistry(env, (registry) => {
|
|
530
|
+
const hub = registry.hubs[name];
|
|
531
|
+
if (!hub) throw new WriteError(`hub "${name}" is not registered; registered: ${Object.keys(registry.hubs).join(", ") || "(none)"}`);
|
|
532
|
+
delete registry.hubs[name];
|
|
533
|
+
return hub;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/** Whether an entry may no longer say what its config.yaml says: the file changed, or a path it holds now resolves differently, as when a listed repository was cloned. */
|
|
537
|
+
function stale(hub) {
|
|
538
|
+
const text = readText(join(hub.data, "config.yaml"));
|
|
539
|
+
if (text === null && hub.config === "") return false;
|
|
540
|
+
if (text === null || contentHash(text) !== hub.config) return true;
|
|
541
|
+
return [
|
|
542
|
+
hub.root,
|
|
543
|
+
hub.data,
|
|
544
|
+
...Object.values(hub.repos),
|
|
545
|
+
...Object.values(hub.references)
|
|
546
|
+
].some((path) => realPath(path) !== path);
|
|
547
|
+
}
|
|
548
|
+
/** Derive the entries of `names` again where stale; what cannot be updated keeps its entry and says why. */
|
|
549
|
+
function refresh(registry, names, env) {
|
|
550
|
+
const warnings = [];
|
|
551
|
+
for (const name of names) {
|
|
552
|
+
const hub = registry.hubs[name];
|
|
553
|
+
if (!hub || !stale(hub)) continue;
|
|
554
|
+
let derived;
|
|
555
|
+
try {
|
|
556
|
+
derived = derive(hub.root, hub.data, env);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
warnings.push(`hub "${name}" is registered from ${hub.root}, which cannot be read again (${error.message}); register it where it is now, or run mainflow hub unregister ${name}`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (derived.name !== name) {
|
|
562
|
+
warnings.push(`${hub.data} now holds hub "${derived.name}", registered as "${name}"; run mainflow hub register ${derived.name} ${hub.root}`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const conflicts = claimConflicts(registry, name, derived.entry);
|
|
566
|
+
if (conflicts.length) {
|
|
567
|
+
warnings.push(conflictMessage(`the changed config.yaml of hub "${name}" is not applied, since another hub already has its repositories:`, conflicts));
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
registry.hubs[name] = derived.entry;
|
|
571
|
+
}
|
|
572
|
+
return warnings;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The registry with the entries of `names` derived again where stale. The file is rewritten only when something
|
|
576
|
+
* changed; when it cannot be, as when another writer holds the lock and `wait` is false, the updated entries are still
|
|
577
|
+
* used for this run.
|
|
578
|
+
*/
|
|
579
|
+
function refreshed(env, names, warn, wait = true) {
|
|
580
|
+
const registry = readRegistry(env);
|
|
581
|
+
const probe = structuredClone(registry);
|
|
582
|
+
const warnings = refresh(probe, names, env);
|
|
583
|
+
if (JSON.stringify(probe) === JSON.stringify(registry)) {
|
|
584
|
+
warnings.forEach(warn);
|
|
585
|
+
return registry;
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
return updateRegistry(env, (locked) => {
|
|
589
|
+
refresh(locked, names, env);
|
|
590
|
+
return locked;
|
|
591
|
+
}, wait);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
warn(`cannot update ${registryFile(env)}: ${error.message}`);
|
|
594
|
+
return probe;
|
|
595
|
+
} finally {
|
|
596
|
+
warnings.forEach(warn);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** Every registered hub, each entry brought up to date with its config.yaml first. */
|
|
600
|
+
function listHubs(options = {}) {
|
|
601
|
+
const env = options.env ?? process.env;
|
|
602
|
+
return refreshed(env, Object.keys(readRegistry(env).hubs), options.warn ?? (() => {}));
|
|
603
|
+
}
|
|
604
|
+
const within = (path, dir) => path === dir || path.startsWith(dir.endsWith(sep) ? dir : dir + sep);
|
|
605
|
+
/**
|
|
606
|
+
* The hub whose primary repository or data directory holds `place` most closely, so a hub registered inside another
|
|
607
|
+
* hub's repository wins there, and a command run in a hub's data directory runs on that hub. Paths are compared exactly
|
|
608
|
+
* as given: they come from realPath, which spells them as the file system does.
|
|
609
|
+
*/
|
|
610
|
+
function hubClaiming(registry, place) {
|
|
611
|
+
let best = null;
|
|
612
|
+
for (const [name, hub] of Object.entries(registry.hubs)) for (const path of [...primaries(hub).map(([, path]) => path), hub.data]) if (within(place, path) && (!best || path.length > best.length)) best = {
|
|
613
|
+
name,
|
|
614
|
+
length: path.length
|
|
615
|
+
};
|
|
616
|
+
return best?.name ?? null;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* The places looked up for `cwd`, in order: its real path, matched against the registered paths that equal or hold it,
|
|
620
|
+
* and for a linked worktree the root of the repository's main working tree, so every worktree of a repository belongs
|
|
621
|
+
* to the hub of its main checkout. The git repository's root is not looked up on its own: when it is registered, the
|
|
622
|
+
* real path of any directory in it already matches. The path inside the worktree is not carried over: a hub below the root of the main
|
|
623
|
+
* checkout (a sample hub under examples/) is that checkout's data, not the worktree's.
|
|
624
|
+
*/
|
|
625
|
+
function places(cwd) {
|
|
626
|
+
const here = realPath(cwd) ?? resolve(cwd);
|
|
627
|
+
const checkout = gitCheckout(here);
|
|
628
|
+
return checkout?.main && checkout.main !== checkout.current ? [here, checkout.main] : [here];
|
|
629
|
+
}
|
|
630
|
+
const lookUp = (registry, spots) => {
|
|
631
|
+
for (const spot of spots) {
|
|
632
|
+
const name = hubClaiming(registry, spot);
|
|
633
|
+
if (name) return name;
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* The hub `cwd` belongs to on this machine, from the registry alone, or null when no registered hub claims it. The
|
|
639
|
+
* entry found is brought up to date with its config.yaml first, and the lookup repeated, since the change may have
|
|
640
|
+
* moved the repository to another hub; when nothing claims `cwd`, every stale entry is, in case a change elsewhere
|
|
641
|
+
* claims it now. `warn` receives what kept the entry found from being brought up to date. With `waitForLock` false, as
|
|
642
|
+
* a hook asks, the registry's lock is tried once: when another writer holds it, the entries derived again are used
|
|
643
|
+
* for this lookup without being written, so a hook never waits.
|
|
644
|
+
*/
|
|
645
|
+
function resolveHub(cwd, options = {}) {
|
|
646
|
+
const env = options.env ?? process.env;
|
|
647
|
+
const warn = options.warn ?? (() => {});
|
|
648
|
+
const wait = options.waitForLock ?? true;
|
|
649
|
+
const spots = places(cwd);
|
|
650
|
+
let registry = readRegistry(env);
|
|
651
|
+
let name = lookUp(registry, spots);
|
|
652
|
+
if (name) {
|
|
653
|
+
registry = refreshed(env, [name], warn, wait);
|
|
654
|
+
name = lookUp(registry, spots);
|
|
655
|
+
}
|
|
656
|
+
if (!name) {
|
|
657
|
+
registry = refreshed(env, Object.keys(registry.hubs), () => {}, wait);
|
|
658
|
+
name = lookUp(registry, spots);
|
|
659
|
+
}
|
|
660
|
+
const hub = name ? registry.hubs[name] : void 0;
|
|
661
|
+
return name && hub ? {
|
|
662
|
+
name,
|
|
663
|
+
root: hub.root,
|
|
664
|
+
data: hub.data
|
|
665
|
+
} : null;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* The registered hub whose data directory holds `file`, or null: a written file belongs to the hub whose data it is,
|
|
669
|
+
* whichever directory the writer runs in. The innermost data directory wins.
|
|
670
|
+
*/
|
|
671
|
+
function hubHoldingFile(file, env = process.env) {
|
|
672
|
+
const dir = realPath(dirname(file));
|
|
673
|
+
const path = realPath(file) ?? (dir ? join(dir, basename(file)) : resolve(file));
|
|
674
|
+
let best = null;
|
|
675
|
+
for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (within(path, hub.data) && (!best || hub.data.length > best.data.length)) best = {
|
|
676
|
+
name,
|
|
677
|
+
root: hub.root,
|
|
678
|
+
data: hub.data
|
|
679
|
+
};
|
|
680
|
+
return best;
|
|
681
|
+
}
|
|
682
|
+
/** The name `dirs` is registered under, or null: how a hub whose config.yaml is not downloaded yet is told apart. */
|
|
683
|
+
function registeredName(dirs, env = process.env) {
|
|
684
|
+
for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (hub.data === dirs.data && hub.root === dirs.root) return name;
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* The registered hub whose repository is `root`, or null: how a hub reaches another hub its config.yaml lists under
|
|
689
|
+
* `hubs` by the path of that hub's repository.
|
|
690
|
+
*/
|
|
691
|
+
function hubAtRoot(root, env = process.env) {
|
|
692
|
+
const real = realPath(root);
|
|
693
|
+
if (!real) return null;
|
|
694
|
+
for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (hub.root === real) return {
|
|
695
|
+
name,
|
|
696
|
+
root: hub.root,
|
|
697
|
+
data: hub.data
|
|
698
|
+
};
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* A hub that still keeps its data inside the repository of `cwd` (`.mainflow/` at its main working tree's root or its
|
|
703
|
+
* own, or in `cwd` itself, as in a directory outside git), with the repository; null when there is none. Only the
|
|
704
|
+
* messages for an unregistered directory use it, to give the command that moves the data and registers the hub.
|
|
705
|
+
*/
|
|
706
|
+
function inRepositoryHubAt(cwd) {
|
|
707
|
+
const here = realPath(cwd) ?? resolve(cwd);
|
|
708
|
+
const checkout = gitCheckout(here);
|
|
709
|
+
for (const root of [
|
|
710
|
+
checkout?.main,
|
|
711
|
+
checkout?.current,
|
|
712
|
+
here
|
|
713
|
+
]) {
|
|
714
|
+
if (!root) continue;
|
|
715
|
+
const found = inRepositoryData(root);
|
|
716
|
+
if (found) return {
|
|
717
|
+
name: found.name,
|
|
718
|
+
root
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
/** What to tell the user when no registered hub claims `cwd`: how to register one, and the exact command when the repository still holds a hub's data. */
|
|
724
|
+
function notRegisteredMessage(cwd) {
|
|
725
|
+
const found = inRepositoryHubAt(cwd);
|
|
726
|
+
const lines = [`No registered hub claims ${realPath(cwd) ?? cwd}. Register the hub it belongs to with: ${REGISTER_USAGE} (the path is the hub repository; the hub's data is in ${userDataDir("<name>")}).`];
|
|
727
|
+
if (found) lines.push(`This repository still keeps the data of hub "${found.name}" in ${join(found.root, DATA_DIR)}; move it and register the hub with: mainflow hub migrate-data ${found.root}`);
|
|
728
|
+
return lines.join("\n");
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
110
731
|
//#region ../core/dist/ids/allocators.js
|
|
111
732
|
/** The next id after the highest one taken, e.g. `m003` after `m001` and `m002`. */
|
|
112
733
|
function nextId(prefix, existing) {
|
|
@@ -471,22 +1092,34 @@ function formatMismatch(found) {
|
|
|
471
1092
|
if (found === 3) return null;
|
|
472
1093
|
return found < 3 ? `data format version ${found} is older than this CLI's (3); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${found} is newer than this CLI supports (3); install the matching mainflow version in this hub`;
|
|
473
1094
|
}
|
|
1095
|
+
/** What the text of a config.yaml asks of a writer, read in one parse; a text that is not YAML throws the parser's error. */
|
|
1096
|
+
function configRequirements(text) {
|
|
1097
|
+
const data = parse$1(text);
|
|
1098
|
+
const found = data?.formatVersion;
|
|
1099
|
+
const range = data?.mainflow;
|
|
1100
|
+
const valid = typeof range === "string" && isMainflowRange(range);
|
|
1101
|
+
return {
|
|
1102
|
+
formatVersion: typeof found === "number" ? found : null,
|
|
1103
|
+
mainflow: valid ? range : null,
|
|
1104
|
+
mainflowInvalid: range !== void 0 && range !== null && !valid
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
474
1107
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
* instead of failing on it.
|
|
1108
|
+
* What the hub's config.yaml asks of a writer; null when the hub has none. A config.yaml that is not YAML is a
|
|
1109
|
+
* WriteError, so the sync server refuses the request instead of failing on it.
|
|
478
1110
|
*/
|
|
479
|
-
function
|
|
1111
|
+
function hubRequirements(hub) {
|
|
480
1112
|
const text = hub.read(CONFIG_FILE);
|
|
481
1113
|
if (text === null) return null;
|
|
482
|
-
let data;
|
|
483
1114
|
try {
|
|
484
|
-
|
|
1115
|
+
return configRequirements(text);
|
|
485
1116
|
} catch (error) {
|
|
486
1117
|
throw new WriteError(`refusing to write: ${hub.where(CONFIG_FILE)} cannot be parsed as YAML: ${error.message}`);
|
|
487
1118
|
}
|
|
488
|
-
|
|
489
|
-
|
|
1119
|
+
}
|
|
1120
|
+
/** The data format config.yaml declares; null when the hub has no config.yaml or it names no number (hubRequirements). */
|
|
1121
|
+
function hubFormatVersion(hub) {
|
|
1122
|
+
return hubRequirements(hub)?.formatVersion ?? null;
|
|
490
1123
|
}
|
|
491
1124
|
/**
|
|
492
1125
|
* Refuse a write to a hub whose data format is not the one this CLI writes: rewriting a document would drop what
|
|
@@ -498,6 +1131,22 @@ function requireWritableFormat(hub) {
|
|
|
498
1131
|
const reason = formatMismatch(found);
|
|
499
1132
|
if (reason) throw new WriteError(`refusing to write: ${reason}`);
|
|
500
1133
|
}
|
|
1134
|
+
/** The package the mainflow CLI is published as. */
|
|
1135
|
+
const CLI_PACKAGE = "mainflow";
|
|
1136
|
+
/**
|
|
1137
|
+
* The command that installs a mainflow CLI of the versions `range` names: pnpm installs the newest of them. Quoted for
|
|
1138
|
+
* the shell, since a range holds `>` and spaces.
|
|
1139
|
+
*/
|
|
1140
|
+
const upgradeCommand = (range) => `pnpm add -g '${CLI_PACKAGE}@${range}'`;
|
|
1141
|
+
/**
|
|
1142
|
+
* Why a CLI of `version`, null for one that did not say, may not write to a hub whose config.yaml asks for the mainflow
|
|
1143
|
+
* versions `required` (HubRequirements.mainflow), with the command that installs one it takes; null when it may.
|
|
1144
|
+
* `after` ends the message: what the sender does next, for a write it sent, so a caller that only warns gives its own.
|
|
1145
|
+
*/
|
|
1146
|
+
function versionMismatch(required, version, after = ", then send the write again") {
|
|
1147
|
+
if (version !== null && satisfies(version, required, MAINFLOW_RANGE)) return null;
|
|
1148
|
+
return `this hub takes writes from mainflow ${required} (mainflow in .mainflow/config.yaml), and the request came from ${version === null ? "a mainflow that does not say its version" : `mainflow ${version}`}; install one with ${upgradeCommand(required)}${after}`;
|
|
1149
|
+
}
|
|
501
1150
|
//#endregion
|
|
502
1151
|
//#region ../core/dist/ops/protocol.js
|
|
503
1152
|
/** `now` of a request: a local minute and its offset from UTC, `Z` for UTC itself. */
|
|
@@ -534,9 +1183,23 @@ const ROUTES = {
|
|
|
534
1183
|
* refused then: the server holds the right content already. GET: download them. Neither carries the protocol version:
|
|
535
1184
|
* the content is named by its own hash.
|
|
536
1185
|
*/
|
|
537
|
-
blob: (hub, hash) => `/hub/${hub}/blob/${hash}
|
|
1186
|
+
blob: (hub, hash) => `/hub/${hub}/blob/${hash}`,
|
|
1187
|
+
/** POST: a page of the hub's change log (ChangeLogRequest, answered with ChangeLogPage) */
|
|
1188
|
+
changes: (hub) => `/hub/${hub}/changes`,
|
|
1189
|
+
/** POST: put the hub, or a path of it, back to what it held after a seq, as a new change (RestoreRequest, answered with RestoreResponse) */
|
|
1190
|
+
restore: (hub) => `/hub/${hub}/restore`,
|
|
1191
|
+
/** POST: convert the hub to the data format of the server's mainflow (MigrateRequest, answered with MigrateResponse) */
|
|
1192
|
+
migrate: (hub) => `/hub/${hub}/migrate`,
|
|
1193
|
+
/** POST: the migration or restore the server runs in batches, or ran last (RewriteRequest, answered with RewriteStatus) */
|
|
1194
|
+
rewrite: (hub) => `/hub/${hub}/rewrite`
|
|
538
1195
|
};
|
|
539
1196
|
/**
|
|
1197
|
+
* The header with the version of the mainflow CLI a request comes from, like 0.43.0, which every request and the
|
|
1198
|
+
* WebSocket's upgrade carry. A hub whose config.yaml names the mainflow versions that may write to it (`mainflow`)
|
|
1199
|
+
* refuses a write without it, or from a version outside them (`client-outdated`).
|
|
1200
|
+
*/
|
|
1201
|
+
const VERSION_HEADER = "x-mainflow-version";
|
|
1202
|
+
/**
|
|
540
1203
|
* The service token of Cloudflare Access, which stands in front of the sync server and lets through only the requests
|
|
541
1204
|
* that carry a valid one: every request, the WebSocket's upgrade included, sends both headers. The sync server itself
|
|
542
1205
|
* checks no credentials, so one deployed without Access in front serves anyone who knows its address.
|
|
@@ -617,116 +1280,25 @@ const SOCKET_MAX_AGE_CLOSE = {
|
|
|
617
1280
|
code: 4e3,
|
|
618
1281
|
reason: "the connection reached its maximum age; reconnect"
|
|
619
1282
|
};
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const LOCK_WAIT_MS = 5e3;
|
|
623
|
-
const LOCK_STALE_MS = 3e4;
|
|
624
|
-
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
625
|
-
/**
|
|
626
|
-
* Run `fn` while holding the hub-wide write lock in `.mainflow/local/`. When the lock had to create that
|
|
627
|
-
* directory and `fn` refuses the write, the directory goes again, so a refused write leaves the hub as it was.
|
|
628
|
-
*/
|
|
629
|
-
function withLock(root, fn) {
|
|
630
|
-
const dir = join(root, DATA_DIR, "local");
|
|
631
|
-
const created = mkdirSync(dir, { recursive: true }) !== void 0;
|
|
632
|
-
try {
|
|
633
|
-
return holdLock(dir, fn);
|
|
634
|
-
} catch (error) {
|
|
635
|
-
if (created) removeIfEmpty(dir);
|
|
636
|
-
throw error;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
function removeIfEmpty(dir) {
|
|
640
|
-
try {
|
|
641
|
-
rmdirSync(dir);
|
|
642
|
-
} catch {}
|
|
643
|
-
}
|
|
644
|
-
function holdLock(dir, fn) {
|
|
645
|
-
const lock = join(dir, "write.lock");
|
|
646
|
-
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
647
|
-
let vanished = 0;
|
|
648
|
-
for (;;) try {
|
|
649
|
-
const fd = openSync(lock, "wx");
|
|
650
|
-
writeSync(fd, String(process.pid));
|
|
651
|
-
closeSync(fd);
|
|
652
|
-
break;
|
|
653
|
-
} catch (error) {
|
|
654
|
-
if (error.code === "ENOENT") {
|
|
655
|
-
mkdirSync(dir, { recursive: true });
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
if (error.code !== "EEXIST") throw error;
|
|
659
|
-
const held = statSync(lock, { throwIfNoEntry: false });
|
|
660
|
-
if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
|
|
661
|
-
rmSync(lock, { force: true });
|
|
662
|
-
continue;
|
|
663
|
-
}
|
|
664
|
-
if (Date.now() > deadline) throw new WriteError(`hub is locked by another writer (${lock}); retry in a moment`);
|
|
665
|
-
sleep(held ? 50 : Math.min(2 ** vanished++, 50));
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
return fn();
|
|
669
|
-
} finally {
|
|
670
|
-
rmSync(lock, { force: true });
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
/** Write through a temporary file and a rename, so a reader never sees half a file. */
|
|
674
|
-
function writeAtomic(file, text) {
|
|
675
|
-
const temp = `${file}.${process.pid}.tmp`;
|
|
676
|
-
writeFileSync(temp, text);
|
|
677
|
-
renameSync(temp, file);
|
|
678
|
-
}
|
|
679
|
-
/** The hub whose data directory is `{root}/.mainflow/`, read and written on the local file system. */
|
|
680
|
-
function nodeStorage(root) {
|
|
681
|
-
const file = (path) => join(root, DATA_DIR, path);
|
|
682
|
-
return {
|
|
683
|
-
read: (path) => {
|
|
684
|
-
const target = file(path);
|
|
685
|
-
return existsSync(target) ? readFileSync(target, "utf8") : null;
|
|
686
|
-
},
|
|
687
|
-
list: (dir) => {
|
|
688
|
-
const target = file(dir);
|
|
689
|
-
if (!existsSync(target)) return [];
|
|
690
|
-
return readdirSync(target, { withFileTypes: true }).map((entry) => ({
|
|
691
|
-
name: entry.name,
|
|
692
|
-
dir: entry.isDirectory()
|
|
693
|
-
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
694
|
-
},
|
|
695
|
-
where: file,
|
|
696
|
-
write: (path, text) => {
|
|
697
|
-
mkdirSync(dirname(file(path)), { recursive: true });
|
|
698
|
-
writeAtomic(file(path), text);
|
|
699
|
-
},
|
|
700
|
-
append: (path, text) => {
|
|
701
|
-
mkdirSync(dirname(file(path)), { recursive: true });
|
|
702
|
-
appendFileSync(file(path), text);
|
|
703
|
-
},
|
|
704
|
-
makeDir: (path) => {
|
|
705
|
-
mkdirSync(file(path), { recursive: true });
|
|
706
|
-
},
|
|
707
|
-
remove: (path) => {
|
|
708
|
-
rmSync(file(path), { force: true });
|
|
709
|
-
},
|
|
710
|
-
lock: (fn) => withLock(root, fn)
|
|
711
|
-
};
|
|
712
|
-
}
|
|
1283
|
+
/** The most changes a page of the change log carries. */
|
|
1284
|
+
const MAX_CHANGE_PAGE = 1e3;
|
|
713
1285
|
//#endregion
|
|
714
1286
|
//#region ../core/dist/io.js
|
|
715
1287
|
/** The time now to the minute with this machine's offset from UTC, the form sessions, actions and decisions use: 2026-09-24T15:30+08:00, or 2026-09-24T07:30Z in UTC. */
|
|
716
1288
|
const nowMinute = () => requestTime();
|
|
717
1289
|
/** The context an operation run from this machine gets: the local time, which is the user's. */
|
|
718
1290
|
const localContext = () => ({ now: nowMinute() });
|
|
719
|
-
/** Run an operation on the hub at `
|
|
720
|
-
function runOp(
|
|
1291
|
+
/** Run an operation on the hub at `dirs` under its write lock, apply its changes and return what it reports. */
|
|
1292
|
+
function runOp(dirs, run) {
|
|
721
1293
|
const ctx = localContext();
|
|
722
|
-
return applyOp(nodeStorage(
|
|
1294
|
+
return applyOp(nodeStorage(dirs), (hub) => {
|
|
723
1295
|
requireWritableFormat(hub);
|
|
724
1296
|
return run(hub, ctx);
|
|
725
1297
|
}).value;
|
|
726
1298
|
}
|
|
727
1299
|
/** Run an operation that must not take the lock, such as one creating the hub the lock would live in. */
|
|
728
|
-
function runUnlocked(
|
|
729
|
-
const storage = nodeStorage(
|
|
1300
|
+
function runUnlocked(dirs, run) {
|
|
1301
|
+
const storage = nodeStorage(dirs);
|
|
730
1302
|
const result = run(storage, localContext());
|
|
731
1303
|
commit(storage, result.changes);
|
|
732
1304
|
return result.value;
|
|
@@ -751,13 +1323,13 @@ function readFrontMatter(file, schema) {
|
|
|
751
1323
|
//#endregion
|
|
752
1324
|
//#region ../core/dist/resolve.js
|
|
753
1325
|
/** The absolute path of a path inside the hub's data directory. */
|
|
754
|
-
const onDisk = (
|
|
755
|
-
const mainlineDir = (
|
|
756
|
-
const taskDir = (
|
|
757
|
-
const mainlineFile = (
|
|
758
|
-
const taskFile = (
|
|
759
|
-
const groupFile = (
|
|
760
|
-
const readConfig = (
|
|
1326
|
+
const onDisk = (dirs, path) => join(dirs.data, path);
|
|
1327
|
+
const mainlineDir = (dirs, id) => onDisk(dirs, mainlineDirIn(nodeStorage(dirs), id));
|
|
1328
|
+
const taskDir = (dirs, mainline, task) => onDisk(dirs, taskDirIn(nodeStorage(dirs), mainline, task));
|
|
1329
|
+
const mainlineFile = (dirs, id) => join(mainlineDir(dirs, id), MAINLINE_FILE);
|
|
1330
|
+
const taskFile = (dirs, mainline, task) => join(taskDir(dirs, mainline, task), TASK_FILE);
|
|
1331
|
+
const groupFile = (dirs, id) => onDisk(dirs, groupFileIn(nodeStorage(dirs), id));
|
|
1332
|
+
const readConfig = (dirs) => readConfigIn(nodeStorage(dirs));
|
|
761
1333
|
//#endregion
|
|
762
1334
|
//#region ../core/dist/refs.js
|
|
763
1335
|
const itemStatus = (item) => item.done ? "done" : item.dropped ? "dropped" : "open";
|
|
@@ -822,8 +1394,7 @@ function refIndex(hub) {
|
|
|
822
1394
|
}
|
|
823
1395
|
return index;
|
|
824
1396
|
}
|
|
825
|
-
|
|
826
|
-
/** Absolute root of a registered hub; `path` is relative to the registering hub's root. */
|
|
1397
|
+
/** Absolute path of the repository of a hub config.yaml lists; `path` is relative to the listing hub's repository. */
|
|
827
1398
|
const remoteRoot = (root, path) => resolve(root, path);
|
|
828
1399
|
/** Documents in `hub` whose links point at `hubName`'s documents; `hubName` undefined means links without a prefix. */
|
|
829
1400
|
function linksInto(hub, hubName) {
|
|
@@ -1070,7 +1641,9 @@ function touchesWithoutRepo(mainlines) {
|
|
|
1070
1641
|
}
|
|
1071
1642
|
/**
|
|
1072
1643
|
* Open tasks of different open mainlines whose `touches` overlap in the same repo and which have no depends-on
|
|
1073
|
-
* between them in either direction, written on the mainline or on the task.
|
|
1644
|
+
* between them in either direction, written on the mainline or on the task. The opening briefing lists them for the
|
|
1645
|
+
* session serving the mainline; check does not warn, and a commit that runs into the overlap is coordinated between
|
|
1646
|
+
* the sessions rather than by adding a depends-on. Tasks without a repo are not compared,
|
|
1074
1647
|
* since their paths have no root to share; touchesWithoutRepo names them.
|
|
1075
1648
|
*/
|
|
1076
1649
|
function touchOverlaps(mainlines, edges) {
|
|
@@ -1156,12 +1729,12 @@ function withSection(markdown, heading, text) {
|
|
|
1156
1729
|
* Read config.yaml and check its format version; throws a HubError naming what is wrong when the hub cannot be
|
|
1157
1730
|
* used at all. The interface answers 503 for it before loading anything else (docs/ui.md "壞檔與讀取失敗").
|
|
1158
1731
|
*/
|
|
1159
|
-
function loadConfig(
|
|
1160
|
-
const file = join(
|
|
1161
|
-
const where =
|
|
1732
|
+
function loadConfig(dirs) {
|
|
1733
|
+
const file = join(dirs.data, "config.yaml");
|
|
1734
|
+
const where = hubPath(dirs, file);
|
|
1162
1735
|
let data;
|
|
1163
1736
|
try {
|
|
1164
|
-
data = parse$1(readText(file) ?? "");
|
|
1737
|
+
data = parse$1(readText$1(file) ?? "");
|
|
1165
1738
|
} catch (error) {
|
|
1166
1739
|
throw new HubError("invalid config.yaml", [{
|
|
1167
1740
|
file: where,
|
|
@@ -1180,7 +1753,7 @@ function loadConfig(root) {
|
|
|
1180
1753
|
if (mismatch) throw new HubError(mismatch, []);
|
|
1181
1754
|
return config;
|
|
1182
1755
|
}
|
|
1183
|
-
function loadHub(
|
|
1756
|
+
function loadHub(dirs, options = {}) {
|
|
1184
1757
|
const problems = [];
|
|
1185
1758
|
const warnings = [];
|
|
1186
1759
|
/** References checked once everything they may point at is loaded */
|
|
@@ -1188,12 +1761,12 @@ function loadHub(root, options = {}) {
|
|
|
1188
1761
|
/** Documents whose file is there but failed to load; references into them are not reported twice */
|
|
1189
1762
|
const unreadable = /* @__PURE__ */ new Set();
|
|
1190
1763
|
const idOf = (path) => ENTRY_DIR.exec(path.split("/").at(-1)?.replace(/\.md$/, "") ?? "")?.[1];
|
|
1191
|
-
const dataDir =
|
|
1192
|
-
const rel = (path) =>
|
|
1764
|
+
const dataDir = dirs.data;
|
|
1765
|
+
const rel = (path) => hubPath(dirs, path);
|
|
1193
1766
|
const read = (file, schema, raw) => {
|
|
1194
1767
|
let data = raw;
|
|
1195
1768
|
if (data === void 0) try {
|
|
1196
|
-
data = parse$1(readText(file) ?? "");
|
|
1769
|
+
data = parse$1(readText$1(file) ?? "");
|
|
1197
1770
|
} catch (error) {
|
|
1198
1771
|
problems.push({
|
|
1199
1772
|
file: rel(file),
|
|
@@ -1219,12 +1792,12 @@ function loadHub(root, options = {}) {
|
|
|
1219
1792
|
message: `directory name must be "${id}-{slug}" in kebab-case`
|
|
1220
1793
|
});
|
|
1221
1794
|
};
|
|
1222
|
-
const config = loadConfig(
|
|
1223
|
-
problems.push(...repoPathProblems(root, config));
|
|
1795
|
+
const config = loadConfig(dirs);
|
|
1796
|
+
problems.push(...repoPathProblems(dirs.root, config));
|
|
1224
1797
|
const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
|
|
1225
1798
|
/** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
|
|
1226
1799
|
const readDocument = (file, schema) => {
|
|
1227
|
-
const text = readText(file);
|
|
1800
|
+
const text = readText$1(file);
|
|
1228
1801
|
if (text === null) {
|
|
1229
1802
|
problems.push({
|
|
1230
1803
|
file: rel(file),
|
|
@@ -1261,7 +1834,7 @@ function loadHub(root, options = {}) {
|
|
|
1261
1834
|
};
|
|
1262
1835
|
};
|
|
1263
1836
|
const mainlines = [];
|
|
1264
|
-
for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
|
|
1837
|
+
for (const mainlineDir of subdirs$1(join(dataDir, "mainlines"))) {
|
|
1265
1838
|
const mainline = readDocument(join(mainlineDir, MAINLINE_FILE), mainlineSchema);
|
|
1266
1839
|
if (!mainline) {
|
|
1267
1840
|
const id = idOf(mainlineDir);
|
|
@@ -1282,7 +1855,7 @@ function loadHub(root, options = {}) {
|
|
|
1282
1855
|
}
|
|
1283
1856
|
}
|
|
1284
1857
|
const tasks = [];
|
|
1285
|
-
for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
|
|
1858
|
+
for (const taskDir of subdirs$1(join(mainlineDir, "tasks"))) {
|
|
1286
1859
|
const task = readDocument(join(taskDir, TASK_FILE), taskSchema);
|
|
1287
1860
|
if (!task) {
|
|
1288
1861
|
unreadable.add(`${mainline.id}/${idOf(taskDir)}`);
|
|
@@ -1389,7 +1962,7 @@ function loadHub(root, options = {}) {
|
|
|
1389
1962
|
message: "expected \"YYYY-MM-DDTHH:mm | target | operation | result [| ref]\""
|
|
1390
1963
|
});
|
|
1391
1964
|
});
|
|
1392
|
-
const remotes = options.remotes === false ? [] : loadRemotes(root, config, problems, warnings);
|
|
1965
|
+
const remotes = options.remotes === false ? [] : loadRemotes(dirs.root, config, problems, warnings);
|
|
1393
1966
|
warnings.push(...groupWarnings(config.hub, groups, mainlines, hubIssues, remotes));
|
|
1394
1967
|
for (const doc of [...mainlines, ...hubIssues]) if (doc.group && splitGroup(doc.group, config.hub).hub !== void 0) pending.push({
|
|
1395
1968
|
ref: doc.group,
|
|
@@ -1423,21 +1996,14 @@ function loadHub(root, options = {}) {
|
|
|
1423
1996
|
message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
|
|
1424
1997
|
});
|
|
1425
1998
|
}
|
|
1426
|
-
for (const o of touchOverlaps(mainlines, deps.edges)) {
|
|
1427
|
-
const pairs = o.paths.map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join(", ");
|
|
1428
|
-
warnings.push({
|
|
1429
|
-
file: o.file,
|
|
1430
|
-
field: "touches",
|
|
1431
|
-
message: `${o.tasks[0]} and ${o.tasks[1]} both touch ${pairs} in repo ${o.repo}, and neither depends on the other; coordinate before both run, or add a depends-on link`
|
|
1432
|
-
});
|
|
1433
|
-
}
|
|
1434
1999
|
for (const t of touchesWithoutRepo(mainlines)) warnings.push({
|
|
1435
2000
|
file: t.file,
|
|
1436
2001
|
field: "repo",
|
|
1437
2002
|
message: `${t.task} lists touches but no repo, so its paths are not compared with other tasks; add the repo they are relative to`
|
|
1438
2003
|
});
|
|
1439
2004
|
return {
|
|
1440
|
-
root,
|
|
2005
|
+
root: dirs.root,
|
|
2006
|
+
data: dirs.data,
|
|
1441
2007
|
config,
|
|
1442
2008
|
mainlines,
|
|
1443
2009
|
issues: hubIssues,
|
|
@@ -1482,7 +2048,10 @@ function groupWarnings(hubName, groups, mainlines, issues, remotes) {
|
|
|
1482
2048
|
}] : [];
|
|
1483
2049
|
});
|
|
1484
2050
|
}
|
|
1485
|
-
/**
|
|
2051
|
+
/**
|
|
2052
|
+
* Load every hub registered in config.yaml, read-only and without its own remotes. Each is found by its repository,
|
|
2053
|
+
* `root` joined with the path config.yaml gives it, in this machine's hub registry, which names its data directory.
|
|
2054
|
+
*/
|
|
1486
2055
|
function loadRemotes(root, config, problems, warnings) {
|
|
1487
2056
|
const file = `${DATA_DIR}/config.yaml`;
|
|
1488
2057
|
return Object.entries(config.hubs ?? {}).map(([name, path]) => {
|
|
@@ -1502,7 +2071,8 @@ function loadRemotes(root, config, problems, warnings) {
|
|
|
1502
2071
|
unavailable: "this hub"
|
|
1503
2072
|
};
|
|
1504
2073
|
}
|
|
1505
|
-
|
|
2074
|
+
const registered = hubAtRoot(remote.root);
|
|
2075
|
+
if (!registered) {
|
|
1506
2076
|
warnings.push({
|
|
1507
2077
|
file,
|
|
1508
2078
|
field: `hubs.${name}`,
|
|
@@ -1514,7 +2084,7 @@ function loadRemotes(root, config, problems, warnings) {
|
|
|
1514
2084
|
};
|
|
1515
2085
|
}
|
|
1516
2086
|
try {
|
|
1517
|
-
const hub = loadHub(
|
|
2087
|
+
const hub = loadHub(registered, { remotes: false });
|
|
1518
2088
|
if (hub.config.hub !== name) {
|
|
1519
2089
|
problems.push({
|
|
1520
2090
|
file,
|
|
@@ -1649,7 +2219,8 @@ function checkReferences(input) {
|
|
|
1649
2219
|
/**
|
|
1650
2220
|
* A listed repository counts as part of the hub, so the PreToolUse hook lets grants act inside it. A path that
|
|
1651
2221
|
* resolves to / or to a directory above the hub would put everything under it in scope. A path that does not
|
|
1652
|
-
* exist is fine: another machine may not have cloned that repository yet.
|
|
2222
|
+
* exist is fine: another machine may not have cloned that repository yet. The hub repository is always a primary
|
|
2223
|
+
* repository of its hub, so it cannot be listed as a reference.
|
|
1653
2224
|
*/
|
|
1654
2225
|
function repoPathProblems(root, config) {
|
|
1655
2226
|
const real = (path) => {
|
|
@@ -1660,7 +2231,7 @@ function repoPathProblems(root, config) {
|
|
|
1660
2231
|
}
|
|
1661
2232
|
};
|
|
1662
2233
|
const hub = real(root) ?? root;
|
|
1663
|
-
return
|
|
2234
|
+
return listedRepos(config.repos).flatMap(({ name, path, role }) => {
|
|
1664
2235
|
const dir = real(resolve(root, path));
|
|
1665
2236
|
if (dir === null) return [];
|
|
1666
2237
|
const problem = (message) => [{
|
|
@@ -1670,6 +2241,7 @@ function repoPathProblems(root, config) {
|
|
|
1670
2241
|
}];
|
|
1671
2242
|
if (dir === parse(dir).root) return problem(`"${path}" resolves to the file system root; list the repository's own directory`);
|
|
1672
2243
|
if (hub.startsWith(dir.endsWith(sep) ? dir : dir + sep)) return problem(`"${path}" resolves to ${dir}, above the hub; list the repository's own directory`);
|
|
2244
|
+
if (role === "reference" && dir === hub) return problem(`"${path}" is the hub repository, which always belongs to this hub; list it without role: reference`);
|
|
1673
2245
|
return [];
|
|
1674
2246
|
});
|
|
1675
2247
|
}
|
|
@@ -1815,8 +2387,9 @@ function addRepo(hub, input) {
|
|
|
1815
2387
|
requireSlug(input.name);
|
|
1816
2388
|
const draft = new Draft(hub);
|
|
1817
2389
|
editYaml(draft, CONFIG_FILE, configSchema, (c) => {
|
|
1818
|
-
|
|
1819
|
-
|
|
2390
|
+
const listed = c.repos[input.name];
|
|
2391
|
+
if (listed !== void 0 && repoPath(listed) !== input.path) throw new WriteError(`repo "${input.name}" is already registered as ${repoPath(listed)}`);
|
|
2392
|
+
c.repos[input.name] ??= input.path;
|
|
1820
2393
|
});
|
|
1821
2394
|
return draft.result(input.name);
|
|
1822
2395
|
}
|
|
@@ -3001,7 +3574,7 @@ function requireMainlineTarget(hub, until) {
|
|
|
3001
3574
|
/** The action an `until` waits for; throws when the condition matches none of the forms. */
|
|
3002
3575
|
function untilTarget(until) {
|
|
3003
3576
|
const parsed = parseUntil(until);
|
|
3004
|
-
if (!parsed) throw new WriteError(`--until takes a condition like ${UNTIL_FORMS.map((f) => f.format).join(", ")}, got "${until}"`);
|
|
3577
|
+
if (!parsed) throw new WriteError(removedUntil(until) ?? `--until takes a condition like ${UNTIL_FORMS.map((f) => f.format).join(", ")}, got "${until}"`);
|
|
3005
3578
|
return parsed.kind === "action-done" ? `${parsed.session}#${parsed.action}` : void 0;
|
|
3006
3579
|
}
|
|
3007
3580
|
function updateAction(hub, input, ctx) {
|
|
@@ -3062,6 +3635,9 @@ const rename = (o, from, to) => {
|
|
|
3062
3635
|
}
|
|
3063
3636
|
};
|
|
3064
3637
|
const isClosed = (kind, status) => CLOSED_STATUS[kind].includes(status);
|
|
3638
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3639
|
+
/** How messages and the report name a file: from the hub root, the way the CLI user sees them. */
|
|
3640
|
+
const rel = (path) => `${DATA_DIR}/${path}`;
|
|
3065
3641
|
/** Drop the file's own H1, which format 2 replaces with the title in the front matter. */
|
|
3066
3642
|
const withoutTitle = (markdown) => markdown.replace(/^\s*# [^\n]*\n/, "").replace(/^\n+/, "");
|
|
3067
3643
|
/**
|
|
@@ -3105,122 +3681,198 @@ function migrateListItem(item) {
|
|
|
3105
3681
|
return item;
|
|
3106
3682
|
}
|
|
3107
3683
|
const openItem = (item) => !item.done && !item.dropped;
|
|
3684
|
+
/** The text of `path`; a WriteError when the hub holds no file there. */
|
|
3685
|
+
function readFile(hub, path) {
|
|
3686
|
+
const text = hub.read(path);
|
|
3687
|
+
if (text === null) throw new WriteError(`${hub.where(path)} does not exist`);
|
|
3688
|
+
return text;
|
|
3689
|
+
}
|
|
3690
|
+
/** `text` read as YAML, which must be a mapping: a WriteError naming `path` otherwise. */
|
|
3691
|
+
function yamlRecord(text, path) {
|
|
3692
|
+
let data;
|
|
3693
|
+
try {
|
|
3694
|
+
data = parse$1(text);
|
|
3695
|
+
} catch (error) {
|
|
3696
|
+
throw new WriteError(`${rel(path)} is not valid YAML: ${error.message}`);
|
|
3697
|
+
}
|
|
3698
|
+
if (!isRecord(data)) throw new WriteError(`${rel(path)} is not a YAML mapping of attributes, so it cannot be migrated; fix it and migrate again`);
|
|
3699
|
+
return data;
|
|
3700
|
+
}
|
|
3701
|
+
/** A Markdown document's front matter, which must be a YAML mapping, and its body: a WriteError naming `path` otherwise. */
|
|
3702
|
+
function frontMatterOf(text, path) {
|
|
3703
|
+
let split;
|
|
3704
|
+
try {
|
|
3705
|
+
split = splitFrontMatter(text);
|
|
3706
|
+
} catch (error) {
|
|
3707
|
+
throw new WriteError(`${rel(path)} has front matter that is not valid YAML: ${error.message}`);
|
|
3708
|
+
}
|
|
3709
|
+
if (!isRecord(split.data)) throw new WriteError(`${rel(path)} has no front matter, the YAML mapping of its attributes between --- lines at its top, so it cannot be migrated; add it, or move the file out of the hub, and migrate again`);
|
|
3710
|
+
return {
|
|
3711
|
+
data: split.data,
|
|
3712
|
+
body: split.body
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
/** The list at `field` of a format 1 document, every item a mapping; none for a field it lacks. */
|
|
3716
|
+
function itemsOf(data, field, path) {
|
|
3717
|
+
const value = data[field] ?? [];
|
|
3718
|
+
if (!Array.isArray(value) || !value.every(isRecord)) throw new WriteError(`${rel(path)}: ${field} is not a list of items, so it cannot be migrated; fix it and migrate again`);
|
|
3719
|
+
return value;
|
|
3720
|
+
}
|
|
3721
|
+
const subdirs = (hub, dir) => hub.list(dir).filter((e) => e.dir).map((e) => `${dir}/${e.name}`);
|
|
3722
|
+
const markdownIn = (hub, dir) => hub.list(dir).map((e) => e.name).filter((n) => n.endsWith(".md"));
|
|
3108
3723
|
/**
|
|
3109
|
-
*
|
|
3110
|
-
*
|
|
3724
|
+
* The data format config.yaml names, to migrate from: 1 or 2. A WriteError for a hub already of this CLI's format, of
|
|
3725
|
+
* a format this CLI does not convert, or without a readable config.yaml.
|
|
3111
3726
|
*/
|
|
3112
|
-
function
|
|
3113
|
-
const
|
|
3114
|
-
const read = (path) => {
|
|
3115
|
-
const text = hub.read(path);
|
|
3116
|
-
if (text === null) throw new WriteError(`${hub.where(path)} does not exist`);
|
|
3117
|
-
return text;
|
|
3118
|
-
};
|
|
3119
|
-
const subdirs = (dir) => hub.list(dir).filter((e) => e.dir).map((e) => `${dir}/${e.name}`);
|
|
3120
|
-
const markdownIn = (dir) => hub.list(dir).map((e) => e.name).filter((n) => n.endsWith(".md"));
|
|
3121
|
-
const config = parse$1(read(CONFIG_FILE));
|
|
3727
|
+
function migrationSource(hub) {
|
|
3728
|
+
const config = yamlRecord(readFile(hub, CONFIG_FILE), CONFIG_FILE);
|
|
3122
3729
|
const from = Number(config.formatVersion);
|
|
3123
3730
|
if (from === 3) throw new WriteError(`${rel(CONFIG_FILE)} is already format 3; nothing to migrate`);
|
|
3124
3731
|
if (from !== 1 && from !== 2) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 or 2 to 3`);
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3732
|
+
return from;
|
|
3733
|
+
}
|
|
3734
|
+
/** A mainline's directory: mainline.yaml and context.md into mainline.md, each task.yaml and notes.md into task.md, each decision in its new shape. */
|
|
3735
|
+
function convertMainline(hub, mainlineDir, part) {
|
|
3128
3736
|
const document = (file, schema, data, body) => {
|
|
3129
|
-
writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
|
|
3130
|
-
};
|
|
3131
|
-
|
|
3132
|
-
const
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3737
|
+
part.writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
|
|
3738
|
+
};
|
|
3739
|
+
const yamlFile = `${mainlineDir}/mainline.yaml`;
|
|
3740
|
+
const yamlText = hub.read(yamlFile);
|
|
3741
|
+
if (yamlText === null) return;
|
|
3742
|
+
const m = yamlRecord(yamlText, yamlFile);
|
|
3743
|
+
dropNull(m, "archivedAt");
|
|
3744
|
+
if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
|
|
3745
|
+
const criteria = itemsOf(m, "criteria", yamlFile).map(migrateListItem);
|
|
3746
|
+
m.criteria = criteria;
|
|
3747
|
+
const id = String(m.id);
|
|
3748
|
+
const open = !isClosed("mainline", m.status);
|
|
3749
|
+
if (open) {
|
|
3750
|
+
for (const c of criteria) if (openItem(c) && !c.doneWhen) part.missing.push(`${id}#${c.id} doneWhen`);
|
|
3751
|
+
}
|
|
3752
|
+
const contextFile = `${mainlineDir}/context.md`;
|
|
3753
|
+
const contextText = hub.read(contextFile);
|
|
3754
|
+
const context = contextText === null ? "" : withoutTitle(contextText);
|
|
3755
|
+
document(`${mainlineDir}/${MAINLINE_FILE}`, mainlineSchema, m, `\n${context.trimEnd()}\n`);
|
|
3756
|
+
part.removes.push(yamlFile, ...contextText === null ? [] : [contextFile]);
|
|
3757
|
+
for (const taskDir of subdirs(hub, `${mainlineDir}/tasks`)) {
|
|
3758
|
+
const taskYaml = `${taskDir}/task.yaml`;
|
|
3759
|
+
const taskText = hub.read(taskYaml);
|
|
3760
|
+
if (taskText === null) continue;
|
|
3761
|
+
const t = yamlRecord(taskText, taskYaml);
|
|
3762
|
+
const content = typeof t.content === "string" ? t.content.trim() : "";
|
|
3763
|
+
drop(t, "content");
|
|
3764
|
+
dropNull(t, "parent", "repo");
|
|
3765
|
+
t.created ??= m.created;
|
|
3766
|
+
const checklist = itemsOf(t, "checklist", taskYaml).map(migrateListItem);
|
|
3767
|
+
t.checklist = checklist;
|
|
3768
|
+
const doneDays = checklist.map((c) => c.doneAt).filter((d) => typeof d === "string").sort();
|
|
3769
|
+
if (isClosed("task", t.status) && doneDays.length) t.closedAt = doneDays.at(-1);
|
|
3770
|
+
let body = content ? withSection(TASK_BODY, "目標", content) : TASK_BODY;
|
|
3771
|
+
const notesFile = `${taskDir}/notes.md`;
|
|
3772
|
+
const notes = hub.read(notesFile);
|
|
3773
|
+
if (notes !== null) {
|
|
3774
|
+
body = foldNotes(body, notes);
|
|
3775
|
+
part.removes.push(notesFile);
|
|
3776
|
+
}
|
|
3777
|
+
const taskRef = `${id}/${String(t.id)}`;
|
|
3778
|
+
if (open && !isClosed("task", t.status)) {
|
|
3779
|
+
for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(body, section)) part.missing.push(`${taskRef} ${section}`);
|
|
3780
|
+
for (const c of checklist) if (openItem(c) && !c.doneWhen) part.missing.push(`${taskRef}#${c.id} doneWhen`);
|
|
3781
|
+
}
|
|
3782
|
+
document(`${taskDir}/${TASK_FILE}`, taskSchema, t, body);
|
|
3783
|
+
part.removes.push(taskYaml);
|
|
3784
|
+
}
|
|
3785
|
+
const decisionsDir = `${mainlineDir}/decisions`;
|
|
3786
|
+
for (const name of markdownIn(hub, decisionsDir)) {
|
|
3787
|
+
const file = `${decisionsDir}/${name}`;
|
|
3788
|
+
const { data: d, body } = frontMatterOf(readFile(hub, file), file);
|
|
3789
|
+
rename(d, "date", "created");
|
|
3790
|
+
if (d.status !== "proposed") d.decidedAt = d.created;
|
|
3791
|
+
dropNull(d, "supersededBy");
|
|
3792
|
+
rename(d, "blocks", "affects");
|
|
3793
|
+
document(file, decisionSchema, d, body);
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* Convert one unit of a format 1 hub (migrationUnit) as it is now: what it writes and removes, which only its own files.
|
|
3798
|
+
* A WriteError, naming the file, for a document it cannot read or one that would be invalid in the current format.
|
|
3799
|
+
*/
|
|
3800
|
+
function convertUnit(hub, unit) {
|
|
3801
|
+
const part = {
|
|
3802
|
+
writes: /* @__PURE__ */ new Map(),
|
|
3803
|
+
removes: [],
|
|
3804
|
+
missing: []
|
|
3805
|
+
};
|
|
3806
|
+
if (unit.kind === "mainline") {
|
|
3807
|
+
convertMainline(hub, unit.path, part);
|
|
3808
|
+
return part;
|
|
3809
|
+
}
|
|
3810
|
+
const { data, body } = frontMatterOf(readFile(hub, unit.path), unit.path);
|
|
3811
|
+
if (unit.kind === "issue") rename(data, "closedBy", "resolution");
|
|
3812
|
+
else {
|
|
3813
|
+
rename(data, "date", "created");
|
|
3814
|
+
rename(data, "tags", "labels");
|
|
3815
|
+
}
|
|
3816
|
+
part.writes.set(unit.path, frontMatterText(validate(unit.kind === "issue" ? issueSchema : findingSchema, data, rel(unit.path)), body));
|
|
3817
|
+
return part;
|
|
3818
|
+
}
|
|
3819
|
+
/**
|
|
3820
|
+
* config.yaml in the current format, from the format `from` it names (migrationSource): its path and text. Format 2
|
|
3821
|
+
* to 3 changes how new times are written, not the times already there, so only the version changes, and the line is
|
|
3822
|
+
* replaced in place to keep the rest of config.yaml byte for byte.
|
|
3823
|
+
*/
|
|
3824
|
+
function convertConfig(hub, from) {
|
|
3825
|
+
const configText = readFile(hub, CONFIG_FILE);
|
|
3826
|
+
const config = yamlRecord(configText, CONFIG_FILE);
|
|
3208
3827
|
const versionLine = /^formatVersion:[ \t]*2[ \t]*$/m;
|
|
3209
3828
|
config.formatVersion = 3;
|
|
3210
3829
|
if (from === 2 && versionLine.test(configText)) {
|
|
3211
3830
|
validate(configSchema, config, rel(CONFIG_FILE));
|
|
3212
|
-
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3831
|
+
return [CONFIG_FILE, configText.replace(versionLine, `formatVersion: 3`)];
|
|
3832
|
+
}
|
|
3833
|
+
return [CONFIG_FILE, toYaml(validate(configSchema, config, rel(CONFIG_FILE)))];
|
|
3834
|
+
}
|
|
3835
|
+
/** The report of the migration from format `from` that `parts` make up, in their order: each file by its path from the hub root. */
|
|
3836
|
+
function migrationReport(from, parts) {
|
|
3837
|
+
return {
|
|
3218
3838
|
from,
|
|
3219
3839
|
to: 3,
|
|
3220
|
-
written: [...writes.keys()].map(rel),
|
|
3221
|
-
removed: removes.map(rel),
|
|
3222
|
-
missingDefinitions: missing
|
|
3840
|
+
written: parts.flatMap((part) => [...part.writes.keys()].map(rel)),
|
|
3841
|
+
removed: parts.flatMap((part) => part.removes.map(rel)),
|
|
3842
|
+
missingDefinitions: parts.flatMap((part) => part.missing)
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Convert a format 1 or 2 hub to the current format. Times already written are never changed. The report lists what the changes write and remove; a dry
|
|
3847
|
+
* run reports them without applying them.
|
|
3848
|
+
*/
|
|
3849
|
+
function migrateHub$1(hub) {
|
|
3850
|
+
const from = migrationSource(hub);
|
|
3851
|
+
const parts = [];
|
|
3852
|
+
if (from === 1) {
|
|
3853
|
+
for (const path of subdirs(hub, MAINLINES_DIR)) parts.push(convertUnit(hub, {
|
|
3854
|
+
kind: "mainline",
|
|
3855
|
+
path
|
|
3856
|
+
}));
|
|
3857
|
+
for (const name of markdownIn(hub, ISSUES_DIR)) parts.push(convertUnit(hub, {
|
|
3858
|
+
kind: "issue",
|
|
3859
|
+
path: `${ISSUES_DIR}/${name}`
|
|
3860
|
+
}));
|
|
3861
|
+
for (const name of markdownIn(hub, FINDINGS_DIR)) parts.push(convertUnit(hub, {
|
|
3862
|
+
kind: "finding",
|
|
3863
|
+
path: `${FINDINGS_DIR}/${name}`
|
|
3864
|
+
}));
|
|
3865
|
+
}
|
|
3866
|
+
const [configFile, configText] = convertConfig(hub, from);
|
|
3867
|
+
parts.push({
|
|
3868
|
+
writes: /* @__PURE__ */ new Map([[configFile, configText]]),
|
|
3869
|
+
removes: [],
|
|
3870
|
+
missing: []
|
|
3223
3871
|
});
|
|
3872
|
+
const draft = new Draft(hub);
|
|
3873
|
+
for (const part of parts) for (const [file, text] of part.writes) draft.write(file, text);
|
|
3874
|
+
for (const part of parts) for (const file of part.removes) draft.remove(file);
|
|
3875
|
+
return draft.result(migrationReport(from, parts));
|
|
3224
3876
|
}
|
|
3225
3877
|
//#endregion
|
|
3226
3878
|
//#region ../core/dist/ops/operations.js
|
|
@@ -3284,6 +3936,68 @@ function applyOperation(storage, name, input, ctx) {
|
|
|
3284
3936
|
});
|
|
3285
3937
|
}
|
|
3286
3938
|
//#endregion
|
|
3939
|
+
//#region ../core/dist/ops/scope.js
|
|
3940
|
+
/** Names programs leave behind while writing: editor swap and backup files, temporary files, Finder's. */
|
|
3941
|
+
const TRANSIENT = [
|
|
3942
|
+
/\.tmp$/,
|
|
3943
|
+
/\.sw[a-p]$/,
|
|
3944
|
+
/\.swx$/,
|
|
3945
|
+
/~$/,
|
|
3946
|
+
/^\.#/,
|
|
3947
|
+
/^4913$/,
|
|
3948
|
+
/\.crswap$/,
|
|
3949
|
+
/^\.DS_Store$/
|
|
3950
|
+
];
|
|
3951
|
+
/** Directories never synced, nor anything in them, at any depth. */
|
|
3952
|
+
const OWN_DIRS = /* @__PURE__ */ new Set(["scratch", "node_modules"]);
|
|
3953
|
+
/**
|
|
3954
|
+
* The parts of `path`, or null when it is no plain relative path, as no file of the hub has, or when a directory in
|
|
3955
|
+
* it, `path` itself too when `dir`, is one never synced (OWN_DIRS). `.git` is never synced, as a directory or as the
|
|
3956
|
+
* file a git worktree has.
|
|
3957
|
+
*/
|
|
3958
|
+
function partsOf(path, dir) {
|
|
3959
|
+
const parts = path.split("/");
|
|
3960
|
+
if (parts.some((part) => part === "" || part === "." || part === ".." || part === ".git")) return null;
|
|
3961
|
+
return (dir ? parts : parts.slice(0, -1)).some((part) => OWN_DIRS.has(part)) ? null : parts;
|
|
3962
|
+
}
|
|
3963
|
+
/** Whether the file at `path` is synced: not in a directory never synced, not in local/ but local/sessions/, and not a transient name. */
|
|
3964
|
+
function syncedPath(path) {
|
|
3965
|
+
const parts = partsOf(path, false);
|
|
3966
|
+
if (parts === null) return false;
|
|
3967
|
+
if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
|
|
3968
|
+
return !TRANSIENT.some((pattern) => pattern.test(parts.at(-1)));
|
|
3969
|
+
}
|
|
3970
|
+
/** Whether the directory at `path` may hold synced files, so a scan walks into it: local/ itself and local/sessions/ but nothing else in local/. */
|
|
3971
|
+
function mayHoldSynced(path) {
|
|
3972
|
+
if (path === "") return true;
|
|
3973
|
+
if (partsOf(path, true) === null) return false;
|
|
3974
|
+
return path === "local" || !path.startsWith("local/") || path === "local/sessions";
|
|
3975
|
+
}
|
|
3976
|
+
/** Whether the directory at `path` is synced: one that may hold synced files, apart from .mainflow/ and local/, which every machine has of its own. */
|
|
3977
|
+
const syncedDir = (path) => path !== "" && path !== "local" && mayHoldSynced(path);
|
|
3978
|
+
/** Whether the file or directory (`dir`) at `path` is of the sync scope: syncedPath for a file, syncedDir for a directory. */
|
|
3979
|
+
const syncedEntry = (path, dir) => dir ? syncedDir(path) : syncedPath(path);
|
|
3980
|
+
//#endregion
|
|
3981
|
+
//#region ../core/dist/ops/digest.js
|
|
3982
|
+
const DIGEST_DIGITS = 40;
|
|
3983
|
+
/** Whether the entry at `path`, a directory's when `dir`, is in the digest: one of the sync scope. */
|
|
3984
|
+
const digestCovers = syncedEntry;
|
|
3985
|
+
/** The term of one entry in the digest: a file by its path and content hash, a directory (`hash` null) by its path. */
|
|
3986
|
+
function digestTerm(path, hash) {
|
|
3987
|
+
return BigInt(`0x${contentHash(hash === null ? `d\0${path}` : `f\0${path}\0${hash}`)}`);
|
|
3988
|
+
}
|
|
3989
|
+
/** A digest as it is stored and sent: 40 hex digits. */
|
|
3990
|
+
function digestText(value) {
|
|
3991
|
+
return value.toString(16).padStart(DIGEST_DIGITS, "0");
|
|
3992
|
+
}
|
|
3993
|
+
/** The digest of the files (path to content hash) and directories given, those it covers (digestCovers). */
|
|
3994
|
+
function hubDigest(files, dirs) {
|
|
3995
|
+
let value = 0n;
|
|
3996
|
+
for (const [path, hash] of files) if (digestCovers(path, false)) value ^= digestTerm(path, hash);
|
|
3997
|
+
for (const dir of dirs) if (digestCovers(dir, true)) value ^= digestTerm(dir, null);
|
|
3998
|
+
return digestText(value);
|
|
3999
|
+
}
|
|
4000
|
+
//#endregion
|
|
3287
4001
|
//#region ../core/dist/ids/renumber.js
|
|
3288
4002
|
/** The id at the end of a reference: c003 in m001/t002#c003, a002 in claude-code-1a2b3c4d#a002. */
|
|
3289
4003
|
const lastId = (ref) => /([a-z]\d{3})$/.exec(ref)?.[1] ?? "";
|
|
@@ -3669,7 +4383,7 @@ const sessionPath = (id) => `/sessions/${id}`;
|
|
|
3669
4383
|
/** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
|
|
3670
4384
|
const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
|
|
3671
4385
|
/** A file under a mainline directory, or a reference to a mainline, task, decision, issue, finding or group. */
|
|
3672
|
-
const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[ifw]\d{3})(?![\w\]])/g;
|
|
4386
|
+
const MENTION = /\.mainflow\/(?:[a-z0-9-]+\/)?mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[ifw]\d{3})(?![\w\]])/g;
|
|
3673
4387
|
/** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
|
|
3674
4388
|
function linkMentions(text, context) {
|
|
3675
4389
|
const index = refIndex(context.hub);
|
|
@@ -3752,7 +4466,7 @@ function decorateReply(text, context) {
|
|
|
3752
4466
|
return line ? `${body}\n\n${line}\n` : `${body}\n`;
|
|
3753
4467
|
}
|
|
3754
4468
|
/** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
|
|
3755
|
-
const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
|
|
4469
|
+
const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/(?:[a-z0-9-]+\/)?mainlines/.test(text) || endsWithMarker(text);
|
|
3756
4470
|
//#endregion
|
|
3757
4471
|
//#region ../core/dist/grants.js
|
|
3758
4472
|
/** Mainline statuses whose grants apply. */
|
|
@@ -3763,7 +4477,7 @@ const GRANTING_GROUP_STATUS = ["open"];
|
|
|
3763
4477
|
* The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
|
|
3764
4478
|
* must fail closed catches it and asks.
|
|
3765
4479
|
*/
|
|
3766
|
-
function grantScope(
|
|
4480
|
+
function grantScope(dirs, session) {
|
|
3767
4481
|
if (!session) return {
|
|
3768
4482
|
ok: false,
|
|
3769
4483
|
reason: "這場會話沒有 mainflow 紀錄"
|
|
@@ -3795,11 +4509,11 @@ function grantScope(root, session) {
|
|
|
3795
4509
|
const grants = [];
|
|
3796
4510
|
const served = {};
|
|
3797
4511
|
const closed = [];
|
|
3798
|
-
const hubName = readConfig(
|
|
4512
|
+
const hubName = readConfig(dirs).hub;
|
|
3799
4513
|
const groups = new Map(direct);
|
|
3800
4514
|
const reachedByMainline = /* @__PURE__ */ new Set();
|
|
3801
4515
|
for (const id of mainlines) {
|
|
3802
|
-
const mainline = readFrontMatter(mainlineFile(
|
|
4516
|
+
const mainline = readFrontMatter(mainlineFile(dirs, id), mainlineSchema).data;
|
|
3803
4517
|
if (!GRANTING_STATUS.includes(mainline.status)) {
|
|
3804
4518
|
closed.push(`${id} 是 ${mainline.status}`);
|
|
3805
4519
|
continue;
|
|
@@ -3819,7 +4533,7 @@ function grantScope(root, session) {
|
|
|
3819
4533
|
for (const [id, via] of groups) {
|
|
3820
4534
|
let group;
|
|
3821
4535
|
try {
|
|
3822
|
-
group = readFrontMatter(groupFile(
|
|
4536
|
+
group = readFrontMatter(groupFile(dirs, id), groupSchema).data;
|
|
3823
4537
|
} catch (error) {
|
|
3824
4538
|
if (reachedByMainline.has(id)) continue;
|
|
3825
4539
|
throw error;
|
|
@@ -3878,9 +4592,9 @@ function matchGrants(scope, requests) {
|
|
|
3878
4592
|
* the hook allows for it; empty when the session has none or they cannot be read, since a briefing must not fail
|
|
3879
4593
|
* on them.
|
|
3880
4594
|
*/
|
|
3881
|
-
function grantLines(
|
|
4595
|
+
function grantLines(dirs, session) {
|
|
3882
4596
|
try {
|
|
3883
|
-
const result = grantScope(
|
|
4597
|
+
const result = grantScope(dirs, session);
|
|
3884
4598
|
if (!result.ok) return [];
|
|
3885
4599
|
return result.scope.grants.map((g) => {
|
|
3886
4600
|
const parsed = parseGrantTarget(g.grant.action, g.grant.target);
|
|
@@ -3894,26 +4608,26 @@ function grantLines(root, session) {
|
|
|
3894
4608
|
//#endregion
|
|
3895
4609
|
//#region ../core/dist/sessions.js
|
|
3896
4610
|
/** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
|
|
3897
|
-
function loadSessions(
|
|
3898
|
-
return readSessionsIn(nodeStorage(
|
|
4611
|
+
function loadSessions(dirs) {
|
|
4612
|
+
return readSessionsIn(nodeStorage(dirs));
|
|
3899
4613
|
}
|
|
3900
|
-
function readSession(
|
|
3901
|
-
return readSessionIn(nodeStorage(
|
|
4614
|
+
function readSession(dirs, id) {
|
|
4615
|
+
return readSessionIn(nodeStorage(dirs), id);
|
|
3902
4616
|
}
|
|
3903
4617
|
/**
|
|
3904
4618
|
* Find the action a reference names. A handoff drops the action and names its new place in the note, so the
|
|
3905
4619
|
* reference is followed there: a wait on the old reference keeps working after the work moved to another session.
|
|
3906
4620
|
*/
|
|
3907
|
-
function resolveAction(
|
|
3908
|
-
return resolveActionIn(nodeStorage(
|
|
4621
|
+
function resolveAction(dirs, ref) {
|
|
4622
|
+
return resolveActionIn(nodeStorage(dirs), ref);
|
|
3909
4623
|
}
|
|
3910
4624
|
/** Where a session's transcript is on disk: a platform records an absolute path, a hub may record one relative to its root. */
|
|
3911
|
-
function transcriptPath(
|
|
3912
|
-
return isAbsolute(transcript) ? transcript : join(root, transcript);
|
|
4625
|
+
function transcriptPath(dirs, transcript) {
|
|
4626
|
+
return isAbsolute(transcript) ? transcript : join(dirs.root, transcript);
|
|
3913
4627
|
}
|
|
3914
4628
|
/** Remember the digest of what the last turn reminder listed in full; null forgets it, so the next reminder lists everything again. */
|
|
3915
|
-
function setReminderDigest(
|
|
3916
|
-
runOp(
|
|
4629
|
+
function setReminderDigest(dirs, id, digest) {
|
|
4630
|
+
runOp(dirs, (hub) => setReminderDigest$1(hub, {
|
|
3917
4631
|
id,
|
|
3918
4632
|
digest
|
|
3919
4633
|
}));
|
|
@@ -3992,14 +4706,16 @@ function readSyncState(data) {
|
|
|
3992
4706
|
}
|
|
3993
4707
|
/**
|
|
3994
4708
|
* Kinds telling that a file did not reach the server. Once the file is the confirmed version it did, whenever it last
|
|
3995
|
-
* changed: a binary file an older server skipped is uploaded later as it is, without being written again
|
|
4709
|
+
* changed: a binary file an older server skipped is uploaded later as it is, without being written again, and so is a
|
|
4710
|
+
* write the hub refused for the CLI's version, once a mainflow it takes runs here.
|
|
3996
4711
|
*/
|
|
3997
4712
|
const UNSENT_KINDS = /* @__PURE__ */ new Set([
|
|
3998
4713
|
"skipped-binary",
|
|
3999
4714
|
"skipped-large",
|
|
4000
4715
|
"binary-text-file",
|
|
4001
4716
|
"upload-failing",
|
|
4002
|
-
"download-failed"
|
|
4717
|
+
"download-failed",
|
|
4718
|
+
"client-outdated"
|
|
4003
4719
|
]);
|
|
4004
4720
|
/**
|
|
4005
4721
|
* Whether the files show a notice settled. With a saved text, once the saved text is removed. Without one, when the
|
|
@@ -4024,18 +4740,18 @@ function settled(data, notice, state) {
|
|
|
4024
4740
|
return false;
|
|
4025
4741
|
}
|
|
4026
4742
|
}
|
|
4027
|
-
const handledFile = (
|
|
4743
|
+
const handledFile = (dirs) => join(dirs.data, HANDLED_FILE);
|
|
4028
4744
|
/** The keys marked handled by hand. */
|
|
4029
|
-
function readHandledKeys(
|
|
4030
|
-
const keys = readJson$1(handledFile(
|
|
4745
|
+
function readHandledKeys(dirs) {
|
|
4746
|
+
const keys = readJson$1(handledFile(dirs), {}).keys;
|
|
4031
4747
|
return keys && typeof keys === "object" && !Array.isArray(keys) ? keys : {};
|
|
4032
4748
|
}
|
|
4033
4749
|
/** Every warn-level line of notices.jsonl, newest first, each with how it was handled if it was; unreadable lines are skipped. */
|
|
4034
|
-
function readNotices(
|
|
4035
|
-
const data =
|
|
4750
|
+
function readNotices(dirs) {
|
|
4751
|
+
const data = dirs.data;
|
|
4036
4752
|
const file = join(data, NOTICES_FILE);
|
|
4037
4753
|
if (!existsSync(file)) return [];
|
|
4038
|
-
const manual = readHandledKeys(
|
|
4754
|
+
const manual = readHandledKeys(dirs);
|
|
4039
4755
|
const state = readSyncState(data);
|
|
4040
4756
|
const notices = [];
|
|
4041
4757
|
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
@@ -4050,19 +4766,19 @@ function readNotices(root) {
|
|
|
4050
4766
|
return notices.reverse();
|
|
4051
4767
|
}
|
|
4052
4768
|
/** The notices nobody has handled yet, newest first. */
|
|
4053
|
-
const unhandledNotices = (
|
|
4769
|
+
const unhandledNotices = (dirs) => readNotices(dirs).filter((n) => n.handled === void 0);
|
|
4054
4770
|
/**
|
|
4055
4771
|
* Mark notices handled by hand, writing handled.json by a rename so a reader never sees half of it. `keys` names the
|
|
4056
4772
|
* notices (noticeKey); without it every notice unhandled now is marked. Returns the number marked.
|
|
4057
4773
|
*/
|
|
4058
|
-
function markNoticesHandled(
|
|
4059
|
-
const unhandled = new Set(unhandledNotices(
|
|
4774
|
+
function markNoticesHandled(dirs, keys) {
|
|
4775
|
+
const unhandled = new Set(unhandledNotices(dirs).map(noticeKey));
|
|
4060
4776
|
const wanted = (keys ?? [...unhandled]).filter((key) => unhandled.has(key));
|
|
4061
4777
|
if (wanted.length === 0) return 0;
|
|
4062
|
-
const marked = readHandledKeys(
|
|
4778
|
+
const marked = readHandledKeys(dirs);
|
|
4063
4779
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4064
4780
|
for (const key of wanted) marked[key] = now;
|
|
4065
|
-
const file = handledFile(
|
|
4781
|
+
const file = handledFile(dirs);
|
|
4066
4782
|
mkdirSync(dirname(file), { recursive: true });
|
|
4067
4783
|
const temp = `${file}.${process.pid}.tmp`;
|
|
4068
4784
|
writeFileSync(temp, JSON.stringify({ keys: marked }, null, 1));
|
|
@@ -4071,12 +4787,12 @@ function markNoticesHandled(root, keys) {
|
|
|
4071
4787
|
}
|
|
4072
4788
|
//#endregion
|
|
4073
4789
|
//#region ../core/dist/briefing.js
|
|
4074
|
-
function describe(
|
|
4790
|
+
function describe(dirs, ref) {
|
|
4075
4791
|
const parsed = parseRef(ref);
|
|
4076
4792
|
if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion" && parsed?.kind !== "groupCriterion") return null;
|
|
4077
4793
|
try {
|
|
4078
4794
|
if (parsed.kind === "groupCriterion") {
|
|
4079
|
-
const group = readFrontMatter(groupFile(
|
|
4795
|
+
const group = readFrontMatter(groupFile(dirs, parsed.group), groupSchema).data;
|
|
4080
4796
|
const criterion = group.criteria?.find((r) => r.id === parsed.criterion);
|
|
4081
4797
|
return criterion ? {
|
|
4082
4798
|
ref,
|
|
@@ -4085,7 +4801,7 @@ function describe(root, ref) {
|
|
|
4085
4801
|
where: `${group.id} ${group.title} › 解決條件`
|
|
4086
4802
|
} : null;
|
|
4087
4803
|
}
|
|
4088
|
-
const mainline = readFrontMatter(mainlineFile(
|
|
4804
|
+
const mainline = readFrontMatter(mainlineFile(dirs, parsed.mainline), mainlineSchema).data;
|
|
4089
4805
|
if (parsed.kind === "criterion") {
|
|
4090
4806
|
const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
|
|
4091
4807
|
return criterion ? {
|
|
@@ -4095,7 +4811,7 @@ function describe(root, ref) {
|
|
|
4095
4811
|
where: `${mainline.id} ${mainline.title} › 解決條件`
|
|
4096
4812
|
} : null;
|
|
4097
4813
|
}
|
|
4098
|
-
const task = readFrontMatter(taskFile(
|
|
4814
|
+
const task = readFrontMatter(taskFile(dirs, parsed.mainline, parsed.task), taskSchema).data;
|
|
4099
4815
|
const item = task.checklist.find((c) => c.id === parsed.item);
|
|
4100
4816
|
if (!item) return null;
|
|
4101
4817
|
return {
|
|
@@ -4109,11 +4825,11 @@ function describe(root, ref) {
|
|
|
4109
4825
|
}
|
|
4110
4826
|
}
|
|
4111
4827
|
/** The criteria a served checklist item pushes forward, as m{NNN}#r{NNN}; empty for a criterion or an item that cannot be read. */
|
|
4112
|
-
function criteriaOf(
|
|
4828
|
+
function criteriaOf(dirs, ref) {
|
|
4113
4829
|
const parsed = parseRef(ref);
|
|
4114
4830
|
if (parsed?.kind !== "checklistItem") return [];
|
|
4115
4831
|
try {
|
|
4116
|
-
return (readFrontMatter(taskFile(
|
|
4832
|
+
return (readFrontMatter(taskFile(dirs, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
|
|
4117
4833
|
} catch {
|
|
4118
4834
|
return [];
|
|
4119
4835
|
}
|
|
@@ -4123,9 +4839,9 @@ function criteriaOf(root, ref) {
|
|
|
4123
4839
|
* serves pushes forward, named as the item with its criterion. Two items serving the same criterion do not meet,
|
|
4124
4840
|
* since splitting a criterion into items is how work on it runs in parallel.
|
|
4125
4841
|
*/
|
|
4126
|
-
function sharedServes(
|
|
4842
|
+
function sharedServes(dirs, mine, theirs) {
|
|
4127
4843
|
const shared = mine.filter((ref) => theirs.includes(ref));
|
|
4128
|
-
const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(
|
|
4844
|
+
const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(dirs, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
|
|
4129
4845
|
const mineCriteria = mine.filter((ref) => parseRef(ref)?.kind === "criterion");
|
|
4130
4846
|
const theirCriteria = theirs.filter((ref) => parseRef(ref)?.kind === "criterion");
|
|
4131
4847
|
return [.../* @__PURE__ */ new Set([
|
|
@@ -4135,12 +4851,12 @@ function sharedServes(root, mine, theirs) {
|
|
|
4135
4851
|
])];
|
|
4136
4852
|
}
|
|
4137
4853
|
/** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
|
|
4138
|
-
function queueWarnings(
|
|
4854
|
+
function queueWarnings(dirs, session, cli) {
|
|
4139
4855
|
const warnings = [];
|
|
4140
4856
|
const doing = session.actions.filter((a) => a.status === "doing");
|
|
4141
4857
|
if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
|
|
4142
4858
|
for (const action of doing) {
|
|
4143
|
-
const served = action.serves.map((ref) => describe(
|
|
4859
|
+
const served = action.serves.map((ref) => describe(dirs, ref));
|
|
4144
4860
|
if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c 或 m#r> 改正。`);
|
|
4145
4861
|
else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選或達成。做完了就執行 ${cli} action set ${action.id} --status done。`);
|
|
4146
4862
|
}
|
|
@@ -4163,14 +4879,14 @@ function waitingLine(a) {
|
|
|
4163
4879
|
return `${a.id} ${a.do}${parts.length > 0 ? `(${parts.join(";")})` : ""}`;
|
|
4164
4880
|
}
|
|
4165
4881
|
/** Mainlines a session could be pushing forward. */
|
|
4166
|
-
const openMainlines = (
|
|
4882
|
+
const openMainlines = (dirs) => loadHub(dirs).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
|
|
4167
4883
|
/** How recently a session's transcript must have been written for the session to count as possibly still running. */
|
|
4168
4884
|
const RUNNING_WINDOW_MS = 9e5;
|
|
4169
4885
|
/** Whether the session's transcript was written within the running window; false when no transcript is recorded or readable. */
|
|
4170
|
-
function mayBeRunning(
|
|
4886
|
+
function mayBeRunning(dirs, session) {
|
|
4171
4887
|
if (!session.transcript) return false;
|
|
4172
4888
|
try {
|
|
4173
|
-
return Date.now() - statSync(transcriptPath(
|
|
4889
|
+
return Date.now() - statSync(transcriptPath(dirs, session.transcript)).mtimeMs < RUNNING_WINDOW_MS;
|
|
4174
4890
|
} catch {
|
|
4175
4891
|
return false;
|
|
4176
4892
|
}
|
|
@@ -4195,21 +4911,21 @@ function family(sessions, id) {
|
|
|
4195
4911
|
return members.add(id);
|
|
4196
4912
|
}
|
|
4197
4913
|
/** The actions in this session that a blocked action elsewhere waits for, named by `waitingFor` or by an `until` of kind action-done. */
|
|
4198
|
-
function waitedFor(
|
|
4914
|
+
function waitedFor(dirs, blocked) {
|
|
4199
4915
|
const until = blocked.blockedBy?.until ? parseUntil(blocked.blockedBy.until) : null;
|
|
4200
4916
|
const refs = [waitingForAction(blocked.blockedBy?.waitingFor), until?.kind === "action-done" ? `${until.session}#${until.action}` : void 0];
|
|
4201
|
-
return [...new Set(refs.filter((r) => r !== void 0).map((r) => resolveAction(
|
|
4917
|
+
return [...new Set(refs.filter((r) => r !== void 0).map((r) => resolveAction(dirs, r).ref))];
|
|
4202
4918
|
}
|
|
4203
4919
|
/**
|
|
4204
4920
|
* One line for each blocked action in another session that waits for an action of this one, followed through
|
|
4205
4921
|
* handoffs, so the session being waited for sees it without the other side having to send a message. An action
|
|
4206
4922
|
* already done or dropped has nothing left to deliver, so a wait on it is not shown.
|
|
4207
4923
|
*/
|
|
4208
|
-
function waiterLines(
|
|
4924
|
+
function waiterLines(dirs, session) {
|
|
4209
4925
|
const lines = [];
|
|
4210
|
-
for (const other of loadSessions(
|
|
4926
|
+
for (const other of loadSessions(dirs).sessions) {
|
|
4211
4927
|
if (other.id === session.id || other.archivedAt || other.status === "handed-off") continue;
|
|
4212
|
-
for (const blocked of other.actions.filter((a) => a.status === "blocked")) for (const ref of waitedFor(
|
|
4928
|
+
for (const blocked of other.actions.filter((a) => a.status === "blocked")) for (const ref of waitedFor(dirs, blocked)) {
|
|
4213
4929
|
const mine = ref.startsWith(`${session.id}#`) ? session.actions.find((a) => `${session.id}#${a.id}` === ref) : void 0;
|
|
4214
4930
|
if (!mine || mine.status === "done" || mine.status === "dropped") continue;
|
|
4215
4931
|
const until = blocked.blockedBy?.until ? `。條件:${blocked.blockedBy.until}` : "";
|
|
@@ -4223,16 +4939,16 @@ function waiterLines(root, session) {
|
|
|
4223
4939
|
* action also serves: the same item or criterion, or a criterion and an item that pushes it forward. Sessions in the same delegation tree are left out: a subagent working on the item
|
|
4224
4940
|
* its parent handed it is not a second claim.
|
|
4225
4941
|
*/
|
|
4226
|
-
function duplicateClaimLines(
|
|
4227
|
-
const { sessions } = loadSessions(
|
|
4942
|
+
function duplicateClaimLines(dirs, sessionId, actionId) {
|
|
4943
|
+
const { sessions } = loadSessions(dirs);
|
|
4228
4944
|
const action = sessions.find((s) => s.id === sessionId)?.actions.find((a) => a.id === actionId);
|
|
4229
4945
|
if (!action || action.serves.length === 0) return [];
|
|
4230
4946
|
const related = family(sessions, sessionId);
|
|
4231
4947
|
const lines = [];
|
|
4232
4948
|
for (const other of sessions) {
|
|
4233
|
-
if (related.has(other.id) || !mayBeRunning(
|
|
4949
|
+
if (related.has(other.id) || !mayBeRunning(dirs, other)) continue;
|
|
4234
4950
|
for (const theirs of other.actions.filter((a) => a.status === "doing")) {
|
|
4235
|
-
const shared = sharedServes(
|
|
4951
|
+
const shared = sharedServes(dirs, action.serves, theirs.serves);
|
|
4236
4952
|
if (shared.length > 0) lines.push(`⚠ ${other.id}#${theirs.id}(15 分鐘內有活動)也在做 ${shared.join("、")},先協調再開始`);
|
|
4237
4953
|
}
|
|
4238
4954
|
}
|
|
@@ -4242,30 +4958,30 @@ function duplicateClaimLines(root, sessionId, actionId) {
|
|
|
4242
4958
|
* Lines naming the other top-level sessions that still hold unfinished actions, with the command that takes them
|
|
4243
4959
|
* over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
|
|
4244
4960
|
*/
|
|
4245
|
-
function handoffLines(
|
|
4246
|
-
const others = loadSessions(
|
|
4961
|
+
function handoffLines(dirs, session, cli) {
|
|
4962
|
+
const others = loadSessions(dirs).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
|
|
4247
4963
|
if (others.length === 0) return [];
|
|
4248
|
-
return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(
|
|
4964
|
+
return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(dirs, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isCarried).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
|
|
4249
4965
|
}
|
|
4250
|
-
const servedLine = (
|
|
4251
|
-
const s = describe(
|
|
4966
|
+
const servedLine = (dirs, action) => action.serves.map((ref) => {
|
|
4967
|
+
const s = describe(dirs, ref);
|
|
4252
4968
|
return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目或條件)`;
|
|
4253
4969
|
}).join(";") || "沒有指定服務的 checklist 項目或解決條件";
|
|
4254
4970
|
/**
|
|
4255
4971
|
* Lines for each pair of overlapping touches that involves a mainline an action in progress serves: the other
|
|
4256
4972
|
* mainline and task, and the paths they share. Empty when the session serves nothing that overlaps.
|
|
4257
4973
|
*/
|
|
4258
|
-
function overlapLines(
|
|
4974
|
+
function overlapLines(dirs, session) {
|
|
4259
4975
|
const mine = new Set(session.actions.filter((a) => a.status === "doing").flatMap((a) => a.serves.map((ref) => parseRef(ref))).flatMap((parsed) => parsed && "mainline" in parsed ? [parsed.mainline] : []));
|
|
4260
4976
|
if (mine.size === 0) return [];
|
|
4261
|
-
const hub = loadHub(
|
|
4977
|
+
const hub = loadHub(dirs, { remotes: false });
|
|
4262
4978
|
const lines = [];
|
|
4263
4979
|
for (const o of touchOverlaps(hub.mainlines, dependencyGraph(hub).edges)) {
|
|
4264
4980
|
const [own, other] = mine.has(o.tasks[0].split("/")[0]) ? [0, 1] : mine.has(o.tasks[1].split("/")[0]) ? [1, 0] : [-1, -1];
|
|
4265
4981
|
if (own < 0) continue;
|
|
4266
4982
|
const paths = o.paths.map(([a, b]) => own === 0 ? [a, b] : [b, a]).map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join("、");
|
|
4267
4983
|
const title = hub.mainlines.find((m) => m.id === o.tasks[other].split("/")[0])?.title;
|
|
4268
|
-
lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}
|
|
4984
|
+
lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}),提交時撞上就和對方的會話協調先後,不必記 depends-on`);
|
|
4269
4985
|
}
|
|
4270
4986
|
return lines;
|
|
4271
4987
|
}
|
|
@@ -4274,8 +4990,8 @@ const BRIEFING_NOTICES = 5;
|
|
|
4274
4990
|
/** Longest message summary of a notice in the briefing, in characters. */
|
|
4275
4991
|
const NOTICE_SUMMARY = 80;
|
|
4276
4992
|
/** The unhandled sync notices for the briefing: up to BRIEFING_NOTICES, the total, and where the page lists them; empty when none. */
|
|
4277
|
-
function syncNoticeLines(
|
|
4278
|
-
const notices = unhandledNotices(
|
|
4993
|
+
function syncNoticeLines(dirs) {
|
|
4994
|
+
const notices = unhandledNotices(dirs);
|
|
4279
4995
|
if (notices.length === 0) return [];
|
|
4280
4996
|
const summary = (message) => message.length > NOTICE_SUMMARY ? `${message.slice(0, NOTICE_SUMMARY)}…` : message;
|
|
4281
4997
|
return [
|
|
@@ -4285,13 +5001,13 @@ function syncNoticeLines(root) {
|
|
|
4285
5001
|
];
|
|
4286
5002
|
}
|
|
4287
5003
|
/** Full briefing for the start of a session, after resuming, clearing or compacting. */
|
|
4288
|
-
function sessionBriefing(
|
|
5004
|
+
function sessionBriefing(dirs, session, options) {
|
|
4289
5005
|
const { cli } = options;
|
|
4290
5006
|
const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
|
|
4291
5007
|
const doing = current(session);
|
|
4292
5008
|
const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
|
|
4293
5009
|
if (session.actions.length === 0) {
|
|
4294
|
-
const active = openMainlines(
|
|
5010
|
+
const active = openMainlines(dirs);
|
|
4295
5011
|
lines.push("這場會話還沒有行動。");
|
|
4296
5012
|
if (active.length > 0) {
|
|
4297
5013
|
lines.push("進行中的主脈絡:");
|
|
@@ -4300,21 +5016,21 @@ function sessionBriefing(root, session, options) {
|
|
|
4300
5016
|
}
|
|
4301
5017
|
} else {
|
|
4302
5018
|
lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
|
|
4303
|
-
if (doing) lines.push(`服務:${servedLine(
|
|
4304
|
-
const grants = grantLines(
|
|
5019
|
+
if (doing) lines.push(`服務:${servedLine(dirs, doing)}`);
|
|
5020
|
+
const grants = grantLines(dirs, session);
|
|
4305
5021
|
if (grants.length > 0) lines.push("目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants);
|
|
4306
5022
|
if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
|
|
4307
5023
|
const blocked = session.actions.filter((a) => a.status === "blocked");
|
|
4308
5024
|
if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
|
|
4309
5025
|
}
|
|
4310
|
-
lines.push(...waiterLines(
|
|
4311
|
-
const overlaps = overlapLines(
|
|
5026
|
+
lines.push(...waiterLines(dirs, session));
|
|
5027
|
+
const overlaps = overlapLines(dirs, session);
|
|
4312
5028
|
if (overlaps.length > 0) lines.push("目前行動服務的主脈絡和其他進行中的主脈絡預計動到同樣的路徑,開始改之前先和對方協調,或加 depends-on:", ...overlaps);
|
|
4313
|
-
if (!session.actions.some(isOpen)) lines.push(...handoffLines(
|
|
4314
|
-
const warnings = queueWarnings(
|
|
5029
|
+
if (!session.actions.some(isOpen)) lines.push(...handoffLines(dirs, session, cli));
|
|
5030
|
+
const warnings = queueWarnings(dirs, session, cli);
|
|
4315
5031
|
if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
|
|
4316
|
-
lines.push(...syncNoticeLines(
|
|
4317
|
-
lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。主脈絡還沒拆任務時(查核與規劃階段),--serves 指向解決條件 <m#r>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題>
|
|
5032
|
+
lines.push(...syncNoticeLines(dirs));
|
|
5033
|
+
lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。主脈絡還沒拆任務時(查核與規劃階段),--serves 指向解決條件 <m#r>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。這些檔案在 hub 的資料目錄 ${dirs.data},訊息裡的 .mainflow/ 指的就是這個目錄。`, `- 改完資料目錄裡的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
|
|
4318
5034
|
return lines.join("\n");
|
|
4319
5035
|
}
|
|
4320
5036
|
/**
|
|
@@ -4322,17 +5038,17 @@ function sessionBriefing(root, session, options) {
|
|
|
4322
5038
|
* Records a digest of the grants, waits and warnings it lists in the session file, only when the digest changes;
|
|
4323
5039
|
* `saveDigest` records it instead of writing the file here, as a host hook in remote mode sends it to the sync server.
|
|
4324
5040
|
*/
|
|
4325
|
-
function turnReminder(
|
|
4326
|
-
if (session.actions.length === 0) return openMainlines(
|
|
5041
|
+
function turnReminder(dirs, session, options) {
|
|
5042
|
+
if (session.actions.length === 0) return openMainlines(dirs).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
|
|
4327
5043
|
const doing = current(session);
|
|
4328
5044
|
const open = session.actions.filter(isOpen);
|
|
4329
5045
|
const blocked = session.actions.filter((a) => a.status === "blocked");
|
|
4330
5046
|
const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length > 0 ? `[mainflow] ${session.id} 目前沒有進行中的行動。` : blocked.length > 0 ? `[mainflow] ${session.id} 的行動在等外部條件:${blocked.map(waitingLine).join(";")}。有條件(until)的可以在背景執行 ${options.cli} wait <a編號> 等它成立。條件出現後用 ${options.cli} action set <a編號> --status doing 繼續;要先做別的事,用 ${options.cli} action add 列出行動。` : `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。`;
|
|
4331
5047
|
const header = headerLine(session);
|
|
4332
5048
|
const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
|
|
4333
|
-
const grants = grantLines(
|
|
4334
|
-
const waiters = waiterLines(
|
|
4335
|
-
const warnings = queueWarnings(
|
|
5049
|
+
const grants = grantLines(dirs, session);
|
|
5050
|
+
const waiters = waiterLines(dirs, session);
|
|
5051
|
+
const warnings = queueWarnings(dirs, session, options.cli);
|
|
4336
5052
|
const digest = contentHash(JSON.stringify([
|
|
4337
5053
|
doing?.id ?? null,
|
|
4338
5054
|
grants,
|
|
@@ -4340,9 +5056,9 @@ function turnReminder(root, session, options) {
|
|
|
4340
5056
|
warnings
|
|
4341
5057
|
]));
|
|
4342
5058
|
const repeated = session.reminderDigest === digest;
|
|
4343
|
-
if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(
|
|
5059
|
+
if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(dirs, session.id)) try {
|
|
4344
5060
|
if (options.saveDigest) options.saveDigest(digest);
|
|
4345
|
-
else setReminderDigest(
|
|
5061
|
+
else setReminderDigest(dirs, session.id, digest);
|
|
4346
5062
|
} catch {}
|
|
4347
5063
|
const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
|
|
4348
5064
|
const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
|
|
@@ -4358,66 +5074,8 @@ function turnReminder(root, session, options) {
|
|
|
4358
5074
|
}
|
|
4359
5075
|
//#endregion
|
|
4360
5076
|
//#region ../core/dist/waiting.js
|
|
4361
|
-
|
|
4362
|
-
const
|
|
4363
|
-
/** Whether git finds a repository at the hub root; false as well when git itself cannot run. */
|
|
4364
|
-
function inGitRepository(root) {
|
|
4365
|
-
try {
|
|
4366
|
-
execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
4367
|
-
cwd: root,
|
|
4368
|
-
stdio: "ignore"
|
|
4369
|
-
});
|
|
4370
|
-
return true;
|
|
4371
|
-
} catch {
|
|
4372
|
-
return false;
|
|
4373
|
-
}
|
|
4374
|
-
}
|
|
4375
|
-
/** Whether a commit on the branch has the text in its message; git runs at the hub root, so the branch is the hub repository's. */
|
|
4376
|
-
function commitState(root, branch, text) {
|
|
4377
|
-
let log;
|
|
4378
|
-
try {
|
|
4379
|
-
log = execFileSync("git", [
|
|
4380
|
-
"log",
|
|
4381
|
-
"--format=%h %s%n%b%x1e",
|
|
4382
|
-
branch,
|
|
4383
|
-
"--"
|
|
4384
|
-
], {
|
|
4385
|
-
cwd: root,
|
|
4386
|
-
encoding: "utf8",
|
|
4387
|
-
stdio: [
|
|
4388
|
-
"ignore",
|
|
4389
|
-
"pipe",
|
|
4390
|
-
"pipe"
|
|
4391
|
-
],
|
|
4392
|
-
maxBuffer: 268435456
|
|
4393
|
-
});
|
|
4394
|
-
} catch {
|
|
4395
|
-
if (!inGitRepository(root)) return {
|
|
4396
|
-
met: false,
|
|
4397
|
-
unreachable: true,
|
|
4398
|
-
detail: `the hub root ${root} is not in a git repository, so no commit can appear there`
|
|
4399
|
-
};
|
|
4400
|
-
return {
|
|
4401
|
-
met: false,
|
|
4402
|
-
detail: `branch ${branch} is not in the repository at the hub root yet`
|
|
4403
|
-
};
|
|
4404
|
-
}
|
|
4405
|
-
for (const record of log.split(RECORD)) {
|
|
4406
|
-
const entry = record.replace(/^\n/, "");
|
|
4407
|
-
const space = entry.indexOf(" ");
|
|
4408
|
-
const message = entry.slice(space + 1);
|
|
4409
|
-
if (space > 0 && message.includes(text)) return {
|
|
4410
|
-
met: true,
|
|
4411
|
-
detail: `commit ${entry.slice(0, space)} on ${branch} contains "${text}": ${message.split("\n")[0]}`
|
|
4412
|
-
};
|
|
4413
|
-
}
|
|
4414
|
-
return {
|
|
4415
|
-
met: false,
|
|
4416
|
-
detail: `no commit on ${branch} contains "${text}"`
|
|
4417
|
-
};
|
|
4418
|
-
}
|
|
4419
|
-
function actionState(root, ref) {
|
|
4420
|
-
const found = resolveAction(root, ref);
|
|
5077
|
+
function actionState(dirs, ref) {
|
|
5078
|
+
const found = resolveAction(dirs, ref);
|
|
4421
5079
|
if ("missing" in found) return {
|
|
4422
5080
|
met: false,
|
|
4423
5081
|
detail: found.missing
|
|
@@ -4442,16 +5100,16 @@ function actionState(root, ref) {
|
|
|
4442
5100
|
* hub is not registered here. A registered hub that is missing on this machine or fails to load may return, so
|
|
4443
5101
|
* that case keeps waiting. A prefix equal to this hub's own name reads the local hub.
|
|
4444
5102
|
*/
|
|
4445
|
-
function mainlineState(
|
|
5103
|
+
function mainlineState(dirs, text) {
|
|
4446
5104
|
const ref = parseRef(text);
|
|
4447
5105
|
if (ref?.kind !== "mainline") return {
|
|
4448
5106
|
met: false,
|
|
4449
5107
|
unreachable: true,
|
|
4450
5108
|
detail: `"${text}" is not a mainline reference like m001 or other-hub:m001`
|
|
4451
5109
|
};
|
|
4452
|
-
let target =
|
|
5110
|
+
let target = dirs;
|
|
4453
5111
|
try {
|
|
4454
|
-
const config = readConfig(
|
|
5112
|
+
const config = readConfig(dirs);
|
|
4455
5113
|
if (ref.hub !== void 0 && ref.hub !== config.hub) {
|
|
4456
5114
|
const path = config.hubs?.[ref.hub];
|
|
4457
5115
|
if (path === void 0) return {
|
|
@@ -4459,11 +5117,12 @@ function mainlineState(root, text) {
|
|
|
4459
5117
|
unreachable: true,
|
|
4460
5118
|
detail: `hub "${ref.hub}" is not registered, so ${text} can never be read; run "mainflow hub add ${ref.hub} <path>"`
|
|
4461
5119
|
};
|
|
4462
|
-
|
|
4463
|
-
if (!
|
|
5120
|
+
const registered = hubAtRoot(remoteRoot(dirs.root, path));
|
|
5121
|
+
if (!registered) return {
|
|
4464
5122
|
met: false,
|
|
4465
5123
|
detail: `hub "${ref.hub}" is not at ${path} on this machine, so ${text} cannot be read yet`
|
|
4466
5124
|
};
|
|
5125
|
+
target = registered;
|
|
4467
5126
|
}
|
|
4468
5127
|
} catch (error) {
|
|
4469
5128
|
return {
|
|
@@ -4503,13 +5162,15 @@ function mainlineState(root, text) {
|
|
|
4503
5162
|
detail: `mainline ${text} is ${status}`
|
|
4504
5163
|
};
|
|
4505
5164
|
}
|
|
4506
|
-
/**
|
|
4507
|
-
|
|
5165
|
+
/**
|
|
5166
|
+
* Check a wait condition once. A file is relative to the hub repository,
|
|
5167
|
+
* or in the data directory when named `.mainflow/{path}`.
|
|
5168
|
+
*/
|
|
5169
|
+
function untilState(dirs, until) {
|
|
4508
5170
|
switch (until.kind) {
|
|
4509
|
-
case "
|
|
4510
|
-
case "
|
|
4511
|
-
case "
|
|
4512
|
-
case "file": return existsSync(resolve(root, until.path)) ? {
|
|
5171
|
+
case "action-done": return actionState(dirs, `${until.session}#${until.action}`);
|
|
5172
|
+
case "mainline-done": return mainlineState(dirs, until.mainline);
|
|
5173
|
+
case "file": return existsSync(dataFile(dirs, until.path)) ? {
|
|
4513
5174
|
met: true,
|
|
4514
5175
|
detail: `${until.path} exists`
|
|
4515
5176
|
} : {
|
|
@@ -4520,9 +5181,9 @@ function untilState(root, until) {
|
|
|
4520
5181
|
}
|
|
4521
5182
|
//#endregion
|
|
4522
5183
|
//#region ../core/dist/write.js
|
|
4523
|
-
function initHub(
|
|
4524
|
-
runUnlocked(
|
|
4525
|
-
return
|
|
5184
|
+
function initHub(dirs, hub) {
|
|
5185
|
+
runUnlocked(dirs, (h) => initHub$1(h, { name: hub }));
|
|
5186
|
+
return dirs.data;
|
|
4526
5187
|
}
|
|
4527
5188
|
//#endregion
|
|
4528
5189
|
//#region ../core/dist/archive.js
|
|
@@ -7356,17 +8017,70 @@ async function fetchTranscripts(source, wanted) {
|
|
|
7356
8017
|
}
|
|
7357
8018
|
//#endregion
|
|
7358
8019
|
//#region ../core/dist/retro/receiver.js
|
|
7359
|
-
/**
|
|
8020
|
+
/**
|
|
8021
|
+
* Where a command reads the receiver from: the same names the measurement upload hooks use, so one setting serves both.
|
|
8022
|
+
* The address is the origin of the transcript upload URL; the Access service token is read from the environment first,
|
|
8023
|
+
* then from the macOS keychain.
|
|
8024
|
+
*/
|
|
7360
8025
|
const RECEIVER_ENV = {
|
|
7361
|
-
url: "
|
|
7362
|
-
accessClientId: "
|
|
7363
|
-
accessClientSecret: "
|
|
8026
|
+
url: "TRANSCRIPT_UPLOAD_URL",
|
|
8027
|
+
accessClientId: "TELEMETRY_ACCESS_CLIENT_ID",
|
|
8028
|
+
accessClientSecret: "TELEMETRY_ACCESS_CLIENT_SECRET"
|
|
7364
8029
|
};
|
|
7365
|
-
/** The
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
8030
|
+
/** The macOS keychain items (generic passwords) holding the same service token. */
|
|
8031
|
+
const RECEIVER_KEYCHAIN = {
|
|
8032
|
+
accessClientId: "telemetry-access-client-id",
|
|
8033
|
+
accessClientSecret: "telemetry-access-client-secret"
|
|
8034
|
+
};
|
|
8035
|
+
function defaultReceiverSources(env) {
|
|
8036
|
+
return {
|
|
8037
|
+
settingsEnv: () => {
|
|
8038
|
+
try {
|
|
8039
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim() || join(env.HOME?.trim() || homedir(), ".claude");
|
|
8040
|
+
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
|
|
8041
|
+
return isObject(settings) && isObject(settings.env) ? settings.env : {};
|
|
8042
|
+
} catch {
|
|
8043
|
+
return {};
|
|
8044
|
+
}
|
|
8045
|
+
},
|
|
8046
|
+
keychain: (service) => {
|
|
8047
|
+
if (process.platform !== "darwin") return "";
|
|
8048
|
+
try {
|
|
8049
|
+
return execFileSync("security", [
|
|
8050
|
+
"find-generic-password",
|
|
8051
|
+
"-s",
|
|
8052
|
+
service,
|
|
8053
|
+
"-w"
|
|
8054
|
+
], {
|
|
8055
|
+
encoding: "utf8",
|
|
8056
|
+
stdio: [
|
|
8057
|
+
"ignore",
|
|
8058
|
+
"pipe",
|
|
8059
|
+
"ignore"
|
|
8060
|
+
],
|
|
8061
|
+
timeout: 5e3
|
|
8062
|
+
}).trim();
|
|
8063
|
+
} catch {
|
|
8064
|
+
return "";
|
|
8065
|
+
}
|
|
8066
|
+
}
|
|
8067
|
+
};
|
|
8068
|
+
}
|
|
8069
|
+
/**
|
|
8070
|
+
* The receiver the environment, the Claude Code user settings and the keychain name, or null when none of the three
|
|
8071
|
+
* values is found. A partial set is passed on, so the report says which is missing. The address is the origin of
|
|
8072
|
+
* TRANSCRIPT_UPLOAD_URL (the upload URL itself when it is no URL); each Access value is the environment variable, else
|
|
8073
|
+
* the keychain item. The keychain is asked only for what the environment lacks.
|
|
8074
|
+
*/
|
|
8075
|
+
function receiverFromEnv(env, sources = defaultReceiverSources(env)) {
|
|
8076
|
+
const text = (v) => typeof v === "string" ? v.trim() : "";
|
|
8077
|
+
const upload = text(env[RECEIVER_ENV.url]) || text(sources.settingsEnv()[RECEIVER_ENV.url]);
|
|
8078
|
+
let url = upload;
|
|
8079
|
+
try {
|
|
8080
|
+
url = new URL(upload).origin;
|
|
8081
|
+
} catch {}
|
|
8082
|
+
const accessClientId = text(env[RECEIVER_ENV.accessClientId]) || sources.keychain(RECEIVER_KEYCHAIN.accessClientId).trim();
|
|
8083
|
+
const accessClientSecret = text(env[RECEIVER_ENV.accessClientSecret]) || sources.keychain(RECEIVER_KEYCHAIN.accessClientSecret).trim();
|
|
7370
8084
|
return url || accessClientId || accessClientSecret ? {
|
|
7371
8085
|
url,
|
|
7372
8086
|
accessClientId,
|
|
@@ -7431,9 +8145,9 @@ async function readReceiver(config, scopes) {
|
|
|
7431
8145
|
problems
|
|
7432
8146
|
});
|
|
7433
8147
|
const absent = [
|
|
7434
|
-
...config.url ? [] : [RECEIVER_ENV.url],
|
|
7435
|
-
...config.accessClientId ? [] : [RECEIVER_ENV.accessClientId],
|
|
7436
|
-
...config.accessClientSecret ? [] : [RECEIVER_ENV.accessClientSecret]
|
|
8148
|
+
...config.url ? [] : [`${RECEIVER_ENV.url}(環境變數或 Claude Code 使用者設定的 env)`],
|
|
8149
|
+
...config.accessClientId ? [] : [`${RECEIVER_ENV.accessClientId}(環境變數或鑰匙圈 ${RECEIVER_KEYCHAIN.accessClientId})`],
|
|
8150
|
+
...config.accessClientSecret ? [] : [`${RECEIVER_ENV.accessClientSecret}(環境變數或鑰匙圈 ${RECEIVER_KEYCHAIN.accessClientSecret})`]
|
|
7437
8151
|
];
|
|
7438
8152
|
if (absent.length) {
|
|
7439
8153
|
problems.push(`${absent.join("、")} 沒有設定,沒有讀取接收端`);
|
|
@@ -7793,24 +8507,24 @@ const secondText = (ms) => {
|
|
|
7793
8507
|
return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 8)}`;
|
|
7794
8508
|
};
|
|
7795
8509
|
/** Where transcripts fetched from the receiver are kept: in the hub's local directory, which the hub ignores. */
|
|
7796
|
-
const transcriptCache = (
|
|
8510
|
+
const transcriptCache = (dirs) => join(dirs.data, "local", "transcripts");
|
|
7797
8511
|
/** The transcript to read: the recorded path when the file is there, else the copy fetched from the receiver, else the recorded path. */
|
|
7798
|
-
function transcriptOf(
|
|
7799
|
-
const recorded = transcriptPath(
|
|
8512
|
+
function transcriptOf(dirs, transcript) {
|
|
8513
|
+
const recorded = transcriptPath(dirs, transcript);
|
|
7800
8514
|
if (existsSync(recorded)) return recorded;
|
|
7801
|
-
const cached = join(transcriptCache(
|
|
8515
|
+
const cached = join(transcriptCache(dirs), basename(recorded));
|
|
7802
8516
|
return existsSync(cached) ? cached : recorded;
|
|
7803
8517
|
}
|
|
7804
8518
|
/** Sessions serving the mainline whose recorded transcript is not on this machine, as the receiver knows them. */
|
|
7805
|
-
function missingTranscripts(
|
|
8519
|
+
function missingTranscripts(dirs, mainlineId) {
|
|
7806
8520
|
const wanted = /* @__PURE__ */ new Map();
|
|
7807
|
-
for (const s of loadSessions(
|
|
8521
|
+
for (const s of loadSessions(dirs).sessions) {
|
|
7808
8522
|
if (!s.transcript || !s.actions.some((a) => servesMainline(a, mainlineId))) continue;
|
|
7809
|
-
if (existsSync(transcriptPath(
|
|
8523
|
+
if (existsSync(transcriptPath(dirs, s.transcript))) continue;
|
|
7810
8524
|
const sid = basename(s.transcript, ".jsonl");
|
|
7811
8525
|
wanted.set(sid, {
|
|
7812
8526
|
sid,
|
|
7813
|
-
dir: transcriptCache(
|
|
8527
|
+
dir: transcriptCache(dirs)
|
|
7814
8528
|
});
|
|
7815
8529
|
}
|
|
7816
8530
|
return [...wanted.values()];
|
|
@@ -7922,8 +8636,8 @@ const timed = (f) => {
|
|
|
7922
8636
|
* counts inside its window; a subagent counts whole when the Agent call that started it falls inside its parent's
|
|
7923
8637
|
* window, and not at all otherwise. Without the call, the subagent's first record stands in for it.
|
|
7924
8638
|
*/
|
|
7925
|
-
function readGroups(
|
|
7926
|
-
const serving = loadSessions(
|
|
8639
|
+
function readGroups(dirs, mainlineId, windowOf) {
|
|
8640
|
+
const serving = loadSessions(dirs).sessions.filter((s) => s.actions.some((a) => servesMainline(a, mainlineId)));
|
|
7927
8641
|
const groups = [];
|
|
7928
8642
|
const bare = [];
|
|
7929
8643
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -7947,7 +8661,7 @@ function readGroups(root, mainlineId, windowOf) {
|
|
|
7947
8661
|
};
|
|
7948
8662
|
const ordered = [...serving].sort((a, b) => Number(Boolean(a.delegatedFrom)) - Number(Boolean(b.delegatedFrom)));
|
|
7949
8663
|
for (const s of ordered) {
|
|
7950
|
-
const file = s.transcript ? transcriptOf(
|
|
8664
|
+
const file = s.transcript ? transcriptOf(dirs, s.transcript) : void 0;
|
|
7951
8665
|
if (s.delegatedFrom) {
|
|
7952
8666
|
if (!file || seen.has(file)) continue;
|
|
7953
8667
|
const parent = groups.find((g) => g.session === s.delegatedFrom);
|
|
@@ -7998,8 +8712,8 @@ function readGroups(root, mainlineId, windowOf) {
|
|
|
7998
8712
|
* Read the transcripts of every session with an action serving the mainline, and their subagents', into cost,
|
|
7999
8713
|
* attribution to promptex nodes, anomalies, review rounds and the offline analyses.
|
|
8000
8714
|
*/
|
|
8001
|
-
function retroReport(
|
|
8002
|
-
return buildReport(
|
|
8715
|
+
function retroReport(dirs, mainlineId, options = {}) {
|
|
8716
|
+
return buildReport(dirs, readRetro(dirs, mainlineId, options), options);
|
|
8003
8717
|
}
|
|
8004
8718
|
/**
|
|
8005
8719
|
* retroReport with the measurement receiver's export: the transcripts are read first, since their windows decide
|
|
@@ -8007,9 +8721,9 @@ function retroReport(root, mainlineId, options = {}) {
|
|
|
8007
8721
|
* in part, leaves the report as retroReport would make it from what was read, with what is missing listed in
|
|
8008
8722
|
* `measurements.problems`.
|
|
8009
8723
|
*/
|
|
8010
|
-
async function retroReportFromReceiver(
|
|
8724
|
+
async function retroReportFromReceiver(dirs, mainlineId, receiver, options = {}) {
|
|
8011
8725
|
if (receiver.url && receiver.accessClientId && receiver.accessClientSecret) {
|
|
8012
|
-
const wanted = missingTranscripts(
|
|
8726
|
+
const wanted = missingTranscripts(dirs, mainlineId);
|
|
8013
8727
|
if (wanted.length) {
|
|
8014
8728
|
const { warnings } = await fetchTranscripts({
|
|
8015
8729
|
url: receiver.url,
|
|
@@ -8020,19 +8734,19 @@ async function retroReportFromReceiver(root, mainlineId, receiver, options = {})
|
|
|
8020
8734
|
for (const line of warnings) (receiver.warn ?? ((l) => process.stderr.write(`${l}\n`)))(line);
|
|
8021
8735
|
}
|
|
8022
8736
|
}
|
|
8023
|
-
const read = readRetro(
|
|
8737
|
+
const read = readRetro(dirs, mainlineId, options);
|
|
8024
8738
|
const t = performance.now();
|
|
8025
8739
|
const exported = await readReceiver(receiver, receiverScopes(read.groups));
|
|
8026
|
-
return buildReport(
|
|
8740
|
+
return buildReport(dirs, read, {
|
|
8027
8741
|
...options,
|
|
8028
8742
|
receiver: exported
|
|
8029
8743
|
}, performance.now() - t);
|
|
8030
8744
|
}
|
|
8031
|
-
function readRetro(
|
|
8745
|
+
function readRetro(dirs, mainlineId, options) {
|
|
8032
8746
|
const started = performance.now();
|
|
8033
|
-
const mainline = loadHub(
|
|
8747
|
+
const mainline = loadHub(dirs, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
|
|
8034
8748
|
if (!mainline) throw new WriteError(`mainline ${mainlineId} not found; run "mainflow status --all" to list mainlines`);
|
|
8035
|
-
const [{ groups, bare }, readMs] = timed(() => readGroups(
|
|
8749
|
+
const [{ groups, bare }, readMs] = timed(() => readGroups(dirs, mainlineId, options.windowOf));
|
|
8036
8750
|
return {
|
|
8037
8751
|
started,
|
|
8038
8752
|
mainline,
|
|
@@ -8042,7 +8756,7 @@ function readRetro(root, mainlineId, options) {
|
|
|
8042
8756
|
readMs
|
|
8043
8757
|
};
|
|
8044
8758
|
}
|
|
8045
|
-
function buildReport(
|
|
8759
|
+
function buildReport(dirs, read, options, receiverMs) {
|
|
8046
8760
|
const { started, mainline, mainlineId, groups, bare, readMs } = read;
|
|
8047
8761
|
const receiver = options.receiver;
|
|
8048
8762
|
const measuredRuns = [];
|
|
@@ -8189,7 +8903,7 @@ function buildReport(root, read, options, receiverMs) {
|
|
|
8189
8903
|
}))).sort((a, b) => b.runs - a.runs);
|
|
8190
8904
|
const modelList = [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
|
|
8191
8905
|
const transcriptTotal = modelList.reduce((n, m) => n + (m.cost ?? 0), 0);
|
|
8192
|
-
const nodes = loadNodeRegistry(options.promptexRoots ?? [homedir(), root]);
|
|
8906
|
+
const nodes = loadNodeRegistry(options.promptexRoots ?? [homedir(), dirs.root]);
|
|
8193
8907
|
const metas = groups.flatMap((g) => g.subagents.map((a) => ({
|
|
8194
8908
|
agentId: basename(a.file, ".jsonl").replace(/^agent-/, ""),
|
|
8195
8909
|
...a.meta
|
|
@@ -8316,9 +9030,9 @@ function buildReport(root, read, options, receiverMs) {
|
|
|
8316
9030
|
//#endregion
|
|
8317
9031
|
//#region ../core/dist/migrate.js
|
|
8318
9032
|
/** Convert a format 1 hub on disk to the current format, writing nothing when `dryRun` is set. */
|
|
8319
|
-
function migrateHub(
|
|
8320
|
-
const storage = nodeStorage(
|
|
8321
|
-
if (storage.read("config.yaml") === null) throw new WriteError(`no hub at ${root}; run mainflow init first`);
|
|
9033
|
+
function migrateHub(dirs, options = {}) {
|
|
9034
|
+
const storage = nodeStorage(dirs);
|
|
9035
|
+
if (storage.read("config.yaml") === null) throw new WriteError(`no hub at ${dirs.root}; run mainflow init first`);
|
|
8322
9036
|
return applyOp(storage, (hub) => {
|
|
8323
9037
|
const result = migrateHub$1(hub);
|
|
8324
9038
|
return options.dryRun ? {
|
|
@@ -8328,6 +9042,177 @@ function migrateHub(root, options = {}) {
|
|
|
8328
9042
|
}).value;
|
|
8329
9043
|
}
|
|
8330
9044
|
//#endregion
|
|
9045
|
+
//#region ../core/dist/migrate-data.js
|
|
9046
|
+
/** What the original data directory is renamed to, inside the hub repository, once the hub reads from its new place. */
|
|
9047
|
+
const MIGRATED_NAME = `${DATA_DIR}.migrated`;
|
|
9048
|
+
/** Paths of the data directory, relative to it with `/`, that the copy leaves out. */
|
|
9049
|
+
const LEFT_OUT = /* @__PURE__ */ new Set([".git", "local/write.lock"]);
|
|
9050
|
+
/**
|
|
9051
|
+
* Every entry under `dir` but the ones left out, by its path relative to `dir` with `/`; symbolic links are not followed.
|
|
9052
|
+
* Refused when an entry is none of a file, a directory or a symbolic link, such as a named pipe, which the copy cannot take.
|
|
9053
|
+
*/
|
|
9054
|
+
function inventory(dir) {
|
|
9055
|
+
const items = /* @__PURE__ */ new Map();
|
|
9056
|
+
const walk = (at) => {
|
|
9057
|
+
for (const name of readdirSync(join(dir, at)).sort()) {
|
|
9058
|
+
const path = at ? `${at}/${name}` : name;
|
|
9059
|
+
if (LEFT_OUT.has(path)) continue;
|
|
9060
|
+
const full = join(dir, path);
|
|
9061
|
+
const info = lstatSync(full);
|
|
9062
|
+
if (info.isSymbolicLink()) items.set(path, {
|
|
9063
|
+
kind: "link",
|
|
9064
|
+
target: readlinkSync(full)
|
|
9065
|
+
});
|
|
9066
|
+
else if (info.isDirectory()) {
|
|
9067
|
+
items.set(path, { kind: "dir" });
|
|
9068
|
+
walk(path);
|
|
9069
|
+
} else if (info.isFile()) items.set(path, {
|
|
9070
|
+
kind: "file",
|
|
9071
|
+
hash: createHash("sha256").update(readFileSync(full)).digest("hex"),
|
|
9072
|
+
size: info.size
|
|
9073
|
+
});
|
|
9074
|
+
else throw new WriteError(`${full} is neither a file, a directory nor a symbolic link, so it cannot be copied; move it out of ${dir} and run this again`);
|
|
9075
|
+
}
|
|
9076
|
+
};
|
|
9077
|
+
walk("");
|
|
9078
|
+
return items;
|
|
9079
|
+
}
|
|
9080
|
+
/** The first difference between the original's entries and the copy's, or null when the copy holds the same. */
|
|
9081
|
+
function difference(original, copy) {
|
|
9082
|
+
for (const [path, item] of original) {
|
|
9083
|
+
const copied = copy.get(path);
|
|
9084
|
+
if (!copied) return `${path} is missing from the copy`;
|
|
9085
|
+
if (JSON.stringify(copied) !== JSON.stringify(item)) return `${path} differs in the copy`;
|
|
9086
|
+
}
|
|
9087
|
+
for (const path of copy.keys()) if (!original.has(path)) return `${path} is in the copy only`;
|
|
9088
|
+
return null;
|
|
9089
|
+
}
|
|
9090
|
+
/** `<root>/.mainflow.migrated`, or the first of `.mainflow.migrated.2`, `.3` … that is free. */
|
|
9091
|
+
function keptName(root) {
|
|
9092
|
+
for (let n = 1;; n++) {
|
|
9093
|
+
const kept = join(root, n === 1 ? MIGRATED_NAME : `${MIGRATED_NAME}.${n}`);
|
|
9094
|
+
if (!existsSync(kept) && !isLink(kept)) return kept;
|
|
9095
|
+
}
|
|
9096
|
+
}
|
|
9097
|
+
const isLink = (path) => {
|
|
9098
|
+
try {
|
|
9099
|
+
return lstatSync(path).isSymbolicLink();
|
|
9100
|
+
} catch {
|
|
9101
|
+
return false;
|
|
9102
|
+
}
|
|
9103
|
+
};
|
|
9104
|
+
/** Whether `dir` is missing or an empty directory, so the copy can take its place. */
|
|
9105
|
+
const vacant = (dir) => !existsSync(dir) || statSync(dir).isDirectory() && readdirSync(dir).length === 0;
|
|
9106
|
+
/**
|
|
9107
|
+
* Move the data of the hub whose repository is `path` from `<path>/.mainflow` to `$MAINFLOW_HOME/<name>`, and register
|
|
9108
|
+
* the hub with its data there. With `dryRun` nothing changes: the move is checked and described. Refused when the
|
|
9109
|
+
* repository holds no hub data, when the hub already moved its data, when the new place is taken, when the hub could
|
|
9110
|
+
* not be registered from there, or when `busy` names something that still reads and writes the old place, such as a
|
|
9111
|
+
* running sync client; `busy` is asked again once the move holds the write lock, so nothing starts between the check
|
|
9112
|
+
* and the move.
|
|
9113
|
+
*/
|
|
9114
|
+
async function migrateHubData(path, options = {}) {
|
|
9115
|
+
const env = options.env ?? process.env;
|
|
9116
|
+
const dryRun = options.dryRun ?? false;
|
|
9117
|
+
const root = realPath(path);
|
|
9118
|
+
if (!root) throw new WriteError(`${path} does not exist`);
|
|
9119
|
+
const found = inRepositoryData(root);
|
|
9120
|
+
if (!found) {
|
|
9121
|
+
const registered = hubAtRoot(root, env);
|
|
9122
|
+
if (registered) throw new WriteError(`hub "${registered.name}" already keeps its data in ${registered.data}; ${join(root, DATA_DIR)} holds no hub data to move`);
|
|
9123
|
+
throw new WriteError(`no hub data to move at ${root}: ${join(root, DATA_DIR, "config.yaml")} does not exist`);
|
|
9124
|
+
}
|
|
9125
|
+
const { name } = found;
|
|
9126
|
+
const source = realPath(found.data);
|
|
9127
|
+
const to = join(realPath(mainflowHome(env)) ?? mainflowHome(env), name);
|
|
9128
|
+
const previous = readRegistry(env).hubs[name] ?? null;
|
|
9129
|
+
if (previous && previous.root !== root) throw new WriteError(`hub "${name}" is registered from ${previous.root}, not ${root}; move its data from there, or run mainflow hub unregister ${name} first`);
|
|
9130
|
+
if (previous && previous.data === (realPath(to) ?? to)) throw new WriteError(`hub "${name}" already moved its data to ${to} and reads it from there; ${found.data} is the original an earlier move could not rename. Check that the hub works from ${to}, then rename or delete ${found.data} yourself.`);
|
|
9131
|
+
if (!vacant(to)) throw new WriteError(`${to} already exists, so the data of hub "${name}" cannot move there. When it holds this hub from an earlier move, check it and register it with: mainflow hub register ${name} ${root}. Otherwise move it away and run this again.`);
|
|
9132
|
+
previewRegistration(name, root, env, { data: source });
|
|
9133
|
+
const worktree = statSync(join(source, ".git"), { throwIfNoEntry: false })?.isFile() ?? false;
|
|
9134
|
+
const files = [...inventory(source).values()].filter((item) => item.kind !== "dir");
|
|
9135
|
+
const bytes = files.reduce((sum, item) => sum + (item.kind === "file" ? item.size : 0), 0);
|
|
9136
|
+
const move = {
|
|
9137
|
+
name,
|
|
9138
|
+
root,
|
|
9139
|
+
from: found.data,
|
|
9140
|
+
to,
|
|
9141
|
+
files: files.length,
|
|
9142
|
+
bytes,
|
|
9143
|
+
worktree,
|
|
9144
|
+
kept: keptName(root),
|
|
9145
|
+
previous,
|
|
9146
|
+
dryRun
|
|
9147
|
+
};
|
|
9148
|
+
const busy = async () => {
|
|
9149
|
+
const reason = options.busy ? await options.busy({
|
|
9150
|
+
root,
|
|
9151
|
+
data: found.data
|
|
9152
|
+
}, name) : null;
|
|
9153
|
+
if (reason) throw new WriteError(reason);
|
|
9154
|
+
};
|
|
9155
|
+
if (dryRun) {
|
|
9156
|
+
await busy();
|
|
9157
|
+
return move;
|
|
9158
|
+
}
|
|
9159
|
+
const staging = join(mainflowHome(env), `.${name}.migrating`);
|
|
9160
|
+
const created = !existsSync(to);
|
|
9161
|
+
mkdirSync(mainflowHome(env), { recursive: true });
|
|
9162
|
+
return withLockAsync(source, async () => {
|
|
9163
|
+
await busy();
|
|
9164
|
+
rmSync(staging, {
|
|
9165
|
+
recursive: true,
|
|
9166
|
+
force: true
|
|
9167
|
+
});
|
|
9168
|
+
let placed = false;
|
|
9169
|
+
try {
|
|
9170
|
+
const copied = inventory(copyData(source, staging));
|
|
9171
|
+
const differs = difference(inventory(source), copied);
|
|
9172
|
+
if (differs) throw new WriteError(`the copy of ${source} in ${staging} is not the same (${differs})`);
|
|
9173
|
+
renameSync(staging, to);
|
|
9174
|
+
placed = true;
|
|
9175
|
+
registerHub(name, root, env, { data: to });
|
|
9176
|
+
} catch (error) {
|
|
9177
|
+
rmSync(staging, {
|
|
9178
|
+
recursive: true,
|
|
9179
|
+
force: true
|
|
9180
|
+
});
|
|
9181
|
+
if (placed) {
|
|
9182
|
+
rmSync(to, {
|
|
9183
|
+
recursive: true,
|
|
9184
|
+
force: true
|
|
9185
|
+
});
|
|
9186
|
+
if (!created) mkdirSync(to);
|
|
9187
|
+
}
|
|
9188
|
+
throw error instanceof WriteError ? new WriteError(`${error.message}; nothing moved, and ${found.data} is as it was`) : new WriteError(`could not move the data of hub "${name}" to ${to}: ${error.message}; nothing moved, and ${found.data} is as it was`);
|
|
9189
|
+
}
|
|
9190
|
+
try {
|
|
9191
|
+
renameSync(found.data, move.kept);
|
|
9192
|
+
} catch (error) {
|
|
9193
|
+
return {
|
|
9194
|
+
...move,
|
|
9195
|
+
kept: found.data,
|
|
9196
|
+
notRenamed: error.message
|
|
9197
|
+
};
|
|
9198
|
+
}
|
|
9199
|
+
rmSync(join(move.kept, "local", "write.lock"), { force: true });
|
|
9200
|
+
return move;
|
|
9201
|
+
});
|
|
9202
|
+
}
|
|
9203
|
+
/** Copy the data directory `source` to `target`, leaving out LEFT_OUT, with each file's times kept; returns `target`. */
|
|
9204
|
+
function copyData(source, target) {
|
|
9205
|
+
cpSync(source, target, {
|
|
9206
|
+
recursive: true,
|
|
9207
|
+
errorOnExist: true,
|
|
9208
|
+
force: false,
|
|
9209
|
+
preserveTimestamps: true,
|
|
9210
|
+
verbatimSymlinks: true,
|
|
9211
|
+
filter: (from) => !LEFT_OUT.has(relative(source, from).split(sep).join("/"))
|
|
9212
|
+
});
|
|
9213
|
+
return target;
|
|
9214
|
+
}
|
|
9215
|
+
//#endregion
|
|
8331
9216
|
//#region ../core/dist/group-plan.js
|
|
8332
9217
|
/** Sessions that can still hold work: not archived, and active or paused. */
|
|
8333
9218
|
const live = (s) => !s.archivedAt && (s.status === "active" || s.status === "paused");
|
|
@@ -8501,13 +9386,13 @@ const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
|
|
|
8501
9386
|
* port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
|
|
8502
9387
|
* different hub of the same name stops here: overwriting it would silently take that hub's host name.
|
|
8503
9388
|
*/
|
|
8504
|
-
async function syncHost(
|
|
9389
|
+
async function syncHost(dirs, hub, port, options = {}) {
|
|
8505
9390
|
const sites = caddySites();
|
|
8506
9391
|
if (!sites || !existsSync(sites)) return {
|
|
8507
9392
|
url: null,
|
|
8508
9393
|
reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
|
|
8509
9394
|
};
|
|
8510
|
-
const dir = join(
|
|
9395
|
+
const dir = join(dirs.data, "local", "caddy");
|
|
8511
9396
|
const file = join(dir, `${hub}.caddy`);
|
|
8512
9397
|
const link = join(sites, `mainflow-${hub}.caddy`);
|
|
8513
9398
|
removeDanglingLinks(sites);
|
|
@@ -8550,14 +9435,14 @@ async function syncHost(root, hub, port, options = {}) {
|
|
|
8550
9435
|
* from loading every other site. Only a link that points into `root` is removed; the reload failure, if any,
|
|
8551
9436
|
* is returned for the caller to log.
|
|
8552
9437
|
*/
|
|
8553
|
-
function removeHostLink(
|
|
9438
|
+
function removeHostLink(dirs, hub, reload = reloadCaddy) {
|
|
8554
9439
|
const sites = caddySites();
|
|
8555
9440
|
if (!sites) return null;
|
|
8556
9441
|
const link = join(sites, `mainflow-${hub}.caddy`);
|
|
8557
9442
|
const linked = lstatSync(link, { throwIfNoEntry: false });
|
|
8558
9443
|
let removed = false;
|
|
8559
9444
|
if (linked?.isSymbolicLink()) {
|
|
8560
|
-
if (resolve(dirname(link), readlinkSync(link)).startsWith(resolve(
|
|
9445
|
+
if (resolve(dirname(link), readlinkSync(link)).startsWith(resolve(dirs.data) + "/")) {
|
|
8561
9446
|
rmSync(link);
|
|
8562
9447
|
removed = true;
|
|
8563
9448
|
}
|
|
@@ -8597,4 +9482,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
|
|
|
8597
9482
|
return true;
|
|
8598
9483
|
}
|
|
8599
9484
|
//#endregion
|
|
8600
|
-
export {
|
|
9485
|
+
export { loadConfig as $, WriteError as $t, transcriptPath as A, MAINLINE_FILE as At, applyOffline as B, registeredName as Bt, turnReminder as C, readsAsText as Ct, readNotices as D, hubFormatVersion as Dt, parseWarn as E, formatMismatch as Et, headerLine as F, inRepositoryHubAt as Ft, scopeOfPath as G, nodeStorage as Gt, renumberFiles as H, resolveHub as Ht, mayDecorate as I, listHubs as It, syncedDir as J, hubPath as Jt, hubDigest as K, currentBranch as Kt, attributeTrace as L, notRegisteredMessage as Lt, matchGrants as M, requireSlug as Mt, decorateReply as N, hubAtRoot as Nt, loadSessions as O, hubRequirements as Ot, endsWithMarker as P, hubHoldingFile as Pt, HubError as Q, DATA_DIR as Qt, traceTranscript as R, readRegistry as Rt, sessionBriefing as S, exceedsFileLimit as St, markNoticesHandled as T, textBytes as Tt, renumberPath as U, unregisterHub as Ut, renumberRequest as V, registryFile as Vt, renumberText as W, userDataDir as Wt, applyOperation as X, worktreesOf as Xt, syncedPath as Y, realPath as Yt, checkHubName as Z, ConflictError as Zt, ARCHIVE_KINDS as _, MAX_FILE_BYTES as _t, openInApp as a, memberOfGroup as at, untilState as b, VERSION_HEADER as bt, siteBlock as c, unavailableHubs as ct, migrateHubData as d, readFrontMatter as dt, contentHash as en, loadHub as et, migrateHub as f, readYaml as ft, RETRY_RULES as g, MAX_CHANGE_PAGE as gt, receiverFromEnv as h, MAX_BLOB_BYTES as ht, hostName as i, linksInto as it, grantScope as j, TASK_FILE as jt, readSession as k, versionMismatch as kt, syncHost as l, taskFile as lt, retroReportFromReceiver as m, BLOB_HASH as mt, DEFAULT_UI_PORT as n, dependencyGraph as nt, removeDanglingLinks as o, refIndex as ot, retroReport as p, ACCESS_HEADERS as pt, mayHoldSynced as q, dataFile as qt, caddySites as r, mainlineEdges as rt, removeHostLink as s, remoteMembers as st, APP_BUNDLE_ID as t, splitFrontMatter as tn, sectionText as tt, groupPlan as u, nowMinute as ut, setArchivedMany as v, ROUTES as vt, HANDLED_FILE as w, requestTime as wt, duplicateClaimLines as x, comparePaths as xt, initHub as y, SOCKET_MAX_AGE_CLOSE as yt, transcriptTitle as z, registerHub as zt };
|