@erdemtuna/doc-review 0.9.0 → 0.10.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 +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comparison-view.js +380 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +210 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +249 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +300 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/view-identity.js +112 -0
- package/package.json +24 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
package/src/semantic-snapshot.js
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capture the reviewed, live DOM as inert descriptors, never historical HTML.
|
|
3
|
-
* Keep this function self-contained: the SDK can inject its toString() source.
|
|
4
|
-
* Limits fail the entire capture rather than publish a truncated baseline.
|
|
5
|
-
*/
|
|
6
|
-
export function captureSemanticSnapshot(document) {
|
|
7
|
-
const limits = {
|
|
8
|
-
blocks: 2000, nodes: 30000, text: 1000000, blockText: 100000,
|
|
9
|
-
runs: 1000, depth: 64, attribute: 4096, milliseconds: 1500,
|
|
10
|
-
};
|
|
11
|
-
const started = Date.now();
|
|
12
|
-
let visited = 0;
|
|
13
|
-
let characters = 0;
|
|
14
|
-
const blocks = [];
|
|
15
|
-
const limitations = new Set([
|
|
16
|
-
"hidden-or-virtualized-content", "closed-shadow-roots",
|
|
17
|
-
"cross-origin-frames", "canvas-content", "same-url-image-bytes",
|
|
18
|
-
]);
|
|
19
|
-
const fail = (reason) => {
|
|
20
|
-
const error = new Error(`Semantic capture unavailable: ${reason}`);
|
|
21
|
-
error.code = "SEMANTIC_CAPTURE_LIMIT";
|
|
22
|
-
error.reason = reason;
|
|
23
|
-
throw error;
|
|
24
|
-
};
|
|
25
|
-
if (!document?.body || !document.defaultView) {
|
|
26
|
-
const error = new Error("Semantic capture requires a live document");
|
|
27
|
-
error.code = "SEMANTIC_CAPTURE_UNAVAILABLE";
|
|
28
|
-
throw error;
|
|
29
|
-
}
|
|
30
|
-
const excluded = new Set([
|
|
31
|
-
"script", "style", "noscript", "template", "head", "input", "textarea",
|
|
32
|
-
"select", "option", "datalist", "output", "embed", "object",
|
|
33
|
-
]);
|
|
34
|
-
const blockTags = new Set([
|
|
35
|
-
"address", "article", "aside", "blockquote", "body", "button", "caption", "dd",
|
|
36
|
-
"details", "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure",
|
|
37
|
-
"footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "img",
|
|
38
|
-
"li", "main", "nav", "ol", "p", "pre", "section", "summary", "table",
|
|
39
|
-
"tbody", "td", "tfoot", "th", "thead", "tr", "ul",
|
|
40
|
-
]);
|
|
41
|
-
const structural = new Set([
|
|
42
|
-
"blockquote", "caption", "dd", "details", "dl", "dt", "figcaption",
|
|
43
|
-
"figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "li",
|
|
44
|
-
"ol", "pre", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
|
|
45
|
-
]);
|
|
46
|
-
const markNames = {
|
|
47
|
-
b: "strong", strong: "strong", i: "em", em: "em", u: "underline",
|
|
48
|
-
s: "strike", del: "delete", ins: "insert", code: "code", kbd: "kbd",
|
|
49
|
-
samp: "samp", sub: "sub", sup: "sup", mark: "mark",
|
|
50
|
-
};
|
|
51
|
-
const attributeNames = [
|
|
52
|
-
"id", "href", "src", "alt", "title", "start", "reversed", "value",
|
|
53
|
-
"rowspan", "colspan", "scope", "headers", "type",
|
|
54
|
-
];
|
|
55
|
-
const tick = (depth) => {
|
|
56
|
-
if (++visited > limits.nodes) fail("nodes");
|
|
57
|
-
if (depth > limits.depth) fail("depth");
|
|
58
|
-
if (visited % 32 === 0 && Date.now() - started > limits.milliseconds) fail("time");
|
|
59
|
-
};
|
|
60
|
-
const bounded = (value) => {
|
|
61
|
-
if (value.length > limits.attribute) fail("attribute");
|
|
62
|
-
characters += value.length;
|
|
63
|
-
if (characters > limits.text) fail("text");
|
|
64
|
-
return value;
|
|
65
|
-
};
|
|
66
|
-
const urlValue = (value) => {
|
|
67
|
-
// References are inert strings, but do not retain inline payloads or credentials.
|
|
68
|
-
if (/^\s*(?:javascript|vbscript|data|blob):/i.test(value)) {
|
|
69
|
-
limitations.add("unsafe-or-inline-url");
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
const url = new URL(value, document.baseURI);
|
|
74
|
-
if (["javascript:", "vbscript:", "data:", "blob:"].includes(url.protocol)) {
|
|
75
|
-
limitations.add("unsafe-or-inline-url");
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
if (url.username || url.password) {
|
|
79
|
-
limitations.add("credentialed-url");
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
} catch {
|
|
83
|
-
limitations.add("invalid-url");
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
return bounded(value);
|
|
87
|
-
};
|
|
88
|
-
const preservedRuns = new WeakSet();
|
|
89
|
-
const attributesFor = (element) => {
|
|
90
|
-
const attrs = {};
|
|
91
|
-
for (const key of attributeNames) {
|
|
92
|
-
if (key === "value" && element.localName !== "li") continue;
|
|
93
|
-
if (key === "type" && element.localName !== "button") continue;
|
|
94
|
-
if (!element.hasAttribute(key)) continue;
|
|
95
|
-
const value = element.getAttribute(key);
|
|
96
|
-
const safe = key === "href" || key === "src" ? urlValue(value) : bounded(value);
|
|
97
|
-
if (safe !== null) attrs[key] = safe;
|
|
98
|
-
}
|
|
99
|
-
return attrs;
|
|
100
|
-
};
|
|
101
|
-
const escapeString = (value) => value.replace(/[\\"\n\r\f]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
|
|
102
|
-
const visibleStyle = (element) => {
|
|
103
|
-
if (element.hasAttribute("hidden") || element.hasAttribute("inert") ||
|
|
104
|
-
element.getAttribute("aria-hidden") === "true") return null;
|
|
105
|
-
const style = document.defaultView.getComputedStyle(element);
|
|
106
|
-
return style.display !== "none" && style.visibility !== "hidden" &&
|
|
107
|
-
style.visibility !== "collapse" && style.contentVisibility !== "hidden" &&
|
|
108
|
-
style.opacity !== "0" ? style : null;
|
|
109
|
-
};
|
|
110
|
-
const addRun = (block, text, marks, href, preserve) => {
|
|
111
|
-
if (!text) return;
|
|
112
|
-
if (!preserve) {
|
|
113
|
-
text = text.replace(/[\t\n\r\f ]+/g, " ");
|
|
114
|
-
if (/[ \t\r\n\f]$/.test(block.text)) text = text.replace(/^ /, "");
|
|
115
|
-
}
|
|
116
|
-
if (!text) return;
|
|
117
|
-
characters += text.length;
|
|
118
|
-
if (characters > limits.text) fail("text");
|
|
119
|
-
if (block.text.length + text.length > limits.blockText) fail("block-text");
|
|
120
|
-
const previous = block.runs.at(-1);
|
|
121
|
-
if (previous && previous.href === href &&
|
|
122
|
-
previous.marks.length === marks.length && previous.marks.every((m, i) => m === marks[i])) {
|
|
123
|
-
previous.text += text;
|
|
124
|
-
if (preserve) preservedRuns.add(previous);
|
|
125
|
-
} else {
|
|
126
|
-
if (block.runs.length >= limits.runs) fail("runs");
|
|
127
|
-
const run = { text, marks: [...marks], ...(href === undefined ? {} : { href }) };
|
|
128
|
-
if (preserve) preservedRuns.add(run);
|
|
129
|
-
block.runs.push(run);
|
|
130
|
-
}
|
|
131
|
-
block.text += text;
|
|
132
|
-
};
|
|
133
|
-
const walk = (node, context, depth, selector, path, marks, href, preserve) => {
|
|
134
|
-
tick(depth);
|
|
135
|
-
if (node.nodeType === 3) {
|
|
136
|
-
if (context) addRun(context, node.nodeValue, marks, href, preserve);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (node.nodeType !== 1) return;
|
|
140
|
-
const tag = node.localName.toLowerCase();
|
|
141
|
-
if (excluded.has(tag) || node.hasAttribute("data-eh-ui") ||
|
|
142
|
-
node.hasAttribute("data-eh-sdk") || node.hasAttribute("data-eh-route")) return;
|
|
143
|
-
const style = visibleStyle(node);
|
|
144
|
-
if (!style) return;
|
|
145
|
-
if (tag === "iframe") {
|
|
146
|
-
limitations.add("embedded-documents");
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
if (tag === "canvas") return;
|
|
150
|
-
if (tag === "svg" || tag === "video" || tag === "audio") {
|
|
151
|
-
limitations.add("non-text-media");
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
if (node.shadowRoot) limitations.add("open-shadow-roots");
|
|
155
|
-
const reviewMark = tag === "mark" && node.hasAttribute("data-eh-mark");
|
|
156
|
-
let block = context;
|
|
157
|
-
const isBlock = blockTags.has(tag);
|
|
158
|
-
if (isBlock) {
|
|
159
|
-
if (blocks.length >= limits.blocks) fail("blocks");
|
|
160
|
-
const attributes = attributesFor(node);
|
|
161
|
-
// An authored ID is useful evidence, not a globally stable revision ID.
|
|
162
|
-
const ownSelector = attributes.id
|
|
163
|
-
? `${selector}[id="${escapeString(attributes.id)}"]`
|
|
164
|
-
: selector;
|
|
165
|
-
if (ownSelector.length > limits.attribute) fail("selector");
|
|
166
|
-
block = {
|
|
167
|
-
id: `b${blocks.length + 1}`, tag, selector: ownSelector, text: "",
|
|
168
|
-
attributes, runs: [], path: [...path],
|
|
169
|
-
};
|
|
170
|
-
blocks.push(block);
|
|
171
|
-
const pathEntry = attributes.id ? `${tag}#${attributes.id}` : tag;
|
|
172
|
-
if (pathEntry.length > limits.attribute) fail("path");
|
|
173
|
-
path = [...path, pathEntry];
|
|
174
|
-
}
|
|
175
|
-
const nextMarks = [...marks];
|
|
176
|
-
if (!reviewMark && markNames[tag]) nextMarks.push(markNames[tag]);
|
|
177
|
-
if (style.fontWeight === "bold" || Number(style.fontWeight) >= 600) nextMarks.push("strong");
|
|
178
|
-
if (/^(italic|oblique)/.test(style.fontStyle)) nextMarks.push("em");
|
|
179
|
-
if (style.textDecorationLine?.includes("underline")) nextMarks.push("underline");
|
|
180
|
-
if (style.textDecorationLine?.includes("line-through")) nextMarks.push("strike");
|
|
181
|
-
const normalizedMarks = [...new Set(nextMarks)].sort();
|
|
182
|
-
let nextHref = href;
|
|
183
|
-
if (tag === "a" && node.hasAttribute("href")) {
|
|
184
|
-
const safe = urlValue(node.getAttribute("href"));
|
|
185
|
-
nextHref = safe === null ? undefined : safe;
|
|
186
|
-
}
|
|
187
|
-
const codeWhitespace = preserve || tag === "pre" || tag === "code" ||
|
|
188
|
-
/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
|
|
189
|
-
if (tag === "br" && block) addRun(block, "\n", normalizedMarks, nextHref, true);
|
|
190
|
-
const siblings = new Map();
|
|
191
|
-
for (const child of node.childNodes) {
|
|
192
|
-
let childSelector = selector;
|
|
193
|
-
if (child.nodeType === 1) {
|
|
194
|
-
const name = child.localName.toLowerCase();
|
|
195
|
-
const index = (siblings.get(name) || 0) + 1;
|
|
196
|
-
siblings.set(name, index);
|
|
197
|
-
childSelector = `${selector} > ${name}:nth-of-type(${index})`;
|
|
198
|
-
}
|
|
199
|
-
// Closed <details> expose only the summary; never capture concealed answers.
|
|
200
|
-
if (tag === "details" && !node.hasAttribute("open") &&
|
|
201
|
-
!(child.nodeType === 1 && child.localName === "summary")) continue;
|
|
202
|
-
walk(child, block, depth + 1, childSelector, path, normalizedMarks, nextHref, codeWhitespace);
|
|
203
|
-
}
|
|
204
|
-
if (isBlock && !codeWhitespace) {
|
|
205
|
-
while (block.runs.length && !block.runs[0].text.trim() &&
|
|
206
|
-
!preservedRuns.has(block.runs[0])) block.runs.shift();
|
|
207
|
-
while (block.runs.length && !block.runs.at(-1).text.trim() &&
|
|
208
|
-
!preservedRuns.has(block.runs.at(-1))) block.runs.pop();
|
|
209
|
-
if (block.runs.length) {
|
|
210
|
-
if (!preservedRuns.has(block.runs[0])) {
|
|
211
|
-
block.runs[0].text = block.runs[0].text.replace(/^[\t\n\r\f ]+/, "");
|
|
212
|
-
}
|
|
213
|
-
if (!preservedRuns.has(block.runs.at(-1))) {
|
|
214
|
-
block.runs.at(-1).text = block.runs.at(-1).text.replace(/[\t\n\r\f ]+$/, "");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
block.text = block.runs.map((run) => run.text).join("");
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
walk(document.body, null, 0, "body", [], [], undefined, false);
|
|
221
|
-
if (Date.now() - started > limits.milliseconds) fail("time");
|
|
222
|
-
return {
|
|
223
|
-
version: 1,
|
|
224
|
-
blocks: blocks.filter((block) => block.text || structural.has(block.tag) ||
|
|
225
|
-
block.attributes.id || block.attributes.title),
|
|
226
|
-
limitations: [...limitations].sort(),
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export const SEMANTIC_SNAPSHOT_SOURCE = `(${captureSemanticSnapshot.toString()})`;
|
package/src/serialize.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Document serialization, shared by the in-page SDK and Node tests.
|
|
3
|
-
*
|
|
4
|
-
* Everything doc-review adds to a live page — the overlay, highlight marks, the
|
|
5
|
-
* injected style/script, contenteditable — must vanish on the way back to disk,
|
|
6
|
-
* so the saved file renders exactly as it does standalone.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const UI_ATTR = "data-eh-ui";
|
|
10
|
-
export const MARK_ATTR = "data-eh-mark";
|
|
11
|
-
|
|
12
|
-
/** Serialize a document to the HTML that should live on disk. */
|
|
13
|
-
export function serializeDocument(doc) {
|
|
14
|
-
const clone = doc.documentElement.cloneNode(true);
|
|
15
|
-
clone.querySelectorAll(`[${UI_ATTR}]`).forEach((n) => n.remove());
|
|
16
|
-
clone.querySelectorAll("script[data-eh-sdk], style[data-eh-sdk]").forEach((n) => n.remove());
|
|
17
|
-
clone.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
|
|
18
|
-
const parent = mark.parentNode;
|
|
19
|
-
if (!parent) return;
|
|
20
|
-
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
21
|
-
parent.removeChild(mark);
|
|
22
|
-
});
|
|
23
|
-
clone.querySelectorAll("[data-eh-el]").forEach((n) => n.removeAttribute("data-eh-el"));
|
|
24
|
-
const body = clone.querySelector("body");
|
|
25
|
-
if (body) {
|
|
26
|
-
body.removeAttribute("contenteditable");
|
|
27
|
-
body.removeAttribute("spellcheck");
|
|
28
|
-
}
|
|
29
|
-
clone.normalize();
|
|
30
|
-
const doctype = doc.doctype ? `<!DOCTYPE ${doc.doctype.name}>\n` : "";
|
|
31
|
-
return `${doctype}${clone.outerHTML}\n`;
|
|
32
|
-
}
|
package/src/server-entry.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { start } from "./server.js";
|
|
2
|
-
|
|
3
|
-
const port = Number(process.env.DOC_REVIEW_PORT || 0);
|
|
4
|
-
let review;
|
|
5
|
-
try {
|
|
6
|
-
review = await start(port);
|
|
7
|
-
} catch (err) {
|
|
8
|
-
if (err.code !== "SERVER_LOCKED") {
|
|
9
|
-
console.error(`doc-review server could not start: ${err.message}`);
|
|
10
|
-
process.exitCode = 1;
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// The detached server exits on its own once idle (see IDLE_SHUTDOWN_MS).
|
|
15
|
-
async function shutdown() {
|
|
16
|
-
if (!review) return;
|
|
17
|
-
try {
|
|
18
|
-
await review.dispose();
|
|
19
|
-
} catch (err) {
|
|
20
|
-
console.error(`doc-review server shutdown failed: ${err.message}`);
|
|
21
|
-
process.exitCode = 1;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
process.once("SIGTERM", shutdown);
|
|
26
|
-
process.once("SIGINT", shutdown);
|
package/src/server-lock.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import { ensureStateDir, serverLockPath, serverPath } from "./paths.js";
|
|
4
|
-
|
|
5
|
-
const sameOwner = (left, right) =>
|
|
6
|
-
!!left &&
|
|
7
|
-
!!right &&
|
|
8
|
-
Number(left.pid) === Number(right.pid) &&
|
|
9
|
-
String(left.instance_id || "") === String(right.instance_id || "");
|
|
10
|
-
|
|
11
|
-
export function readServerLock() {
|
|
12
|
-
try {
|
|
13
|
-
return JSON.parse(fs.readFileSync(serverLockPath(), "utf8"));
|
|
14
|
-
} catch (err) {
|
|
15
|
-
if (err.code === "ENOENT") return null;
|
|
16
|
-
throw err;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isProcessAlive(pid) {
|
|
21
|
-
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
22
|
-
try {
|
|
23
|
-
process.kill(Number(pid), 0);
|
|
24
|
-
return true;
|
|
25
|
-
} catch (err) {
|
|
26
|
-
if (err.code === "EPERM") return true;
|
|
27
|
-
if (err.code === "ESRCH") return false;
|
|
28
|
-
throw err;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function acquireServerLock({ pid = process.pid, instanceId = crypto.randomBytes(16).toString("hex") } = {}) {
|
|
33
|
-
ensureStateDir();
|
|
34
|
-
const owner = { pid, instance_id: instanceId };
|
|
35
|
-
const lockFile = serverLockPath();
|
|
36
|
-
|
|
37
|
-
for (;;) {
|
|
38
|
-
try {
|
|
39
|
-
fs.writeFileSync(lockFile, JSON.stringify(owner), { flag: "wx", mode: 0o600 });
|
|
40
|
-
return owner;
|
|
41
|
-
} catch (err) {
|
|
42
|
-
if (err.code !== "EEXIST") throw err;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const existing = readServerLock();
|
|
46
|
-
if (!existing || !existing.instance_id || !Number.isInteger(Number(existing.pid))) {
|
|
47
|
-
const invalid = new Error("The doc-review server lock is malformed and cannot be reclaimed safely.");
|
|
48
|
-
invalid.code = "SERVER_LOCK_INVALID";
|
|
49
|
-
throw invalid;
|
|
50
|
-
}
|
|
51
|
-
if (isProcessAlive(Number(existing.pid))) {
|
|
52
|
-
const locked = new Error(`Doc Review state is already owned by server PID ${existing.pid}.`);
|
|
53
|
-
locked.code = "SERVER_LOCKED";
|
|
54
|
-
locked.owner = existing;
|
|
55
|
-
throw locked;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const quarantine = `${lockFile}.${existing.instance_id}.${crypto.randomBytes(6).toString("hex")}.stale`;
|
|
59
|
-
try {
|
|
60
|
-
fs.renameSync(lockFile, quarantine);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
if (err.code === "ENOENT") continue;
|
|
63
|
-
throw err;
|
|
64
|
-
}
|
|
65
|
-
const moved = JSON.parse(fs.readFileSync(quarantine, "utf8"));
|
|
66
|
-
if (!sameOwner(moved, existing)) {
|
|
67
|
-
throw new Error("The doc-review server lock changed while reclaiming it.");
|
|
68
|
-
}
|
|
69
|
-
fs.unlinkSync(quarantine);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function releaseServerLock(owner) {
|
|
74
|
-
const existing = readServerLock();
|
|
75
|
-
if (!sameOwner(existing, owner)) return false;
|
|
76
|
-
try {
|
|
77
|
-
fs.unlinkSync(serverLockPath());
|
|
78
|
-
return true;
|
|
79
|
-
} catch (err) {
|
|
80
|
-
if (err.code === "ENOENT") return false;
|
|
81
|
-
throw err;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function removeOwnedServerRecord(owner) {
|
|
86
|
-
let record;
|
|
87
|
-
try {
|
|
88
|
-
record = JSON.parse(fs.readFileSync(serverPath(), "utf8"));
|
|
89
|
-
} catch (err) {
|
|
90
|
-
if (err.code === "ENOENT") return false;
|
|
91
|
-
throw err;
|
|
92
|
-
}
|
|
93
|
-
if (!sameOwner(record, owner)) return false;
|
|
94
|
-
try {
|
|
95
|
-
fs.unlinkSync(serverPath());
|
|
96
|
-
return true;
|
|
97
|
-
} catch (err) {
|
|
98
|
-
if (err.code === "ENOENT") return false;
|
|
99
|
-
throw err;
|
|
100
|
-
}
|
|
101
|
-
}
|