@erdemtuna/doc-review 0.12.0 → 0.13.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 +23 -25
- package/lib/SKILL.md +65 -202
- package/lib/agent-handoff.js +24 -0
- package/lib/agent-output.js +287 -0
- package/lib/anchor-text.js +31 -19
- package/lib/chrome-api.js +23 -4
- package/lib/chrome.html +0 -25
- package/lib/cli.js +282 -104
- package/lib/comment-target.js +4 -0
- package/lib/contracts/agent.js +122 -0
- package/lib/contracts/feedback.js +369 -1
- package/lib/contracts/frame.js +81 -0
- package/lib/contracts/history.js +189 -1
- package/lib/contracts/index.js +7 -2
- package/lib/contracts/page-boundary.js +187 -0
- package/lib/contracts/validation.js +200 -0
- package/lib/conversation-anchor-controller.js +84 -0
- package/lib/conversation-capture.js +81 -0
- package/lib/conversation-controller.js +798 -0
- package/lib/conversation-save.js +175 -0
- package/lib/conversation-server.js +184 -0
- package/lib/conversation-shell.js +1013 -0
- package/lib/conversation-store.js +809 -0
- package/lib/frame-controller.js +8 -8
- package/lib/frame-policy.js +1 -0
- package/lib/history-policy.js +4 -15
- package/lib/history-server.js +42 -326
- package/lib/html-transform.js +19 -4
- package/lib/icons.js +323 -0
- package/lib/new-message-target.js +27 -0
- package/lib/paths.js +2 -2
- package/lib/poll-transport.js +109 -26
- package/lib/positioning.js +51 -0
- package/lib/references/context-and-recovery.md +130 -0
- package/lib/references/response-contract.md +108 -0
- package/lib/references/source-edits.md +47 -0
- package/lib/revision-store.js +1 -1
- package/lib/save-controller.js +28 -14
- package/lib/sdk.js +357 -35
- package/lib/server.js +73 -595
- package/lib/setup.js +17 -48
- package/lib/state.js +21 -7
- package/lib/thread-anchor-controller.js +56 -0
- package/lib/toolbar-controller.js +3 -3
- package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
- package/lib/ui/chrome.css +1079 -2782
- package/lib/ui/chrome.js +81 -17
- package/package.json +2 -2
- package/lib/chrome-client.js +0 -1706
package/lib/contracts/index.js
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
1
|
+
export * from "./page.js";
|
|
2
|
+
export * from "./page-boundary.js";
|
|
3
|
+
export * from "./feedback.js";
|
|
4
|
+
export * from "./history.js";
|
|
5
|
+
export * from "./frame.js";
|
|
6
|
+
export * from "./validation.js";
|
|
7
|
+
export * from "./agent.js";
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { assertNoOutstandingWork, directEditSchema, reviewerMutationSchema, sendRequestSchema, submissionSchema, } from "./feedback.js";
|
|
2
|
+
import { pagedSchema } from "./history.js";
|
|
3
|
+
import { array, booleanValue, enumeration, id, integer, literal, nullable, object, refine, reject, rejectSave, text, timestamp, union, version, } from "./validation.js";
|
|
4
|
+
export const CONVERSATION_STATE_FILE = "conversation-state.json";
|
|
5
|
+
export const reviewSchema = refine(object({
|
|
6
|
+
reviewId: id, entryKey: id, version, state: enumeration(["open", "ended"]),
|
|
7
|
+
createdAt: timestamp, endedAt: nullable(timestamp),
|
|
8
|
+
}), (review) => {
|
|
9
|
+
if ((review.state === "ended") !== (review.endedAt !== null))
|
|
10
|
+
reject("INVALID_INPUT", "End time must match review state.");
|
|
11
|
+
});
|
|
12
|
+
export const canonicalPageSchema = object({
|
|
13
|
+
pageKey: id,
|
|
14
|
+
target: union(object({ kind: literal("file"), path: text() }), object({ kind: literal("url"), url: text() })),
|
|
15
|
+
sourceHash: nullable(id),
|
|
16
|
+
});
|
|
17
|
+
export const reviewPageSchema = object({
|
|
18
|
+
reviewId: id, sequence: integer(1), page: canonicalPageSchema, joinedAt: timestamp,
|
|
19
|
+
savePolicy: enumeration(["writable", "feedback-only"]),
|
|
20
|
+
pendingMessageCount: integer(), pendingEditCount: integer(),
|
|
21
|
+
writeBlockedBy: array(object({ reviewId: id, submissionId: id })),
|
|
22
|
+
revert: nullable(object({ baselineRevisionId: id, sourceHash: id })),
|
|
23
|
+
canRevert: booleanValue,
|
|
24
|
+
});
|
|
25
|
+
export const reviewPageListSchema = pagedSchema(reviewPageSchema);
|
|
26
|
+
export const openReviewRequestSchema = object({ operation: literal("open"), requestId: id, target: text() });
|
|
27
|
+
export const reviewReadRequestSchema = union(object({ operation: literal("read-review"), reviewId: id, entryKey: id }), object({ operation: literal("read-page"), reviewId: id, entryKey: id, pageKey: id }), object({ operation: literal("status"), reviewId: id, entryKey: id }), object({ operation: literal("poll"), reviewId: id, entryKey: id }), object({ operation: literal("submission"), reviewId: id, entryKey: id, submissionId: id }), object({ operation: literal("receipt"), reviewId: id, entryKey: id, requestId: id }), object({ operation: literal("open-receipt"), target: text(), requestId: id }));
|
|
28
|
+
export const pollResponseSchema = union(refine(object({ state: literal("work"), review: reviewSchema, submission: submissionSchema }), ({ review, submission }) => {
|
|
29
|
+
if (submission.state !== "delivered" || submission.reviewId !== review.reviewId || submission.entryKey !== review.entryKey) {
|
|
30
|
+
reject("INVALID_INPUT", "Poll must return delivered work from the exact review, including after End.");
|
|
31
|
+
}
|
|
32
|
+
}), object({ state: literal("waiting"), review: refine(reviewSchema, (review) => {
|
|
33
|
+
if (review.state !== "open")
|
|
34
|
+
reject("INVALID_INPUT", "Ended review cannot wait without outstanding work.");
|
|
35
|
+
}) }), object({ state: literal("ended"), review: refine(reviewSchema, (review) => {
|
|
36
|
+
if (review.state !== "ended")
|
|
37
|
+
reject("INVALID_INPUT", "Open review cannot signal end.");
|
|
38
|
+
}) }));
|
|
39
|
+
export const reviewStatusSchema = object({
|
|
40
|
+
review: reviewSchema,
|
|
41
|
+
openThreadCount: integer(), attentionEditCount: integer(),
|
|
42
|
+
pendingMessageCount: integer(), pendingEditCount: integer(),
|
|
43
|
+
work: nullable(object({ submissionId: id, state: enumeration(["queued", "delivered"]), version })),
|
|
44
|
+
blockers: array(object({ reviewId: id, submissionId: id, targetKeys: array(id) })),
|
|
45
|
+
});
|
|
46
|
+
export const statusObservationSchema = union(object({ state: literal("known"), status: reviewStatusSchema }), object({ state: literal("unknown"), reason: enumeration(["offline", "unavailable", "invalid-response"]) }));
|
|
47
|
+
export function assertReviewScope(scope, reference, pageKeys = []) {
|
|
48
|
+
if (!scope.authenticated)
|
|
49
|
+
reject("UNAUTHORIZED", "Local API authentication required.");
|
|
50
|
+
reviewSchema.parse(scope.review);
|
|
51
|
+
if (scope.review.reviewId !== reference.reviewId || scope.review.entryKey !== reference.entryKey) {
|
|
52
|
+
reject("SCOPE_MISMATCH", "Wrong durable review or canonical entry.");
|
|
53
|
+
}
|
|
54
|
+
for (const key of pageKeys) {
|
|
55
|
+
if (!scope.pageKeys.includes(key))
|
|
56
|
+
reject("SCOPE_MISMATCH", "Page is not authorized in this review.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function ownedThread(scope, items, threadId) {
|
|
60
|
+
const thread = items.threads.find((item) => item.threadId === threadId);
|
|
61
|
+
if (!thread)
|
|
62
|
+
return reject("NOT_FOUND", "Unknown thread.");
|
|
63
|
+
if (thread.reviewId !== scope.review.reviewId)
|
|
64
|
+
reject("SCOPE_MISMATCH", "Thread belongs to another review.");
|
|
65
|
+
assertReviewScope(scope, scope.review, [thread.pageKey]);
|
|
66
|
+
return thread;
|
|
67
|
+
}
|
|
68
|
+
function ownedEdit(scope, items, pageKey, editId) {
|
|
69
|
+
const edit = items.edits.find((item) => item.editId === editId);
|
|
70
|
+
if (!edit)
|
|
71
|
+
return reject("NOT_FOUND", "Unknown edit.");
|
|
72
|
+
directEditSchema.parse(edit);
|
|
73
|
+
if (edit.reviewId !== scope.review.reviewId || edit.pageKey !== pageKey)
|
|
74
|
+
reject("SCOPE_MISMATCH", "Edit belongs to another review/page.");
|
|
75
|
+
return edit;
|
|
76
|
+
}
|
|
77
|
+
export function validateSendSelection(input, scope, items) {
|
|
78
|
+
const request = sendRequestSchema.parse(input);
|
|
79
|
+
assertReviewScope(scope, request, request.pageKeys);
|
|
80
|
+
for (const selected of request.messages) {
|
|
81
|
+
const thread = ownedThread(scope, items, selected.threadId);
|
|
82
|
+
const message = items.messages.find((item) => item.messageId === selected.messageId);
|
|
83
|
+
if (!message)
|
|
84
|
+
reject("NOT_FOUND", "Unknown reviewer message.");
|
|
85
|
+
if (message.reviewId !== request.reviewId || message.threadId !== thread.threadId || !request.pageKeys.includes(thread.pageKey)) {
|
|
86
|
+
reject("SCOPE_MISMATCH", "Message does not belong to this selected thread/page.");
|
|
87
|
+
}
|
|
88
|
+
if (message.submissionId !== null)
|
|
89
|
+
reject("MESSAGE_IMMUTABLE", "Submitted messages cannot be queued again.");
|
|
90
|
+
if (thread.status !== "open")
|
|
91
|
+
reject("THREAD_BUSY", "Reopen the thread before sending.");
|
|
92
|
+
if (message.version !== selected.version)
|
|
93
|
+
reject("VERSION_CONFLICT", "Selected message changed.");
|
|
94
|
+
}
|
|
95
|
+
for (const selected of request.edits) {
|
|
96
|
+
const edit = ownedEdit(scope, items, selected.pageKey, selected.editId);
|
|
97
|
+
if (edit.version !== selected.version)
|
|
98
|
+
reject("VERSION_CONFLICT", "Selected edit changed.");
|
|
99
|
+
if (edit.source.state === "saved") {
|
|
100
|
+
const source = items.sources.find((item) => item.pageKey === edit.pageKey);
|
|
101
|
+
if (source && source.reviewId !== request.reviewId)
|
|
102
|
+
reject("SCOPE_MISMATCH", "Source evidence belongs to another review.");
|
|
103
|
+
if (!source || !source.writable || source.sourceHash !== edit.source.evidence.sourceHash) {
|
|
104
|
+
reject("SAVE_EVIDENCE_CONFLICT", "Save evidence no longer matches current writable source at Send.");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Pure preconditions only; the store must run these in its accepting transaction, after exact replay. */
|
|
110
|
+
export function validateReviewerMutation(input, scope, items, submissions) {
|
|
111
|
+
const request = reviewerMutationSchema.parse(input);
|
|
112
|
+
assertReviewScope(scope, request, "pageKey" in request ? [request.pageKey] : []);
|
|
113
|
+
if (scope.review.state === "ended")
|
|
114
|
+
reject("REVIEW_ENDED", "Saved reviewer content is read-only.");
|
|
115
|
+
if (request.expectedVersion !== scope.review.version)
|
|
116
|
+
reject("VERSION_CONFLICT", "Review changed; refresh before a new mutation.");
|
|
117
|
+
if ("threadId" in request) {
|
|
118
|
+
const thread = ownedThread(scope, items, request.threadId);
|
|
119
|
+
const messages = items.messages.filter((message) => message.threadId === thread.threadId);
|
|
120
|
+
if (request.operation === "reply" && thread.status !== "open")
|
|
121
|
+
reject("THREAD_BUSY", "Reopen before replying.");
|
|
122
|
+
if (request.operation === "set-thread-status" && request.status === "resolved" &&
|
|
123
|
+
messages.some((message) => message.submissionId === null || submissions.some((submission) => submission.submissionId === message.submissionId && (submission.state === "queued" || submission.state === "delivered")))) {
|
|
124
|
+
reject("THREAD_BUSY", "Pending or outstanding messages prevent Resolve.");
|
|
125
|
+
}
|
|
126
|
+
if (request.operation === "delete-thread" && messages.some((message) => message.submissionId !== null)) {
|
|
127
|
+
reject("MESSAGE_IMMUTABLE", "Only never-submitted threads may be deleted.");
|
|
128
|
+
}
|
|
129
|
+
if (request.operation === "update-message") {
|
|
130
|
+
const message = items.messages.find((item) => item.messageId === request.messageId);
|
|
131
|
+
if (!message)
|
|
132
|
+
reject("NOT_FOUND", "Unknown reviewer message.");
|
|
133
|
+
if (message.reviewId !== request.reviewId || message.threadId !== thread.threadId)
|
|
134
|
+
reject("SCOPE_MISMATCH", "Wrong thread message.");
|
|
135
|
+
if (message.submissionId !== null)
|
|
136
|
+
reject("MESSAGE_IMMUTABLE", "Submitted instructions are immutable.");
|
|
137
|
+
if (message.version !== request.messageVersion)
|
|
138
|
+
reject("VERSION_CONFLICT", "Message changed.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (request.operation === "send") {
|
|
142
|
+
validateSendSelection(request, scope, items);
|
|
143
|
+
assertNoOutstandingWork([request.entryKey, ...request.pageKeys], submissions);
|
|
144
|
+
for (const selected of request.edits) {
|
|
145
|
+
if (submissions.some((submission) => submission.edits.some((edit) => edit.reviewId === request.reviewId && edit.editId === selected.editId && edit.version === selected.version))) {
|
|
146
|
+
reject("MESSAGE_IMMUTABLE", "Previously submitted edit versions require a deliberate new edit record.");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (request.operation === "record-edit" || request.operation === "save-edit" || request.operation === "revert") {
|
|
151
|
+
assertNoOutstandingWork([request.entryKey, request.pageKey], submissions);
|
|
152
|
+
if (request.operation === "save-edit" || request.operation === "revert") {
|
|
153
|
+
const source = items.sources.find((item) => item.pageKey === request.pageKey);
|
|
154
|
+
if (source && source.reviewId !== request.reviewId)
|
|
155
|
+
reject("SCOPE_MISMATCH", "Source/revert ownership belongs to another review.");
|
|
156
|
+
if (!source?.writable)
|
|
157
|
+
rejectSave("source-unavailable", "Source is unavailable or no longer writable.");
|
|
158
|
+
if (source.sourceHash !== request.expectedSourceHash)
|
|
159
|
+
rejectSave("source-changed", "Writable source changed.");
|
|
160
|
+
if (request.operation === "revert" && (source.revert?.baselineRevisionId !== request.baselineRevisionId ||
|
|
161
|
+
source.revert.sourceHash !== source.sourceHash)) {
|
|
162
|
+
reject("SAVE_EVIDENCE_CONFLICT", "Revert baseline/write ownership does not belong to this review.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (request.operation === "record-edit" && (request.editId === undefined) !== (request.editVersion === undefined)) {
|
|
166
|
+
reject("INVALID_INPUT", "Updating an edit requires its identity and version together.");
|
|
167
|
+
}
|
|
168
|
+
if ("editId" in request && request.editId !== undefined) {
|
|
169
|
+
const edit = ownedEdit(scope, items, request.pageKey, request.editId);
|
|
170
|
+
if (edit.version !== request.editVersion)
|
|
171
|
+
reject("VERSION_CONFLICT", "Edit changed.");
|
|
172
|
+
if (submissions.some((submission) => submission.edits.some((saved) => saved.reviewId === edit.reviewId && saved.editId === edit.editId))) {
|
|
173
|
+
reject("MESSAGE_IMMUTABLE", "Submitted edits are immutable; create a new record.");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return request;
|
|
178
|
+
}
|
|
179
|
+
export function assertEntityScope(scope, reference, entity) {
|
|
180
|
+
assertReviewScope(scope, reference);
|
|
181
|
+
if (!entity)
|
|
182
|
+
reject("NOT_FOUND", "Unknown identity; do not substitute the newest review.");
|
|
183
|
+
if (entity.reviewId !== reference.reviewId)
|
|
184
|
+
reject("SCOPE_MISMATCH", "Identity belongs to another review.");
|
|
185
|
+
if (entity.pageKey !== undefined)
|
|
186
|
+
assertReviewScope(scope, reference, [entity.pageKey]);
|
|
187
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** Pure boundary decoders: strict objects, no coercion, stripping, or truncation. */
|
|
2
|
+
export const CONTRACT_LIMITS = Object.freeze({
|
|
3
|
+
requestBytes: 24 * 1024 * 1024,
|
|
4
|
+
quoteUnits: 4_000, contextUnits: 1_000, selectorUnits: 2_000, labelUnits: 500,
|
|
5
|
+
editCodePoints: 200_000, editAssets: 20,
|
|
6
|
+
pageDefault: 50, pageMaximum: 100,
|
|
7
|
+
});
|
|
8
|
+
export const ERROR_STATUS = {
|
|
9
|
+
INVALID_INPUT: 400, MALFORMED_JSON: 400, INVALID_CURSOR: 400,
|
|
10
|
+
UNAUTHORIZED: 401, SCOPE_MISMATCH: 403, NOT_FOUND: 404,
|
|
11
|
+
REQUEST_CONFLICT: 409, VERSION_CONFLICT: 409, REVIEW_ENDED: 409,
|
|
12
|
+
WORK_OUTSTANDING: 409, MESSAGE_IMMUTABLE: 409, THREAD_BUSY: 409,
|
|
13
|
+
SUBMISSION_ABANDONED: 409, ALREADY_HANDLED: 409, RESPONSE_COVERAGE: 409,
|
|
14
|
+
SAVE_EVIDENCE_CONFLICT: 409, EMPTY_SUBMISSION: 400,
|
|
15
|
+
WORKFLOW_REMOVED: 410,
|
|
16
|
+
INPUT_TOO_LARGE: 413, SNAPSHOT_TOO_LARGE: 413,
|
|
17
|
+
INTERNAL_ERROR: 500, STATE_PERSIST_FAILED: 503,
|
|
18
|
+
};
|
|
19
|
+
export class ContractError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
path;
|
|
22
|
+
saveReason;
|
|
23
|
+
status;
|
|
24
|
+
constructor(code, message, path = "$", saveReason) {
|
|
25
|
+
super(`${path}: ${message}`);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.path = path;
|
|
28
|
+
this.saveReason = saveReason;
|
|
29
|
+
this.name = "ContractError";
|
|
30
|
+
this.status = ERROR_STATUS[code];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function reject(code, message, path = "$") {
|
|
34
|
+
throw new ContractError(code, message, path);
|
|
35
|
+
}
|
|
36
|
+
export function rejectSave(reason, message) {
|
|
37
|
+
throw new ContractError("SAVE_EVIDENCE_CONFLICT", message, "$", reason);
|
|
38
|
+
}
|
|
39
|
+
export function schema(parse) {
|
|
40
|
+
return { parse: (value, path = "$") => parse(value, path) };
|
|
41
|
+
}
|
|
42
|
+
export function object(shape) {
|
|
43
|
+
return { fields: shape, ...schema((value, path) => {
|
|
44
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
45
|
+
(Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) {
|
|
46
|
+
return reject("INVALID_INPUT", "Expected a JSON object.", path);
|
|
47
|
+
}
|
|
48
|
+
const input = value;
|
|
49
|
+
for (const key of Object.keys(input)) {
|
|
50
|
+
if (!Object.hasOwn(shape, key))
|
|
51
|
+
reject("INVALID_INPUT", `Unknown field ${key}.`, path);
|
|
52
|
+
}
|
|
53
|
+
const output = {};
|
|
54
|
+
for (const [key, decoder] of Object.entries(shape)) {
|
|
55
|
+
if (!Object.hasOwn(input, key) && "optional" in decoder)
|
|
56
|
+
continue;
|
|
57
|
+
output[key] = decoder.parse(input[key], `${path}.${key}`);
|
|
58
|
+
}
|
|
59
|
+
return output;
|
|
60
|
+
}) };
|
|
61
|
+
}
|
|
62
|
+
export function optional(inner) {
|
|
63
|
+
return { ...inner, optional: true };
|
|
64
|
+
}
|
|
65
|
+
export function nullable(inner) {
|
|
66
|
+
return schema((value, path) => value === null ? null : inner.parse(value, path));
|
|
67
|
+
}
|
|
68
|
+
export function literal(expected) {
|
|
69
|
+
return { literalValue: expected, ...schema((value, path) => value === expected ? expected : reject("INVALID_INPUT", `Expected ${expected}.`, path)) };
|
|
70
|
+
}
|
|
71
|
+
export function enumeration(values) {
|
|
72
|
+
return schema((value, path) => typeof value === "string" && values.includes(value)
|
|
73
|
+
? value : reject("INVALID_INPUT", `Expected ${values.join(" | ")}.`, path));
|
|
74
|
+
}
|
|
75
|
+
export const booleanValue = schema((value, path) => typeof value === "boolean"
|
|
76
|
+
? value : reject("INVALID_INPUT", "Expected a boolean.", path));
|
|
77
|
+
export function integer(min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
78
|
+
return schema((value, path) => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max
|
|
79
|
+
? value : reject("INVALID_INPUT", `Expected an integer in ${min}..${max}.`, path));
|
|
80
|
+
}
|
|
81
|
+
export function text(max = Infinity, nonempty = true, codePoints = false) {
|
|
82
|
+
return schema((value, path) => {
|
|
83
|
+
if (typeof value !== "string" || (nonempty && !value.trim()))
|
|
84
|
+
reject("INVALID_INPUT", "Expected nonempty text.", path);
|
|
85
|
+
const length = codePoints ? [...value].length : value.length;
|
|
86
|
+
if (length > max)
|
|
87
|
+
reject("INPUT_TOO_LARGE", `Text exceeds ${max} ${codePoints ? "code points" : "UTF-16 units"}.`, path);
|
|
88
|
+
return value;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export const id = text();
|
|
92
|
+
export const version = integer(1);
|
|
93
|
+
export const timestamp = integer();
|
|
94
|
+
export function array(inner, max = Infinity) {
|
|
95
|
+
return schema((value, path) => {
|
|
96
|
+
if (!Array.isArray(value))
|
|
97
|
+
reject("INVALID_INPUT", "Expected an array.", path);
|
|
98
|
+
if (value.length > max)
|
|
99
|
+
reject("INPUT_TOO_LARGE", `Array exceeds ${max} entries.`, path);
|
|
100
|
+
return Array.from(value, (entry, index) => inner.parse(entry, `${path}[${index}]`));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function union(...members) {
|
|
104
|
+
const discriminant = Object.keys(members[0]?.fields ?? {}).find((key) => members.every((member) => member.fields?.[key]?.literalValue !== undefined) &&
|
|
105
|
+
new Set(members.map((member) => member.fields?.[key]?.literalValue)).size === members.length);
|
|
106
|
+
return schema((value, path) => {
|
|
107
|
+
if (discriminant && value && typeof value === "object") {
|
|
108
|
+
const tag = value[discriminant];
|
|
109
|
+
const selected = members.find((member) => member.fields?.[discriminant]?.literalValue === tag);
|
|
110
|
+
if (!selected)
|
|
111
|
+
reject("INVALID_INPUT", `Unknown ${discriminant}.`, path);
|
|
112
|
+
return selected.parse(value, path);
|
|
113
|
+
}
|
|
114
|
+
const errors = [];
|
|
115
|
+
for (const member of members) {
|
|
116
|
+
try {
|
|
117
|
+
return member.parse(value, path);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!(error instanceof ContractError))
|
|
121
|
+
throw error;
|
|
122
|
+
errors.push(error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
throw errors.find((error) => error.code === "INPUT_TOO_LARGE") ??
|
|
126
|
+
new ContractError("INVALID_INPUT", "No matching contract variant.", path);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
export function refine(inner, check) {
|
|
130
|
+
return { ...inner, ...schema((value, path) => {
|
|
131
|
+
const parsed = inner.parse(value, path);
|
|
132
|
+
check(parsed);
|
|
133
|
+
return parsed;
|
|
134
|
+
}) };
|
|
135
|
+
}
|
|
136
|
+
export function unique(values, path = "$") {
|
|
137
|
+
if (new Set(values).size !== values.length)
|
|
138
|
+
reject("INVALID_INPUT", "Duplicate identity.", path);
|
|
139
|
+
}
|
|
140
|
+
export function utf8Length(value) {
|
|
141
|
+
let bytes = 0;
|
|
142
|
+
for (const character of value) {
|
|
143
|
+
const point = character.codePointAt(0);
|
|
144
|
+
bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
145
|
+
}
|
|
146
|
+
return bytes;
|
|
147
|
+
}
|
|
148
|
+
export function parseContractJson(raw, decoder) {
|
|
149
|
+
if (utf8Length(raw) > CONTRACT_LIMITS.requestBytes)
|
|
150
|
+
reject("INPUT_TOO_LARGE", "Request exceeds 24 MiB UTF-8.");
|
|
151
|
+
let value;
|
|
152
|
+
try {
|
|
153
|
+
value = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!(error instanceof SyntaxError))
|
|
157
|
+
throw error;
|
|
158
|
+
return reject("MALFORMED_JSON", "Expected a JSON document.");
|
|
159
|
+
}
|
|
160
|
+
return decoder.parse(value);
|
|
161
|
+
}
|
|
162
|
+
/** Key order is immaterial; array order and exact text are part of a request. */
|
|
163
|
+
export function canonicalJson(value) {
|
|
164
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
165
|
+
return JSON.stringify(value);
|
|
166
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
167
|
+
return JSON.stringify(value);
|
|
168
|
+
if (Array.isArray(value))
|
|
169
|
+
return `[${Array.from(value, canonicalJson).join(",")}]`;
|
|
170
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
171
|
+
const record = value;
|
|
172
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
173
|
+
}
|
|
174
|
+
return reject("INVALID_INPUT", "Expected JSON data.");
|
|
175
|
+
}
|
|
176
|
+
export const failureSchema = refine(object({
|
|
177
|
+
ok: literal(false),
|
|
178
|
+
error: object({
|
|
179
|
+
code: enumeration(Object.keys(ERROR_STATUS)),
|
|
180
|
+
message: text(), status: integer(400, 599), retryable: booleanValue,
|
|
181
|
+
saveReason: optional(enumeration(["source-changed", "source-unavailable", "evidence-mismatch", "unsafe-content"])),
|
|
182
|
+
}),
|
|
183
|
+
}), ({ error }) => {
|
|
184
|
+
if (error.status !== ERROR_STATUS[error.code] || error.retryable !== (error.code === "STATE_PERSIST_FAILED")) {
|
|
185
|
+
reject("INVALID_INPUT", "Error code/status/retryability mismatch.");
|
|
186
|
+
}
|
|
187
|
+
if (error.saveReason && error.code !== "SAVE_EVIDENCE_CONFLICT")
|
|
188
|
+
reject("INVALID_INPUT", "Save reason requires a save conflict.");
|
|
189
|
+
});
|
|
190
|
+
export function contractFailure(error) {
|
|
191
|
+
return failureSchema.parse({ ok: false, error: {
|
|
192
|
+
code: error.code, message: error.message, status: error.status, retryable: error.code === "STATE_PERSIST_FAILED",
|
|
193
|
+
...(error.saveReason ? { saveReason: error.saveReason } : {}),
|
|
194
|
+
} });
|
|
195
|
+
}
|
|
196
|
+
export function transportOutcomeSchema(accepted) {
|
|
197
|
+
return union(object({ state: literal("accepted"), value: accepted }), object({ state: literal("rejected"), failure: failureSchema }), object({ state: literal("unknown"), requestId: id, reason: enumeration([
|
|
198
|
+
"timeout", "disconnected", "invalid-response", "unavailable",
|
|
199
|
+
]) }));
|
|
200
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { frameAnchorsSchema, frameAnchorStatesSchema, frameThreadActionSchema, isCurrentFrameProjection, sameFrameAnchorProjection, validateFrameAnchorStates, validateFrameThreadAction, } from "./contracts/frame.js";
|
|
2
|
+
import { reject } from "./contracts/validation.js";
|
|
3
|
+
import { sameThreadTarget } from "./comment-target.js";
|
|
4
|
+
export function describeConversationAnchor(state) {
|
|
5
|
+
if (!state)
|
|
6
|
+
return { canJump: false, offscreen: false, reason: "Checking the target in the current render." };
|
|
7
|
+
if (state.state === "found")
|
|
8
|
+
return {
|
|
9
|
+
canJump: true, offscreen: state.relation !== "visible",
|
|
10
|
+
reason: "",
|
|
11
|
+
};
|
|
12
|
+
const reason = state.state === "missing" ? "The original target was not found. The conversation is still available."
|
|
13
|
+
: state.state === "ambiguous" ? "Multiple targets match. No highlight or jump was chosen."
|
|
14
|
+
: ({
|
|
15
|
+
hidden: "The target is hidden. Reveal it in the document before showing it.",
|
|
16
|
+
"not-measurable": "The target cannot currently be measured.",
|
|
17
|
+
"invalid-selector": "The original element selector is unavailable in this render.",
|
|
18
|
+
"render-loading": "The document is still loading; target availability is not known.",
|
|
19
|
+
"render-changed": "The render or element identity changed. No replacement target was chosen.",
|
|
20
|
+
"render-unavailable": "The document render is unavailable; this does not mean the source target is missing.",
|
|
21
|
+
}[state.reason]);
|
|
22
|
+
return { canJump: false, offscreen: false, reason };
|
|
23
|
+
}
|
|
24
|
+
/** Shell-side geometry only; the conversation owner retains all user state. */
|
|
25
|
+
export function createConversationAnchorController() {
|
|
26
|
+
let projection = null;
|
|
27
|
+
let states = [];
|
|
28
|
+
let revision = 0;
|
|
29
|
+
return {
|
|
30
|
+
get projection() { return projection; },
|
|
31
|
+
get states() { return states; },
|
|
32
|
+
reset() { projection = null; states = []; },
|
|
33
|
+
project(input) {
|
|
34
|
+
const next = frameAnchorsSchema.parse({ ...input, projectionRevision: revision + 1 });
|
|
35
|
+
if (projection && sameFrameAnchorProjection(next, projection))
|
|
36
|
+
return false;
|
|
37
|
+
const sameRender = projection && ["capability", "reviewId", "pageKey", "renderId", "generation"].every((key) => projection[key] === next[key]);
|
|
38
|
+
states = sameRender ? states.filter((state) => next.anchors.some((anchor) => anchor.threadId === state.threadId &&
|
|
39
|
+
JSON.stringify(anchor.target) === JSON.stringify(projection.anchors.find((previous) => previous.threadId === state.threadId)?.target))) : [];
|
|
40
|
+
projection = next;
|
|
41
|
+
revision = next.projectionRevision;
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
receive(input) {
|
|
45
|
+
if (!projection)
|
|
46
|
+
return reject("SCOPE_MISMATCH", "No current conversation projection.");
|
|
47
|
+
const report = frameAnchorStatesSchema.parse(input);
|
|
48
|
+
if (!isCurrentFrameProjection(report, projection))
|
|
49
|
+
return false;
|
|
50
|
+
states = validateFrameAnchorStates(report, projection).anchors;
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
incoming(input) {
|
|
54
|
+
if (!projection)
|
|
55
|
+
return reject("SCOPE_MISMATCH", "No current conversation projection.");
|
|
56
|
+
const action = frameThreadActionSchema.parse(input);
|
|
57
|
+
if (action.action === "reveal")
|
|
58
|
+
reject("INVALID_INPUT", "Only the shell may request target reveal.");
|
|
59
|
+
if (!isCurrentFrameProjection(action, projection))
|
|
60
|
+
return null;
|
|
61
|
+
validateFrameThreadAction(action, projection);
|
|
62
|
+
if (action.action === "activate" && !states.some((state) => state.threadId === action.threadId && state.state === "found")) {
|
|
63
|
+
reject("INVALID_INPUT", "Activation requires a currently verified target.");
|
|
64
|
+
}
|
|
65
|
+
return action;
|
|
66
|
+
},
|
|
67
|
+
outgoing(action, threadId) {
|
|
68
|
+
if (!projection)
|
|
69
|
+
return reject("SCOPE_MISMATCH", "No current conversation projection.");
|
|
70
|
+
if (action !== "dismiss" && !states.some((state) => state.threadId === threadId && state.state === "found")) {
|
|
71
|
+
reject("INVALID_INPUT", "Target is not currently available. Continue in Feedback.");
|
|
72
|
+
}
|
|
73
|
+
const { anchors: _anchors, ...scope } = projection;
|
|
74
|
+
return validateFrameThreadAction({ ...scope, type: "eh:threadAction", action, threadId }, projection);
|
|
75
|
+
},
|
|
76
|
+
peers(threadId) {
|
|
77
|
+
const own = states.find((state) => state.threadId === threadId);
|
|
78
|
+
if (own?.state !== "found")
|
|
79
|
+
return [threadId];
|
|
80
|
+
return states.filter((state) => sameThreadTarget(state, own))
|
|
81
|
+
.map((state) => state.threadId);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ApiError, record } from "./chrome-api.js";
|
|
2
|
+
import { normalizeView } from "./view-identity.js";
|
|
3
|
+
export function resultCaptureKey(scope) {
|
|
4
|
+
const view = normalizeView(scope.view);
|
|
5
|
+
return JSON.stringify([scope.reviewId, scope.entryKey, scope.submissionId, scope.pageKey,
|
|
6
|
+
scope.sessionId, scope.renderId, scope.generation, scope.sourceHash,
|
|
7
|
+
view.status, view.tabs.map(({ groupId, tabId, panelId }) => [groupId, tabId, panelId])]);
|
|
8
|
+
}
|
|
9
|
+
export function decodeComparison(value, mode) {
|
|
10
|
+
const result = record(value);
|
|
11
|
+
if (typeof result.available !== "boolean" || result.mode !== mode)
|
|
12
|
+
throw new Error("Invalid comparison response.");
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
/** Automatic and explicit captures share one flight for the exact observed scope. */
|
|
16
|
+
export function createResultCaptures(options) {
|
|
17
|
+
const flights = new Map();
|
|
18
|
+
const failures = new Map();
|
|
19
|
+
const resultKey = (scope) => JSON.stringify([scope.reviewId, scope.entryKey, scope.submissionId, scope.pageKey]);
|
|
20
|
+
const requireCurrent = (scope) => {
|
|
21
|
+
if (!options.current(scope))
|
|
22
|
+
throw new Error("The page or visible view changed during capture. Retry on the current page.");
|
|
23
|
+
};
|
|
24
|
+
const content = async (scope) => {
|
|
25
|
+
requireCurrent(scope);
|
|
26
|
+
const value = decodeComparison(await options.compare(scope), "content");
|
|
27
|
+
requireCurrent(scope);
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
function request(scope) {
|
|
31
|
+
const key = resultCaptureKey(scope), existing = flights.get(key);
|
|
32
|
+
if (existing)
|
|
33
|
+
return existing;
|
|
34
|
+
const flight = Promise.resolve().then(async () => {
|
|
35
|
+
try {
|
|
36
|
+
let value = await content(scope);
|
|
37
|
+
if (!value.available) {
|
|
38
|
+
try {
|
|
39
|
+
requireCurrent(scope);
|
|
40
|
+
await options.capture(scope);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
requireCurrent(scope);
|
|
44
|
+
if (!(error instanceof ApiError && error.status === 409 && error.code === "VERSION_CONFLICT"))
|
|
45
|
+
throw error;
|
|
46
|
+
value = await content(scope);
|
|
47
|
+
if (!value.available)
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
value = await content(scope);
|
|
51
|
+
requireCurrent(scope);
|
|
52
|
+
await options.refresh();
|
|
53
|
+
requireCurrent(scope);
|
|
54
|
+
}
|
|
55
|
+
if (!value.available)
|
|
56
|
+
throw new Error(typeof value.reason === "string" ? value.reason : "Rendered Content comparison is unavailable.");
|
|
57
|
+
failures.delete(resultKey(scope));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (options.current(scope))
|
|
61
|
+
failures.set(resultKey(scope), {
|
|
62
|
+
scope, message: error instanceof Error ? error.message : String(error),
|
|
63
|
+
});
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
options.changed();
|
|
68
|
+
}
|
|
69
|
+
}).finally(() => { flights.delete(key); });
|
|
70
|
+
flights.set(key, flight);
|
|
71
|
+
return flight;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
request,
|
|
75
|
+
confirmContent(scope, value) {
|
|
76
|
+
if (decodeComparison(value, "content").available && failures.delete(resultKey(scope)))
|
|
77
|
+
options.changed();
|
|
78
|
+
},
|
|
79
|
+
get failures() { return [...failures.values()]; },
|
|
80
|
+
};
|
|
81
|
+
}
|