@stll/folio-cli 0.1.0 → 0.2.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 +24 -2
- package/dist/cli.js +11 -0
- package/dist/document.d.ts +2 -16
- package/dist/document.js +1 -14
- package/dist/editor-lease.d.ts +114 -0
- package/dist/editor-lease.js +294 -0
- package/dist/execute-read.d.ts +3 -1
- package/dist/execute-read.js +1 -1
- package/dist/execute-write.d.ts +29 -1
- package/dist/execute-write.js +111 -26
- package/dist/file-system.d.ts +23 -0
- package/dist/file-system.js +23 -0
- package/dist/journal.d.ts +17 -1
- package/dist/journal.js +37 -2
- package/dist/lock.d.ts +25 -2
- package/dist/lock.js +133 -27
- package/dist/output-file.d.ts +2 -1
- package/dist/output-file.js +2 -2
- package/dist/package.js +1 -1
- package/dist/save.d.ts +48 -0
- package/dist/save.js +354 -0
- package/dist/serve.js +42 -26
- package/dist/sidecar.d.ts +1 -1
- package/dist/sidecar.js +1 -1
- package/dist/transaction.d.ts +2 -1
- package/dist/transaction.js +2 -1
- package/package.json +11 -3
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
|
-
|
|
109
|
-
|
|
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,
|
package/dist/document.d.ts
CHANGED
|
@@ -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 };
|
package/dist/execute-read.d.ts
CHANGED
|
@@ -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 };
|
package/dist/execute-read.js
CHANGED
|
@@ -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 };
|
package/dist/execute-write.d.ts
CHANGED
|
@@ -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 };
|