@erdemtuna/doc-review 0.8.0 → 0.9.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 +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/edit-limits.js +23 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** A capture is valid only for the exact frame that answered the request. */
|
|
2
|
+
export function sameRender(a, b) {
|
|
3
|
+
return !!a && !!b && a.key === b.key && a.renderId === b.renderId &&
|
|
4
|
+
a.generation === b.generation && !!a.renderId;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function createCaptureRequests({ send, current, timeout = 6000 }) {
|
|
8
|
+
const pending = new Map();
|
|
9
|
+
let sequence = 0;
|
|
10
|
+
return {
|
|
11
|
+
request({ timeout: requestTimeout = timeout, signal } = {}) {
|
|
12
|
+
const identity = { ...current() };
|
|
13
|
+
if (signal?.aborted) return Promise.reject(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
|
|
14
|
+
if (!identity.renderId || identity.loading) return Promise.reject(captureError("CAPTURE_NOT_READY", "The page is not ready to capture"));
|
|
15
|
+
const requestId = `capture-${++sequence}`;
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const finish = (error, value) => {
|
|
18
|
+
if (!pending.has(requestId)) return;
|
|
19
|
+
pending.delete(requestId);
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
signal?.removeEventListener("abort", abort);
|
|
22
|
+
if (error) reject(error);
|
|
23
|
+
else resolve(value);
|
|
24
|
+
};
|
|
25
|
+
const abort = () => finish(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
|
|
26
|
+
const timer = setTimeout(() => finish(captureError("CAPTURE_TIMEOUT",
|
|
27
|
+
"The page did not confirm a stable capture. Recapture when it is ready.")), requestTimeout);
|
|
28
|
+
pending.set(requestId, { identity, finish });
|
|
29
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
30
|
+
try { send({ type: "eh:captureSnapshot", requestId, requireStable: true }); }
|
|
31
|
+
catch (error) { finish(error); }
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
receive(message) {
|
|
35
|
+
const request = pending.get(message.requestId);
|
|
36
|
+
if (!request) return false;
|
|
37
|
+
if (!sameRender(request.identity, current())) request.finish(captureError("CAPTURE_CHANGED", "The page changed during capture. Recapture the latest version."));
|
|
38
|
+
else if (message.error || !message.snapshot) request.finish(captureError(message.error?.code || "CAPTURE_UNAVAILABLE",
|
|
39
|
+
message.error?.message || message.error || "Semantic capture is unavailable"));
|
|
40
|
+
else request.finish(null, {
|
|
41
|
+
...request.identity, semantic: message.snapshot, sourceHash: message.sourceHash,
|
|
42
|
+
semanticCapturedAt: Number.isFinite(message.capturedAt) ? message.capturedAt : undefined,
|
|
43
|
+
view: message.view,
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
cancel(reason = "The page changed during capture") {
|
|
48
|
+
for (const request of pending.values()) {
|
|
49
|
+
request.finish(captureError("CAPTURE_CANCELLED", reason));
|
|
50
|
+
}
|
|
51
|
+
pending.clear();
|
|
52
|
+
},
|
|
53
|
+
get size() { return pending.size; },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function captureError(code, message) {
|
|
58
|
+
return Object.assign(new Error(message), { code });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function draftCount({ compose, commentUi }, note = "") {
|
|
62
|
+
return Number(!!compose) + Number(!!commentUi?.edit) + Number(!!note.trim());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function excerpt(value) {
|
|
66
|
+
if (typeof value === "string") return value;
|
|
67
|
+
if (!value) return "";
|
|
68
|
+
return String(value.text ?? value.excerpt ?? value.content ?? value.label ?? "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function changeKind(item) {
|
|
72
|
+
const kind = String(item.kind || item.type || "").toLowerCase();
|
|
73
|
+
return kind === "added" || kind === "removed" ? kind : "modified";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pendingCaptureTarget(round, key, readyAt) {
|
|
77
|
+
if (round?.feedbackStatus !== "acknowledged" || !(readyAt > 0)) return null;
|
|
78
|
+
return round.targets?.find((target) => target.key === key && !target.resultRevisionId &&
|
|
79
|
+
["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending")) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function defaultHistoryRound(rounds) {
|
|
83
|
+
return rounds.find((round) => round.completedAt || round.captureStatus === "ready" ||
|
|
84
|
+
["complete", "completed"].includes(round.status)) || rounds[0] || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function comparisonFreshness(comparison, targetKey, current) {
|
|
88
|
+
if (targetKey !== current.key) return "Different page: this saved comparison does not describe the page open in Latest version.";
|
|
89
|
+
if (!current.ready || current.pendingReload || current.dirty) return "Latest version is unverified: reload or finish saving before comparing it with this captured result.";
|
|
90
|
+
if (current.kind === "url") {
|
|
91
|
+
return comparison.capturedSessionId === current.sessionId && comparison.capturedGeneration === current.generation
|
|
92
|
+
? "Same live render generation; the current DOM is not verified against the captured result. Live content may have changed."
|
|
93
|
+
: "Latest live render is different or unverified. This historical comparison cannot identify exact targets on the live page.";
|
|
94
|
+
}
|
|
95
|
+
if (!comparison.sourceHash || !current.sourceHash) return "Latest source is unverified against this captured result.";
|
|
96
|
+
return comparison.sourceHash === current.sourceHash
|
|
97
|
+
? "Latest file source matches the captured result. Runtime DOM changes are not verified by a source match."
|
|
98
|
+
: "Latest source is newer or different from the captured result. You are viewing a historical comparison.";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function canJumpToCurrent(item, result, current) {
|
|
102
|
+
return item.confidence === "exact" && !!item.navigation?.selector && !item.unresolved && changeKind(item) !== "removed" &&
|
|
103
|
+
!!result?.renderId && sameRender(result, current);
|
|
104
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { defaultHistoryRound } from "./history-client.js";
|
|
2
|
+
|
|
3
|
+
const idOf = (round) => round?.roundId || round?.id;
|
|
4
|
+
const retryDelays = [1000, 2000, 4000];
|
|
5
|
+
|
|
6
|
+
export function transientCaptureError(error) {
|
|
7
|
+
return ["CAPTURE_TIMEOUT", "CAPTURE_NOT_READY", "CAPTURE_UNSTABLE", "CAPTURE_BUSY", "VIEW_CHANGED"].includes(error?.code) ||
|
|
8
|
+
[408, 429, 502, 503, 504].includes(error?.status) ||
|
|
9
|
+
(error instanceof TypeError && /fetch|network|load failed/i.test(error.message));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function requireCaptureSuccess(result, key) {
|
|
13
|
+
if (result?.ok === true) return result;
|
|
14
|
+
throw Object.assign(new Error(result?.round?.targets?.find((target) => target.key === key)?.capture?.error ||
|
|
15
|
+
"Content capture is unavailable"), { code: "CAPTURE_UNAVAILABLE" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One capture at a time; delayed/finished attempts never block other rounds. */
|
|
19
|
+
export function createCaptureCoordinator({ candidates, ready, capture, now = Date.now,
|
|
20
|
+
setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
21
|
+
const attempts = new Map();
|
|
22
|
+
let timer = null;
|
|
23
|
+
let active = null;
|
|
24
|
+
let epoch = 0;
|
|
25
|
+
let stopped = false;
|
|
26
|
+
function tick() {
|
|
27
|
+
clearTimer(timer);
|
|
28
|
+
timer = null;
|
|
29
|
+
if (stopped || active || !ready()) return;
|
|
30
|
+
const jobs = candidates();
|
|
31
|
+
const job = jobs.find(({ id }) => {
|
|
32
|
+
const attempt = attempts.get(id);
|
|
33
|
+
return !attempt || (!attempt.done && attempt.due <= now());
|
|
34
|
+
});
|
|
35
|
+
if (!job) {
|
|
36
|
+
const due = jobs.map(({ id }) => attempts.get(id)).filter((entry) => entry && !entry.done)
|
|
37
|
+
.reduce((next, entry) => Math.min(next, entry.due), Infinity);
|
|
38
|
+
if (Number.isFinite(due)) timer = setTimer(tick, Math.max(0, due - now()));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const attempt = attempts.get(job.id) || { count: 0, due: 0, done: false };
|
|
42
|
+
attempts.set(job.id, attempt);
|
|
43
|
+
attempt.count++;
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const started = epoch;
|
|
46
|
+
active = controller;
|
|
47
|
+
Promise.resolve().then(() => {
|
|
48
|
+
if (controller.signal.aborted) return;
|
|
49
|
+
return capture(job, controller.signal);
|
|
50
|
+
}).then(() => { attempt.done = true; }, (error) => {
|
|
51
|
+
if (transientCaptureError(error) && attempt.count <= retryDelays.length) {
|
|
52
|
+
attempt.due = now() + retryDelays[attempt.count - 1];
|
|
53
|
+
} else attempt.done = true;
|
|
54
|
+
}).finally(() => {
|
|
55
|
+
if (started !== epoch) return;
|
|
56
|
+
active = null;
|
|
57
|
+
tick();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
tick,
|
|
62
|
+
reset() {
|
|
63
|
+
epoch++;
|
|
64
|
+
active?.abort();
|
|
65
|
+
active = null;
|
|
66
|
+
clearTimer(timer);
|
|
67
|
+
timer = null;
|
|
68
|
+
attempts.clear();
|
|
69
|
+
},
|
|
70
|
+
stop() {
|
|
71
|
+
this.reset();
|
|
72
|
+
stopped = true;
|
|
73
|
+
},
|
|
74
|
+
get busy() { return !!active; },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function historyPresentation({ loading, error, round, target, comparison = {}, failure }) {
|
|
79
|
+
const modes = ["content", "source"].filter((mode) => comparison[mode]?.available === true);
|
|
80
|
+
if (loading && !round) return { state: "loading", message: "Loading comparison…", modes };
|
|
81
|
+
if (error && !round) return { state: "failed", message: "History could not be loaded. Try again.", modes };
|
|
82
|
+
if (!round) return { state: "empty", message: "No review rounds yet. Send feedback to start.", modes };
|
|
83
|
+
if (modes.length) {
|
|
84
|
+
const partial = modes.length < 2 || !!target?.baselineUnavailable || !!target?.resultUnavailable;
|
|
85
|
+
return { state: partial ? "partial" : "available", modes,
|
|
86
|
+
message: modes.includes("content")
|
|
87
|
+
? partial ? "Content available; some comparison coverage is missing." : "Comparison ready."
|
|
88
|
+
: "Source available. Content is pending or unavailable." };
|
|
89
|
+
}
|
|
90
|
+
if (failure || error || target?.capture?.status === "failed") {
|
|
91
|
+
return { state: "failed", message: "Content capture failed. Feedback is safe; retry when ready.", modes };
|
|
92
|
+
}
|
|
93
|
+
if (target?.capture?.status === "unavailable" || target?.resultRevisionId) {
|
|
94
|
+
return { state: "partial", message: "Comparison unavailable for these snapshots. Feedback is safe.", modes };
|
|
95
|
+
}
|
|
96
|
+
return { state: "waiting", message: round.feedbackStatus === "acknowledged"
|
|
97
|
+
? "Waiting for a stable result snapshot. You can keep reviewing."
|
|
98
|
+
: "Waiting for the agent's result.", modes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fetch into local values, then publish atomically so selectors never relabel stale content. */
|
|
102
|
+
export function createHistoryController({ request, changed = () => {}, refreshed = () => {} }) {
|
|
103
|
+
const state = {
|
|
104
|
+
rounds: [], selectedId: null, round: null, targetKey: null, mode: "content", preferredMode: null,
|
|
105
|
+
index: 0, loading: false, error: null, captureBusy: false, finalizing: false, failures: new Map(),
|
|
106
|
+
};
|
|
107
|
+
let revision = 0;
|
|
108
|
+
const targets = new Map();
|
|
109
|
+
async function refresh() {
|
|
110
|
+
const current = ++revision;
|
|
111
|
+
state.loading = true;
|
|
112
|
+
state.error = null;
|
|
113
|
+
if (state.round && (idOf(state.round) !== state.selectedId ||
|
|
114
|
+
!state.round.targets?.some((target) => target.key === state.targetKey && target.comparison))) state.round = null;
|
|
115
|
+
changed();
|
|
116
|
+
try {
|
|
117
|
+
const collection = await request("");
|
|
118
|
+
if (current !== revision) return false;
|
|
119
|
+
state.rounds = collection.rounds || [];
|
|
120
|
+
if (!state.rounds.some((round) => idOf(round) === state.selectedId)) {
|
|
121
|
+
state.selectedId = idOf(defaultHistoryRound(state.rounds)) || null;
|
|
122
|
+
}
|
|
123
|
+
const selectedId = state.selectedId;
|
|
124
|
+
let round = null;
|
|
125
|
+
let targetKey = state.targetKey || targets.get(selectedId);
|
|
126
|
+
if (selectedId) {
|
|
127
|
+
const detail = await request(`/${selectedId}`);
|
|
128
|
+
if (current !== revision) return false;
|
|
129
|
+
round = detail.round || detail;
|
|
130
|
+
const summary = state.rounds.find((item) => idOf(item) === selectedId);
|
|
131
|
+
for (const target of round.targets || []) {
|
|
132
|
+
target.filename ||= summary?.targets?.find((item) => item.key === target.key)?.filename;
|
|
133
|
+
}
|
|
134
|
+
const target = round.targets?.find((item) => item.key === targetKey) || round.targets?.[0];
|
|
135
|
+
targetKey = target?.key || null;
|
|
136
|
+
if (target) {
|
|
137
|
+
const results = await Promise.allSettled(["content", "source"].map((mode) =>
|
|
138
|
+
request(`/${selectedId}/compare?key=${encodeURIComponent(target.key)}&mode=${mode}`)));
|
|
139
|
+
if (current !== revision) return false;
|
|
140
|
+
target.comparison = Object.fromEntries(["content", "source"].map((mode, index) =>
|
|
141
|
+
[mode, results[index].status === "fulfilled" ? results[index].value
|
|
142
|
+
: { available: false, counts: null, reason: results[index].reason.message }]));
|
|
143
|
+
if (results.every((result) => result.status === "rejected")) state.error = results[0].reason;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
state.round = round;
|
|
147
|
+
state.targetKey = targetKey;
|
|
148
|
+
if (selectedId) targets.set(selectedId, targetKey);
|
|
149
|
+
state.loading = false;
|
|
150
|
+
changed();
|
|
151
|
+
refreshed();
|
|
152
|
+
return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (current !== revision) return false;
|
|
155
|
+
state.error = error;
|
|
156
|
+
state.loading = false;
|
|
157
|
+
changed();
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
state, refresh,
|
|
163
|
+
selectRound(id) {
|
|
164
|
+
state.selectedId = id;
|
|
165
|
+
state.targetKey = targets.get(id) || null;
|
|
166
|
+
state.index = 0;
|
|
167
|
+
return refresh();
|
|
168
|
+
},
|
|
169
|
+
selectTarget(key) { state.targetKey = key; state.index = 0; return refresh(); },
|
|
170
|
+
cancel() { revision++; state.loading = false; },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Optional capture cannot veto delivery; a failed refresh cannot masquerade as failed Send. */
|
|
175
|
+
export async function deliverFeedback({ save, capture, deliver, committed, refresh }) {
|
|
176
|
+
await save();
|
|
177
|
+
let snapshot = null;
|
|
178
|
+
let captureFailure = null;
|
|
179
|
+
try { snapshot = await capture(); } catch (error) { captureFailure = error; }
|
|
180
|
+
await deliver(snapshot);
|
|
181
|
+
committed();
|
|
182
|
+
let refreshFailure = null;
|
|
183
|
+
try { if (await refresh() === false) refreshFailure = new Error("History could not be refreshed"); }
|
|
184
|
+
catch (error) { refreshFailure = error; }
|
|
185
|
+
return { captureFailure, refreshFailure };
|
|
186
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { COMPLETED_ROUNDS_TO_KEEP, isRevisionId } from "./revision-schema.js";
|
|
2
|
+
|
|
3
|
+
function references(value, out = new Set()) {
|
|
4
|
+
if (isRevisionId(value)) out.add(value);
|
|
5
|
+
else if (Array.isArray(value)) for (const item of value) references(item, out);
|
|
6
|
+
else if (value && typeof value === "object") for (const item of Object.values(value)) references(item, out);
|
|
7
|
+
return out;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function unsentRevisionReferences(data) {
|
|
11
|
+
const out = new Set();
|
|
12
|
+
for (const page of Object.values(data.pages || {})) {
|
|
13
|
+
references(page.comments, out);
|
|
14
|
+
references(page.edits, out);
|
|
15
|
+
references(page.revisionRefs, out);
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function historyRevisionReferences(data) {
|
|
21
|
+
const out = unsentRevisionReferences(data);
|
|
22
|
+
references(data.histories, out);
|
|
23
|
+
references(data.batches, out);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function retainHistory(data) {
|
|
28
|
+
const pinned = unsentRevisionReferences(data);
|
|
29
|
+
let changed = false;
|
|
30
|
+
for (const history of Object.values(data.histories || {})) {
|
|
31
|
+
const completed = history.rounds
|
|
32
|
+
.filter((round) => round.completedAt)
|
|
33
|
+
.sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
|
|
34
|
+
const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
|
|
35
|
+
const rounds = history.rounds.filter((round) =>
|
|
36
|
+
!round.completedAt || keep.has(round.roundId) || [...references(round.targets)].some((id) => pinned.has(id)));
|
|
37
|
+
if (rounds.length !== history.rounds.length) {
|
|
38
|
+
history.rounds = rounds;
|
|
39
|
+
changed = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return changed;
|
|
43
|
+
}
|