@hraness/kb 0.17.1
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/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
package/src/note-lock.ts
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
unlink,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
|
|
15
|
+
const LOCK_SCHEMA_VERSION = 1 as const;
|
|
16
|
+
const MAX_LOCK_BYTES = 4 * 1024;
|
|
17
|
+
const DEFAULT_STALE_AFTER_MS = 5 * 60 * 1_000;
|
|
18
|
+
const DEFAULT_HEARTBEAT_MS = 5_000;
|
|
19
|
+
const DEFAULT_POLL_INTERVAL_MS = 20;
|
|
20
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
|
|
21
|
+
const MAX_RECLAIM_ATTEMPTS = 8;
|
|
22
|
+
|
|
23
|
+
interface LockOwner {
|
|
24
|
+
readonly version: typeof LOCK_SCHEMA_VERSION;
|
|
25
|
+
readonly pid: number;
|
|
26
|
+
readonly token: string;
|
|
27
|
+
readonly acquiredAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RegularObservedLock {
|
|
31
|
+
readonly kind: "regular";
|
|
32
|
+
readonly device: bigint;
|
|
33
|
+
readonly inode: bigint;
|
|
34
|
+
readonly modifiedAtMs: number;
|
|
35
|
+
readonly owner: LockOwner | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UnsafeObservedLock {
|
|
39
|
+
readonly kind: "unsafe";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ObservedLock = RegularObservedLock | UnsafeObservedLock;
|
|
43
|
+
|
|
44
|
+
export interface NoteLock {
|
|
45
|
+
/** Absolute path in the external XDG cache hierarchy. */
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly assertOwned: () => Promise<void>;
|
|
48
|
+
readonly release: () => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface NoteLockDependencies {
|
|
52
|
+
readonly pid: number;
|
|
53
|
+
readonly now: () => Date;
|
|
54
|
+
readonly monotonicNow: () => number;
|
|
55
|
+
readonly token: () => string;
|
|
56
|
+
readonly isProcessAlive: (pid: number) => boolean;
|
|
57
|
+
readonly sleep: (milliseconds: number) => Promise<void>;
|
|
58
|
+
readonly staleAfterMs: number;
|
|
59
|
+
readonly heartbeatMs: number;
|
|
60
|
+
readonly pollIntervalMs: number;
|
|
61
|
+
/** Deterministic race seam after a lock path is moved to a tombstone. */
|
|
62
|
+
readonly afterTombstoneMove?: (tombstone: string, lockPath: string) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NoteLockOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Override the XDG cache home. This is primarily useful for isolated callers
|
|
68
|
+
* and tests; the resulting lock directory must remain outside the vault.
|
|
69
|
+
*/
|
|
70
|
+
readonly cacheHome?: string;
|
|
71
|
+
readonly waitTimeoutMs?: number;
|
|
72
|
+
readonly dependencies?: Partial<NoteLockDependencies>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type FileLeaseOptions = Omit<NoteLockOptions, "cacheHome">;
|
|
76
|
+
|
|
77
|
+
export class NoteLockBusyError extends Error {
|
|
78
|
+
readonly lockPath: string;
|
|
79
|
+
|
|
80
|
+
constructor(lockPath: string) {
|
|
81
|
+
super("this note is already being edited");
|
|
82
|
+
this.name = "NoteLockBusyError";
|
|
83
|
+
this.lockPath = lockPath;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class NoteLockLostError extends Error {
|
|
88
|
+
readonly lockPath: string;
|
|
89
|
+
|
|
90
|
+
constructor(lockPath: string) {
|
|
91
|
+
super("the note lock is no longer owned by this process");
|
|
92
|
+
this.name = "NoteLockLostError";
|
|
93
|
+
this.lockPath = lockPath;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
98
|
+
return typeof error === "object"
|
|
99
|
+
&& error !== null
|
|
100
|
+
&& "code" in error
|
|
101
|
+
&& error.code === code;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sha256(value: string): string {
|
|
105
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function within(root: string, candidate: string): boolean {
|
|
109
|
+
const fromRoot = relative(root, candidate);
|
|
110
|
+
return fromRoot === ""
|
|
111
|
+
|| (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultCacheHome(): string {
|
|
115
|
+
const configured = process.env.XDG_CACHE_HOME;
|
|
116
|
+
return configured !== undefined && isAbsolute(configured)
|
|
117
|
+
? configured
|
|
118
|
+
: join(homedir(), ".cache");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultDependencies(): NoteLockDependencies {
|
|
122
|
+
return {
|
|
123
|
+
pid: process.pid,
|
|
124
|
+
now: () => new Date(),
|
|
125
|
+
monotonicNow: () => performance.now(),
|
|
126
|
+
token: () => randomUUID(),
|
|
127
|
+
isProcessAlive: (pid) => {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, 0);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return !isErrno(error, "ESRCH");
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
sleep: async (milliseconds) => {
|
|
136
|
+
await new Promise<void>((resolveSleep) => {
|
|
137
|
+
setTimeout(resolveSleep, milliseconds);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
staleAfterMs: DEFAULT_STALE_AFTER_MS,
|
|
141
|
+
heartbeatMs: DEFAULT_HEARTBEAT_MS,
|
|
142
|
+
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolvedDependencies(
|
|
147
|
+
overrides: Partial<NoteLockDependencies> | undefined,
|
|
148
|
+
): NoteLockDependencies {
|
|
149
|
+
const dependencies = { ...defaultDependencies(), ...overrides };
|
|
150
|
+
if (!Number.isSafeInteger(dependencies.pid) || dependencies.pid <= 0) {
|
|
151
|
+
throw new TypeError("a note lock requires a positive process ID");
|
|
152
|
+
}
|
|
153
|
+
for (const [label, value] of [
|
|
154
|
+
["staleAfterMs", dependencies.staleAfterMs],
|
|
155
|
+
["heartbeatMs", dependencies.heartbeatMs],
|
|
156
|
+
["pollIntervalMs", dependencies.pollIntervalMs],
|
|
157
|
+
] as const) {
|
|
158
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
159
|
+
throw new TypeError(`${label} must be a positive finite duration`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return dependencies;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function lockPathFor(
|
|
166
|
+
vaultRootInput: string,
|
|
167
|
+
canonicalNoteId: string,
|
|
168
|
+
cacheHomeInput: string | undefined,
|
|
169
|
+
): Promise<string> {
|
|
170
|
+
if (
|
|
171
|
+
canonicalNoteId === ""
|
|
172
|
+
|| canonicalNoteId.includes("\0")
|
|
173
|
+
|| canonicalNoteId.includes("\n")
|
|
174
|
+
|| canonicalNoteId.includes("\r")
|
|
175
|
+
) {
|
|
176
|
+
throw new TypeError("a note lock requires a non-empty single-line note ID");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const vaultRoot = await realpath(resolve(vaultRootInput));
|
|
180
|
+
const cacheHome = resolve(cacheHomeInput ?? defaultCacheHome());
|
|
181
|
+
const requestedDirectory = join(
|
|
182
|
+
cacheHome,
|
|
183
|
+
"hraness-kb",
|
|
184
|
+
"note-locks",
|
|
185
|
+
sha256(vaultRoot),
|
|
186
|
+
);
|
|
187
|
+
await mkdir(requestedDirectory, { recursive: true, mode: 0o700 });
|
|
188
|
+
const requestedMetadata = await lstat(requestedDirectory);
|
|
189
|
+
if (!requestedMetadata.isDirectory() || requestedMetadata.isSymbolicLink()) {
|
|
190
|
+
throw new Error("the note lock root must be a real directory");
|
|
191
|
+
}
|
|
192
|
+
const lockDirectory = await realpath(requestedDirectory);
|
|
193
|
+
if (within(vaultRoot, lockDirectory)) {
|
|
194
|
+
throw new Error("the note lock root must remain outside the vault");
|
|
195
|
+
}
|
|
196
|
+
return join(lockDirectory, `${sha256(canonicalNoteId)}.lock`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseOwner(value: unknown): LockOwner | null {
|
|
200
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
201
|
+
const record = value as Readonly<Record<string, unknown>>;
|
|
202
|
+
const version = record["version"];
|
|
203
|
+
const pid = record["pid"];
|
|
204
|
+
const token = record["token"];
|
|
205
|
+
const acquiredAt = record["acquiredAt"];
|
|
206
|
+
if (
|
|
207
|
+
version !== LOCK_SCHEMA_VERSION
|
|
208
|
+
|| typeof pid !== "number"
|
|
209
|
+
|| !Number.isSafeInteger(pid)
|
|
210
|
+
|| pid <= 0
|
|
211
|
+
|| typeof token !== "string"
|
|
212
|
+
|| !/^[0-9a-z-]{16,128}$/iu.test(token)
|
|
213
|
+
|| typeof acquiredAt !== "string"
|
|
214
|
+
|| Number.isNaN(Date.parse(acquiredAt))
|
|
215
|
+
) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return { version, pid, token, acquiredAt };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function observeLock(path: string): Promise<ObservedLock | null> {
|
|
222
|
+
let metadata;
|
|
223
|
+
try {
|
|
224
|
+
metadata = await lstat(path, { bigint: true });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (isErrno(error, "ENOENT")) return null;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
!metadata.isFile()
|
|
231
|
+
|| metadata.isSymbolicLink()
|
|
232
|
+
|| metadata.nlink !== 1n
|
|
233
|
+
|| metadata.size > BigInt(MAX_LOCK_BYTES)
|
|
234
|
+
) {
|
|
235
|
+
return { kind: "unsafe" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let handle;
|
|
239
|
+
try {
|
|
240
|
+
handle = await open(
|
|
241
|
+
path,
|
|
242
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return isErrno(error, "ENOENT") ? null : { kind: "unsafe" };
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const opened = await handle.stat({ bigint: true });
|
|
249
|
+
if (
|
|
250
|
+
!opened.isFile()
|
|
251
|
+
|| opened.nlink !== 1n
|
|
252
|
+
|| opened.dev !== metadata.dev
|
|
253
|
+
|| opened.ino !== metadata.ino
|
|
254
|
+
|| opened.size !== metadata.size
|
|
255
|
+
|| opened.size > BigInt(MAX_LOCK_BYTES)
|
|
256
|
+
) {
|
|
257
|
+
return { kind: "unsafe" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const bytes = new Uint8Array(Number(opened.size));
|
|
261
|
+
let offset = 0;
|
|
262
|
+
while (offset < bytes.byteLength) {
|
|
263
|
+
const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
264
|
+
if (result.bytesRead === 0) return { kind: "unsafe" };
|
|
265
|
+
offset += result.bytesRead;
|
|
266
|
+
}
|
|
267
|
+
const overflow = new Uint8Array(1);
|
|
268
|
+
if ((await handle.read(overflow, 0, 1, Number(opened.size))).bytesRead !== 0) {
|
|
269
|
+
return { kind: "unsafe" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const finished = await handle.stat({ bigint: true });
|
|
273
|
+
let finalPath;
|
|
274
|
+
try {
|
|
275
|
+
finalPath = await lstat(path, { bigint: true });
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (isErrno(error, "ENOENT")) return null;
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
if (
|
|
281
|
+
!finalPath.isFile()
|
|
282
|
+
|| finalPath.isSymbolicLink()
|
|
283
|
+
|| finalPath.nlink !== 1n
|
|
284
|
+
|| finalPath.dev !== opened.dev
|
|
285
|
+
|| finalPath.ino !== opened.ino
|
|
286
|
+
|| finalPath.size !== opened.size
|
|
287
|
+
|| finished.size !== opened.size
|
|
288
|
+
|| finished.mtimeNs !== opened.mtimeNs
|
|
289
|
+
|| finished.ctimeNs !== opened.ctimeNs
|
|
290
|
+
) {
|
|
291
|
+
return { kind: "unsafe" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let value: unknown;
|
|
295
|
+
try {
|
|
296
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
297
|
+
} catch {
|
|
298
|
+
value = null;
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
kind: "regular",
|
|
302
|
+
device: opened.dev,
|
|
303
|
+
inode: opened.ino,
|
|
304
|
+
modifiedAtMs: Number(opened.mtimeMs),
|
|
305
|
+
owner: parseOwner(value),
|
|
306
|
+
};
|
|
307
|
+
} finally {
|
|
308
|
+
await handle.close();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function sameIdentity(left: RegularObservedLock, right: RegularObservedLock): boolean {
|
|
313
|
+
return left.device === right.device && left.inode === right.inode;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function restoreUnexpectedLock(tombstone: string, lockPath: string): Promise<void> {
|
|
317
|
+
try {
|
|
318
|
+
// POSIX rename would overwrite a lock created after the tombstone move.
|
|
319
|
+
// Hard-link restoration is atomic and fails without clobbering that owner.
|
|
320
|
+
await link(tombstone, lockPath);
|
|
321
|
+
} catch {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await unlink(tombstone);
|
|
326
|
+
} catch {
|
|
327
|
+
// Both paths still identify the same inode. A quarantine entry is safer
|
|
328
|
+
// than deleting a name after another path race.
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function reclaimObservedLock(
|
|
333
|
+
lockPath: string,
|
|
334
|
+
observed: RegularObservedLock,
|
|
335
|
+
token: string,
|
|
336
|
+
dependencies: NoteLockDependencies,
|
|
337
|
+
): Promise<boolean> {
|
|
338
|
+
const tombstone = `${lockPath}.stale-${token}`;
|
|
339
|
+
try {
|
|
340
|
+
await rename(lockPath, tombstone);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (isErrno(error, "ENOENT") || isErrno(error, "EEXIST")) return false;
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
await dependencies.afterTombstoneMove?.(tombstone, lockPath);
|
|
346
|
+
const moved = await observeLock(tombstone);
|
|
347
|
+
if (moved === null || moved.kind !== "regular" || !sameIdentity(observed, moved)) {
|
|
348
|
+
await restoreUnexpectedLock(tombstone, lockPath);
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
await unlink(tombstone);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function releaseOwnedLock(
|
|
356
|
+
lockPath: string,
|
|
357
|
+
owner: LockOwner,
|
|
358
|
+
dependencies: NoteLockDependencies,
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
const tombstone = `${lockPath}.release-${owner.token}`;
|
|
361
|
+
try {
|
|
362
|
+
await rename(lockPath, tombstone);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (isErrno(error, "ENOENT")) return;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
await dependencies.afterTombstoneMove?.(tombstone, lockPath);
|
|
368
|
+
const moved = await observeLock(tombstone);
|
|
369
|
+
if (moved?.kind !== "regular" || moved.owner?.token !== owner.token) {
|
|
370
|
+
await restoreUnexpectedLock(tombstone, lockPath);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
await unlink(tombstone);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function tryAcquire(
|
|
377
|
+
lockPath: string,
|
|
378
|
+
dependencies: NoteLockDependencies,
|
|
379
|
+
): Promise<NoteLock> {
|
|
380
|
+
for (let attempt = 0; attempt < MAX_RECLAIM_ATTEMPTS; attempt += 1) {
|
|
381
|
+
const owner: LockOwner = {
|
|
382
|
+
version: LOCK_SCHEMA_VERSION,
|
|
383
|
+
pid: dependencies.pid,
|
|
384
|
+
token: dependencies.token(),
|
|
385
|
+
acquiredAt: dependencies.now().toISOString(),
|
|
386
|
+
};
|
|
387
|
+
let handle;
|
|
388
|
+
try {
|
|
389
|
+
handle = await open(
|
|
390
|
+
lockPath,
|
|
391
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW,
|
|
392
|
+
0o600,
|
|
393
|
+
);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
if (!isErrno(error, "EEXIST")) throw error;
|
|
396
|
+
const observed = await observeLock(lockPath);
|
|
397
|
+
if (observed === null) continue;
|
|
398
|
+
if (observed.kind === "unsafe") throw new NoteLockBusyError(lockPath);
|
|
399
|
+
const ageMs = Math.max(0, dependencies.now().getTime() - observed.modifiedAtMs);
|
|
400
|
+
const ownerAlive = observed.owner !== null
|
|
401
|
+
&& dependencies.isProcessAlive(observed.owner.pid);
|
|
402
|
+
if (
|
|
403
|
+
ownerAlive
|
|
404
|
+
|| (observed.owner === null && ageMs <= dependencies.staleAfterMs)
|
|
405
|
+
) {
|
|
406
|
+
throw new NoteLockBusyError(lockPath);
|
|
407
|
+
}
|
|
408
|
+
if (await reclaimObservedLock(lockPath, observed, owner.token, dependencies)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, { encoding: "utf8" });
|
|
416
|
+
// Persist the owner before exposing the lease. The external lock is
|
|
417
|
+
// disposable coordination state, so its directory entry need not
|
|
418
|
+
// survive a machine crash; authored Markdown has its own durable install.
|
|
419
|
+
await handle.sync();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
await handle.close().catch(() => undefined);
|
|
422
|
+
const created = await observeLock(lockPath);
|
|
423
|
+
if (created?.kind === "regular") {
|
|
424
|
+
await reclaimObservedLock(lockPath, created, owner.token, dependencies)
|
|
425
|
+
.catch(() => undefined);
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let leaseOperationTail = Promise.resolve();
|
|
431
|
+
const runLeaseOperation = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
432
|
+
const result = leaseOperationTail.then(operation);
|
|
433
|
+
leaseOperationTail = result.then(() => undefined, () => undefined);
|
|
434
|
+
return result;
|
|
435
|
+
};
|
|
436
|
+
let heartbeatQueued = false;
|
|
437
|
+
let released = false;
|
|
438
|
+
let releasePromise: Promise<void> | undefined;
|
|
439
|
+
const timer = setInterval(() => {
|
|
440
|
+
if (heartbeatQueued || releasePromise !== undefined) return;
|
|
441
|
+
heartbeatQueued = true;
|
|
442
|
+
void runLeaseOperation(async () => {
|
|
443
|
+
if (released) return;
|
|
444
|
+
const now = dependencies.now();
|
|
445
|
+
await handle.utimes(now, now);
|
|
446
|
+
}).catch(() => undefined).finally(() => {
|
|
447
|
+
heartbeatQueued = false;
|
|
448
|
+
});
|
|
449
|
+
}, dependencies.heartbeatMs);
|
|
450
|
+
timer.unref();
|
|
451
|
+
return {
|
|
452
|
+
path: lockPath,
|
|
453
|
+
assertOwned: () => runLeaseOperation(async () => {
|
|
454
|
+
if (released) throw new NoteLockLostError(lockPath);
|
|
455
|
+
const observed = await observeLock(lockPath);
|
|
456
|
+
if (observed?.kind !== "regular" || observed.owner?.token !== owner.token) {
|
|
457
|
+
throw new NoteLockLostError(lockPath);
|
|
458
|
+
}
|
|
459
|
+
}),
|
|
460
|
+
release: async () => {
|
|
461
|
+
if (releasePromise !== undefined) return releasePromise;
|
|
462
|
+
clearInterval(timer);
|
|
463
|
+
releasePromise = runLeaseOperation(async () => {
|
|
464
|
+
released = true;
|
|
465
|
+
await handle.close();
|
|
466
|
+
await releaseOwnedLock(lockPath, owner, dependencies);
|
|
467
|
+
});
|
|
468
|
+
return releasePromise;
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
throw new NoteLockBusyError(lockPath);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Acquire a vault-and-note-scoped local lock in the external XDG cache.
|
|
477
|
+
*
|
|
478
|
+
* The note ID is hashed only for the cache filename; authoring remains keyed by
|
|
479
|
+
* the exact canonical ID. Live owners are never reclaimed. Dead owners and
|
|
480
|
+
* expired incomplete acquisitions are quarantined and identity-checked before
|
|
481
|
+
* deletion.
|
|
482
|
+
*/
|
|
483
|
+
export async function acquireNoteLock(
|
|
484
|
+
vaultRoot: string,
|
|
485
|
+
canonicalNoteId: string,
|
|
486
|
+
options: NoteLockOptions = {},
|
|
487
|
+
): Promise<NoteLock> {
|
|
488
|
+
const lockPath = await lockPathFor(vaultRoot, canonicalNoteId, options.cacheHome);
|
|
489
|
+
return acquireFileLease(lockPath, options);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Acquire the same identity-checked local lease at one caller-owned canonical path. */
|
|
493
|
+
export async function acquireFileLease(
|
|
494
|
+
lockPathInput: string,
|
|
495
|
+
options: FileLeaseOptions = {},
|
|
496
|
+
): Promise<NoteLock> {
|
|
497
|
+
if (typeof lockPathInput !== "string" || lockPathInput.trim() === "") {
|
|
498
|
+
throw new TypeError("a file lease requires a non-empty path");
|
|
499
|
+
}
|
|
500
|
+
const lockPath = resolve(lockPathInput);
|
|
501
|
+
const requestedDirectory = dirname(lockPath);
|
|
502
|
+
const directory = await realpath(requestedDirectory);
|
|
503
|
+
const metadata = await lstat(directory);
|
|
504
|
+
if (
|
|
505
|
+
directory !== requestedDirectory
|
|
506
|
+
|| !metadata.isDirectory()
|
|
507
|
+
|| metadata.isSymbolicLink()
|
|
508
|
+
|| dirname(lockPath) !== directory
|
|
509
|
+
) {
|
|
510
|
+
throw new Error("the file lease parent must be one canonical real directory");
|
|
511
|
+
}
|
|
512
|
+
const dependencies = resolvedDependencies(options.dependencies);
|
|
513
|
+
const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
514
|
+
if (!Number.isFinite(waitTimeoutMs) || waitTimeoutMs < 0) {
|
|
515
|
+
throw new TypeError("waitTimeoutMs must be a non-negative finite duration");
|
|
516
|
+
}
|
|
517
|
+
const deadline = dependencies.monotonicNow() + waitTimeoutMs;
|
|
518
|
+
|
|
519
|
+
for (;;) {
|
|
520
|
+
try {
|
|
521
|
+
return await tryAcquire(lockPath, dependencies);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (!(error instanceof NoteLockBusyError)) throw error;
|
|
524
|
+
const remaining = deadline - dependencies.monotonicNow();
|
|
525
|
+
if (remaining <= 0) throw error;
|
|
526
|
+
await dependencies.sleep(Math.min(dependencies.pollIntervalMs, remaining));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|