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,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as WriteError, B as applyOffline, Bt as registeredName, Ct as readsAsText, G as scopeOfPath, Gt as nodeStorage, H as renumberFiles, J as syncedDir, K as hubDigest, Ot as hubRequirements, St as exceedsFileLimit, Tt as textBytes, U as renumberPath, V as renumberRequest, W as renumberText, X as applyOperation, Y as syncedPath, Zt as ConflictError, _t as MAX_FILE_BYTES, bt as VERSION_HEADER, en as contentHash, ht as MAX_BLOB_BYTES, kt as versionMismatch, mt as BLOB_HASH, pt as ACCESS_HEADERS, q as mayHoldSynced, tn as splitFrontMatter, ut as nowMinute, vt as ROUTES, wt as requestTime, xt as comparePaths, yt as SOCKET_MAX_AGE_CLOSE } from "./host-BJvqZy-i.js";
|
|
2
|
+
import { t as CLI_VERSION } from "./version-Dvv4jwF1.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, watch, writeFileSync } from "node:fs";
|
|
4
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -15,14 +16,13 @@ import { EventEmitter } from "node:events";
|
|
|
15
16
|
import { COPYFILE_EXCL } from "node:constants";
|
|
16
17
|
import { appendFile, mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
17
18
|
import { diff3Merge } from "node-diff3";
|
|
18
|
-
import ignore from "ignore";
|
|
19
19
|
//#region ../sync/dist/paths.js
|
|
20
20
|
/** The sync client's own directory, inside local/ so it is neither synced nor committed. */
|
|
21
21
|
const SYNC_DIR = "local/sync";
|
|
22
22
|
/** The settings a machine keeps instead of the environment variables: { url, clientId, clientSecret }. Never synced or committed. */
|
|
23
23
|
const SETTINGS_FILE = "local/sync.json";
|
|
24
|
-
const syncPaths = (
|
|
25
|
-
const data =
|
|
24
|
+
const syncPaths = (dirs) => {
|
|
25
|
+
const data = dirs.data;
|
|
26
26
|
const own = join(data, SYNC_DIR);
|
|
27
27
|
return {
|
|
28
28
|
data,
|
|
@@ -50,27 +50,8 @@ const syncPaths = (root) => {
|
|
|
50
50
|
log: join(own, "sync.log")
|
|
51
51
|
};
|
|
52
52
|
};
|
|
53
|
-
/**
|
|
54
|
-
const
|
|
55
|
-
/\.tmp$/,
|
|
56
|
-
/\.sw[a-p]$/,
|
|
57
|
-
/\.swx$/,
|
|
58
|
-
/~$/,
|
|
59
|
-
/^\.#/,
|
|
60
|
-
/^4913$/,
|
|
61
|
-
/\.crswap$/,
|
|
62
|
-
/^\.DS_Store$/
|
|
63
|
-
];
|
|
64
|
-
/** Whether a file at `path` (relative to .mainflow/) is synced. */
|
|
65
|
-
function isSynced(path) {
|
|
66
|
-
const parts = path.split("/");
|
|
67
|
-
if (parts.some((part) => part === "" || part === "." || part === "..")) return false;
|
|
68
|
-
if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
|
|
69
|
-
const name = parts.at(-1);
|
|
70
|
-
return !TRANSIENT.some((pattern) => pattern.test(name));
|
|
71
|
-
}
|
|
72
|
-
/** Whether the directory at `path` may hold synced files, so a scan walks into it. */
|
|
73
|
-
const mayHoldSynced = (path) => path !== "local" ? !path.startsWith("local/") || path === "local/sessions" : true;
|
|
53
|
+
/** Whether a file at `path` (relative to .mainflow/) is synced (syncedPath in the core package). */
|
|
54
|
+
const isSynced = syncedPath;
|
|
74
55
|
/**
|
|
75
56
|
* Whether the client's file watcher watches inside the directory at `path`: one that may hold synced files, and
|
|
76
57
|
* local/sync/ itself for the outbox; the directories under local/sync/ hold only the client's own files.
|
|
@@ -89,22 +70,30 @@ const ENV = {
|
|
|
89
70
|
clientSecret: "MAINFLOW_SYNC_CLIENT_SECRET",
|
|
90
71
|
onDemand: "MAINFLOW_SYNC_ON_DEMAND"
|
|
91
72
|
};
|
|
92
|
-
/** The values of MAINFLOW_SYNC_ON_DEMAND that
|
|
73
|
+
/** The values of MAINFLOW_SYNC_ON_DEMAND that keep it on; any other value that is not empty (such as 0) turns it off. */
|
|
93
74
|
const ON = /* @__PURE__ */ new Set([
|
|
94
75
|
"1",
|
|
95
76
|
"true",
|
|
96
77
|
"on"
|
|
97
78
|
]);
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The hub's name as config.yaml gives it. A hub whose config.yaml is not downloaded yet, as in a new environment before
|
|
81
|
+
* its first `mainflow sync`, has the name it was registered under.
|
|
82
|
+
*/
|
|
83
|
+
function hubName(dirs, env = process.env) {
|
|
84
|
+
const file = join(dirs.data, "config.yaml");
|
|
85
|
+
if (!existsSync(file)) {
|
|
86
|
+
const registered = registeredName(dirs, env);
|
|
87
|
+
if (registered) return registered;
|
|
88
|
+
}
|
|
89
|
+
const config = parse$1(readFileSync(file, "utf8"));
|
|
90
|
+
if (typeof config?.hub !== "string") throw new Error(`${file} names no hub`);
|
|
102
91
|
return config.hub;
|
|
103
92
|
}
|
|
104
93
|
const text = (value) => typeof value === "string" ? value : "";
|
|
105
94
|
/** What .mainflow/local/sync.json holds, nothing when there is no such file. */
|
|
106
|
-
function stored(
|
|
107
|
-
const file = join(
|
|
95
|
+
function stored(dirs) {
|
|
96
|
+
const file = join(dirs.data, SETTINGS_FILE);
|
|
108
97
|
if (!existsSync(file)) return {};
|
|
109
98
|
try {
|
|
110
99
|
return JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
@@ -115,20 +104,20 @@ function stored(root) {
|
|
|
115
104
|
/**
|
|
116
105
|
* The sync settings of the hub at `root`, or null when either half of the token is missing. Each value comes from its
|
|
117
106
|
* environment variable, or from sync.json when the variable is unset or empty; the server is DEFAULT_SYNC_URL when
|
|
118
|
-
* neither names one.
|
|
107
|
+
* neither names one, and on-demand download is on unless one of them sets it off.
|
|
119
108
|
*/
|
|
120
|
-
function syncSettings(
|
|
121
|
-
const saved = stored(
|
|
109
|
+
function syncSettings(dirs, env = process.env) {
|
|
110
|
+
const saved = stored(dirs);
|
|
122
111
|
const url = env[ENV.url] || text(saved.url) || "https://mainflow-sync.carlos209310.space";
|
|
123
112
|
const clientId = env[ENV.clientId] || text(saved.clientId);
|
|
124
113
|
const clientSecret = env[ENV.clientSecret] || text(saved.clientSecret);
|
|
125
114
|
if (!clientId || !clientSecret) return null;
|
|
126
|
-
const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand
|
|
115
|
+
const onDemand = env[ENV.onDemand] ? ON.has(env[ENV.onDemand].trim().toLowerCase()) : saved.onDemand !== false;
|
|
127
116
|
return {
|
|
128
117
|
url: url.replace(/\/+$/, ""),
|
|
129
118
|
clientId,
|
|
130
119
|
clientSecret,
|
|
131
|
-
hub: hubName(
|
|
120
|
+
hub: hubName(dirs, env),
|
|
132
121
|
onDemand
|
|
133
122
|
};
|
|
134
123
|
}
|
|
@@ -142,9 +131,9 @@ const EARLIER_TOKEN = {
|
|
|
142
131
|
* nothing reads now: where that token is and which two values to set instead, never the token itself. Null when the
|
|
143
132
|
* settings are whole or no earlier token is kept, so a hub that never synced writes to its local files without a word.
|
|
144
133
|
*/
|
|
145
|
-
function earlierToken(
|
|
146
|
-
if (syncSettings(
|
|
147
|
-
const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(
|
|
134
|
+
function earlierToken(dirs, env = process.env) {
|
|
135
|
+
if (syncSettings(dirs, env)) return null;
|
|
136
|
+
const where = [...env[EARLIER_TOKEN.env] ? [EARLIER_TOKEN.env] : [], ...text(stored(dirs)[EARLIER_TOKEN.key]) ? [`the ${EARLIER_TOKEN.key} key of .mainflow/${SETTINGS_FILE}`] : []];
|
|
148
137
|
if (where.length === 0) return null;
|
|
149
138
|
return `${where.join(" and ")} ${where.length > 1 ? "are" : "is"} no longer read, so mainflow does not write through the sync server; set ${ENV.clientId} and ${ENV.clientSecret}, or clientId and clientSecret in .mainflow/${SETTINGS_FILE}, to a Cloudflare Access service token`;
|
|
150
139
|
}
|
|
@@ -172,6 +161,32 @@ var Unreachable = class extends Error {
|
|
|
172
161
|
this.broken = broken;
|
|
173
162
|
}
|
|
174
163
|
};
|
|
164
|
+
/**
|
|
165
|
+
* The hub takes writes only from other mainflow versions than this CLI's (`client-outdated`): nothing of the request was
|
|
166
|
+
* applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again after `retryAfter` seconds; the
|
|
167
|
+
* message says which versions the hub takes and the command that installs one.
|
|
168
|
+
*/
|
|
169
|
+
var ClientOutdatedError = class extends Unreachable {
|
|
170
|
+
retryAfter;
|
|
171
|
+
constructor(message, retryAfter) {
|
|
172
|
+
super(message, false);
|
|
173
|
+
this.retryAfter = retryAfter;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* The hub is being migrated or restored in batches, or such a rewrite stopped partway (`hub-rewriting`): nothing of the
|
|
178
|
+
* request was applied or kept. Unreachable as any 5xx, so a write stays in the outbox and goes again; `rewrite` is the
|
|
179
|
+
* rewrite's state, for a command that waits for it or says what holds the hub.
|
|
180
|
+
*/
|
|
181
|
+
var HubRewritingError = class extends Unreachable {
|
|
182
|
+
rewrite;
|
|
183
|
+
retryAfter;
|
|
184
|
+
constructor(message, rewrite, retryAfter) {
|
|
185
|
+
super(message, false);
|
|
186
|
+
this.rewrite = rewrite;
|
|
187
|
+
this.retryAfter = retryAfter;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
175
190
|
/** The server refused before the hub (400, 404) or failed (500), or Cloudflare Access in front of it refused (AccessRefused). */
|
|
176
191
|
var ServerError = class extends Error {
|
|
177
192
|
status;
|
|
@@ -261,7 +276,8 @@ var ServerApi = class {
|
|
|
261
276
|
wire: 0,
|
|
262
277
|
body: 0,
|
|
263
278
|
sent: 0,
|
|
264
|
-
listed: 0
|
|
279
|
+
listed: 0,
|
|
280
|
+
unchanged: 0
|
|
265
281
|
};
|
|
266
282
|
constructor(settings, timeoutMs = 3e4) {
|
|
267
283
|
this.settings = settings;
|
|
@@ -283,11 +299,15 @@ var ServerApi = class {
|
|
|
283
299
|
maxFreeSockets: 1
|
|
284
300
|
});
|
|
285
301
|
}
|
|
286
|
-
/**
|
|
302
|
+
/**
|
|
303
|
+
* The service token's headers, and the CLI's version when the settings name it (VERSION_HEADER), which every request
|
|
304
|
+
* and the WebSocket's upgrade carry. Never logged.
|
|
305
|
+
*/
|
|
287
306
|
get access() {
|
|
288
307
|
return {
|
|
289
308
|
[ACCESS_HEADERS.clientId]: this.settings.clientId,
|
|
290
|
-
[ACCESS_HEADERS.clientSecret]: this.settings.clientSecret
|
|
309
|
+
[ACCESS_HEADERS.clientSecret]: this.settings.clientSecret,
|
|
310
|
+
...this.settings.version === void 0 ? {} : { [VERSION_HEADER]: this.settings.version }
|
|
291
311
|
};
|
|
292
312
|
}
|
|
293
313
|
/** A failure to reach the server or of the connection, as the Unreachable it is. */
|
|
@@ -319,8 +339,13 @@ var ServerApi = class {
|
|
|
319
339
|
} catch {
|
|
320
340
|
throw new Unreachable(`the sync server answered ${answer.status} without a readable body`, true);
|
|
321
341
|
}
|
|
322
|
-
if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
|
|
323
342
|
const refused = parsed;
|
|
343
|
+
if (answer.status === 503 && refused.reason === "client-outdated") throw new ClientOutdatedError(`the sync server refused the write: ${refused.message ?? "this mainflow version may not write to the hub"}`, Math.max(1, Number(answer.retryAfter) || 60));
|
|
344
|
+
if (answer.status === 503 && refused.reason === "hub-rewriting") {
|
|
345
|
+
const rewriting = parsed;
|
|
346
|
+
throw new HubRewritingError(rewriting.message, rewriting.rewrite, Math.max(1, Number(answer.retryAfter) || 10));
|
|
347
|
+
}
|
|
348
|
+
if (answer.status >= 500) throw new Unreachable(`the sync server failed (${answer.status})`, true);
|
|
324
349
|
if (answer.status === 404 || refused.reason === "malformed") throw new ServerError(`the sync server refused: ${refused.message ?? answer.status}`, answer.status);
|
|
325
350
|
return {
|
|
326
351
|
status: answer.status,
|
|
@@ -543,6 +568,7 @@ var ServerApi = class {
|
|
|
543
568
|
this.transfer.batches++;
|
|
544
569
|
this.transfer.files += body.files.filter((entry) => entry.kind === "file").length;
|
|
545
570
|
this.transfer.listed += body.files.filter((entry) => entry.kind === "listed").length;
|
|
571
|
+
if (body.unchanged === true) this.transfer.unchanged++;
|
|
546
572
|
this.transfer.wire += wire;
|
|
547
573
|
this.transfer.body += size;
|
|
548
574
|
this.transfer.sent += sent;
|
|
@@ -558,6 +584,9 @@ var ServerApi = class {
|
|
|
558
584
|
* `listOnly` asks for the files under those prefixes as `listed`, without their text. A part of the hub alone is taken
|
|
559
585
|
* from `after` on, and ends with the batch that covers `through`; only the paths of that range are told of. `blobs` takes
|
|
560
586
|
* the binary files too, each that differs from `have` as `blob`; `have` names a binary file by its sha256 then.
|
|
587
|
+
* `digest` names the digest of `have` and the directories the client holds (hubDigest), asked on the first batch of a
|
|
588
|
+
* whole hub's snapshot with `blobs`: a server whose hub it is the digest of answers that nothing differs, and the
|
|
589
|
+
* snapshot is that one batch, with the client's directories as the entries, as the server would have listed them.
|
|
561
590
|
*/
|
|
562
591
|
async snapshot(have = /* @__PURE__ */ new Map(), options = {}) {
|
|
563
592
|
const paths = [...have.keys()].sort(comparePaths);
|
|
@@ -569,14 +598,23 @@ var ServerApi = class {
|
|
|
569
598
|
for (;;) {
|
|
570
599
|
while (after !== void 0 && start < paths.length && comparePaths(paths[start], after) <= 0) start++;
|
|
571
600
|
const range = Object.fromEntries(paths.slice(start).map((path) => [path, have.get(path)]));
|
|
572
|
-
const
|
|
601
|
+
const asking = options.digest !== void 0 && options.blobs === true && after === void 0;
|
|
602
|
+
const { seq, files, next, unchanged } = await this.batch({
|
|
573
603
|
protocol: 3,
|
|
574
604
|
have: range,
|
|
575
605
|
unknown: true,
|
|
576
606
|
...after === void 0 ? {} : { after },
|
|
607
|
+
...asking ? { digest: options.digest.value } : {},
|
|
577
608
|
...options.listOnly?.length ? { listOnly: options.listOnly } : {},
|
|
578
609
|
...options.blobs ? { blobs: true } : {}
|
|
579
610
|
});
|
|
611
|
+
if (unchanged === true && asking) return [{
|
|
612
|
+
seq,
|
|
613
|
+
files: options.digest.dirs.map((path) => ({
|
|
614
|
+
kind: "dir",
|
|
615
|
+
path
|
|
616
|
+
}))
|
|
617
|
+
}];
|
|
580
618
|
if (parts.length > 0 && seq < parts.at(-1).seq) break;
|
|
581
619
|
parts.push({
|
|
582
620
|
seq,
|
|
@@ -613,6 +651,53 @@ var ServerApi = class {
|
|
|
613
651
|
return body;
|
|
614
652
|
}
|
|
615
653
|
/**
|
|
654
|
+
* A page of the hub's change log (ROUTES.changes), newest first. A server from before the log has no such route: a
|
|
655
|
+
* ServerError of status 404, as is any refusal.
|
|
656
|
+
*/
|
|
657
|
+
async changes(request = {}) {
|
|
658
|
+
const { body } = await this.call("POST", ROUTES.changes(this.settings.hub), {
|
|
659
|
+
protocol: 3,
|
|
660
|
+
...request
|
|
661
|
+
}, "read");
|
|
662
|
+
if (!body.ok) throw new ServerError(`the sync server refused the change log: ${body.message}`, body.status);
|
|
663
|
+
return body;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Put the hub, or a path of it, back to a seq or a time (ROUTES.restore): the hub's answer, applied, set off in batches
|
|
667
|
+
* (`started`), or refused. It goes on a connection of its own, as an upload does, and is never sent again, since it has
|
|
668
|
+
* no requestId: one sent again after its answer was lost finds the hub as it left it only when nothing was written in
|
|
669
|
+
* between, and else puts that writing back too. A server from before the log has no such route: a ServerError of status
|
|
670
|
+
* 404. A hub being rewritten throws HubRewritingError.
|
|
671
|
+
*/
|
|
672
|
+
async restore(request) {
|
|
673
|
+
return (await this.call("POST", ROUTES.restore(this.settings.hub), {
|
|
674
|
+
protocol: 3,
|
|
675
|
+
...request
|
|
676
|
+
}, "fresh")).body;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Convert the hub to the server's data format on the server (ROUTES.migrate): the hub's answer, applied, set off in
|
|
680
|
+
* batches (`started`), or refused. It goes on a connection of its own and is never sent again, as a restore. A server
|
|
681
|
+
* from before the route answers 404 from its router: a ServerError of status 404. A hub being rewritten throws
|
|
682
|
+
* HubRewritingError.
|
|
683
|
+
*/
|
|
684
|
+
async migrate(request = {}) {
|
|
685
|
+
return (await this.call("POST", ROUTES.migrate(this.settings.hub), {
|
|
686
|
+
protocol: 3,
|
|
687
|
+
...request
|
|
688
|
+
}, "fresh")).body;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The migration or restore the server runs on the hub in batches, or ran last (ROUTES.rewrite). It goes on a connection
|
|
692
|
+
* of the reads, so one the server closed meanwhile is asked again. A server from before batches has no such route: a
|
|
693
|
+
* ServerError of status 404.
|
|
694
|
+
*/
|
|
695
|
+
async rewrite() {
|
|
696
|
+
const { body } = await this.call("POST", ROUTES.rewrite(this.settings.hub), { protocol: 3 }, "read");
|
|
697
|
+
if (!body.ok) throw new ServerError(`the sync server refused to tell the hub's rewrite: ${body.message}`, body.status);
|
|
698
|
+
return body;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
616
701
|
* Open the hub's broadcast connection, taking the binary files too (ROUTES.socket). It opens before the client knows
|
|
617
702
|
* whether the server keeps binary files (blobRoute), and a server from before them reads nothing of `blobs`.
|
|
618
703
|
*/
|
|
@@ -764,135 +849,6 @@ function mergeThreeWay(current, base, incoming) {
|
|
|
764
849
|
};
|
|
765
850
|
}
|
|
766
851
|
//#endregion
|
|
767
|
-
//#region ../sync/dist/scope.js
|
|
768
|
-
/** The name of a file of ignore rules. */
|
|
769
|
-
const GITIGNORE = ".gitignore";
|
|
770
|
-
const decoder$1 = new TextDecoder("utf-8", { fatal: true });
|
|
771
|
-
/** The text of the file at `file`, or null when there is none or it cannot be read. */
|
|
772
|
-
function readText(file) {
|
|
773
|
-
try {
|
|
774
|
-
return readFileSync(file, "utf8");
|
|
775
|
-
} catch {
|
|
776
|
-
return null;
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
/** The hub's ignore rules, by the directory of each .gitignore, as last read. */
|
|
780
|
-
var SyncScope = class {
|
|
781
|
-
/** Each .gitignore's text and rules by its directory, relative to .mainflow/ with `/`, '' for .mainflow/ itself */
|
|
782
|
-
rules = /* @__PURE__ */ new Map();
|
|
783
|
-
/** Whether each path, a directory's ending in `/`, is ignored under the rules as they are */
|
|
784
|
-
decided = /* @__PURE__ */ new Map();
|
|
785
|
-
changes = 0;
|
|
786
|
-
/** How many times the rules changed: a caller that noted it tells whether they changed since. */
|
|
787
|
-
get generation() {
|
|
788
|
-
return this.changes;
|
|
789
|
-
}
|
|
790
|
-
/** The text of `dir`'s .gitignore as the rules have it, null for none. */
|
|
791
|
-
text(dir) {
|
|
792
|
-
return this.rules.get(dir)?.text ?? null;
|
|
793
|
-
}
|
|
794
|
-
/** Take `text` as the .gitignore of `dir`, null for none; true when that changes the rules. */
|
|
795
|
-
set(dir, text) {
|
|
796
|
-
if ((this.rules.get(dir)?.text ?? null) === text) return false;
|
|
797
|
-
if (text === null) this.rules.delete(dir);
|
|
798
|
-
else this.rules.set(dir, {
|
|
799
|
-
text,
|
|
800
|
-
matcher: ignore().add(text)
|
|
801
|
-
});
|
|
802
|
-
this.decided.clear();
|
|
803
|
-
this.changes++;
|
|
804
|
-
return true;
|
|
805
|
-
}
|
|
806
|
-
/** Forget the rules of every directory but `dirs`, as when a scan of the whole hub no longer finds the others. */
|
|
807
|
-
keepOnly(dirs) {
|
|
808
|
-
for (const dir of this.rules.keys()) if (!dirs.has(dir)) this.set(dir, null);
|
|
809
|
-
}
|
|
810
|
-
/** Whether the file at `path`, relative to .mainflow/, is synced: by the sync client's own rules, and not ignored. */
|
|
811
|
-
synced(path) {
|
|
812
|
-
return isSynced(path) && !this.ignored(path, false);
|
|
813
|
-
}
|
|
814
|
-
/** Whether a scan walks into the directory at `path`: one that may hold synced files, and not ignored. */
|
|
815
|
-
walks(path) {
|
|
816
|
-
return mayHoldSynced(path) && !this.ignored(path, true);
|
|
817
|
-
}
|
|
818
|
-
/** Whether git leaves the file or directory (`dir`) at `path` out: it, or a directory above it, is ignored. */
|
|
819
|
-
ignored(path, dir) {
|
|
820
|
-
if (path === "local" || path.startsWith("local/")) return false;
|
|
821
|
-
const parts = path.split("/");
|
|
822
|
-
if (parts.some((part) => part === "" || part === "." || part === "..")) return true;
|
|
823
|
-
const key = dir ? `${path}/` : path;
|
|
824
|
-
const known = this.decided.get(key);
|
|
825
|
-
if (known !== void 0) return known;
|
|
826
|
-
let out = false;
|
|
827
|
-
for (let n = 1; n <= parts.length && !out; n++) out = this.matches(parts, n, n < parts.length || dir);
|
|
828
|
-
this.decided.set(key, out);
|
|
829
|
-
return out;
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Whether the rules ignore the first `n` parts of a path, a directory's when `dir`: each .gitignore above it in turn,
|
|
833
|
-
* from .mainflow/ down, the last one with a pattern that matches deciding.
|
|
834
|
-
*/
|
|
835
|
-
matches(parts, n, dir) {
|
|
836
|
-
if (parts[n - 1] === ".git") return true;
|
|
837
|
-
let out = false;
|
|
838
|
-
for (let depth = 0; depth < n; depth++) {
|
|
839
|
-
const rules = this.rules.get(parts.slice(0, depth).join("/"));
|
|
840
|
-
if (!rules) continue;
|
|
841
|
-
const test = rules.matcher.test(`${parts.slice(depth, n).join("/")}${dir ? "/" : ""}`);
|
|
842
|
-
if (test.ignored) out = true;
|
|
843
|
-
else if (test.unignored) out = false;
|
|
844
|
-
}
|
|
845
|
-
return out;
|
|
846
|
-
}
|
|
847
|
-
/**
|
|
848
|
-
* Read every .gitignore under `data`, the hub's .mainflow/, again, and return the synced files there; it walks only into
|
|
849
|
-
* the directories the rules leave in, as git does. Synchronous, for a start and for `mainflow check`.
|
|
850
|
-
*/
|
|
851
|
-
load(data) {
|
|
852
|
-
const files = [];
|
|
853
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
854
|
-
const visit = (dir) => {
|
|
855
|
-
let entries;
|
|
856
|
-
try {
|
|
857
|
-
entries = readdirSync(join(data, dir), { withFileTypes: true });
|
|
858
|
-
} catch {
|
|
859
|
-
return;
|
|
860
|
-
}
|
|
861
|
-
dirs.add(dir);
|
|
862
|
-
this.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? readText(join(data, dir, GITIGNORE)) : null);
|
|
863
|
-
for (const entry of entries) {
|
|
864
|
-
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
865
|
-
if (entry.isDirectory()) {
|
|
866
|
-
if (this.walks(path)) visit(path);
|
|
867
|
-
} else if (entry.isFile() && this.synced(path)) files.push(path);
|
|
868
|
-
}
|
|
869
|
-
};
|
|
870
|
-
visit("");
|
|
871
|
-
this.keepOnly(dirs);
|
|
872
|
-
return files;
|
|
873
|
-
}
|
|
874
|
-
};
|
|
875
|
-
/**
|
|
876
|
-
* The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text the hub's
|
|
877
|
-
* .gitignore files leave in. The sync client leaves them out of sync; `mainflow check` warns of them.
|
|
878
|
-
*/
|
|
879
|
-
function oversizedFiles(root) {
|
|
880
|
-
const data = join(root, DATA_DIR);
|
|
881
|
-
const out = [];
|
|
882
|
-
for (const path of new SyncScope().load(data)) {
|
|
883
|
-
const file = join(data, path);
|
|
884
|
-
try {
|
|
885
|
-
if (statSync(file).size <= 19e5) continue;
|
|
886
|
-
const bytes = textBytes(decoder$1.decode(readFileSync(file)));
|
|
887
|
-
if (bytes > 19e5) out.push({
|
|
888
|
-
path,
|
|
889
|
-
bytes
|
|
890
|
-
});
|
|
891
|
-
} catch {}
|
|
892
|
-
}
|
|
893
|
-
return out;
|
|
894
|
-
}
|
|
895
|
-
//#endregion
|
|
896
852
|
//#region ../sync/dist/state.js
|
|
897
853
|
/** The requestId an outbox entry goes under: its id, with the number of the try for a binary file's write sent again. */
|
|
898
854
|
const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.id}-${entry.retry}` : entry.id;
|
|
@@ -954,18 +910,18 @@ function writeJson(file, value) {
|
|
|
954
910
|
writeFileSync(temp, JSON.stringify(value, null, 1));
|
|
955
911
|
renameSync(temp, file);
|
|
956
912
|
}
|
|
957
|
-
const readKnown = (
|
|
913
|
+
const readKnown = (dirs) => readJson(syncPaths(dirs).known, {}).files ?? {};
|
|
958
914
|
/** The directories known.json names; none from a client before it kept them. */
|
|
959
|
-
const readKnownDirs = (
|
|
960
|
-
const dirs = readJson(syncPaths(
|
|
915
|
+
const readKnownDirs = (hub) => {
|
|
916
|
+
const dirs = readJson(syncPaths(hub).known, {}).dirs;
|
|
961
917
|
return Array.isArray(dirs) ? dirs.filter((dir) => typeof dir === "string") : [];
|
|
962
918
|
};
|
|
963
919
|
/** What known.json writes before a binary file's sha256, apart from a text's content hash. */
|
|
964
920
|
const BLOB_PREFIX = "blob:";
|
|
965
921
|
/** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
|
|
966
|
-
function readKnownText(
|
|
922
|
+
function readKnownText(dirs, hash) {
|
|
967
923
|
try {
|
|
968
|
-
const text = readFileSync(join(syncPaths(
|
|
924
|
+
const text = readFileSync(join(syncPaths(dirs).knownTexts, hash), "utf8");
|
|
969
925
|
return contentHash(text) === hash ? text : void 0;
|
|
970
926
|
} catch {
|
|
971
927
|
return;
|
|
@@ -978,8 +934,8 @@ function readKnownText(root, hash) {
|
|
|
978
934
|
* step leaves a known.json whose every text is in known/. A binary file is given as its sha256 with BLOB_PREFIX, and no text.
|
|
979
935
|
* `dirs` are the directories it names.
|
|
980
936
|
*/
|
|
981
|
-
function writeKnown(
|
|
982
|
-
const dir = syncPaths(
|
|
937
|
+
function writeKnown(hub, files, dirs = []) {
|
|
938
|
+
const dir = syncPaths(hub).knownTexts;
|
|
983
939
|
mkdirSync(dir, { recursive: true });
|
|
984
940
|
const named = new Set(Object.values(files).map((file) => file.hash));
|
|
985
941
|
const held = new Set(readdirSync(dir));
|
|
@@ -993,25 +949,25 @@ function writeKnown(root, files, dirs = []) {
|
|
|
993
949
|
files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])),
|
|
994
950
|
...dirs.length > 0 ? { dirs } : {}
|
|
995
951
|
};
|
|
996
|
-
writeJson(syncPaths(
|
|
952
|
+
writeJson(syncPaths(hub).known, known);
|
|
997
953
|
for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
|
|
998
954
|
}
|
|
999
|
-
const writeQueue = (
|
|
1000
|
-
const readRemote = (
|
|
1001
|
-
const writeRemote = (
|
|
955
|
+
const writeQueue = (dirs, entries) => writeJson(syncPaths(dirs).queue, { entries });
|
|
956
|
+
const readRemote = (dirs) => readJson(syncPaths(dirs).remote, {}).files ?? {};
|
|
957
|
+
const writeRemote = (dirs, files) => writeJson(syncPaths(dirs).remote, { files });
|
|
1002
958
|
/** The queue as queue.json has it; an entry from before entries had ids gets one, written back. Call under the write lock. */
|
|
1003
|
-
function readQueue(
|
|
1004
|
-
const entries = readJson(syncPaths(
|
|
959
|
+
function readQueue(dirs) {
|
|
960
|
+
const entries = readJson(syncPaths(dirs).queue, {}).entries ?? [];
|
|
1005
961
|
if (entries.every((entry) => typeof entry.id === "string")) return entries;
|
|
1006
962
|
const named = entries.map((entry) => ({
|
|
1007
963
|
...entry,
|
|
1008
964
|
id: entry.id ?? entryId()
|
|
1009
965
|
}));
|
|
1010
|
-
writeQueue(
|
|
966
|
+
writeQueue(dirs, named);
|
|
1011
967
|
return named;
|
|
1012
968
|
}
|
|
1013
969
|
/** Run `fn` under the hub's write lock, the one every mainflow writer of this machine takes. */
|
|
1014
|
-
const underLock = (
|
|
970
|
+
const underLock = (dirs, fn) => nodeStorage(dirs).lock(fn);
|
|
1015
971
|
//#endregion
|
|
1016
972
|
//#region ../sync/dist/swap-helper.js
|
|
1017
973
|
const HELPER_SOURCE = "mainflow-swap.c";
|
|
@@ -1386,14 +1342,14 @@ const SWAP_PAUSE_MS = 6e4;
|
|
|
1386
1342
|
/** queue.json, relative to .mainflow/: other writers append to it, and the client sends what they appended */
|
|
1387
1343
|
const OUTBOX = `${SYNC_DIR}/queue.json`;
|
|
1388
1344
|
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1389
|
-
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1345
|
+
const decoder$1 = new TextDecoder("utf-8", { fatal: true });
|
|
1390
1346
|
const sameLocal = (a, b) => a.kind === b.kind && (a.kind !== "text" || a.text === b.text) && (a.kind !== "binary" || a.hash === b.hash);
|
|
1391
1347
|
/** What `bytes` read from a file are: a text when they are UTF-8, a binary file otherwise. */
|
|
1392
1348
|
function contentOf(bytes) {
|
|
1393
1349
|
try {
|
|
1394
1350
|
return {
|
|
1395
1351
|
kind: "text",
|
|
1396
|
-
text: decoder.decode(bytes)
|
|
1352
|
+
text: decoder$1.decode(bytes)
|
|
1397
1353
|
};
|
|
1398
1354
|
} catch {
|
|
1399
1355
|
return {
|
|
@@ -1566,7 +1522,7 @@ function holdsFiles(parts, held) {
|
|
|
1566
1522
|
}
|
|
1567
1523
|
var SyncClient = class extends EventEmitter {
|
|
1568
1524
|
opts;
|
|
1569
|
-
|
|
1525
|
+
dirs;
|
|
1570
1526
|
paths;
|
|
1571
1527
|
api;
|
|
1572
1528
|
problems = [];
|
|
@@ -1664,14 +1620,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
1664
1620
|
uploadWanted = false;
|
|
1665
1621
|
/** Why the server refused the upload of this environment's copy, as last reported (snapshotNow); null once it holds the hub */
|
|
1666
1622
|
uploadRefusal = null;
|
|
1667
|
-
/** The hub's .gitignore files, which leave a file out of sync as they leave it out of git (src/scope.ts) */
|
|
1668
|
-
scope = new SyncScope();
|
|
1669
|
-
/**
|
|
1670
|
-
* The generation of the rules (SyncScope.generation) the last snapshot was asked for under. A file the rules left out
|
|
1671
|
-
* then was not taken in, whatever the server sent, nor a broadcast of it since; rules changed after it may let such a
|
|
1672
|
-
* file in, so the snapshot is taken again (walk), and a file not taken in from the server waits for it (scopeStale).
|
|
1673
|
-
*/
|
|
1674
|
-
scopeTaken = -1;
|
|
1675
1623
|
/**
|
|
1676
1624
|
* The paths the server last named `unknown`: it holds no file there and never removed one. A file here at such a path
|
|
1677
1625
|
* is new to the server, whatever known.json says, as when another environment uploaded the hub to a new server
|
|
@@ -1742,6 +1690,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1742
1690
|
flushRun = null;
|
|
1743
1691
|
resyncing = null;
|
|
1744
1692
|
resyncAgain = false;
|
|
1693
|
+
/**
|
|
1694
|
+
* The snapshot the hub held off while the server migrates or restores it in batches (HubRewritingError): taken again
|
|
1695
|
+
* when its reload broadcast comes, or after the Retry-After it came with, whichever is first. The connection stays
|
|
1696
|
+
* open meanwhile, so the client neither reconnects nor asks for the snapshot more often than that.
|
|
1697
|
+
*/
|
|
1698
|
+
snapshotHeld = false;
|
|
1699
|
+
snapshotTimer;
|
|
1745
1700
|
/** Whether a snapshot has been taken, after which a path the server never mentioned does not exist there */
|
|
1746
1701
|
synced = false;
|
|
1747
1702
|
reconnectTimer;
|
|
@@ -1767,15 +1722,25 @@ var SyncClient = class extends EventEmitter {
|
|
|
1767
1722
|
backoff = 500;
|
|
1768
1723
|
lastPong = 0;
|
|
1769
1724
|
knownTimer;
|
|
1725
|
+
/**
|
|
1726
|
+
* Until when the outbox waits, in ms since the epoch, after the hub refused a write for a while: for this CLI's version
|
|
1727
|
+
* (ClientOutdatedError), or while it is migrated or restored in batches (HubRewritingError). The Retry-After it came
|
|
1728
|
+
* with, so an environment left on an older CLI, or one writing during a rewrite, asks once a while, not once a second.
|
|
1729
|
+
* The write stays in the outbox meanwhile, and idle does not wait for it. With the message last told of an outdated
|
|
1730
|
+
* CLI (a `client-outdated` problem), so the same one is told once, until a write goes through again.
|
|
1731
|
+
*/
|
|
1732
|
+
heldUntil = 0;
|
|
1733
|
+
outdatedMessage = null;
|
|
1770
1734
|
constructor(opts) {
|
|
1771
1735
|
super();
|
|
1772
1736
|
this.opts = opts;
|
|
1773
|
-
this.
|
|
1774
|
-
this.paths = syncPaths(opts.
|
|
1737
|
+
this.dirs = opts.dirs;
|
|
1738
|
+
this.paths = syncPaths(opts.dirs);
|
|
1775
1739
|
this.api = new ServerApi(opts.settings);
|
|
1776
1740
|
this.firstContact = new Promise((resolve) => {
|
|
1777
1741
|
this.once("synced", resolve);
|
|
1778
1742
|
this.once("offline", resolve);
|
|
1743
|
+
this.once("held", resolve);
|
|
1779
1744
|
});
|
|
1780
1745
|
}
|
|
1781
1746
|
trace(line) {
|
|
@@ -1788,26 +1753,13 @@ var SyncClient = class extends EventEmitter {
|
|
|
1788
1753
|
get online() {
|
|
1789
1754
|
return this.connected && !this.stopped;
|
|
1790
1755
|
}
|
|
1791
|
-
/**
|
|
1792
|
-
* Whether the file at `path` is synced: by the sync client's own rules (src/paths.ts) and the hub's .gitignore files as
|
|
1793
|
-
* last read (src/scope.ts). Anything else is left as it is here, whatever the server holds.
|
|
1794
|
-
*/
|
|
1756
|
+
/** Whether the file at `path` is synced (src/paths.ts). Anything else is left as it is here, whatever the server holds. */
|
|
1795
1757
|
syncs(path) {
|
|
1796
|
-
return
|
|
1758
|
+
return isSynced(path);
|
|
1797
1759
|
}
|
|
1798
|
-
/**
|
|
1799
|
-
* Whether the directory at `path` is synced: one that may hold synced files and that the hub's .gitignore files leave
|
|
1800
|
-
* in, apart from local/ itself, which every machine has of its own (src/paths.ts).
|
|
1801
|
-
*/
|
|
1760
|
+
/** Whether the directory at `path` is synced: one that may hold synced files, apart from local/ itself, which every machine has of its own (src/paths.ts). */
|
|
1802
1761
|
syncsDir(path) {
|
|
1803
|
-
return
|
|
1804
|
-
}
|
|
1805
|
-
/**
|
|
1806
|
-
* Whether the hub's .gitignore files changed since the last snapshot was asked for: a file they let in since may be on
|
|
1807
|
-
* the server although the client took nothing of it in.
|
|
1808
|
-
*/
|
|
1809
|
-
get scopeStale() {
|
|
1810
|
-
return this.scope.generation !== this.scopeTaken;
|
|
1762
|
+
return syncedDir(path);
|
|
1811
1763
|
}
|
|
1812
1764
|
/** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
|
|
1813
1765
|
get queued() {
|
|
@@ -1860,7 +1812,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
1860
1812
|
this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
|
|
1861
1813
|
}
|
|
1862
1814
|
}
|
|
1863
|
-
this.scope.load(this.paths.data);
|
|
1864
1815
|
await this.restoreKnown();
|
|
1865
1816
|
this.restoreRemote();
|
|
1866
1817
|
await this.whenFree(() => this.syncQueue());
|
|
@@ -1885,8 +1836,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
1885
1836
|
async restoreKnown() {
|
|
1886
1837
|
this.copied = !existsSync(this.paths.known);
|
|
1887
1838
|
this.blobsCompared = !this.copied && !existsSync(this.paths.copyBlobs);
|
|
1888
|
-
for (const dir of readKnownDirs(this.
|
|
1889
|
-
for (const [path, hash] of Object.entries(readKnown(this.
|
|
1839
|
+
for (const dir of readKnownDirs(this.dirs)) this.syncedDirs.add(dir);
|
|
1840
|
+
for (const [path, hash] of Object.entries(readKnown(this.dirs))) {
|
|
1890
1841
|
if (hash.startsWith("blob:")) {
|
|
1891
1842
|
const disk = {
|
|
1892
1843
|
hash: hash.slice(5),
|
|
@@ -1897,7 +1848,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1897
1848
|
continue;
|
|
1898
1849
|
}
|
|
1899
1850
|
const local = await this.readLocal(path);
|
|
1900
|
-
const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.
|
|
1851
|
+
const text = local.kind === "text" && contentHash(local.text) === hash ? local.text : readKnownText(this.dirs, hash);
|
|
1901
1852
|
const disk = text === void 0 ? { hash } : {
|
|
1902
1853
|
hash,
|
|
1903
1854
|
text
|
|
@@ -1947,6 +1898,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1947
1898
|
this.closing = true;
|
|
1948
1899
|
this.stopped = true;
|
|
1949
1900
|
clearTimeout(this.reconnectTimer);
|
|
1901
|
+
clearTimeout(this.snapshotTimer);
|
|
1950
1902
|
clearInterval(this.pingTimer);
|
|
1951
1903
|
clearInterval(this.warmTimer);
|
|
1952
1904
|
clearInterval(this.rescanTimer);
|
|
@@ -1987,6 +1939,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
1987
1939
|
await new Promise((resolve) => {
|
|
1988
1940
|
this.once("synced", resolve);
|
|
1989
1941
|
this.once("offline", resolve);
|
|
1942
|
+
this.once("held", resolve);
|
|
1990
1943
|
this.connect();
|
|
1991
1944
|
});
|
|
1992
1945
|
}
|
|
@@ -2000,7 +1953,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
2000
1953
|
this.outboxChanged();
|
|
2001
1954
|
for (;;) {
|
|
2002
1955
|
const work = besidesAwaiting ? [...this.work].filter((promise) => !this.awaitingEdit.has(this.workPaths.get(promise) ?? "")) : [...this.work];
|
|
2003
|
-
|
|
1956
|
+
const sendable = this.online && this.heldUntil <= Date.now() && this.nextEntry(this.queue).entry !== void 0;
|
|
1957
|
+
if (!(work.length > 0 || [...this.inflight.values()].some((n) => n > 0) || this.flushing || this.resyncing !== null || sendable)) return true;
|
|
2004
1958
|
if (Date.now() > deadline) return false;
|
|
2005
1959
|
await (work.length > 0 ? Promise.race([Promise.allSettled(work), sleep$1(50)]) : sleep$1(50));
|
|
2006
1960
|
}
|
|
@@ -2299,8 +2253,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2299
2253
|
* reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
|
|
2300
2254
|
* still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
|
|
2301
2255
|
* server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
|
|
2302
|
-
* after the gap are forgotten and the snapshot is taken once more, as it is.
|
|
2303
|
-
* changed after it was asked for (scopeStale): a file they let in since was not taken in. The connection has its
|
|
2256
|
+
* after the gap are forgotten and the snapshot is taken once more, as it is. The connection has its
|
|
2304
2257
|
* snapshot once one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a
|
|
2305
2258
|
* fresh one, rather than send on the snapshot of an earlier connection.
|
|
2306
2259
|
*/
|
|
@@ -2332,11 +2285,17 @@ var SyncClient = class extends EventEmitter {
|
|
|
2332
2285
|
this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
|
|
2333
2286
|
if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
|
|
2334
2287
|
this.resyncAgain = true;
|
|
2335
|
-
} while (
|
|
2288
|
+
} while (this.resyncAgain && !this.closing);
|
|
2336
2289
|
this.current = this.connected && !this.closing;
|
|
2290
|
+
if (this.snapshotHeld) {
|
|
2291
|
+
this.snapshotHeld = false;
|
|
2292
|
+
clearTimeout(this.snapshotTimer);
|
|
2293
|
+
if (this.current) this.emit("synced");
|
|
2294
|
+
}
|
|
2337
2295
|
} catch (error) {
|
|
2338
2296
|
this.current = false;
|
|
2339
|
-
this.
|
|
2297
|
+
if (error instanceof HubRewritingError && this.connected && !this.closing) this.holdSnapshot(error);
|
|
2298
|
+
else this.ws?.close();
|
|
2340
2299
|
throw error;
|
|
2341
2300
|
} finally {
|
|
2342
2301
|
this.resyncing = null;
|
|
@@ -2346,6 +2305,19 @@ var SyncClient = class extends EventEmitter {
|
|
|
2346
2305
|
return this.resyncing;
|
|
2347
2306
|
}
|
|
2348
2307
|
/**
|
|
2308
|
+
* The server held the snapshot off while it migrates or restores the hub in batches: the connection stays open, and
|
|
2309
|
+
* the snapshot is taken again on the reload broadcast that ends the rewrite, or after the Retry-After of the refusal,
|
|
2310
|
+
* whichever is first. Nothing is sent meanwhile, as before any snapshot. A client starting or reconnecting meanwhile
|
|
2311
|
+
* is done waiting for its first contact (`held`).
|
|
2312
|
+
*/
|
|
2313
|
+
holdSnapshot(error) {
|
|
2314
|
+
if (!this.snapshotHeld) this.log(`${error.message}; the snapshot is taken again once it is done`);
|
|
2315
|
+
this.snapshotHeld = true;
|
|
2316
|
+
clearTimeout(this.snapshotTimer);
|
|
2317
|
+
this.snapshotTimer = setTimeout(() => void this.track(this.resync().catch((failed) => this.log(`could not reload the snapshot: ${failed.message}`))), error.retryAfter * 1e3);
|
|
2318
|
+
this.emit("held");
|
|
2319
|
+
}
|
|
2320
|
+
/**
|
|
2349
2321
|
* Take the hub as of the snapshot's seq, asking only for what differs from what the client holds (holdings). The
|
|
2350
2322
|
* snapshot comes in batches, each as of its own seq; the WebSocket was open before the first, so every change after
|
|
2351
2323
|
* it comes as a broadcast, and the first batch's seq is the one the whole snapshot is as of. A file the server sent,
|
|
@@ -2353,8 +2325,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
2353
2325
|
* batch's seq; a file neither named nor sent is not on the server. A broadcast arriving meanwhile may be older or
|
|
2354
2326
|
* newer than either; each file keeps whichever version has the higher seq. A path the server never held a file at
|
|
2355
2327
|
* (`unknown`) is one it has no file at: a file here, even one never synced or one known.json names, is sent as new and
|
|
2356
|
-
* never removed (reconcile, unheld). What the
|
|
2357
|
-
*
|
|
2328
|
+
* never removed (reconcile, unheld). What the client does not sync (syncs), as a file in a scratch/ directory, is not
|
|
2329
|
+
* taken in, whatever the server sends.
|
|
2358
2330
|
*
|
|
2359
2331
|
* A server without the hub first gets the local one. When it refuses the upload and still holds no file, the snapshot
|
|
2360
2332
|
* fails without taking anything in: an empty hub would tell a copy without known.json to remove every file it holds.
|
|
@@ -2387,8 +2359,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
2387
2359
|
/** Whether the hub was uploaded, and why the server refused the upload; asked again, the snapshot may hold the hub */
|
|
2388
2360
|
let uploaded = false;
|
|
2389
2361
|
let refusal = null;
|
|
2390
|
-
/** The rules' generation the snapshot is asked for under: what it holds is taken in as they leave it in */
|
|
2391
|
-
let asked;
|
|
2392
2362
|
/** Whether the snapshot is asked for with the binary files */
|
|
2393
2363
|
let withBlobs = false;
|
|
2394
2364
|
if (!this.probed) await this.probeBlobs();
|
|
@@ -2399,15 +2369,20 @@ var SyncClient = class extends EventEmitter {
|
|
|
2399
2369
|
have = new Map([...held].map(([path, text]) => [path, this.hashOf(path, text)]));
|
|
2400
2370
|
for (const [path, blob] of heldBlobs) have.set(path, blob.hash);
|
|
2401
2371
|
for (const [path, hash] of this.remote) have.set(path, hash);
|
|
2402
|
-
asked = this.scope.generation;
|
|
2403
2372
|
withBlobs = this.blobSupport === true;
|
|
2404
2373
|
if (!withBlobs && !this.blobsCompared && !existsSync(this.paths.copyBlobs)) {
|
|
2405
2374
|
mkdirSync(this.paths.own, { recursive: true });
|
|
2406
2375
|
writeFileSync(this.paths.copyBlobs, "");
|
|
2407
2376
|
}
|
|
2377
|
+
const dirs = this.knownDirs();
|
|
2378
|
+
const digest = withBlobs && have.size > 0 ? {
|
|
2379
|
+
value: hubDigest(have, dirs),
|
|
2380
|
+
dirs
|
|
2381
|
+
} : void 0;
|
|
2408
2382
|
parts = await this.api.snapshot(have, {
|
|
2409
2383
|
listOnly: this.listOnly(this.copied && !this.synced),
|
|
2410
|
-
blobs: this.blobSupport === true
|
|
2384
|
+
blobs: this.blobSupport === true,
|
|
2385
|
+
...digest ? { digest } : {}
|
|
2411
2386
|
});
|
|
2412
2387
|
if (uploaded || holdsFiles(parts, have)) break;
|
|
2413
2388
|
if (this.opts.settings.onDemand === true) {
|
|
@@ -2416,7 +2391,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2416
2391
|
this.problem({
|
|
2417
2392
|
kind: "upload-needed",
|
|
2418
2393
|
path: "",
|
|
2419
|
-
message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub
|
|
2394
|
+
message: `the sync server has no copy of hub ${this.opts.settings.hub}; this environment downloads on demand and may lack files, so it does not upload its copy. Run mainflow sync, with on-demand downloads off, in an environment that keeps every file of the hub (run it there with MAINFLOW_SYNC_ON_DEMAND=0), to upload it; this one waits and syncs once the hub is there`
|
|
2420
2395
|
});
|
|
2421
2396
|
}
|
|
2422
2397
|
throw new Error("the sync server has no copy of the hub, and this environment downloads on demand, so it waits for one that keeps every file to upload it");
|
|
@@ -2529,7 +2504,6 @@ var SyncClient = class extends EventEmitter {
|
|
|
2529
2504
|
}, seq);
|
|
2530
2505
|
}
|
|
2531
2506
|
for (const path of this.syncedDirs) if (!dirs.has(path)) this.receiveDirRemoved(path, seq);
|
|
2532
|
-
this.scopeTaken = asked;
|
|
2533
2507
|
this.log(`took the snapshot at seq ${seq} in ${parts.length} batch${parts.length === 1 ? "" : "es"}: ${sent} files sent, ${present.size - sent} already held here${this.remote.size > 0 ? `, ${this.remote.size} only on the server` : ""}`);
|
|
2534
2508
|
this.takenUpTo(seq);
|
|
2535
2509
|
this.synced = true;
|
|
@@ -2707,7 +2681,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2707
2681
|
if (expected?.blob && expected.hash === disk.hash) this.expected.delete(path);
|
|
2708
2682
|
if (isBlob(this.cloud.get(path))) this.cloud.delete(path);
|
|
2709
2683
|
}
|
|
2710
|
-
if (forgotten.size > 0) await this.whenFree(() => underLock(this.
|
|
2684
|
+
if (forgotten.size > 0) await this.whenFree(() => underLock(this.dirs, () => writeKnown(this.dirs, this.knownFiles(), this.knownDirs())));
|
|
2711
2685
|
return forgotten;
|
|
2712
2686
|
}
|
|
2713
2687
|
/** The server refused the upload, so it holds what it held: the binary files forgetBinaries forgot are known again. */
|
|
@@ -2809,7 +2783,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2809
2783
|
const after = this.expected.get(path);
|
|
2810
2784
|
if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
|
|
2811
2785
|
}
|
|
2812
|
-
const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced &&
|
|
2786
|
+
const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced && (this.blobSupport === true || !this.knownBinary(path)) ? null : void 0;
|
|
2813
2787
|
const disk = this.expected.get(path);
|
|
2814
2788
|
const unheld = target === null && this.unheld.has(path);
|
|
2815
2789
|
if (disk === void 0) {
|
|
@@ -2888,7 +2862,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
2888
2862
|
}
|
|
2889
2863
|
if (text === null) return false;
|
|
2890
2864
|
try {
|
|
2891
|
-
return decoder.decode(bytes) === text;
|
|
2865
|
+
return decoder$1.decode(bytes) === text;
|
|
2892
2866
|
} catch {
|
|
2893
2867
|
return false;
|
|
2894
2868
|
}
|
|
@@ -3000,7 +2974,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3000
2974
|
this.tellOnce(`large:${path}:${hash}`, {
|
|
3001
2975
|
kind: "skipped-large",
|
|
3002
2976
|
path,
|
|
3003
|
-
message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or
|
|
2977
|
+
message: `${path} is larger than ${limit}, so it is not synced and not overwritten; other environments do not see it. ${binary ? "Make it smaller" : "Split it"}, or move it into a scratch/ directory, which is never synced`
|
|
3004
2978
|
});
|
|
3005
2979
|
}
|
|
3006
2980
|
/**
|
|
@@ -3315,7 +3289,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3315
3289
|
await this.fetchPaths([path]).catch((error) => this.log(`${path}: could not download it: ${error.message}`));
|
|
3316
3290
|
}
|
|
3317
3291
|
restoreRemote() {
|
|
3318
|
-
for (const [path, hash] of Object.entries(readRemote(this.
|
|
3292
|
+
for (const [path, hash] of Object.entries(readRemote(this.dirs))) if (this.syncs(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
|
|
3319
3293
|
}
|
|
3320
3294
|
/**
|
|
3321
3295
|
* What remote.json lists: the files only on the server, and the artifacts whose download failed and waits for a retry.
|
|
@@ -3350,7 +3324,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
3350
3324
|
const listed = this.listedRemote();
|
|
3351
3325
|
if (listed.size === 0 && !existsSync(this.paths.remote)) return;
|
|
3352
3326
|
try {
|
|
3353
|
-
writeRemote(this.
|
|
3327
|
+
writeRemote(this.dirs, Object.fromEntries([...listed].sort(([a], [b]) => comparePaths(a, b))));
|
|
3354
3328
|
} catch (error) {
|
|
3355
3329
|
this.log(`could not save local/sync/remote.json: ${error.message}`);
|
|
3356
3330
|
}
|
|
@@ -3941,10 +3915,10 @@ var SyncClient = class extends EventEmitter {
|
|
|
3941
3915
|
* goes first, and no file the server holds there that is not written here yet, nor one only on the server. The
|
|
3942
3916
|
* deepest go first, so the server finds the ones in each removed already; one holding anything holds the ones above it
|
|
3943
3917
|
* too. Each is checked again in the hold of the write lock that queues it, so a directory made again meanwhile stays.
|
|
3944
|
-
* Only once a snapshot was taken
|
|
3918
|
+
* Only once a snapshot was taken, since what the server holds is unknown before.
|
|
3945
3919
|
*/
|
|
3946
3920
|
async queueRemovedDirs() {
|
|
3947
|
-
if (!this.synced || this.
|
|
3921
|
+
if (!this.synced || this.closing) return;
|
|
3948
3922
|
const exists = (dir) => existsSync(join(this.paths.data, dir));
|
|
3949
3923
|
const gone = [...this.syncedDirs].filter((dir) => this.syncsDir(dir) && !exists(dir));
|
|
3950
3924
|
if (gone.length === 0) return;
|
|
@@ -4103,7 +4077,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4103
4077
|
*/
|
|
4104
4078
|
locked(fn) {
|
|
4105
4079
|
if (this.abandoned) throw new Error("abandoned after stop");
|
|
4106
|
-
return underLock(this.
|
|
4080
|
+
return underLock(this.dirs, fn);
|
|
4107
4081
|
}
|
|
4108
4082
|
/**
|
|
4109
4083
|
* Change the outbox: read queue.json, take in the entries other writers appended since it was last read, let `edit`
|
|
@@ -4118,7 +4092,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4118
4092
|
}
|
|
4119
4093
|
/** editQueue for a caller that holds the write lock already. */
|
|
4120
4094
|
editHeld(edit) {
|
|
4121
|
-
const entries = readQueue(this.
|
|
4095
|
+
const entries = readQueue(this.dirs);
|
|
4122
4096
|
const known = new Set(this.queue.map((entry) => entry.id));
|
|
4123
4097
|
let changed = false;
|
|
4124
4098
|
const fresh = [];
|
|
@@ -4142,7 +4116,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4142
4116
|
this.resolveBases(entries);
|
|
4143
4117
|
}
|
|
4144
4118
|
if (changed) {
|
|
4145
|
-
writeQueue(this.
|
|
4119
|
+
writeQueue(this.dirs, entries);
|
|
4146
4120
|
this.pruneStaged(entries);
|
|
4147
4121
|
}
|
|
4148
4122
|
this.queue = entries;
|
|
@@ -4340,6 +4314,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
4340
4314
|
async sendOutbox() {
|
|
4341
4315
|
this.flushing = true;
|
|
4342
4316
|
try {
|
|
4317
|
+
const held = this.heldUntil - Date.now();
|
|
4318
|
+
if (held > 0) return this.retryQueue(held);
|
|
4343
4319
|
while (this.caughtUp) {
|
|
4344
4320
|
let entry;
|
|
4345
4321
|
let wake;
|
|
@@ -4664,9 +4640,28 @@ var SyncClient = class extends EventEmitter {
|
|
|
4664
4640
|
} catch (error) {
|
|
4665
4641
|
if (!(error instanceof Unreachable)) throw error;
|
|
4666
4642
|
this.trace(`send ${entry.id} failed: ${error.message}`);
|
|
4643
|
+
if (error instanceof HubRewritingError) {
|
|
4644
|
+
this.heldUntil = Date.now() + error.retryAfter * 1e3;
|
|
4645
|
+
this.log(`${error.message}; the outbox is sent again in ${error.retryAfter} s`);
|
|
4646
|
+
return null;
|
|
4647
|
+
}
|
|
4648
|
+
if (error instanceof ClientOutdatedError) {
|
|
4649
|
+
this.heldUntil = Date.now() + error.retryAfter * 1e3;
|
|
4650
|
+
if (error.message !== this.outdatedMessage) {
|
|
4651
|
+
const paths = entryPaths(entry);
|
|
4652
|
+
this.problem({
|
|
4653
|
+
kind: "client-outdated",
|
|
4654
|
+
path: paths[0] ?? "",
|
|
4655
|
+
message: `${error.message}. ${request.op}${paths.length ? ` of ${paths.join(", ")}` : ""} and the writes after it wait in the outbox, sent again every ${error.retryAfter} s until then`
|
|
4656
|
+
});
|
|
4657
|
+
}
|
|
4658
|
+
this.outdatedMessage = error.message;
|
|
4659
|
+
return null;
|
|
4660
|
+
}
|
|
4667
4661
|
if (error.broken && !fresh && !this.closing) return this.request(entry, request, true, true);
|
|
4668
4662
|
return null;
|
|
4669
4663
|
}
|
|
4664
|
+
this.outdatedMessage = null;
|
|
4670
4665
|
if (!response.ok && response.reason === "uncertain") {
|
|
4671
4666
|
const paths = entryPaths(entry);
|
|
4672
4667
|
if (entry.kind !== "rmdir") this.problem({
|
|
@@ -4966,7 +4961,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
4966
4961
|
*/
|
|
4967
4962
|
renumberDisk(paths, renumbered) {
|
|
4968
4963
|
const storage = {
|
|
4969
|
-
...nodeStorage(this.
|
|
4964
|
+
...nodeStorage(this.dirs),
|
|
4970
4965
|
lock: (fn) => fn()
|
|
4971
4966
|
};
|
|
4972
4967
|
const seen = new Map([...new Set(paths)].filter((path) => this.syncs(path)).map((path) => [path, this.expected.get(path)]));
|
|
@@ -5299,16 +5294,9 @@ var SyncClient = class extends EventEmitter {
|
|
|
5299
5294
|
}]);
|
|
5300
5295
|
if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
|
|
5301
5296
|
}
|
|
5302
|
-
/**
|
|
5303
|
-
* The synced files here, by path. Each directory's .gitignore is read again on the way, before what the directory
|
|
5304
|
-
* holds, so a change to one takes effect from the next scan on; a directory the rules leave out is not walked into.
|
|
5305
|
-
* Rules changed since the last snapshot was asked for take it again: a file they let in that is on the server, as
|
|
5306
|
-
* another environment sent it while the rules here left it out, comes then, not only on the next connection. A walk
|
|
5307
|
-
* of a snapshot being taken leaves that to it (resync).
|
|
5308
|
-
*/
|
|
5297
|
+
/** The synced files here, by path: a walk into the directories that may hold them alone (src/paths.ts). */
|
|
5309
5298
|
async walk() {
|
|
5310
5299
|
const out = [];
|
|
5311
|
-
const dirs = /* @__PURE__ */ new Set();
|
|
5312
5300
|
const visit = async (dir) => {
|
|
5313
5301
|
let entries;
|
|
5314
5302
|
try {
|
|
@@ -5316,44 +5304,23 @@ var SyncClient = class extends EventEmitter {
|
|
|
5316
5304
|
} catch {
|
|
5317
5305
|
return;
|
|
5318
5306
|
}
|
|
5319
|
-
dirs.add(dir);
|
|
5320
|
-
this.scope.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? await this.readRules(dir) : null);
|
|
5321
5307
|
for (const entry of entries) {
|
|
5322
5308
|
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5323
5309
|
if (entry.isDirectory()) {
|
|
5324
|
-
if (
|
|
5310
|
+
if (mayHoldSynced(path)) await visit(path);
|
|
5325
5311
|
} else if (entry.isFile() && this.syncs(path)) out.push(path);
|
|
5326
5312
|
}
|
|
5327
5313
|
};
|
|
5328
5314
|
await visit("");
|
|
5329
|
-
this.scope.keepOnly(dirs);
|
|
5330
|
-
if (this.scopeStale && this.synced && this.connected && this.resyncing === null && !this.closing) {
|
|
5331
|
-
this.log("the hub's .gitignore changed; reloading the snapshot for the files it lets in");
|
|
5332
|
-
this.track(this.resync().catch((error) => this.log(`could not reload the snapshot: ${error.message}`)));
|
|
5333
|
-
}
|
|
5334
5315
|
return out;
|
|
5335
5316
|
}
|
|
5336
|
-
/**
|
|
5337
|
-
* The text of the .gitignore in `dir`, which is there, as walk takes it. A text other than the rules have may be one
|
|
5338
|
-
* being written, as `cat >` empties a file before it fills it, and a rule missing for a moment would let its files in,
|
|
5339
|
-
* to be sent: it is read again after EMPTY_RECHECK_MS and taken only when it reads the same, empty too. Until then, and
|
|
5340
|
-
* while the file cannot be read, the rules stay as they were; the next walk reads it again.
|
|
5341
|
-
*/
|
|
5342
|
-
async readRules(dir) {
|
|
5343
|
-
const held = this.scope.text(dir);
|
|
5344
|
-
const read = () => readFile(join(this.paths.data, dir, GITIGNORE), "utf8").catch(() => null);
|
|
5345
|
-
const text = await read();
|
|
5346
|
-
if (text === null || text === held) return held;
|
|
5347
|
-
await sleep$1(EMPTY_RECHECK_MS);
|
|
5348
|
-
return await read() === text ? text : held;
|
|
5349
|
-
}
|
|
5350
|
-
/** The directories an upload holds: every one outside local/ that the hub's .gitignore files leave in. */
|
|
5317
|
+
/** The directories an upload holds: every synced one (syncsDir) outside local/. */
|
|
5351
5318
|
async walkDirs(dir = "") {
|
|
5352
5319
|
const out = [];
|
|
5353
5320
|
const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
|
|
5354
5321
|
for (const entry of entries) {
|
|
5355
5322
|
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5356
|
-
if (!entry.isDirectory() || path === "local" ||
|
|
5323
|
+
if (!entry.isDirectory() || path === "local" || !this.syncsDir(path)) continue;
|
|
5357
5324
|
out.push(path, ...await this.walkDirs(path));
|
|
5358
5325
|
}
|
|
5359
5326
|
return out;
|
|
@@ -5401,13 +5368,12 @@ var SyncClient = class extends EventEmitter {
|
|
|
5401
5368
|
return;
|
|
5402
5369
|
}
|
|
5403
5370
|
if (this.syncedDirs.has(path)) this.checkDirs();
|
|
5404
|
-
|
|
5405
|
-
if (!rules && !this.syncs(path)) return;
|
|
5371
|
+
if (!this.syncs(path)) return;
|
|
5406
5372
|
this.trace(`watched ${path}`);
|
|
5407
5373
|
clearTimeout(timers.get(path));
|
|
5408
5374
|
timers.set(path, setTimeout(() => {
|
|
5409
5375
|
timers.delete(path);
|
|
5410
|
-
this.track(this.whenSettled(path).then(() =>
|
|
5376
|
+
this.track(this.whenSettled(path).then(() => this.schedule(path)));
|
|
5411
5377
|
}, SETTLE_MS));
|
|
5412
5378
|
};
|
|
5413
5379
|
try {
|
|
@@ -5511,7 +5477,7 @@ var SyncClient = class extends EventEmitter {
|
|
|
5511
5477
|
const files = this.knownFiles();
|
|
5512
5478
|
const dirs = this.knownDirs();
|
|
5513
5479
|
try {
|
|
5514
|
-
underLock(this.
|
|
5480
|
+
underLock(this.dirs, () => writeKnown(this.dirs, files, dirs));
|
|
5515
5481
|
} catch (error) {
|
|
5516
5482
|
this.log(`could not save local/sync/known.json: ${error.message}`);
|
|
5517
5483
|
}
|
|
@@ -5521,8 +5487,8 @@ var SyncClient = class extends EventEmitter {
|
|
|
5521
5487
|
//#region ../sync/dist/ipc.js
|
|
5522
5488
|
const SECRET_HEADER = "x-mainflow-sync";
|
|
5523
5489
|
/** The running sync client of the hub at `root`, or null when none runs. */
|
|
5524
|
-
function readDaemon(
|
|
5525
|
-
const file = syncPaths(
|
|
5490
|
+
function readDaemon(dirs) {
|
|
5491
|
+
const file = syncPaths(dirs).daemon;
|
|
5526
5492
|
if (!existsSync(file)) return null;
|
|
5527
5493
|
try {
|
|
5528
5494
|
const info = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -5575,8 +5541,8 @@ async function callDaemon(daemon, route, body, timeoutMs = 6e4) {
|
|
|
5575
5541
|
/** How long a probe waits for the sync client's answer; a client holding the write lock answers after it. */
|
|
5576
5542
|
const PROBE_MS = 2e3;
|
|
5577
5543
|
/** The sync client daemon.json names, asked whether it runs; null without daemon.json or a live process at its pid. */
|
|
5578
|
-
async function probeDaemon(
|
|
5579
|
-
const daemon = readDaemon(
|
|
5544
|
+
async function probeDaemon(dirs, timeoutMs = PROBE_MS) {
|
|
5545
|
+
const daemon = readDaemon(dirs);
|
|
5580
5546
|
if (!daemon) return null;
|
|
5581
5547
|
try {
|
|
5582
5548
|
return {
|
|
@@ -5602,9 +5568,9 @@ const sameDaemon = (a, b) => a !== null && a.pid === b.pid && a.port === b.port
|
|
|
5602
5568
|
* Remove daemon.json when it still says what `gone` said, under the write lock, so a sync client that claimed the hub
|
|
5603
5569
|
* meanwhile keeps it. Call holding the lock.
|
|
5604
5570
|
*/
|
|
5605
|
-
function forgetDaemon(
|
|
5606
|
-
if (!sameDaemon(readDaemon(
|
|
5607
|
-
rmSync(syncPaths(
|
|
5571
|
+
function forgetDaemon(dirs, gone) {
|
|
5572
|
+
if (!sameDaemon(readDaemon(dirs), gone)) return false;
|
|
5573
|
+
rmSync(syncPaths(dirs).daemon, { force: true });
|
|
5608
5574
|
return true;
|
|
5609
5575
|
}
|
|
5610
5576
|
/** The Edit a request's body names, its file and its one replacement, or null. */
|
|
@@ -5628,7 +5594,7 @@ function callIn(body) {
|
|
|
5628
5594
|
};
|
|
5629
5595
|
}
|
|
5630
5596
|
/** Serve `client` to this machine's mainflow commands until closed. */
|
|
5631
|
-
async function serveIpc(
|
|
5597
|
+
async function serveIpc(dirs, client) {
|
|
5632
5598
|
const secret = randomBytes(16).toString("hex");
|
|
5633
5599
|
const server = createServer(async (req, res) => {
|
|
5634
5600
|
const reply = (status, value) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value));
|
|
@@ -5675,13 +5641,13 @@ async function serveIpc(root, client) {
|
|
|
5675
5641
|
});
|
|
5676
5642
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
5677
5643
|
const port = server.address().port;
|
|
5678
|
-
const file = syncPaths(
|
|
5679
|
-
const probe = await probeDaemon(
|
|
5680
|
-
const other = underLock(
|
|
5681
|
-
const running = readDaemon(
|
|
5644
|
+
const file = syncPaths(dirs).daemon;
|
|
5645
|
+
const probe = await probeDaemon(dirs);
|
|
5646
|
+
const other = underLock(dirs, () => {
|
|
5647
|
+
const running = readDaemon(dirs);
|
|
5682
5648
|
const reused = running !== null && probe?.state === "gone" && sameDaemon(running, probe.daemon);
|
|
5683
5649
|
if (running && running.pid !== process.pid && !reused) return running;
|
|
5684
|
-
mkdirSync(syncPaths(
|
|
5650
|
+
mkdirSync(syncPaths(dirs).own, { recursive: true });
|
|
5685
5651
|
writeFileSync(file, JSON.stringify({
|
|
5686
5652
|
pid: process.pid,
|
|
5687
5653
|
port,
|
|
@@ -5709,7 +5675,7 @@ async function serveIpc(root, client) {
|
|
|
5709
5675
|
//#region ../sync/dist/daemon.js
|
|
5710
5676
|
async function startDaemon(options) {
|
|
5711
5677
|
const client = new SyncClient(options);
|
|
5712
|
-
const ipc = await serveIpc(options.
|
|
5678
|
+
const ipc = await serveIpc(options.dirs, client);
|
|
5713
5679
|
try {
|
|
5714
5680
|
await client.start();
|
|
5715
5681
|
} catch (error) {
|
|
@@ -5753,11 +5719,11 @@ const FETCH_TOOLS = /* @__PURE__ */ new Set([
|
|
|
5753
5719
|
"MultiEdit"
|
|
5754
5720
|
]);
|
|
5755
5721
|
/** The file of the hub at `root` that `payload` names in file_path, relative to .mainflow/; null outside it. */
|
|
5756
|
-
function hubPath(
|
|
5722
|
+
function hubPath(dirs, payload) {
|
|
5757
5723
|
const input = payload.tool_input ?? {};
|
|
5758
5724
|
if (typeof input.file_path !== "string") return null;
|
|
5759
5725
|
const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? process.cwd(), input.file_path);
|
|
5760
|
-
const path = relative(
|
|
5726
|
+
const path = relative(dirs.data, file).split(sep).join("/");
|
|
5761
5727
|
if (path.startsWith("../") || path === ".." || isAbsolute(path)) return null;
|
|
5762
5728
|
return path;
|
|
5763
5729
|
}
|
|
@@ -5779,11 +5745,11 @@ function editOf(path, payload) {
|
|
|
5779
5745
|
* needs nothing; an Edit of one replacement is replayed on the latest version; another file tool names the file it
|
|
5780
5746
|
* wrote; anything else, such as a Bash command that may have written any file, rescans every file and names none.
|
|
5781
5747
|
*/
|
|
5782
|
-
function planFlush(
|
|
5748
|
+
function planFlush(dirs, payload) {
|
|
5783
5749
|
const tool = payload.tool_name ?? "";
|
|
5784
5750
|
if (!FILE_TOOLS.has(tool)) return { skip: false };
|
|
5785
5751
|
if (typeof (payload.tool_input ?? {}).file_path !== "string") return { skip: false };
|
|
5786
|
-
const path = hubPath(
|
|
5752
|
+
const path = hubPath(dirs, payload);
|
|
5787
5753
|
if (path === null || !isSynced(path)) return { skip: true };
|
|
5788
5754
|
const edit = editOf(path, payload);
|
|
5789
5755
|
return edit ? {
|
|
@@ -5811,11 +5777,11 @@ function hookAnswer(result) {
|
|
|
5811
5777
|
* What to download before the tool in `payload` runs in the hub at `root`: the artifact it reads or writes, when the file
|
|
5812
5778
|
* is not here, so it may be only on the sync server. Null for anything else, which needs nothing.
|
|
5813
5779
|
*/
|
|
5814
|
-
function planFetch(
|
|
5780
|
+
function planFetch(dirs, payload) {
|
|
5815
5781
|
const tool = payload.tool_name ?? "";
|
|
5816
5782
|
if (!FETCH_TOOLS.has(tool)) return null;
|
|
5817
|
-
const path = hubPath(
|
|
5818
|
-
if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(
|
|
5783
|
+
const path = hubPath(dirs, payload);
|
|
5784
|
+
if (path === null || !isSynced(path) || artifactOf(path) === null || existsSync(join(dirs.data, path))) return null;
|
|
5819
5785
|
return {
|
|
5820
5786
|
path,
|
|
5821
5787
|
write: tool !== "Read"
|
|
@@ -5826,8 +5792,8 @@ function planFetch(root, payload) {
|
|
|
5826
5792
|
* change then waits for the Edit's PostToolUse report (docs/sync.md "等 Edit 的回報"). Null for anything else, which
|
|
5827
5793
|
* announces nothing: a change no Edit announced is sent at once.
|
|
5828
5794
|
*/
|
|
5829
|
-
function planAnnounce(
|
|
5830
|
-
const path = hubPath(
|
|
5795
|
+
function planAnnounce(dirs, payload) {
|
|
5796
|
+
const path = hubPath(dirs, payload);
|
|
5831
5797
|
if (path === null || !isSynced(path)) return null;
|
|
5832
5798
|
const edit = editOf(path, payload);
|
|
5833
5799
|
return edit && edit.old !== "" ? edit : null;
|
|
@@ -5839,10 +5805,53 @@ const fetchDenial = (reason) => JSON.stringify({ hookSpecificOutput: {
|
|
|
5839
5805
|
permissionDecisionReason: `mainflow sync: ${reason}`
|
|
5840
5806
|
} });
|
|
5841
5807
|
//#endregion
|
|
5808
|
+
//#region ../sync/dist/oversized.js
|
|
5809
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
5810
|
+
/** The synced files under `data`, the hub's .mainflow/, by path: a walk into the directories that may hold them alone. */
|
|
5811
|
+
function syncedFiles(data) {
|
|
5812
|
+
const files = [];
|
|
5813
|
+
const visit = (dir) => {
|
|
5814
|
+
let entries;
|
|
5815
|
+
try {
|
|
5816
|
+
entries = readdirSync(join(data, dir), { withFileTypes: true });
|
|
5817
|
+
} catch {
|
|
5818
|
+
return;
|
|
5819
|
+
}
|
|
5820
|
+
for (const entry of entries) {
|
|
5821
|
+
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
|
5822
|
+
if (entry.isDirectory()) {
|
|
5823
|
+
if (mayHoldSynced(path)) visit(path);
|
|
5824
|
+
} else if (entry.isFile() && isSynced(path)) files.push(path);
|
|
5825
|
+
}
|
|
5826
|
+
};
|
|
5827
|
+
visit("");
|
|
5828
|
+
return files;
|
|
5829
|
+
}
|
|
5830
|
+
/**
|
|
5831
|
+
* The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text of the sync
|
|
5832
|
+
* scope. The sync client leaves them out of sync; `mainflow check` warns of them.
|
|
5833
|
+
*/
|
|
5834
|
+
function oversizedFiles(dirs) {
|
|
5835
|
+
const data = dirs.data;
|
|
5836
|
+
const out = [];
|
|
5837
|
+
for (const path of syncedFiles(data)) {
|
|
5838
|
+
const file = join(data, path);
|
|
5839
|
+
try {
|
|
5840
|
+
if (statSync(file).size <= 19e5) continue;
|
|
5841
|
+
const bytes = textBytes(decoder.decode(readFileSync(file)));
|
|
5842
|
+
if (bytes > 19e5) out.push({
|
|
5843
|
+
path,
|
|
5844
|
+
bytes
|
|
5845
|
+
});
|
|
5846
|
+
} catch {}
|
|
5847
|
+
}
|
|
5848
|
+
return out;
|
|
5849
|
+
}
|
|
5850
|
+
//#endregion
|
|
5842
5851
|
//#region ../sync/dist/outbox.js
|
|
5843
5852
|
/** Whether the hub at `root` writes through the sync server. */
|
|
5844
|
-
function remoteMode(
|
|
5845
|
-
return readDaemon(
|
|
5853
|
+
function remoteMode(dirs, env = process.env) {
|
|
5854
|
+
return readDaemon(dirs) !== null || syncSettings(dirs, env) !== null;
|
|
5846
5855
|
}
|
|
5847
5856
|
/** Refusals of an operation by the local files, as opposed to a write lock held too long; thrown as they are. */
|
|
5848
5857
|
const refusals = /* @__PURE__ */ new WeakSet();
|
|
@@ -5853,8 +5862,8 @@ const refusedHere = (error) => error instanceof Error && refusals.has(error);
|
|
|
5853
5862
|
* client reads the outbox under the same lock, so it never sees the files this changed without the entry that
|
|
5854
5863
|
* explains them, and never takes them for an edit to send. A refusal here is thrown, and nothing is queued.
|
|
5855
5864
|
*/
|
|
5856
|
-
function queueOperation(
|
|
5857
|
-
const storage = nodeStorage(
|
|
5865
|
+
function queueOperation(dirs, request) {
|
|
5866
|
+
const storage = nodeStorage(dirs);
|
|
5858
5867
|
return storage.lock(() => {
|
|
5859
5868
|
const before = {};
|
|
5860
5869
|
const keep = (path) => {
|
|
@@ -5878,7 +5887,7 @@ function queueOperation(root, request) {
|
|
|
5878
5887
|
const written = {};
|
|
5879
5888
|
for (const change of result.changes) if (change.kind !== "makeDir" && isSynced(change.path)) written[change.path] = held.read(change.path);
|
|
5880
5889
|
const id = entryId();
|
|
5881
|
-
writeQueue(
|
|
5890
|
+
writeQueue(dirs, [...readQueue(dirs), {
|
|
5882
5891
|
id,
|
|
5883
5892
|
kind: "op",
|
|
5884
5893
|
request: pending,
|
|
@@ -5892,8 +5901,8 @@ function queueOperation(root, request) {
|
|
|
5892
5901
|
});
|
|
5893
5902
|
}
|
|
5894
5903
|
/** How many writes wait in the outbox for the server, as queue.json says: not the ones it refused (src/state.ts). */
|
|
5895
|
-
function queuedCount(
|
|
5896
|
-
const file = syncPaths(
|
|
5904
|
+
function queuedCount(dirs) {
|
|
5905
|
+
const file = syncPaths(dirs).queue;
|
|
5897
5906
|
if (!existsSync(file)) return 0;
|
|
5898
5907
|
try {
|
|
5899
5908
|
return (JSON.parse(readFileSync(file, "utf8")).entries ?? []).filter(waiting).length;
|
|
@@ -5909,8 +5918,8 @@ const NOTIFY_MS = 200;
|
|
|
5909
5918
|
* the server's answer, such as a hook or the interface. Without a sync client, or one that does not answer, nothing
|
|
5910
5919
|
* happens: the client's watcher of queue.json and its rescan find the entry, a moment later.
|
|
5911
5920
|
*/
|
|
5912
|
-
async function notifyQueued(
|
|
5913
|
-
const daemon = readDaemon(
|
|
5921
|
+
async function notifyQueued(dirs) {
|
|
5922
|
+
const daemon = readDaemon(dirs);
|
|
5914
5923
|
if (!daemon) return;
|
|
5915
5924
|
try {
|
|
5916
5925
|
await callDaemon(daemon, "/send", {}, NOTIFY_MS);
|
|
@@ -5920,12 +5929,12 @@ async function notifyQueued(root) {
|
|
|
5920
5929
|
* Ask the sync client what became of entry `id`, giving it up to `timeoutMs`. A sync client that is still starting is
|
|
5921
5930
|
* waited for. `waiting` when it is offline, or the time ran out first.
|
|
5922
5931
|
*/
|
|
5923
|
-
async function awaitOutcome(
|
|
5932
|
+
async function awaitOutcome(dirs, id, timeoutMs) {
|
|
5924
5933
|
const deadline = Date.now() + timeoutMs;
|
|
5925
5934
|
for (;;) {
|
|
5926
5935
|
const left = deadline - Date.now();
|
|
5927
5936
|
if (left <= 0) return { state: "waiting" };
|
|
5928
|
-
const daemon = readDaemon(
|
|
5937
|
+
const daemon = readDaemon(dirs);
|
|
5929
5938
|
if (daemon) try {
|
|
5930
5939
|
return await callDaemon(daemon, "/wait", {
|
|
5931
5940
|
id,
|
|
@@ -5938,6 +5947,54 @@ async function awaitOutcome(root, id, timeoutMs) {
|
|
|
5938
5947
|
}
|
|
5939
5948
|
}
|
|
5940
5949
|
//#endregion
|
|
5950
|
+
//#region src/version-gate.ts
|
|
5951
|
+
const INVALID_RANGE = "mainflow in .mainflow/config.yaml is not a valid semver range, so the mainflow version is not checked; run \"mainflow check\" for details";
|
|
5952
|
+
const READ_ENDING = "; writes will be refused until then";
|
|
5953
|
+
/**
|
|
5954
|
+
* Compare this CLI's version with the mainflow versions the hub's config.yaml takes writes from. A write outside the
|
|
5955
|
+
* range is refused; a read is warned about. A `mainflow` that is no range is only warned about, whatever the command,
|
|
5956
|
+
* since core reads it as no limit so that a typo does not shut every writer out. A hub that names no range, has no
|
|
5957
|
+
* readable config.yaml or takes this version is not checked. The comparison is core's versionMismatch, the one the
|
|
5958
|
+
* sync server makes, and its message carries upgradeCommand.
|
|
5959
|
+
*/
|
|
5960
|
+
function versionGate(dirs, version, command) {
|
|
5961
|
+
let requirements;
|
|
5962
|
+
try {
|
|
5963
|
+
requirements = hubRequirements(nodeStorage(dirs));
|
|
5964
|
+
} catch {
|
|
5965
|
+
return { kind: "run" };
|
|
5966
|
+
}
|
|
5967
|
+
if (requirements?.mainflowInvalid) return {
|
|
5968
|
+
kind: "warn",
|
|
5969
|
+
message: INVALID_RANGE
|
|
5970
|
+
};
|
|
5971
|
+
if (!requirements?.mainflow) return { kind: "run" };
|
|
5972
|
+
if (command.writes) {
|
|
5973
|
+
const message = versionMismatch(requirements.mainflow, version);
|
|
5974
|
+
return message === null ? { kind: "run" } : {
|
|
5975
|
+
kind: "refuse",
|
|
5976
|
+
message
|
|
5977
|
+
};
|
|
5978
|
+
}
|
|
5979
|
+
const message = versionMismatch(requirements.mainflow, version, READ_ENDING);
|
|
5980
|
+
return message === null ? { kind: "run" } : {
|
|
5981
|
+
kind: "warn",
|
|
5982
|
+
message
|
|
5983
|
+
};
|
|
5984
|
+
}
|
|
5985
|
+
/**
|
|
5986
|
+
* The checkpoint of every write that goes through the writer (the commands, the interface, the hooks): throws a
|
|
5987
|
+
* WriteError when `version` is outside the range the hub takes writes from, and warns on stderr about a range that is
|
|
5988
|
+
* no range. A hook's write is not checked (`hook`), since a hook has to answer the host whatever the version; the
|
|
5989
|
+
* `hook` command itself warns once before it runs.
|
|
5990
|
+
*/
|
|
5991
|
+
function requireWritableVersion(dirs, version, options = {}) {
|
|
5992
|
+
if (options.hook) return;
|
|
5993
|
+
const verdict = versionGate(dirs, version, { writes: true });
|
|
5994
|
+
if (verdict.kind === "refuse") throw new WriteError(`refusing to write: ${verdict.message}`);
|
|
5995
|
+
if (verdict.kind === "warn") console.error(`! ${verdict.message}`);
|
|
5996
|
+
}
|
|
5997
|
+
//#endregion
|
|
5941
5998
|
//#region src/remote/start.ts
|
|
5942
5999
|
/** How long a sync client started in the background may take to claim the hub, its first request to the server included. */
|
|
5943
6000
|
const STARTING_MS = 6e4;
|
|
@@ -5954,10 +6011,10 @@ const alive = (pid) => {
|
|
|
5954
6011
|
* none. `env` gives the sync server and service token as the environment variables do; for a hub other than the one this
|
|
5955
6012
|
* process was started in it is `{}`, so the process started reads that hub's own .mainflow/local/sync.json.
|
|
5956
6013
|
*/
|
|
5957
|
-
async function startSyncInBackground(
|
|
5958
|
-
const probe = await probeDaemon(
|
|
6014
|
+
async function startSyncInBackground(dirs, env = process.env) {
|
|
6015
|
+
const probe = await probeDaemon(dirs);
|
|
5959
6016
|
if (probe && probe.state !== "gone") return { state: "running" };
|
|
5960
|
-
const paths = syncPaths(
|
|
6017
|
+
const paths = syncPaths(dirs);
|
|
5961
6018
|
const cli = process.argv[1];
|
|
5962
6019
|
if (process.env.MAINFLOW_NO_BACKGROUND_SYNC === "1") return {
|
|
5963
6020
|
state: "failed",
|
|
@@ -5967,10 +6024,10 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
5967
6024
|
state: "failed",
|
|
5968
6025
|
reason: "this process cannot tell where the mainflow CLI is"
|
|
5969
6026
|
};
|
|
5970
|
-
const log =
|
|
5971
|
-
return nodeStorage(
|
|
5972
|
-
const reused = probe?.state === "gone" && forgetDaemon(
|
|
5973
|
-
if (reused === void 0 && readDaemon(
|
|
6027
|
+
const log = paths.log;
|
|
6028
|
+
return nodeStorage(dirs).lock(() => {
|
|
6029
|
+
const reused = probe?.state === "gone" && forgetDaemon(dirs, probe.daemon) ? probe.daemon.pid : void 0;
|
|
6030
|
+
if (reused === void 0 && readDaemon(dirs)) return { state: "starting" };
|
|
5974
6031
|
let previous = null;
|
|
5975
6032
|
try {
|
|
5976
6033
|
previous = existsSync(paths.started) ? JSON.parse(readFileSync(paths.started, "utf8")) : null;
|
|
@@ -5984,7 +6041,7 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
5984
6041
|
let pid;
|
|
5985
6042
|
try {
|
|
5986
6043
|
const child = spawn(process.execPath, [cli, "sync"], {
|
|
5987
|
-
cwd: root,
|
|
6044
|
+
cwd: dirs.root,
|
|
5988
6045
|
detached: true,
|
|
5989
6046
|
stdio: [
|
|
5990
6047
|
"ignore",
|
|
@@ -6026,8 +6083,8 @@ async function startSyncInBackground(root, env = process.env) {
|
|
|
6026
6083
|
/** Added to a write the local files refused, unless the sync client runs with the server's latest state. */
|
|
6027
6084
|
const STALE_COPY = "the local copy may not have caught up with the sync server yet; retry once mainflow sync has caught up (mainflow sync status)";
|
|
6028
6085
|
/** Whether the hub's sync client runs and has the server's latest state, asked with a short wait. */
|
|
6029
|
-
async function caughtUp(
|
|
6030
|
-
const daemon = readDaemon(
|
|
6086
|
+
async function caughtUp(dirs) {
|
|
6087
|
+
const daemon = readDaemon(dirs);
|
|
6031
6088
|
if (!daemon) return false;
|
|
6032
6089
|
try {
|
|
6033
6090
|
return (await callDaemon(daemon, "/status", void 0, 2e3)).caughtUp === true;
|
|
@@ -6053,29 +6110,30 @@ function writtenLines(written) {
|
|
|
6053
6110
|
* other than the one this process was started in, such as another hub the interface shows, passes `{}`, so only its
|
|
6054
6111
|
* own .mainflow/local/sync.json and sync client count.
|
|
6055
6112
|
*/
|
|
6056
|
-
function hubWriter(
|
|
6113
|
+
function hubWriter(dirs, env = process.env, options = {}) {
|
|
6057
6114
|
const startSync = options.startSync ?? startSyncInBackground;
|
|
6058
6115
|
return async (op, input) => {
|
|
6059
|
-
|
|
6060
|
-
|
|
6116
|
+
requireWritableVersion(dirs, CLI_VERSION, { hook: options.hook });
|
|
6117
|
+
if (!remoteMode(dirs, env)) return {
|
|
6118
|
+
value: applyOperation(nodeStorage(dirs), op, input, { now: nowMinute() }).value,
|
|
6061
6119
|
queued: false,
|
|
6062
6120
|
renumbered: [],
|
|
6063
6121
|
notices: []
|
|
6064
6122
|
};
|
|
6065
6123
|
let queuedOp;
|
|
6066
6124
|
try {
|
|
6067
|
-
queuedOp = queueOperation(
|
|
6125
|
+
queuedOp = queueOperation(dirs, {
|
|
6068
6126
|
protocol: 3,
|
|
6069
6127
|
op,
|
|
6070
6128
|
input,
|
|
6071
6129
|
now: requestTime()
|
|
6072
6130
|
});
|
|
6073
6131
|
} catch (error) {
|
|
6074
|
-
if (refusedHere(error) && !await caughtUp(
|
|
6132
|
+
if (refusedHere(error) && !await caughtUp(dirs)) error.message += `; ${STALE_COPY}`;
|
|
6075
6133
|
throw error;
|
|
6076
6134
|
}
|
|
6077
6135
|
const { id, result } = queuedOp;
|
|
6078
|
-
const start = await startSync(
|
|
6136
|
+
const start = await startSync(dirs, env);
|
|
6079
6137
|
const queued = {
|
|
6080
6138
|
value: result.value,
|
|
6081
6139
|
queued: true,
|
|
@@ -6088,10 +6146,10 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
6088
6146
|
stopped: `mainflow sync is not running (${start.reason}); the write waits in .mainflow/local/sync/queue.json until it runs`
|
|
6089
6147
|
};
|
|
6090
6148
|
if (options.confirmMs === void 0) {
|
|
6091
|
-
await notifyQueued(
|
|
6149
|
+
await notifyQueued(dirs);
|
|
6092
6150
|
return queued;
|
|
6093
6151
|
}
|
|
6094
|
-
const outcome = await awaitOutcome(
|
|
6152
|
+
const outcome = await awaitOutcome(dirs, id, options.confirmMs);
|
|
6095
6153
|
if (outcome.state === "applied") {
|
|
6096
6154
|
const { response } = outcome;
|
|
6097
6155
|
return {
|
|
@@ -6111,4 +6169,4 @@ function hubWriter(root, env = process.env, options = {}) {
|
|
|
6111
6169
|
};
|
|
6112
6170
|
}
|
|
6113
6171
|
//#endregion
|
|
6114
|
-
export {
|
|
6172
|
+
export { hubName as A, readRemote as C, Unreachable as D, ServerError as E, syncPaths as F, SETTINGS_FILE as M, SYNC_DIR as N, ENV as O, isSynced as P, watchTree as S, ServerApi as T, DaemonUnavailable as _, versionGate as a, readDaemon as b, oversizedFiles as c, planAnnounce as d, planFetch as f, DaemonRunning as g, startDaemon as h, requireWritableVersion as i, syncSettings as j, earlierToken as k, fetchDenial as l, toolCallOf as m, hubWriter as n, queuedCount as o, planFlush as p, writtenLines as r, remoteMode as s, CONFIRM_MS as t, hookAnswer as u, callDaemon as v, HubRewritingError as w, silentHint as x, probeDaemon as y };
|