@stll/folio-cli 0.1.1 → 0.3.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/README.md CHANGED
@@ -105,8 +105,9 @@ Every change commits, so there is no separate save:
105
105
  content, not identical ZIP bytes.
106
106
  - **Write lease.** A write holds `.<name>.docx.folio-lock` beside the file
107
107
  (pid, host, expiry, and a random token), created complete or not at all.
108
- Another holder, such as an editor session, makes a write fail with
109
- `locked` (exit 10) unless `--force`. Just before journaling and again
108
+ An editor holding it with unsaved edits is asked to save and release
109
+ first (see [Saving from an editor](#saving-from-an-editor)); any other
110
+ holder makes a write fail with `locked` (exit 10) unless `--force`. Just before journaling and again
110
111
  before the rename, a write checks that the lock still carries its token, so
111
112
  a writer whose lease was taken over stops without writing. Reads ignore the
112
113
  lease. A lease whose process has exited, or that expired, is replaced; a
@@ -121,6 +122,27 @@ Every change commits, so there is no separate save:
121
122
  reply of one transaction carries one UTC timestamp, `--date` to fix it for
122
123
  reproducible output. (`compare` stamps its redline with the current time.)
123
124
 
125
+ ### Saving from an editor
126
+
127
+ `folio save <file> --from <saved.docx> --expect-version <fileVersion>` commits
128
+ a whole package a live editor serialized, with the same lease, recovery,
129
+ checks, backup, and journal as a tool call (`tool: "editor_save"`, with
130
+ `--surface` and `--save-strategy` recorded). Unlike the tool commands it may
131
+ rewrite every part, so it is not an MCP tool. `--owner` names the lease holder.
132
+
133
+ An editor with unsaved edits holds the lease long-lived (`acceptsFlush`, 30 s,
134
+ renewed every 10 s) and watches for `.<name>.docx.folio-flush-<id>` requests.
135
+ A write that finds it writes a request and waits up to `--flush-wait` ms
136
+ (default 5000) for the editor to save under its lease (`--lease-token`) and
137
+ release; the write then runs on the saved version. An agent tool's
138
+ `--expect-version` from before the flush is accepted when the journal shows
139
+ the editor's save from exactly that version and every block it targets is a
140
+ package `w14:paraId` in both versions; its text-hash preconditions decide the
141
+ rest. A call targeting a text-derived id (`blockIdSource: synthetic`), or no
142
+ block, is refused with `stale_version` and re-reads. An editor that does not release in time is
143
+ treated by the ordinary lease rules. `@stll/folio-cli/editor-lease` exports
144
+ the editor side.
145
+
124
146
  ### Journal, retries, and recovery
125
147
 
126
148
  Each committed transaction appends a line to `.folio/journal.jsonl` beside the
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { runRender, runServe } from "./preview-commands.js";
9
9
  import { resolveAuthor, resolveTransactionDate } from "./provenance.js";
10
10
  import { findCommand, listCommands, toolAccess } from "./registry.js";
11
11
  import { resolveRoots } from "./roots.js";
12
+ import { parseFlushWait, runSave } from "./save.js";
12
13
  import { Result } from "better-result";
13
14
  import { parseArgs } from "node:util";
14
15
  //#region src/cli.ts
@@ -100,6 +101,11 @@ const WRITE_FLAGS = [
100
101
  flag: "journal",
101
102
  description: "Journal file (default: .folio/journal.jsonl beside the destination).",
102
103
  option: { type: "string" }
104
+ },
105
+ {
106
+ flag: "flush-wait",
107
+ description: "Milliseconds to wait for an editor holding the lease to save and release (default 5000).",
108
+ option: { type: "string" }
103
109
  }
104
110
  ];
105
111
  const DIRECT_FLAG = {
@@ -147,6 +153,7 @@ const rootHelp = () => {
147
153
  "",
148
154
  "Commands:",
149
155
  ...commands,
156
+ ` ${"save".padEnd(12)}Commit a package an editor serialized (not an MCP tool)`,
150
157
  ` ${"render".padEnd(12)}Render pages to PDF, PNG, or HTML`,
151
158
  ` ${"serve".padEnd(12)}Serve a read-only live preview on 127.0.0.1`,
152
159
  ` ${"mcp".padEnd(12)}Serve these tools over MCP on stdio`,
@@ -284,7 +291,10 @@ const execute = async ({ resolved, values, built, io }) => {
284
291
  if (date.isErr()) return date;
285
292
  const waived = values["no-expect-version"] === true;
286
293
  if (waived && built.expectVersion !== void 0) return Result.err(usageError("Pass --expect-version or --no-expect-version, not both."));
294
+ const flushWaitMs = parseFlushWait(stringValue(values["flush-wait"]));
295
+ if (flushWaitMs.isErr()) return flushWaitMs;
287
296
  return await executeWriteTool(tool, call, {
297
+ ...flushWaitMs.value !== void 0 && { flushWaitMs: flushWaitMs.value },
288
298
  sourcePrecondition: waived ? "waived" : "required",
289
299
  destination: destination.value,
290
300
  author: author.value,
@@ -427,6 +437,7 @@ const runFolioCli = async (argv, io) => {
427
437
  if (name === "mcp") return await runMcp(rest, io);
428
438
  if (name === "render") return await runRender(rest, io);
429
439
  if (name === "serve") return await runServe(rest, io);
440
+ if (name === "save") return await runSave(rest, io);
430
441
  const resolved = findCommand(name);
431
442
  if (resolved === void 0) return emit({
432
443
  io,
@@ -1,18 +1,11 @@
1
1
  import { FolioCliError } from "./errors.js";
2
+ import { FileIdentity, NO_FOLLOW, errnoCode, fileVersionOf, sameFile } from "./file-system.js";
2
3
  import { Result } from "better-result";
3
4
  import { FolioDocxReviewer } from "@stll/folio-core/server";
4
5
  //#region src/document.d.ts
5
6
  /** Largest `.docx` the CLI opens; larger inputs are refused before reading. */
6
7
  declare const MAX_DOCUMENT_BYTES: number;
7
- /** A file's version: the lowercase hex SHA-256 of its bytes. */
8
- declare const fileVersionOf: (bytes: Uint8Array) => string;
9
8
  declare const isFileVersion: (value: unknown) => value is string;
10
- /** Which file a path named when it was read: device and inode. */
11
- type FileIdentity = {
12
- dev: number;
13
- ino: number;
14
- };
15
- declare const sameFile: (left: FileIdentity, right: FileIdentity) => boolean;
16
9
  /** The bytes of one input file and the version they hash to. */
17
10
  type LoadedFile = {
18
11
  /** Real path (symlinks resolved) the bytes were read from. */
@@ -23,13 +16,6 @@ type LoadedFile = {
23
16
  /** Hard links to the file; a write refuses a file with more than one. */
24
17
  links: number;
25
18
  };
26
- /**
27
- * `O_NOFOLLOW` where the platform has it: opening a path whose last
28
- * component is a symlink fails instead of following it.
29
- */
30
- declare const NO_FOLLOW: number;
31
- /** The `code` of a Node.js file-system error, when there is one. */
32
- declare const errnoCode: (error: unknown) => string | undefined;
33
19
  /** Read one input file, refusing directories and files over the size bound. */
34
20
  declare const readDocumentFile: (inputPath: string) => Promise<Result<LoadedFile, FolioCliError>>;
35
21
  /**
@@ -40,4 +26,4 @@ declare const openReviewer: (file: LoadedFile, author?: string) => Promise<Resul
40
26
  /** Refuse when the caller's expected version is not the file's current one. */
41
27
  declare const checkExpectedVersion: (file: LoadedFile, expected: string | undefined) => Result<void, FolioCliError>;
42
28
  //#endregion
43
- export { FileIdentity, LoadedFile, MAX_DOCUMENT_BYTES, NO_FOLLOW, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
29
+ export { type FileIdentity, LoadedFile, MAX_DOCUMENT_BYTES, NO_FOLLOW, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
package/dist/document.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { NO_FOLLOW, errnoCode, fileVersionOf, sameFile } from "./file-system.js";
2
3
  import { Result } from "better-result";
3
- import { createHash } from "node:crypto";
4
4
  import { constants } from "node:fs";
5
5
  import { open, realpath, stat } from "node:fs/promises";
6
6
  import path from "node:path";
@@ -9,20 +9,7 @@ import { FolioDocxReviewer } from "@stll/folio-core/server";
9
9
  /** Largest `.docx` the CLI opens; larger inputs are refused before reading. */
10
10
  const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
11
11
  const FILE_VERSION_PATTERN = /^[0-9a-f]{64}$/u;
12
- /** A file's version: the lowercase hex SHA-256 of its bytes. */
13
- const fileVersionOf = (bytes) => createHash("sha256").update(bytes).digest("hex");
14
12
  const isFileVersion = (value) => typeof value === "string" && FILE_VERSION_PATTERN.test(value);
15
- const sameFile = (left, right) => left.dev === right.dev && left.ino === right.ino;
16
- /**
17
- * `O_NOFOLLOW` where the platform has it: opening a path whose last
18
- * component is a symlink fails instead of following it.
19
- */
20
- const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
21
- /** The `code` of a Node.js file-system error, when there is one. */
22
- const errnoCode = (error) => {
23
- if (typeof error !== "object" || error === null || !("code" in error)) return;
24
- return typeof error.code === "string" ? error.code : void 0;
25
- };
26
13
  const describeError = (error) => error instanceof Error ? error.message : String(error);
27
14
  const fileSystemError = (filePath, error) => errnoCode(error) === "ENOENT" ? cliError({
28
15
  code: FOLIO_CLI_ERROR_CODES.notFound,
@@ -0,0 +1,114 @@
1
+ import { FolioCliError } from "./errors.js";
2
+ import { AcquiredLease, LockHolder } from "./lock.js";
3
+ import { Result } from "better-result";
4
+ //#region src/editor-lease.d.ts
5
+ /** How long an editor's lease lasts without renewal. */
6
+ declare const EDITOR_LEASE_MS: number;
7
+ /** How often an editor renews its lease while it holds it. */
8
+ declare const EDITOR_RENEW_MS: number;
9
+ /** How long a writer waits for an editor to save and release by default. */
10
+ declare const DEFAULT_FLUSH_WAIT_MS: number;
11
+ /** How often a writer retries the lease, and an editor re-checks for requests. */
12
+ declare const FLUSH_POLL_MS = 100;
13
+ /** A writer's request that the editor holding `leaseToken` save and release. */
14
+ type FlushRequest = {
15
+ id: string;
16
+ /** The token of the lease the writer found; an editor answers only its own. */
17
+ leaseToken: string;
18
+ /** The writer's lease owner, pid, host, and transaction. */
19
+ owner: string;
20
+ pid: number;
21
+ host: string;
22
+ txId: string;
23
+ requestedAt: string;
24
+ /** After this the writer has stopped waiting; the request is void. */
25
+ deadline: string;
26
+ };
27
+ declare const flushRequestPathFor: (documentPath: string, id: string) => string;
28
+ /**
29
+ * The flush requests beside `documentPath` whose writer is still waiting.
30
+ * Requests past their deadline (a writer that crashed) are removed.
31
+ */
32
+ declare const pendingFlushRequests: (documentPath: string, now?: Date) => Promise<FlushRequest[]>;
33
+ /** Whether a writer waited for an editor, and what came of it. */
34
+ type FlushOutcome =
35
+ /** The lease was free, stale, or held by a holder that does not flush; no one was asked. */
36
+ {
37
+ type: "notAsked";
38
+ } |
39
+ /** The holder released within the wait; `versionBefore` is the file's version when asked. */
40
+ {
41
+ type: "flushed";
42
+ holder: LockHolder;
43
+ versionBefore: string | null;
44
+ } |
45
+ /** The holder never released; the lease was taken by the ordinary rules (stale, or `force`). */
46
+ {
47
+ type: "timedOut";
48
+ holder: LockHolder;
49
+ };
50
+ type LeaseForWrite = {
51
+ lease: AcquiredLease;
52
+ flush: FlushOutcome;
53
+ };
54
+ type AcquireLeaseForWriteOptions = {
55
+ documentPath: string;
56
+ txId: string;
57
+ /** After the wait, take the lease over from a holder that did not release. */
58
+ force: boolean;
59
+ owner?: string;
60
+ /** How long to wait for a flush (default {@link DEFAULT_FLUSH_WAIT_MS}); 0 does not ask. */
61
+ flushWaitMs?: number;
62
+ pollMs?: number;
63
+ };
64
+ /**
65
+ * Take the lease for a write. An editor holding it with unsaved edits is
66
+ * asked to save and release first; see the module comment for the rules.
67
+ */
68
+ declare const acquireLeaseForWrite: ({ documentPath, txId, force, owner, flushWaitMs, pollMs }: AcquireLeaseForWriteOptions) => Promise<Result<LeaseForWrite, FolioCliError>>;
69
+ type AcquireEditorLeaseOptions = {
70
+ documentPath: string;
71
+ /** The editor's lease owner, such as `folio-vscode`. */
72
+ owner: string;
73
+ /** Recorded in the lock; generated when absent. */
74
+ txId?: string;
75
+ leaseMs?: number;
76
+ now?: Date;
77
+ };
78
+ /**
79
+ * Take the lease for an editor session with unsaved edits: long-lived,
80
+ * renewable, and answering flush requests. Refused (`locked`) while another
81
+ * holder has it or a writer's flush request is pending.
82
+ */
83
+ declare const acquireEditorLease: ({ documentPath, owner, txId, leaseMs, now }: AcquireEditorLeaseOptions) => Promise<Result<AcquiredLease, FolioCliError>>;
84
+ type LeaseKeeper = {
85
+ stop: () => void;
86
+ };
87
+ /**
88
+ * Renew `lease` every `intervalMs` until stopped. When a renewal is refused
89
+ * (the lease was taken over or expired), renewing stops and `onLost` runs:
90
+ * the editor must not save under it again.
91
+ */
92
+ declare const keepEditorLeaseAlive: (lease: AcquiredLease, { intervalMs, onLost }: {
93
+ intervalMs?: number;
94
+ onLost: (error: FolioCliError) => void;
95
+ }) => LeaseKeeper;
96
+ type FlushWatcher = {
97
+ close: () => void;
98
+ };
99
+ type WatchFlushRequestsOptions = {
100
+ documentPath: string;
101
+ /** The editor's lease token; requests for any other lease are ignored. */
102
+ token: string;
103
+ /** Called once per request: save the unsaved edits, then release the lease. */
104
+ onRequest: (request: FlushRequest) => void;
105
+ pollMs?: number;
106
+ };
107
+ /**
108
+ * Watch for flush requests addressed to the lease `token`: a directory
109
+ * watch for promptness plus a poll every `pollMs`, since watch events can
110
+ * be dropped or unsupported (network drives).
111
+ */
112
+ declare const watchFlushRequests: ({ documentPath, token, onRequest, pollMs }: WatchFlushRequestsOptions) => FlushWatcher;
113
+ //#endregion
114
+ export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, FlushOutcome, FlushRequest, FlushWatcher, LeaseForWrite, LeaseKeeper, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
@@ -0,0 +1,294 @@
1
+ import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
2
+ import { fileVersionOf } from "./file-system.js";
3
+ import { DEFAULT_LEASE_OWNER, acquireLease, readLease } from "./lock.js";
4
+ import { readSidecarFile, writeNewSidecarFile } from "./sidecar.js";
5
+ import { Result } from "better-result";
6
+ import { watch } from "node:fs";
7
+ import { readFile, readdir, rename, rm } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import { hostname } from "node:os";
11
+ import { setTimeout } from "node:timers/promises";
12
+ //#region src/editor-lease.ts
13
+ /**
14
+ * The editor lease and the flush handshake: how a live editor (the VS Code
15
+ * custom editor, `folio serve --edit`) and a folio write share one `.docx`
16
+ * so that a write is never refused just because someone is typing, and the
17
+ * user's unsaved edits are saved before the write lands on top of them.
18
+ *
19
+ * Files beside `<dir>/<name>.docx`, all created complete (temporary file,
20
+ * then link or rename) and never followed through a symlink:
21
+ *
22
+ * - `.<name>.docx.folio-lock`: the lease (`lock.ts`). While it has unsaved
23
+ * edits, an editor holds it long-lived: its own `owner` (`folio-vscode`),
24
+ * `acceptsFlush: true`, {@link EDITOR_LEASE_MS} long, renewed every
25
+ * {@link EDITOR_RENEW_MS}. The pid is the editor's long-lived process, so
26
+ * a crashed editor's lease is stale at once on the same host and within
27
+ * {@link EDITOR_LEASE_MS} elsewhere.
28
+ * - `.<name>.docx.folio-flush-<id>`: one flush request per waiting writer,
29
+ * JSON {@link FlushRequest} naming the lease token it asks to let go.
30
+ *
31
+ * A writer (a tool call, or `folio save` without the lease) calls
32
+ * {@link acquireLeaseForWrite}. When a live `acceptsFlush` holder refuses
33
+ * it, it writes a request, then retries the lease every
34
+ * {@link FLUSH_POLL_MS} for up to `flushWaitMs` ({@link DEFAULT_FLUSH_WAIT_MS}).
35
+ * Once the holder has saved and released, the writer takes the lease,
36
+ * removes its request, and re-reads the file: its operations run on the
37
+ * version the editor just saved (block ids and `blockTextHash`
38
+ * preconditions decide whether they still apply). If the holder has not
39
+ * released by the deadline, the writer removes its request and falls back
40
+ * to the ordinary lease rules: an expired lease or one whose process exited
41
+ * is replaced, a live one refuses with `locked` (unless `--force`). Nothing
42
+ * is ever written under a live holder that has not released.
43
+ *
44
+ * The editor:
45
+ * 1. On its first unsaved edit, {@link acquireEditorLease}; keep it with
46
+ * {@link keepEditorLeaseAlive}. It is refused while a writer's request is
47
+ * pending (the writer is between the editor's release and its own
48
+ * acquisition); retry after the writer's commit.
49
+ * 2. {@link watchFlushRequests} with the lease's token. On a request, save
50
+ * the unsaved edits under the lease (`folio save --lease-token <token>`,
51
+ * or `saveDocumentBytes` with `leaseToken`), then release it.
52
+ * 3. After a save with no edits pending, release the lease.
53
+ * 4. When the file's version changes on disk (the writer's commit, named by
54
+ * the journal's newest line), reload it before accepting the next save:
55
+ * a save names the version it was based on and refuses a stale one.
56
+ */
57
+ /** How long an editor's lease lasts without renewal. */
58
+ const EDITOR_LEASE_MS = 30 * 1e3;
59
+ /** How often an editor renews its lease while it holds it. */
60
+ const EDITOR_RENEW_MS = 10 * 1e3;
61
+ /** How long a writer waits for an editor to save and release by default. */
62
+ const DEFAULT_FLUSH_WAIT_MS = 5 * 1e3;
63
+ /** How often a writer retries the lease, and an editor re-checks for requests. */
64
+ const FLUSH_POLL_MS = 100;
65
+ const flushPrefixFor = (documentPath) => `.${path.basename(documentPath)}.folio-flush-`;
66
+ const flushRequestPathFor = (documentPath, id) => path.join(path.dirname(documentPath), `${flushPrefixFor(documentPath)}${id}`);
67
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
68
+ const parseRequest = (text) => {
69
+ const parsed = Result.try(() => JSON.parse(text));
70
+ if (parsed.isErr() || !isRecord(parsed.value)) return null;
71
+ const { id, leaseToken, owner, pid, host, txId, requestedAt, deadline } = parsed.value;
72
+ return typeof id === "string" && typeof leaseToken === "string" && typeof owner === "string" && typeof pid === "number" && typeof host === "string" && typeof txId === "string" && typeof requestedAt === "string" && typeof deadline === "string" ? {
73
+ id,
74
+ leaseToken,
75
+ owner,
76
+ pid,
77
+ host,
78
+ txId,
79
+ requestedAt,
80
+ deadline
81
+ } : null;
82
+ };
83
+ /**
84
+ * The flush requests beside `documentPath` whose writer is still waiting.
85
+ * Requests past their deadline (a writer that crashed) are removed.
86
+ */
87
+ const pendingFlushRequests = async (documentPath, now = /* @__PURE__ */ new Date()) => {
88
+ const directory = path.dirname(documentPath);
89
+ const prefix = flushPrefixFor(documentPath);
90
+ const listed = await Result.tryPromise(() => readdir(directory));
91
+ if (listed.isErr()) return [];
92
+ const pending = [];
93
+ for (const name of listed.value.filter((entry) => entry.startsWith(prefix))) {
94
+ const requestPath = path.join(directory, name);
95
+ const bytes = await readSidecarFile(requestPath);
96
+ if (bytes.isErr()) continue;
97
+ const request = parseRequest(new TextDecoder().decode(bytes.value));
98
+ if (request === null) continue;
99
+ if (Date.parse(request.deadline) > now.getTime()) pending.push(request);
100
+ else await rm(requestPath, { force: true });
101
+ }
102
+ return pending;
103
+ };
104
+ const placeRequest = async (documentPath, request) => {
105
+ const target = flushRequestPathFor(documentPath, request.id);
106
+ const temporary = path.join(path.dirname(documentPath), `.${path.basename(documentPath)}.folio-flush.${request.id}.tmp`);
107
+ const written = await writeNewSidecarFile(temporary, new TextEncoder().encode(`${JSON.stringify(request)}\n`));
108
+ if (written.isErr()) return Result.err(written.error);
109
+ if ((await Result.tryPromise(() => rename(temporary, target))).isOk()) return Result.ok();
110
+ await rm(temporary, { force: true });
111
+ return Result.err(cliError({
112
+ code: FOLIO_CLI_ERROR_CODES.internal,
113
+ message: `Cannot write ${target}.`
114
+ }));
115
+ };
116
+ /** The file's version now, or `null` when it cannot be read. */
117
+ const currentVersion = async (documentPath) => {
118
+ const bytes = await Result.tryPromise(() => readFile(documentPath));
119
+ return bytes.isOk() ? fileVersionOf(bytes.value) : null;
120
+ };
121
+ const flushableHolder = async (documentPath) => {
122
+ const state = await readLease(documentPath);
123
+ return state.type === "held" && state.holder.acceptsFlush === true ? state.holder : null;
124
+ };
125
+ /**
126
+ * Take the lease for a write. An editor holding it with unsaved edits is
127
+ * asked to save and release first; see the module comment for the rules.
128
+ */
129
+ const acquireLeaseForWrite = async ({ documentPath, txId, force, owner = DEFAULT_LEASE_OWNER, flushWaitMs = DEFAULT_FLUSH_WAIT_MS, pollMs = 100 }) => {
130
+ const take = (takeOver) => acquireLease({
131
+ documentPath,
132
+ txId,
133
+ owner,
134
+ force: takeOver,
135
+ now: /* @__PURE__ */ new Date()
136
+ });
137
+ const first = await take(false);
138
+ if (first.isOk()) return Result.ok({
139
+ lease: first.value,
140
+ flush: { type: "notAsked" }
141
+ });
142
+ if (first.error.code !== FOLIO_CLI_ERROR_CODES.locked) return Result.err(first.error);
143
+ let holder = await flushableHolder(documentPath);
144
+ if (holder === null || flushWaitMs <= 0) {
145
+ if (!force) return Result.err(first.error);
146
+ const forced = await take(true);
147
+ return forced.isOk() ? Result.ok({
148
+ lease: forced.value,
149
+ flush: { type: "notAsked" }
150
+ }) : Result.err(forced.error);
151
+ }
152
+ const versionBefore = await currentVersion(documentPath);
153
+ const deadline = new Date(Date.now() + flushWaitMs);
154
+ const ask = (target) => ({
155
+ id: randomUUID(),
156
+ leaseToken: target.token,
157
+ owner,
158
+ pid: process.pid,
159
+ host: hostname(),
160
+ txId,
161
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString(),
162
+ deadline: deadline.toISOString()
163
+ });
164
+ let request = ask(holder);
165
+ const placed = await placeRequest(documentPath, request);
166
+ if (placed.isErr()) return Result.err(placed.error);
167
+ try {
168
+ while (Date.now() < deadline.getTime()) {
169
+ await setTimeout(Math.min(pollMs, Math.max(deadline.getTime() - Date.now(), 0)));
170
+ const attempt = await take(false);
171
+ if (attempt.isOk()) return Result.ok({
172
+ lease: attempt.value,
173
+ flush: {
174
+ type: "flushed",
175
+ holder,
176
+ versionBefore
177
+ }
178
+ });
179
+ if (attempt.error.code !== FOLIO_CLI_ERROR_CODES.locked) return Result.err(attempt.error);
180
+ const current = await flushableHolder(documentPath);
181
+ if (current !== null && current.token !== request.leaseToken) {
182
+ await rm(flushRequestPathFor(documentPath, request.id), { force: true });
183
+ holder = current;
184
+ request = ask(current);
185
+ const replaced = await placeRequest(documentPath, request);
186
+ if (replaced.isErr()) return Result.err(replaced.error);
187
+ }
188
+ }
189
+ const last = await take(force);
190
+ if (last.isOk()) return Result.ok({
191
+ lease: last.value,
192
+ flush: {
193
+ type: "timedOut",
194
+ holder
195
+ }
196
+ });
197
+ if (last.error.code !== FOLIO_CLI_ERROR_CODES.locked) return Result.err(last.error);
198
+ return Result.err(cliError({
199
+ code: FOLIO_CLI_ERROR_CODES.locked,
200
+ message: `${documentPath} is held by ${holder.owner} (pid ${holder.pid} on ${holder.host}), which did not save and release it within ${String(flushWaitMs)} ms.`,
201
+ hint: "Save or close the document in the editor and retry, or pass --force to take the lease over.",
202
+ details: {
203
+ holder,
204
+ flush: "timedOut",
205
+ flushWaitMs
206
+ }
207
+ }));
208
+ } finally {
209
+ await rm(flushRequestPathFor(documentPath, request.id), { force: true });
210
+ }
211
+ };
212
+ /**
213
+ * Take the lease for an editor session with unsaved edits: long-lived,
214
+ * renewable, and answering flush requests. Refused (`locked`) while another
215
+ * holder has it or a writer's flush request is pending.
216
+ */
217
+ const acquireEditorLease = async ({ documentPath, owner, txId = `editor-${randomUUID()}`, leaseMs = EDITOR_LEASE_MS, now = /* @__PURE__ */ new Date() }) => {
218
+ const pending = await pendingFlushRequests(documentPath, now);
219
+ if (pending.length > 0) return Result.err(cliError({
220
+ code: FOLIO_CLI_ERROR_CODES.locked,
221
+ message: `A write to ${documentPath} is waiting to take the lease.`,
222
+ hint: "Retry after it commits, then reload the document.",
223
+ details: { pending }
224
+ }));
225
+ return await acquireLease({
226
+ documentPath,
227
+ txId,
228
+ owner,
229
+ leaseMs,
230
+ acceptsFlush: true,
231
+ force: false,
232
+ now
233
+ });
234
+ };
235
+ /**
236
+ * Renew `lease` every `intervalMs` until stopped. When a renewal is refused
237
+ * (the lease was taken over or expired), renewing stops and `onLost` runs:
238
+ * the editor must not save under it again.
239
+ */
240
+ const keepEditorLeaseAlive = (lease, { intervalMs = EDITOR_RENEW_MS, onLost }) => {
241
+ let stopped = false;
242
+ const renew = async () => {
243
+ const renewed = await lease.renew();
244
+ if (renewed.isErr() && !stopped) {
245
+ stopped = true;
246
+ clearInterval(timer);
247
+ onLost(renewed.error);
248
+ }
249
+ };
250
+ const timer = setInterval(() => void renew(), intervalMs);
251
+ timer.unref();
252
+ return { stop: () => {
253
+ stopped = true;
254
+ clearInterval(timer);
255
+ } };
256
+ };
257
+ /**
258
+ * Watch for flush requests addressed to the lease `token`: a directory
259
+ * watch for promptness plus a poll every `pollMs`, since watch events can
260
+ * be dropped or unsupported (network drives).
261
+ */
262
+ const watchFlushRequests = ({ documentPath, token, onRequest, pollMs = 100 }) => {
263
+ const seen = /* @__PURE__ */ new Set();
264
+ const prefix = flushPrefixFor(documentPath);
265
+ let closed = false;
266
+ let checking = false;
267
+ const check = async () => {
268
+ if (closed || checking) return;
269
+ checking = true;
270
+ try {
271
+ for (const request of await pendingFlushRequests(documentPath)) {
272
+ if (closed || request.leaseToken !== token || seen.has(request.id)) continue;
273
+ seen.add(request.id);
274
+ onRequest(request);
275
+ }
276
+ } finally {
277
+ checking = false;
278
+ }
279
+ };
280
+ const watcher = Result.try(() => watch(path.dirname(documentPath), (_event, name) => {
281
+ if (name === null || name.startsWith(prefix)) check();
282
+ }));
283
+ if (watcher.isOk()) watcher.value.on("error", () => void 0);
284
+ const timer = setInterval(() => void check(), pollMs);
285
+ timer.unref();
286
+ check();
287
+ return { close: () => {
288
+ closed = true;
289
+ clearInterval(timer);
290
+ if (watcher.isOk()) watcher.value.close();
291
+ } };
292
+ };
293
+ //#endregion
294
+ export { DEFAULT_FLUSH_WAIT_MS, EDITOR_LEASE_MS, EDITOR_RENEW_MS, FLUSH_POLL_MS, acquireEditorLease, acquireLeaseForWrite, flushRequestPathFor, keepEditorLeaseAlive, pendingFlushRequests, watchFlushRequests };
@@ -1,6 +1,7 @@
1
1
  import { FolioCliError } from "./errors.js";
2
2
  import { FolioFileToolSpec } from "./registry.js";
3
3
  import { Result } from "better-result";
4
+ import { FolioDocxReviewer } from "@stll/folio-core/server";
4
5
  //#region src/execute-read.d.ts
5
6
  /** One call against one file: the envelope plus the tool's own arguments. */
6
7
  type FileToolCall = {
@@ -32,6 +33,7 @@ type FileReadData = {
32
33
  */
33
34
  type FolioBlockIdSource = "package" | "synthetic";
34
35
  type BlockIdSources = ReadonlyMap<string, FolioBlockIdSource>;
36
+ declare const blockIdSources: (reviewer: FolioDocxReviewer) => BlockIdSources;
35
37
  type ReadCursor = {
36
38
  fileVersion: string;
37
39
  afterBlockId: string;
@@ -61,4 +63,4 @@ declare const pageReadDocument: ({ blocks, sources, fileVersion, args, bounds, p
61
63
  /** Run one read tool, or a compare without a destination. Never writes. */
62
64
  declare const executeReadTool: (tool: FolioFileToolSpec, call: FileToolCall, bounds: FolioReadBounds) => Promise<Result<FileReadData, FolioCliError>>;
63
65
  //#endregion
64
- export { CLI_READ_BOUNDS, FileReadData, FileToolCall, FolioBlockIdSource, FolioReadBounds, encodeReadCursor, executeReadTool, pageReadDocument };
66
+ export { CLI_READ_BOUNDS, FileReadData, FileToolCall, FolioBlockIdSource, FolioReadBounds, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument };
@@ -217,4 +217,4 @@ const executeReadTool = async (tool, call, bounds) => {
217
217
  }
218
218
  };
219
219
  //#endregion
220
- export { CLI_READ_BOUNDS, encodeReadCursor, executeReadTool, pageReadDocument };
220
+ export { CLI_READ_BOUNDS, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument };
@@ -1,4 +1,6 @@
1
1
  import { FolioCliError } from "./errors.js";
2
+ import { FileIdentity } from "./file-system.js";
3
+ import "./document.js";
2
4
  import { FolioFileToolSpec } from "./registry.js";
3
5
  import { FileToolCall } from "./execute-read.js";
4
6
  import { WriteDestination } from "./transaction.js";
@@ -27,10 +29,36 @@ type WriteOptions = {
27
29
  journalPath: string | undefined;
28
30
  /** For tools whose edit mode is selectable. */
29
31
  mode: FolioAIEditApplyMode;
32
+ /** How long to wait for an editor holding the lease to save and release (`editor-lease.ts`). */
33
+ flushWaitMs?: number;
30
34
  };
31
35
  /** How a transaction's bytes were produced. */
32
36
  type SaveStrategy = "selective" | "full-repack" | "redline";
37
+ declare const TX_ID_PATTERN: RegExp;
38
+ /** JSON with object keys sorted at every depth, for a stable request hash. */
39
+ declare const canonicalJson: (value: unknown) => string;
40
+ type ResolvedTarget = {
41
+ sourcePath: string;
42
+ sourceIdentity: FileIdentity;
43
+ destinationPath: string;
44
+ };
45
+ /** The `tool` an editor's whole-package save journals (`save.ts`). */
46
+ declare const EDITOR_SAVE_TOOL = "editor_save";
47
+ type ResolveTargetOptions = {
48
+ sourcePath: string;
49
+ destination: WriteDestination;
50
+ /** A redline: never in place, and never onto the revised file either. */
51
+ redline?: {
52
+ revisedPath: unknown;
53
+ };
54
+ };
55
+ /**
56
+ * Resolve source and destination to real paths and refuse a destination
57
+ * that is the input under another name (a symlink, a hard link, a
58
+ * case-insensitive alias): files are compared by device and inode.
59
+ */
60
+ declare const resolveTarget: ({ sourcePath, destination, redline }: ResolveTargetOptions) => Promise<Result<ResolvedTarget, FolioCliError>>;
33
61
  /** Run one write tool as a transaction and return its receipt. */
34
62
  declare const executeWriteTool: (tool: FolioFileToolSpec, call: FileToolCall, options: WriteOptions) => Promise<Result<Readonly<Record<string, unknown>>, FolioCliError>>;
35
63
  //#endregion
36
- export { SaveStrategy, type WriteDestination, WriteOptions, executeWriteTool };
64
+ export { EDITOR_SAVE_TOOL, SaveStrategy, TX_ID_PATTERN, type WriteDestination, WriteOptions, canonicalJson, executeWriteTool, resolveTarget };