mainflow 0.41.0-beta.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { $ as stampMs, G as parseUntil, K as refExample, M as UNTIL_FORMS, W as parseRef, n as CLOSED_STATUS } from "./dist-zSabglPq.js";
3
3
  import { At as WriteError, Ct as TASK_FILE, D as loadSessions, Et as findHubRoot, G as applyOperation, K as checkHubName, O as readSession, St as MAINLINE_FILE, X as sectionText, Y as loadHub, at as nowMinute, b as duplicateClaimLines, ct as nodeStorage, d as migrateHub, et as memberOfGroup, f as retroReport, ft as MAX_FILE_BYTES, h as RETRY_RULES, kt as DATA_DIR, m as receiverFromEnv, nt as remoteMembers, p as retroReportFromReceiver, q as HubError, rt as unavailableHubs, u as groupPlan, v as initHub, wt as requireSlug, x as sessionBriefing, y as untilState } from "./host-BAMZmRy8.js";
4
4
  import { t as listProgress } from "./progress-1BfPg2zN.js";
5
- import { C as ServerError, D as syncSettings, E as hubName, O as SETTINGS_FILE, S as ServerApi, T as earlierToken, _ as readDaemon, a as remoteMode, b as readRemote, c as planAnnounce, d as toolCallOf, f as startDaemon, g as probeDaemon, h as callDaemon, i as queuedCount, j as syncPaths, l as planFetch, m as DaemonUnavailable, n as hubWriter, o as fetchDenial, p as DaemonRunning, r as writtenLines, s as hookAnswer, t as CONFIRM_MS, u as planFlush, v as silentHint, w as ENV, x as oversizedFiles } from "./operation-DlR1P_Pt.js";
5
+ import { C as ServerError, D as syncSettings, E as hubName, O as SETTINGS_FILE, S as ServerApi, T as earlierToken, _ as readDaemon, a as remoteMode, b as readRemote, c as planAnnounce, d as toolCallOf, f as startDaemon, g as probeDaemon, h as callDaemon, i as queuedCount, j as syncPaths, l as planFetch, m as DaemonUnavailable, n as hubWriter, o as fetchDenial, p as DaemonRunning, r as writtenLines, s as hookAnswer, t as CONFIRM_MS, u as planFlush, v as silentHint, w as ENV, x as oversizedFiles } from "./operation-2wx2ex6x.js";
6
6
  import { COMMAND_SPECS, OPTIONS } from "./facts.js";
7
7
  import { hookFallback, parsePayload, runHook } from "./hooks.js";
8
- import { f as CLI_VERSION, i as launchUi, l as startUiInBackground, r as exitCodeFor, u as watchHubGone } from "./launch-CMeKJsNd.js";
8
+ import { f as CLI_VERSION, i as launchUi, l as startUiInBackground, r as exitCodeFor, u as watchHubGone } from "./launch-DosYhbwc.js";
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
11
11
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
package/dist/hooks.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { P as configSchema, W as parseRef, Y as taskSchema, rt as describeRequest } from "./dist-zSabglPq.js";
2
2
  import { A as grantScope, D as loadSessions, Dt as worktreesOf, Et as findHubRoot, F as mayDecorate, M as decorateReply, N as endsWithMarker, O as readSession, P as headerLine, S as turnReminder, Tt as currentBranch, Y as loadHub, bt as formatMismatch, ct as nodeStorage, i as hostName, it as taskFile, j as matchGrants, kt as DATA_DIR, ot as readFrontMatter, q as HubError, st as readYaml, x as sessionBriefing, xt as hubFormatVersion } from "./host-BAMZmRy8.js";
3
- import { n as hubWriter, r as writtenLines } from "./operation-DlR1P_Pt.js";
3
+ import { n as hubWriter, r as writtenLines } from "./operation-2wx2ex6x.js";
4
4
  import { SCENARIO_LINES, SCENARIO_SKIP_KINDS } from "./facts.js";
5
5
  import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
6
6
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
@@ -276,7 +276,7 @@ async function launchUi(root, hub, configured, options) {
276
276
  }
277
277
  let server;
278
278
  if (!reuse) {
279
- const { serveUi } = await import("./ui-9uuI4Jif.js");
279
+ const { serveUi } = await import("./ui-D1dg19Jw.js");
280
280
  server = await serveUi(root, choice.port);
281
281
  writeUiRecord(root, {
282
282
  port: choice.port,
package/dist/launch.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as networkProbe, c as realRoot, d as withUiLock, i as launchUi, l as startUiInBackground, n as choosePort, o as portCandidates, r as exitCodeFor, s as readUiRecord, t as PORT_SPAN, u as watchHubGone } from "./launch-CMeKJsNd.js";
1
+ import { a as networkProbe, c as realRoot, d as withUiLock, i as launchUi, l as startUiInBackground, n as choosePort, o as portCandidates, r as exitCodeFor, s as readUiRecord, t as PORT_SPAN, u as watchHubGone } from "./launch-DosYhbwc.js";
2
2
  export { PORT_SPAN, choosePort, exitCodeFor, launchUi, networkProbe, portCandidates, readUiRecord, realRoot, startUiInBackground, watchHubGone, withUiLock };
@@ -1,6 +1,6 @@
1
1
  import { At as WriteError, B as renumberRequest, G as applyOperation, H as renumberPath, Mt as splitFrontMatter, Ot as ConflictError, U as renumberText, V as renumberFiles, W as scopeOfPath, _t as readsAsText, at as nowMinute, ct as nodeStorage, dt as MAX_BLOB_BYTES, ft as MAX_FILE_BYTES, gt as exceedsFileLimit, ht as comparePaths, jt as contentHash, kt as DATA_DIR, lt as ACCESS_HEADERS, mt as SOCKET_MAX_AGE_CLOSE, pt as ROUTES, ut as BLOB_HASH, vt as requestTime, yt as textBytes, z as applyOffline } from "./host-BAMZmRy8.js";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
3
+ 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
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
@@ -900,7 +900,8 @@ const requestIdOf = (entry) => entry.kind === "blob" && entry.retry ? `${entry.i
900
900
  const waiting = (entry) => entry.refused === void 0;
901
901
  /** A new entry's id. */
902
902
  const entryId = () => randomBytes(8).toString("hex");
903
- const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : [entry.path];
903
+ /** The files an entry writes; none for a directory's removal. */
904
+ const entryPaths = (entry) => entry.kind === "op" ? Object.keys(entry.written) : entry.kind === "rmdir" ? [] : [entry.path];
904
905
  /** A local minute without an offset, the `now` of protocol 1 */
905
906
  const LOCAL_MINUTE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/;
906
907
  /** Protocols whose write requests read the same as the current one's: protocol 3 changed only how a snapshot is taken. */
@@ -954,6 +955,11 @@ function writeJson(file, value) {
954
955
  renameSync(temp, file);
955
956
  }
956
957
  const readKnown = (root) => readJson(syncPaths(root).known, {}).files ?? {};
958
+ /** The directories known.json names; none from a client before it kept them. */
959
+ const readKnownDirs = (root) => {
960
+ const dirs = readJson(syncPaths(root).known, {}).dirs;
961
+ return Array.isArray(dirs) ? dirs.filter((dir) => typeof dir === "string") : [];
962
+ };
957
963
  /** What known.json writes before a binary file's sha256, apart from a text's content hash. */
958
964
  const BLOB_PREFIX = "blob:";
959
965
  /** The text of the version known.json names by `hash`; undefined when it was not kept, as by a sync client before known/. */
@@ -970,8 +976,9 @@ function readKnownText(root, hash) {
970
976
  * there yet; the texts of versions it no longer names are removed. A version whose text the client does not have keeps
971
977
  * the text known/ holds, if any. The new texts go first, then known.json, then the removals, so a client killed at any
972
978
  * 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
+ * `dirs` are the directories it names.
973
980
  */
974
- function writeKnown(root, files) {
981
+ function writeKnown(root, files, dirs = []) {
975
982
  const dir = syncPaths(root).knownTexts;
976
983
  mkdirSync(dir, { recursive: true });
977
984
  const named = new Set(Object.values(files).map((file) => file.hash));
@@ -982,7 +989,10 @@ function writeKnown(root, files) {
982
989
  renameSync(join(dir, `${hash}.tmp`), join(dir, hash));
983
990
  held.add(hash);
984
991
  }
985
- const known = { files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])) };
992
+ const known = {
993
+ files: Object.fromEntries(Object.entries(files).map(([path, file]) => [path, file.hash])),
994
+ ...dirs.length > 0 ? { dirs } : {}
995
+ };
986
996
  writeJson(syncPaths(root).known, known);
987
997
  for (const name of held) if (!named.has(name)) rmSync(join(dir, name), { force: true });
988
998
  }
@@ -1510,7 +1520,7 @@ function heldBase(entry) {
1510
1520
  */
1511
1521
  function textAt(entry, path) {
1512
1522
  if (entry.kind === "op") return entry.written[path];
1513
- if (entry.path !== path || entry.kind === "blob") return void 0;
1523
+ if (entry.path !== path || entry.kind === "blob" || entry.kind === "rmdir") return void 0;
1514
1524
  if (entry.kind === "remove") return null;
1515
1525
  return entry.text;
1516
1526
  }
@@ -1670,6 +1680,15 @@ var SyncClient = class extends EventEmitter {
1670
1680
  unheld = /* @__PURE__ */ new Set();
1671
1681
  /** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
1672
1682
  fileSeq = /* @__PURE__ */ new Map();
1683
+ /** The hub's seq of each directory as the server last named it, held or removed, as fileSeq is of a file */
1684
+ dirSeq = /* @__PURE__ */ new Map();
1685
+ /**
1686
+ * The directories the server held that the client had here, as known.json keeps them: one gone here is sent as removed
1687
+ * (queueRemovedDirs), and one a snapshot names is made here only when it is not among them
1688
+ */
1689
+ syncedDirs = /* @__PURE__ */ new Set();
1690
+ /** The check for directories gone here that is to run a moment from now (checkDirs) */
1691
+ dirCheck = null;
1673
1692
  /** The hub's seq up to which the client has taken in every change: the last snapshot and the broadcasts after it */
1674
1693
  hubSeq = -1;
1675
1694
  /** The seqs of the broadcasts on the current connection above hubSeq with a change between that never arrived */
@@ -1777,6 +1796,13 @@ var SyncClient = class extends EventEmitter {
1777
1796
  return this.scope.synced(path);
1778
1797
  }
1779
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
+ */
1802
+ syncsDir(path) {
1803
+ return path !== "" && path !== "local" && this.scope.walks(path);
1804
+ }
1805
+ /**
1780
1806
  * Whether the hub's .gitignore files changed since the last snapshot was asked for: a file they let in since may be on
1781
1807
  * the server although the client took nothing of it in.
1782
1808
  */
@@ -1859,6 +1885,7 @@ var SyncClient = class extends EventEmitter {
1859
1885
  async restoreKnown() {
1860
1886
  this.copied = !existsSync(this.paths.known);
1861
1887
  this.blobsCompared = !this.copied && !existsSync(this.paths.copyBlobs);
1888
+ for (const dir of readKnownDirs(this.root)) this.syncedDirs.add(dir);
1862
1889
  for (const [path, hash] of Object.entries(readKnown(this.root))) {
1863
1890
  if (hash.startsWith("blob:")) {
1864
1891
  const disk = {
@@ -2113,7 +2140,9 @@ var SyncClient = class extends EventEmitter {
2113
2140
  this.track(this.resync());
2114
2141
  return;
2115
2142
  }
2116
- for (const event of message.files) this.receive(event, seq);
2143
+ for (const event of message.files) if (event.kind === "dir") this.receiveDir(event.path, seq, true);
2144
+ else if (event.kind === "dirRemoved") this.receiveDirRemoved(event.path, seq);
2145
+ else this.receive(event, seq);
2117
2146
  if (this.ahead.size > 0 && this.synced && this.resyncing === null) {
2118
2147
  this.log(`missed the hub's changes ${this.hubSeq + 1} to ${Math.min(...this.ahead) - 1}; reloading the snapshot`);
2119
2148
  this.track(this.resync());
@@ -2135,10 +2164,6 @@ var SyncClient = class extends EventEmitter {
2135
2164
  * as a listed text does in a snapshot.
2136
2165
  */
2137
2166
  receive(event, seq) {
2138
- if (event.kind === "dir") {
2139
- if ((event.path.split("/")[0] !== "local" || event.path === "local/sessions") && !this.scope.ignored(event.path, true)) this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
2140
- return;
2141
- }
2142
2167
  if (!this.syncs(event.path)) return;
2143
2168
  const file = event.kind === "file" ? event.text : event.kind === "blob" ? {
2144
2169
  kind: "blob",
@@ -2180,6 +2205,58 @@ var SyncClient = class extends EventEmitter {
2180
2205
  const mainline = MAINLINE_FILE.exec(event.path)?.[1];
2181
2206
  if (mainline !== void 0 && this.remote.size > 0) this.fetchOpened(mainline);
2182
2207
  }
2208
+ /**
2209
+ * A directory the server holds as of the hub's `seq`, from a broadcast (`live`) or a snapshot. One the client had here
2210
+ * and that is gone now, named by a snapshot, was removed here: it is not made again, and its removal is queued
2211
+ * (queueRemovedDirs). Any other is made here and known from then on; a broadcast's always is, an operation having just
2212
+ * made it. A directory the client does not sync, and a version no newer than the one the client holds, as a snapshot's
2213
+ * after the broadcast of its removal, are left alone.
2214
+ */
2215
+ receiveDir(path, seq, live) {
2216
+ if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
2217
+ this.dirSeq.set(path, seq);
2218
+ const dir = join(this.paths.data, path);
2219
+ if (!live && this.syncedDirs.has(path) && !existsSync(dir)) {
2220
+ this.trace(`dir ${path} seq ${seq}: removed here`);
2221
+ return;
2222
+ }
2223
+ this.knowDir(path);
2224
+ this.track(mkdir(dir, { recursive: true }));
2225
+ }
2226
+ /**
2227
+ * The server removed the directory at `path` as of the hub's `seq`, as a broadcast tells, or a snapshot that no longer
2228
+ * names it: removed here too when nothing is in it, under the write lock, so no mainflow write puts a file in it
2229
+ * meanwhile. One that holds anything, as a file written here not sent yet, stays; the file's write makes it on the
2230
+ * server again.
2231
+ */
2232
+ receiveDirRemoved(path, seq) {
2233
+ if (!this.syncsDir(path) || seq <= (this.dirSeq.get(path) ?? -1)) return;
2234
+ this.dirSeq.set(path, seq);
2235
+ this.forgetDir(path);
2236
+ this.trace(`dir ${path} removed seq ${seq}`);
2237
+ this.track(this.whenFree(() => this.locked(() => this.removeEmptyDir(path))).catch((error) => this.log(`${path}/: ${error.message}`)));
2238
+ }
2239
+ /** Remove the directory at `path` when nothing is in it; one gone already, or holding anything, stays as it is. True when it went. */
2240
+ removeEmptyDir(path) {
2241
+ try {
2242
+ rmdirSync(join(this.paths.data, path));
2243
+ return true;
2244
+ } catch (error) {
2245
+ const code = error.code;
2246
+ if (code !== "ENOENT" && code !== "ENOTEMPTY" && code !== "EEXIST" && code !== "ENOTDIR") throw error;
2247
+ return false;
2248
+ }
2249
+ }
2250
+ /** The server holds the directory at `path`, and the client has it here: known from now on. */
2251
+ knowDir(path) {
2252
+ if (this.syncedDirs.has(path)) return;
2253
+ this.syncedDirs.add(path);
2254
+ this.saveKnown();
2255
+ }
2256
+ /** The server no longer holds the directory at `path`. */
2257
+ forgetDir(path) {
2258
+ if (this.syncedDirs.delete(path)) this.saveKnown();
2259
+ }
2183
2260
  /** Whether the client holds `path` as of the hub's `seq` or later. */
2184
2261
  reached(path, seq) {
2185
2262
  return this.hubSeq >= seq || (this.fileSeq.get(path) ?? -1) >= seq;
@@ -2247,6 +2324,7 @@ var SyncClient = class extends EventEmitter {
2247
2324
  this.log(`the sync server's snapshot stays without the hub's changes ${missing}; taking it as complete`);
2248
2325
  this.ahead.clear();
2249
2326
  for (const [path, seq] of this.fileSeq) if (seq > this.hubSeq) this.fileSeq.delete(path);
2327
+ for (const [path, seq] of this.dirSeq) if (seq > this.hubSeq) this.dirSeq.delete(path);
2250
2328
  stalled = 0;
2251
2329
  this.resyncAgain = true;
2252
2330
  continue;
@@ -2362,6 +2440,7 @@ var SyncClient = class extends EventEmitter {
2362
2440
  if (seq < before) {
2363
2441
  this.log(`the sync server's seq went back from ${before} to ${seq}; taking its snapshot as it is`);
2364
2442
  this.fileSeq.clear();
2443
+ this.dirSeq.clear();
2365
2444
  this.hubSeq = -1;
2366
2445
  this.ahead.clear();
2367
2446
  }
@@ -2372,8 +2451,16 @@ var SyncClient = class extends EventEmitter {
2372
2451
  this.snapshotAnswers.clear();
2373
2452
  const fromCopy = this.copied && !this.synced;
2374
2453
  const blobsFromCopy = withBlobs && !this.blobsCompared;
2454
+ /** The directories the snapshot names: every one the server holds */
2455
+ const dirs = /* @__PURE__ */ new Set();
2375
2456
  for (const part of parts) for (const event of part.files) {
2376
- if (event.kind !== "dir" && !this.syncs(event.path)) continue;
2457
+ if (event.kind === "dir") {
2458
+ named.add(event.path);
2459
+ dirs.add(event.path);
2460
+ this.receiveDir(event.path, part.seq, false);
2461
+ continue;
2462
+ }
2463
+ if (!this.syncs(event.path)) continue;
2377
2464
  if (event.kind === "listed") {
2378
2465
  named.add(event.path);
2379
2466
  if (this.mayStay(event.path, held)) this.keepRemote(event.path, event.hash, part.seq);
@@ -2441,6 +2528,7 @@ var SyncClient = class extends EventEmitter {
2441
2528
  path
2442
2529
  }, seq);
2443
2530
  }
2531
+ for (const path of this.syncedDirs) if (!dirs.has(path)) this.receiveDirRemoved(path, seq);
2444
2532
  this.scopeTaken = asked;
2445
2533
  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` : ""}`);
2446
2534
  this.takenUpTo(seq);
@@ -2456,6 +2544,7 @@ var SyncClient = class extends EventEmitter {
2456
2544
  for (const path of await this.walk()) this.schedule(path);
2457
2545
  for (const path of this.stalledDownloads) this.schedule(path);
2458
2546
  this.stalledDownloads.clear();
2547
+ await this.queueRemovedDirs();
2459
2548
  }
2460
2549
  /**
2461
2550
  * Ask the server whether it keeps binary files (blobSupport), once per connection, before its snapshot. A probe that
@@ -2618,7 +2707,7 @@ var SyncClient = class extends EventEmitter {
2618
2707
  if (expected?.blob && expected.hash === disk.hash) this.expected.delete(path);
2619
2708
  if (isBlob(this.cloud.get(path))) this.cloud.delete(path);
2620
2709
  }
2621
- if (forgotten.size > 0) await this.whenFree(() => underLock(this.root, () => writeKnown(this.root, this.knownFiles())));
2710
+ if (forgotten.size > 0) await this.whenFree(() => underLock(this.root, () => writeKnown(this.root, this.knownFiles(), this.knownDirs())));
2622
2711
  return forgotten;
2623
2712
  }
2624
2713
  /** The server refused the upload, so it holds what it held: the binary files forgetBinaries forgot are known again. */
@@ -2844,6 +2933,9 @@ var SyncClient = class extends EventEmitter {
2844
2933
  * now on (I4). A version only written here, still in the outbox, is never confirmed.
2845
2934
  */
2846
2935
  confirm(path, disk) {
2936
+ if (disk !== null) {
2937
+ for (let at = path.lastIndexOf("/"); at > 0; at = path.lastIndexOf("/", at - 1)) if (this.syncsDir(path.slice(0, at))) this.knowDir(path.slice(0, at));
2938
+ }
2847
2939
  const held = this.confirmed.get(path);
2848
2940
  if (held === void 0 ? false : held === null ? disk === null : disk !== null && held.hash === disk.hash && (held.text !== void 0 || disk.text === void 0)) return;
2849
2941
  this.confirmed.set(path, disk);
@@ -3065,7 +3157,7 @@ var SyncClient = class extends EventEmitter {
3065
3157
  const fetched = [];
3066
3158
  for (const part of parts) for (const event of part.files) {
3067
3159
  if (event.kind === "dir") {
3068
- this.receive(event, part.seq);
3160
+ this.receiveDir(event.path, part.seq, false);
3069
3161
  continue;
3070
3162
  }
3071
3163
  mentioned.add(event.path);
@@ -3843,6 +3935,88 @@ var SyncClient = class extends EventEmitter {
3843
3935
  this.trace(`removal of ${path} changed before it was queued; reconciling it again`);
3844
3936
  this.schedule(path);
3845
3937
  }
3938
+ /**
3939
+ * Queue the removal of each directory the client had here that is gone (syncedDirs), with nothing the client knows of
3940
+ * in it: no file the client left there and has not sent the removal of, no write in the outbox of a file there, which
3941
+ * goes first, and no file the server holds there that is not written here yet, nor one only on the server. The
3942
+ * deepest go first, so the server finds the ones in each removed already; one holding anything holds the ones above it
3943
+ * 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 under the rules as they are, since what the server holds is unknown before.
3945
+ */
3946
+ async queueRemovedDirs() {
3947
+ if (!this.synced || this.scopeStale || this.closing) return;
3948
+ const exists = (dir) => existsSync(join(this.paths.data, dir));
3949
+ const gone = [...this.syncedDirs].filter((dir) => this.syncsDir(dir) && !exists(dir));
3950
+ if (gone.length === 0) return;
3951
+ gone.sort((a, b) => b.split("/").length - a.split("/").length || comparePaths(a, b));
3952
+ const queued = [];
3953
+ await this.whenFree(() => this.editQueue((entries) => {
3954
+ const files = [
3955
+ ...[...this.expected].flatMap(([path, disk]) => disk === null ? [] : [path]),
3956
+ ...[...this.cloud].flatMap(([path, file]) => file === null ? [] : [path]),
3957
+ ...this.remote.keys(),
3958
+ ...this.awaiting,
3959
+ ...entries.flatMap(entryPaths)
3960
+ ];
3961
+ const held = /* @__PURE__ */ new Set();
3962
+ const hold = (dir) => {
3963
+ for (let at = dir.length; at > 0; at = dir.lastIndexOf("/", at - 1)) held.add(dir.slice(0, at));
3964
+ };
3965
+ for (const path of files) hold(path.slice(0, Math.max(0, path.lastIndexOf("/"))));
3966
+ for (const dir of gone) {
3967
+ if (held.has(dir) || exists(dir)) {
3968
+ hold(dir);
3969
+ continue;
3970
+ }
3971
+ if (entries.some((entry) => entry.kind === "rmdir" && entry.path === dir)) continue;
3972
+ entries.push({
3973
+ kind: "rmdir",
3974
+ path: dir,
3975
+ id: entryId()
3976
+ });
3977
+ queued.push(dir);
3978
+ }
3979
+ return queued.length > 0;
3980
+ }));
3981
+ if (queued.length === 0) return;
3982
+ this.log(`queued rmdir ${queued.join(", ")} (${this.queue.length} waiting)`);
3983
+ if (this.online) this.track(this.flushQueue());
3984
+ }
3985
+ /**
3986
+ * Queue the removal of the directories gone here a moment from now, once for every call in that moment; counted as
3987
+ * work under way meanwhile, so idle() waits for it.
3988
+ */
3989
+ checkDirs() {
3990
+ if (this.closing || this.dirCheck) return;
3991
+ this.dirCheck = this.track(sleep$1(SETTLE_MS).then(() => {
3992
+ this.dirCheck = null;
3993
+ return this.queueRemovedDirs();
3994
+ }).catch((error) => this.log(`directories: ${error.message}`)));
3995
+ }
3996
+ /**
3997
+ * What the server answered to the removal of a directory: removed, or not there, it is known no more. Kept, as when
3998
+ * another environment has just put a file in it, it is made here again, as the server holds it, so it is not sent
3999
+ * again; the file comes as the server's broadcast. Refused, as by a server from before dir.remove (`unknown-operation`),
4000
+ * it is known no more either: the next snapshot makes it here again, as before, rather than it being sent again and again.
4001
+ */
4002
+ async settleRmdir(entry, answer) {
4003
+ const { path } = entry;
4004
+ const response = answer;
4005
+ this.trace(`response ${path}/ ${response.ok ? response.value : `${response.reason}: ${response.message}`}`);
4006
+ if (!response.ok) {
4007
+ this.forgetDir(path);
4008
+ this.dirSeq.delete(path);
4009
+ this.info(path, `the directory was removed here and the sync server did not remove it (${response.message}); it comes back with the next snapshot`);
4010
+ return;
4011
+ }
4012
+ if (response.value === "kept") {
4013
+ for (const notice of response.notices) this.info(notice.path, `${notice.message} (removed here; the directory is back)`);
4014
+ await this.whenFree(() => this.locked(() => mkdirSync(join(this.paths.data, path), { recursive: true })));
4015
+ return;
4016
+ }
4017
+ this.forgetDir(path);
4018
+ if (response.files.length > 0) await this.delivery(path, response.seq);
4019
+ }
3846
4020
  async settleRemove(entry, response) {
3847
4021
  const { path } = entry;
3848
4022
  if (!response.ok) return this.refuse(entry, [{
@@ -3920,7 +4094,7 @@ var SyncClient = class extends EventEmitter {
3920
4094
  hash: entry.hash,
3921
4095
  blob: true
3922
4096
  });
3923
- else this.expected.set(entry.path, null);
4097
+ else if (entry.kind === "remove") this.expected.set(entry.path, null);
3924
4098
  }
3925
4099
  /**
3926
4100
  * Hold the write lock for `fn`; work stop() abandoned gets an error ('abandoned after stop') instead, so it leaves
@@ -4379,7 +4553,7 @@ var SyncClient = class extends EventEmitter {
4379
4553
  if (at < 0) return false;
4380
4554
  entries.splice(at, 1);
4381
4555
  for (const later of entries.slice(at)) {
4382
- if (later.kind === "op" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
4556
+ if (later.kind === "op" || later.kind === "rmdir" || later.path !== entry.path || later.sent !== void 0 || later.base !== entry.hash) continue;
4383
4557
  if (later.kind === "remove" && entry.base === null) continue;
4384
4558
  later.base = entry.base;
4385
4559
  }
@@ -4422,6 +4596,7 @@ var SyncClient = class extends EventEmitter {
4422
4596
  this.renumbering.delete(entry.id);
4423
4597
  if (renumbered) this.log(`renumbered ${renumbered.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
4424
4598
  for (const path of entryPaths(entry)) this.schedule(path);
4599
+ if (entry.kind !== "rmdir") this.checkDirs();
4425
4600
  this.emit("settled", entry.id);
4426
4601
  }
4427
4602
  /**
@@ -4494,7 +4669,7 @@ var SyncClient = class extends EventEmitter {
4494
4669
  }
4495
4670
  if (!response.ok && response.reason === "uncertain") {
4496
4671
  const paths = entryPaths(entry);
4497
- this.problem({
4672
+ if (entry.kind !== "rmdir") this.problem({
4498
4673
  kind: "uncertain",
4499
4674
  path: paths[0] ?? "",
4500
4675
  message: `${request.op} of ${paths.join(", ")} was sent before without an answer, and the sync server no longer keeps its answers from then (${response.message}); it is sent again as a new request and may apply twice, so check the result for a duplicate`
@@ -4505,8 +4680,8 @@ var SyncClient = class extends EventEmitter {
4505
4680
  }
4506
4681
  /**
4507
4682
  * The request an outbox entry sends, made when it is first sent, from the entry alone: an operation on the current
4508
- * protocol, a deletion with the time it is first sent, an Edit's replacement to be made again on the server's latest
4509
- * version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
4683
+ * protocol, a deletion with the time it is first sent, a directory's removal, an Edit's replacement to be made again on
4684
+ * the server's latest version, an edit as patchRequest makes it. Null for an edit that cannot be sent: its base is a write the server
4510
4685
  * refused or did not take as it was, so its base never became known, or moving it onto the server's merge overlaps.
4511
4686
  * Every write ahead of it was answered, so a base still unknown never will be.
4512
4687
  */
@@ -4529,6 +4704,11 @@ var SyncClient = class extends EventEmitter {
4529
4704
  size: entry.size,
4530
4705
  base: entry.base
4531
4706
  };
4707
+ if (entry.kind === "rmdir") return {
4708
+ protocol: 3,
4709
+ op: "dir.remove",
4710
+ path: entry.path
4711
+ };
4532
4712
  if (entry.replace) return {
4533
4713
  protocol: 3,
4534
4714
  op: "text.replace",
@@ -4675,6 +4855,7 @@ var SyncClient = class extends EventEmitter {
4675
4855
  last: entry.replaced
4676
4856
  }, request.text);
4677
4857
  else if (entry.kind === "blob") await this.settleBlob(entry, response);
4858
+ else if (entry.kind === "rmdir") await this.settleRmdir(entry, response);
4678
4859
  return true;
4679
4860
  } finally {
4680
4861
  for (const path of paths) {
@@ -4827,6 +5008,26 @@ var SyncClient = class extends EventEmitter {
4827
5008
  text
4828
5009
  });
4829
5010
  }
5011
+ for (const path of [...seen.keys(), ...binary.map(([path]) => path)]) this.pruneMoved(path, renumberPath(path, renumbered));
5012
+ }
5013
+ /**
5014
+ * Remove the directories above `from` that its move to `to` emptied, the deepest first, up to the first one that still
5015
+ * holds anything or is above `to` as well. One the server holds goes on the server too, as any directory gone here
5016
+ * (queueRemovedDirs). It does not take the write lock: its caller holds it.
5017
+ */
5018
+ pruneMoved(from, to) {
5019
+ if (from === to) return;
5020
+ for (let at = from.lastIndexOf("/"); at > 0; at = from.lastIndexOf("/", at - 1)) {
5021
+ const dir = from.slice(0, at);
5022
+ if (to.startsWith(`${dir}/`)) return;
5023
+ try {
5024
+ if (!this.removeEmptyDir(dir)) return;
5025
+ } catch (error) {
5026
+ this.log(`${dir}/: could not remove it after renumbering: ${error.message}`);
5027
+ return;
5028
+ }
5029
+ this.trace(`dir ${dir} emptied by renumbering, removed here`);
5030
+ }
4830
5031
  }
4831
5032
  commit(storage, changes) {
4832
5033
  for (const change of changes) if (change.kind === "write") storage.write(change.path, change.text);
@@ -5159,7 +5360,8 @@ var SyncClient = class extends EventEmitter {
5159
5360
  }
5160
5361
  /**
5161
5362
  * Compare every synced file's size, modification time and inode with the last scan and reconcile what changed,
5162
- * plus every file expected on disk that is gone. A file modified in the last moment may still be being written,
5363
+ * plus every file expected on disk that is gone, and queue the removal of the directories gone here
5364
+ * (queueRemovedDirs). A file modified in the last moment may still be being written,
5163
5365
  * so it waits for the next scan unless `immediate`. `baseline` only records what is there. An immediate scan waits for
5164
5366
  * the reconciles it started unless `settle` is false, for a caller that waits for them itself (flushTool).
5165
5367
  */
@@ -5180,6 +5382,7 @@ var SyncClient = class extends EventEmitter {
5180
5382
  this.signatures.delete(path);
5181
5383
  this.schedule(path);
5182
5384
  }
5385
+ await this.queueRemovedDirs();
5183
5386
  if (settle) await Promise.allSettled(this.chains.values());
5184
5387
  }
5185
5388
  /**
@@ -5197,6 +5400,7 @@ var SyncClient = class extends EventEmitter {
5197
5400
  }, SETTLE_MS));
5198
5401
  return;
5199
5402
  }
5403
+ if (this.syncedDirs.has(path)) this.checkDirs();
5200
5404
  const rules = path === ".gitignore" || path.endsWith(`/.gitignore`);
5201
5405
  if (!rules && !this.syncs(path)) return;
5202
5406
  this.trace(`watched ${path}`);
@@ -5296,13 +5500,18 @@ var SyncClient = class extends EventEmitter {
5296
5500
  for (const [path, disk] of this.confirmed) if (disk) files[path] = disk.blob ? { hash: `${BLOB_PREFIX}${disk.hash}` } : disk;
5297
5501
  return files;
5298
5502
  }
5503
+ /** The directories known.json names (syncedDirs), in path order. */
5504
+ knownDirs() {
5505
+ return [...this.syncedDirs].sort(comparePaths);
5506
+ }
5299
5507
  saveKnownNow(final = false) {
5300
5508
  if (this.abandoned && !final) return;
5301
5509
  clearTimeout(this.knownTimer);
5302
5510
  this.knownTimer = void 0;
5303
5511
  const files = this.knownFiles();
5512
+ const dirs = this.knownDirs();
5304
5513
  try {
5305
- underLock(this.root, () => writeKnown(this.root, files));
5514
+ underLock(this.root, () => writeKnown(this.root, files, dirs));
5306
5515
  } catch (error) {
5307
5516
  this.log(`could not save local/sync/known.json: ${error.message}`);
5308
5517
  }
@@ -1,8 +1,8 @@
1
1
  import { P as configSchema } from "./dist-zSabglPq.js";
2
2
  import { $ as linksInto, At as WriteError, C as HANDLED_FILE, D as loadSessions, E as readNotices$1, I as attributeTrace, J as loadConfig, L as traceTranscript, O as readSession, Ot as ConflictError, Q as mainlineEdges, R as transcriptTitle, T as parseWarn, X as sectionText, Y as loadHub, Z as dependencyGraph, _ as setArchivedMany, a as openInApp, g as ARCHIVE_KINDS, k as transcriptPath, kt as DATA_DIR, nt as remoteMembers, q as HubError, st as readYaml, tt as refIndex, u as groupPlan, w as markNoticesHandled } from "./host-BAMZmRy8.js";
3
3
  import { t as listProgress } from "./progress-1BfPg2zN.js";
4
- import { A as isSynced, _ as readDaemon, a as remoteMode, b as readRemote, h as callDaemon, j as syncPaths, k as SYNC_DIR, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as watchTree } from "./operation-DlR1P_Pt.js";
5
- import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-CMeKJsNd.js";
4
+ import { A as isSynced, _ as readDaemon, a as remoteMode, b as readRemote, h as callDaemon, j as syncPaths, k as SYNC_DIR, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as watchTree } from "./operation-2wx2ex6x.js";
5
+ import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-DosYhbwc.js";
6
6
  import { createRequire } from "node:module";
7
7
  import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
8
8
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mainflow",
3
- "version": "0.41.0-beta.1",
3
+ "version": "0.42.0",
4
4
  "description": "本機多 AI 協作的主脈絡任務管理:CLI 與介面",
5
5
  "license": "MIT",
6
6
  "type": "module",