spectater 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -2
- package/bin/spectater.js +376 -0
- package/package.json +59 -4
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/changes.js +82 -0
- package/src/config.js +44 -0
- package/src/discovery.js +158 -0
- package/src/files.js +25 -0
- package/src/hooks.js +145 -0
- package/src/markdown.js +120 -0
- package/src/queue.js +29 -0
- package/src/review.js +402 -0
- package/src/server.js +291 -0
- package/src/storage.js +381 -0
- package/src/watch.js +113 -0
- package/web/app.js +705 -0
- package/web/favicon.svg +14 -0
- package/web/index.html +158 -0
- package/web/style.css +647 -0
- package/web/theme.js +45 -0
package/src/markdown.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import gfm from 'remark-gfm';
|
|
2
|
+
import parse from 'remark-parse';
|
|
3
|
+
import { unified } from 'unified';
|
|
4
|
+
|
|
5
|
+
const parser = unified().use(parse).use(gfm);
|
|
6
|
+
const escapeHtml = (text) =>
|
|
7
|
+
String(text).replace(
|
|
8
|
+
/[&<>"']/g,
|
|
9
|
+
(char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char],
|
|
10
|
+
);
|
|
11
|
+
export function render(source) {
|
|
12
|
+
const tree = parser.parse(source);
|
|
13
|
+
function node(n) {
|
|
14
|
+
const children = () => (n.children || []).map(node).join('');
|
|
15
|
+
const mapped = (text) => {
|
|
16
|
+
const start = n.position.start.offset,
|
|
17
|
+
end = n.position.end.offset;
|
|
18
|
+
const precision = source.slice(start, end) === text ? 'exact' : 'enclosing';
|
|
19
|
+
return `<span data-start="${start}" data-end="${end}" data-precision="${precision}">${escapeHtml(text)}</span>`;
|
|
20
|
+
};
|
|
21
|
+
switch (n.type) {
|
|
22
|
+
case 'root':
|
|
23
|
+
return children();
|
|
24
|
+
case 'text':
|
|
25
|
+
return mapped(n.value);
|
|
26
|
+
case 'inlineCode':
|
|
27
|
+
return `<code>${mapped(n.value)}</code>`;
|
|
28
|
+
case 'code':
|
|
29
|
+
return `<pre><code>${mapped(n.value)}</code></pre>`;
|
|
30
|
+
case 'html':
|
|
31
|
+
return `<code>${mapped(n.value)}</code>`;
|
|
32
|
+
case 'heading':
|
|
33
|
+
return `<h${n.depth}>${children()}</h${n.depth}>`;
|
|
34
|
+
case 'paragraph':
|
|
35
|
+
return `<p>${children()}</p>`;
|
|
36
|
+
case 'strong':
|
|
37
|
+
return `<strong>${children()}</strong>`;
|
|
38
|
+
case 'emphasis':
|
|
39
|
+
return `<em>${children()}</em>`;
|
|
40
|
+
case 'delete':
|
|
41
|
+
return `<del>${children()}</del>`;
|
|
42
|
+
case 'blockquote':
|
|
43
|
+
return `<blockquote>${children()}</blockquote>`;
|
|
44
|
+
case 'list':
|
|
45
|
+
return n.ordered
|
|
46
|
+
? `<ol start="${Number.isInteger(n.start) ? n.start : 1}">${children()}</ol>`
|
|
47
|
+
: `<ul>${children()}</ul>`;
|
|
48
|
+
case 'listItem':
|
|
49
|
+
return `<li>${n.checked == null ? '' : `<span aria-label="${n.checked ? 'Complete' : 'Incomplete'}">[${n.checked ? 'x' : ' '}] </span>`}${children()}</li>`;
|
|
50
|
+
case 'table':
|
|
51
|
+
return `<div class="table-scroll"><table>${(n.children || []).map((row, i) => `<tr>${row.children.map((cell) => `<${i ? 'td' : 'th'}>${node(cell)}</${i ? 'td' : 'th'}>`).join('')}</tr>`).join('')}</table></div>`;
|
|
52
|
+
case 'tableCell':
|
|
53
|
+
return children();
|
|
54
|
+
case 'link': {
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = new URL(n.url);
|
|
58
|
+
if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) url = parsed.href;
|
|
59
|
+
} catch {
|
|
60
|
+
/* Relative source links have no browser route. */
|
|
61
|
+
}
|
|
62
|
+
return url
|
|
63
|
+
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${children()}</a>`
|
|
64
|
+
: children();
|
|
65
|
+
}
|
|
66
|
+
case 'image':
|
|
67
|
+
case 'imageReference':
|
|
68
|
+
return mapped(`[Image: ${n.alt || 'image'}; external loading disabled]`);
|
|
69
|
+
case 'linkReference':
|
|
70
|
+
return children();
|
|
71
|
+
case 'break':
|
|
72
|
+
return '<br>';
|
|
73
|
+
case 'thematicBreak':
|
|
74
|
+
return '<hr>';
|
|
75
|
+
default:
|
|
76
|
+
return n.children
|
|
77
|
+
? children()
|
|
78
|
+
: `<code>${escapeHtml(source.slice(n.position?.start.offset, n.position?.end.offset))}</code>`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return node(tree);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function anchor(snapshot, input) {
|
|
85
|
+
const { start, end, precision, quote } = input;
|
|
86
|
+
if (
|
|
87
|
+
!Number.isInteger(start) ||
|
|
88
|
+
!Number.isInteger(end) ||
|
|
89
|
+
start < 0 ||
|
|
90
|
+
end <= start ||
|
|
91
|
+
end > snapshot.source.length ||
|
|
92
|
+
!['exact', 'enclosing'].includes(precision) ||
|
|
93
|
+
typeof quote !== 'string' ||
|
|
94
|
+
!quote.length ||
|
|
95
|
+
quote.length > 10000
|
|
96
|
+
)
|
|
97
|
+
throw new Error('Invalid selection. Select source text again.');
|
|
98
|
+
const slice = snapshot.source.slice(start, end);
|
|
99
|
+
if (slice.length > 10000) throw new Error('Selection exceeds 10,000 characters.');
|
|
100
|
+
if (precision === 'exact' && quote !== slice)
|
|
101
|
+
throw new Error('Selection does not match the reviewed source.');
|
|
102
|
+
return {
|
|
103
|
+
start,
|
|
104
|
+
end,
|
|
105
|
+
precision,
|
|
106
|
+
quote,
|
|
107
|
+
slice,
|
|
108
|
+
prefix: snapshot.source.slice(Math.max(0, start - 32), start),
|
|
109
|
+
suffix: snapshot.source.slice(end, end + 32),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function locate(comment, snapshot) {
|
|
114
|
+
if (!snapshot) return { status: 'orphaned' };
|
|
115
|
+
if (comment.hash === snapshot.hash) return { status: 'current', suggestion: comment.start };
|
|
116
|
+
const needle = comment.prefix + comment.slice + comment.suffix;
|
|
117
|
+
const first = snapshot.source.indexOf(needle);
|
|
118
|
+
if (first < 0 || snapshot.source.indexOf(needle, first + 1) >= 0) return { status: 'orphaned' };
|
|
119
|
+
return { status: 'stale', suggestion: first + comment.prefix.length };
|
|
120
|
+
}
|
package/src/queue.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const batchStatus = (state, id) =>
|
|
2
|
+
state.queue?.[id]?.status || (state.activity?.[id] ? 'claimed' : 'queued');
|
|
3
|
+
export const deliverable = (state, batch) =>
|
|
4
|
+
['queued', 'claimed'].includes(batchStatus(state, batch.id));
|
|
5
|
+
const signature = (comment) =>
|
|
6
|
+
JSON.stringify([comment.hash, comment.start, comment.end, comment.body]);
|
|
7
|
+
|
|
8
|
+
export function pendingComments(state) {
|
|
9
|
+
const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
|
|
10
|
+
const open = state.comments.filter((comment) => comment.state === 'open' && !comment.deletedAt);
|
|
11
|
+
if (editing) {
|
|
12
|
+
const ids = new Set(editing.entries.map((entry) => entry.id));
|
|
13
|
+
return open.filter((comment) => ids.has(comment.id));
|
|
14
|
+
}
|
|
15
|
+
const latest = new Map(),
|
|
16
|
+
queued = new Set();
|
|
17
|
+
for (const batch of state.batches) {
|
|
18
|
+
if (!deliverable(state, batch)) continue;
|
|
19
|
+
for (const entry of batch.entries) {
|
|
20
|
+
latest.set(entry.id, entry);
|
|
21
|
+
if (batchStatus(state, batch.id) === 'queued') queued.add(entry.id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return open.filter(
|
|
25
|
+
(comment) =>
|
|
26
|
+
!queued.has(comment.id) &&
|
|
27
|
+
(!latest.has(comment.id) || signature(latest.get(comment.id)) !== signature(comment)),
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/review.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { compareSources, sourceChanges } from './changes.js';
|
|
3
|
+
import { createDiscovery, readDocument } from './discovery.js';
|
|
4
|
+
import { anchor, locate, render } from './markdown.js';
|
|
5
|
+
import { batchStatus, deliverable, pendingComments } from './queue.js';
|
|
6
|
+
import { feedback, openStore } from './storage.js';
|
|
7
|
+
|
|
8
|
+
export async function openReview(options, dependencies = {}) {
|
|
9
|
+
const discovery = dependencies.discover || createDiscovery();
|
|
10
|
+
let review = dependencies.initial || (await discovery(options));
|
|
11
|
+
const store = await openStore(review, dependencies.directory);
|
|
12
|
+
let current = new Map();
|
|
13
|
+
let rendered = new Map();
|
|
14
|
+
let tail = Promise.resolve();
|
|
15
|
+
let fingerprint;
|
|
16
|
+
const observers = new Set();
|
|
17
|
+
const notify = (event) => {
|
|
18
|
+
for (const observer of observers) {
|
|
19
|
+
try {
|
|
20
|
+
observer(event);
|
|
21
|
+
} catch {
|
|
22
|
+
observers.delete(observer);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const serial = (fn) => {
|
|
27
|
+
const job = tail.then(fn);
|
|
28
|
+
tail = job.catch(() => {});
|
|
29
|
+
return job;
|
|
30
|
+
};
|
|
31
|
+
async function refresh(initial) {
|
|
32
|
+
const next = initial || (await discovery(options));
|
|
33
|
+
if (next.reviewId !== review.reviewId)
|
|
34
|
+
throw new Error('Planning identity changed. Reopen explicitly.');
|
|
35
|
+
const documents = await Promise.all(
|
|
36
|
+
next.documents
|
|
37
|
+
.filter((d) => d.supported)
|
|
38
|
+
.map(async (d) => [d.id, await readDocument(next, d)]),
|
|
39
|
+
);
|
|
40
|
+
if (documents.reduce((n, [, s]) => n + Buffer.byteLength(s.source), 0) > 16 * 1024 * 1024)
|
|
41
|
+
throw new Error('Selected documents exceed 16 MiB.');
|
|
42
|
+
const nextFingerprint = JSON.stringify([
|
|
43
|
+
next.schemaName,
|
|
44
|
+
next.source,
|
|
45
|
+
next.artifacts,
|
|
46
|
+
next.documents,
|
|
47
|
+
documents.map(([id, snapshot]) => [id, snapshot.hash]),
|
|
48
|
+
]);
|
|
49
|
+
if (nextFingerprint === fingerprint) return;
|
|
50
|
+
const state = store.state;
|
|
51
|
+
for (const [, snapshot] of documents) state.snapshots[snapshot.hash] = snapshot;
|
|
52
|
+
const manifest = Object.fromEntries(
|
|
53
|
+
[...documents]
|
|
54
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
55
|
+
.map(([id, snapshot]) => [id, { path: snapshot.path, hash: snapshot.hash }]),
|
|
56
|
+
);
|
|
57
|
+
if (
|
|
58
|
+
state.currentDocuments &&
|
|
59
|
+
JSON.stringify(manifest) !== JSON.stringify(state.currentDocuments)
|
|
60
|
+
)
|
|
61
|
+
state.previousDocuments = state.currentDocuments;
|
|
62
|
+
state.currentDocuments = manifest;
|
|
63
|
+
state.documentIds = next.documents.map((d) => d.id);
|
|
64
|
+
state.schemaName = next.schemaName;
|
|
65
|
+
state.revision++;
|
|
66
|
+
const html = new Map(
|
|
67
|
+
documents.map(([, snapshot]) => [
|
|
68
|
+
snapshot.hash,
|
|
69
|
+
rendered.get(snapshot.hash) ?? render(snapshot.source),
|
|
70
|
+
]),
|
|
71
|
+
);
|
|
72
|
+
await store.commit(state);
|
|
73
|
+
fingerprint = nextFingerprint;
|
|
74
|
+
review = next;
|
|
75
|
+
current = new Map(documents);
|
|
76
|
+
rendered = html;
|
|
77
|
+
notify({ type: 'refresh', revision: state.revision });
|
|
78
|
+
}
|
|
79
|
+
function view() {
|
|
80
|
+
const state = store.state;
|
|
81
|
+
return {
|
|
82
|
+
reviewId: review.reviewId,
|
|
83
|
+
root: review.root,
|
|
84
|
+
changeRoot: review.changeRoot,
|
|
85
|
+
change: review.change,
|
|
86
|
+
defaultView: options.defaultView || 'rendered',
|
|
87
|
+
source: review.source,
|
|
88
|
+
schemaName: review.schemaName,
|
|
89
|
+
artifacts: review.artifacts,
|
|
90
|
+
revision: state.revision,
|
|
91
|
+
documents: review.documents.map((d) => ({
|
|
92
|
+
id: d.id,
|
|
93
|
+
path: d.path,
|
|
94
|
+
supported: d.supported,
|
|
95
|
+
artifact: d.artifact,
|
|
96
|
+
...current.get(d.id),
|
|
97
|
+
html: rendered.get(current.get(d.id)?.hash),
|
|
98
|
+
})),
|
|
99
|
+
comments: state.comments
|
|
100
|
+
.filter((c) => !c.deletedAt)
|
|
101
|
+
.map((c) => ({ ...c, ...locate(c, current.get(c.documentId)) })),
|
|
102
|
+
deletedComments: state.comments
|
|
103
|
+
.filter((c) => c.deletedAt)
|
|
104
|
+
.map((c) => ({ ...c, ...locate(c, current.get(c.documentId)) })),
|
|
105
|
+
endedAt: state.endedAt,
|
|
106
|
+
endRequestedAt: state.endRequestedAt,
|
|
107
|
+
changes: sourceChanges(state),
|
|
108
|
+
hasPreviousSource: Boolean(state.previousDocuments),
|
|
109
|
+
activity: state.activity || {},
|
|
110
|
+
queue: state.queue || {},
|
|
111
|
+
pendingCommentIds: pendingComments(state).map((comment) => comment.id),
|
|
112
|
+
batches: state.batches.map(({ entries, ...batch }) => ({
|
|
113
|
+
...batch,
|
|
114
|
+
count: entries.length + (batch.message ? 1 : 0),
|
|
115
|
+
commentIds: entries.map((entry) => entry.id),
|
|
116
|
+
})),
|
|
117
|
+
snapshots: Object.fromEntries(state.comments.map((c) => [c.hash, state.snapshots[c.hash]])),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function checkRevision(input) {
|
|
121
|
+
if (input.revision !== store.state.revision)
|
|
122
|
+
throw new Error('Revision conflict. Preserve your draft and refresh.');
|
|
123
|
+
for (const document of review.documents.filter((d) => d.supported)) {
|
|
124
|
+
try {
|
|
125
|
+
if ((await readDocument(review, document)).hash !== current.get(document.id)?.hash)
|
|
126
|
+
throw new Error('Source changed.');
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error(`Revision conflict: ${error.message} Preserve your draft and refresh.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const now = () => new Date().toISOString();
|
|
133
|
+
const outstanding = (state) =>
|
|
134
|
+
state.batches.some((batch) => ['queued', 'editing'].includes(batchStatus(state, batch.id)));
|
|
135
|
+
const finishPendingEnd = (state) => {
|
|
136
|
+
if (state.endRequestedAt && !outstanding(state)) {
|
|
137
|
+
state.endedAt = now();
|
|
138
|
+
delete state.endRequestedAt;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const text = (value, label) => {
|
|
142
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 10000)
|
|
143
|
+
throw new Error(`${label} must contain 1–10,000 characters.`);
|
|
144
|
+
return value.trim();
|
|
145
|
+
};
|
|
146
|
+
const metadataActions = new Set(['end', 'resume', 'cancel-batch', 'edit-batch']);
|
|
147
|
+
const findComment = (state, id) => {
|
|
148
|
+
const comment = state.comments.find((c) => c.id === id);
|
|
149
|
+
if (!comment) throw new Error('Unknown comment.');
|
|
150
|
+
return comment;
|
|
151
|
+
};
|
|
152
|
+
const liveComment = (state, id) => {
|
|
153
|
+
const comment = findComment(state, id);
|
|
154
|
+
if (comment.deletedAt) throw new Error('Comment is deleted. Restore it before editing.');
|
|
155
|
+
return comment;
|
|
156
|
+
};
|
|
157
|
+
const queuedBatch = (state, id) => {
|
|
158
|
+
const batch = state.batches.find((item) => item.id === id);
|
|
159
|
+
if (!batch) throw new Error('Unknown feedback batch.');
|
|
160
|
+
if (!['queued', 'editing'].includes(batchStatus(state, batch.id)))
|
|
161
|
+
throw new Error('Batch already handed off or withdrawn. Send a correction instead.');
|
|
162
|
+
return batch;
|
|
163
|
+
};
|
|
164
|
+
const actions = {
|
|
165
|
+
end(state) {
|
|
166
|
+
if (outstanding(state))
|
|
167
|
+
throw new Error('Deliver or cancel queued submissions before ending the review.');
|
|
168
|
+
state.endedAt ||= now();
|
|
169
|
+
delete state.endRequestedAt;
|
|
170
|
+
},
|
|
171
|
+
resume(state) {
|
|
172
|
+
delete state.endedAt;
|
|
173
|
+
delete state.endRequestedAt;
|
|
174
|
+
},
|
|
175
|
+
'cancel-batch'(state, input) {
|
|
176
|
+
const batch = queuedBatch(state, input.id);
|
|
177
|
+
state.queue ||= {};
|
|
178
|
+
state.queue[batch.id] = { status: 'cancelled', at: now() };
|
|
179
|
+
finishPendingEnd(state);
|
|
180
|
+
},
|
|
181
|
+
'edit-batch'(state, input) {
|
|
182
|
+
const batch = queuedBatch(state, input.id);
|
|
183
|
+
if (
|
|
184
|
+
state.batches.some(
|
|
185
|
+
(item) => item.id !== batch.id && batchStatus(state, item.id) === 'editing',
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
throw new Error('Finish or cancel the current queued edit first.');
|
|
189
|
+
state.queue ||= {};
|
|
190
|
+
state.queue[batch.id] = { status: 'editing', at: now() };
|
|
191
|
+
},
|
|
192
|
+
comment(state, input) {
|
|
193
|
+
const snapshot = current.get(input.documentId);
|
|
194
|
+
if (!snapshot || snapshot.hash !== input.hash)
|
|
195
|
+
throw new Error('Revision conflict: select the current document.');
|
|
196
|
+
state.comments.push({
|
|
197
|
+
id: randomUUID(),
|
|
198
|
+
documentId: input.documentId,
|
|
199
|
+
path: snapshot.path,
|
|
200
|
+
hash: snapshot.hash,
|
|
201
|
+
...anchor(snapshot, input),
|
|
202
|
+
body: text(input.body, 'Comment'),
|
|
203
|
+
createdAt: now(),
|
|
204
|
+
state: 'open',
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
submit(state, input) {
|
|
208
|
+
const message = input.message ? text(input.message, 'Message') : undefined;
|
|
209
|
+
const entries = pendingComments(state).map((c) => ({
|
|
210
|
+
...c,
|
|
211
|
+
line: state.snapshots[c.hash].source.slice(0, c.start).split('\n').length,
|
|
212
|
+
}));
|
|
213
|
+
const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
|
|
214
|
+
if (!entries.length && !message) {
|
|
215
|
+
if (input.end && !editing) {
|
|
216
|
+
if (outstanding(state)) state.endRequestedAt = now();
|
|
217
|
+
else actions.end(state);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
editing
|
|
222
|
+
? 'Keep an open comment or cancel the queued batch.'
|
|
223
|
+
: 'Add a message or an open comment that is new or changed before submitting.',
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const batch = {
|
|
227
|
+
id: randomUUID(),
|
|
228
|
+
sequence: state.batches.length + 1,
|
|
229
|
+
submittedAt: now(),
|
|
230
|
+
...(message ? { message } : {}),
|
|
231
|
+
entries,
|
|
232
|
+
};
|
|
233
|
+
state.batches.push(batch);
|
|
234
|
+
if (editing)
|
|
235
|
+
state.queue[editing.id] = {
|
|
236
|
+
status: 'superseded',
|
|
237
|
+
at: batch.submittedAt,
|
|
238
|
+
replacementId: batch.id,
|
|
239
|
+
};
|
|
240
|
+
if (input.end) state.endRequestedAt = batch.submittedAt;
|
|
241
|
+
},
|
|
242
|
+
restore(state, input) {
|
|
243
|
+
delete findComment(state, input.id).deletedAt;
|
|
244
|
+
},
|
|
245
|
+
delete(state, input) {
|
|
246
|
+
liveComment(state, input.id).deletedAt = now();
|
|
247
|
+
},
|
|
248
|
+
edit(state, input) {
|
|
249
|
+
const comment = liveComment(state, input.id);
|
|
250
|
+
comment.body = text(input.body, 'Comment');
|
|
251
|
+
comment.updatedAt = now();
|
|
252
|
+
},
|
|
253
|
+
resolve(state, input) {
|
|
254
|
+
const comment = liveComment(state, input.id);
|
|
255
|
+
comment.state = comment.state === 'open' ? 'resolved' : 'open';
|
|
256
|
+
},
|
|
257
|
+
reattach(state, input) {
|
|
258
|
+
const comment = liveComment(state, input.id);
|
|
259
|
+
const snapshot = current.get(comment.documentId);
|
|
260
|
+
const result = locate(comment, snapshot);
|
|
261
|
+
if (result.status !== 'stale')
|
|
262
|
+
throw new Error('No unique suggested anchor. Select source and add a new comment.');
|
|
263
|
+
comment.previousAnchors = [
|
|
264
|
+
...(comment.previousAnchors || []),
|
|
265
|
+
{ hash: comment.hash, start: comment.start, end: comment.end, slice: comment.slice },
|
|
266
|
+
];
|
|
267
|
+
Object.assign(
|
|
268
|
+
comment,
|
|
269
|
+
anchor(snapshot, {
|
|
270
|
+
start: result.suggestion,
|
|
271
|
+
end: result.suggestion + comment.slice.length,
|
|
272
|
+
precision: 'exact',
|
|
273
|
+
quote: comment.slice,
|
|
274
|
+
}),
|
|
275
|
+
{ hash: snapshot.hash },
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
try {
|
|
280
|
+
await refresh(review);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await store.close();
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
directory: store.directory,
|
|
287
|
+
subscribe(observer) {
|
|
288
|
+
observers.add(observer);
|
|
289
|
+
return () => observers.delete(observer);
|
|
290
|
+
},
|
|
291
|
+
view,
|
|
292
|
+
refresh: () =>
|
|
293
|
+
serial(async () => {
|
|
294
|
+
if (store.state.endedAt)
|
|
295
|
+
throw new Error('Review ended. Resume explicitly to refresh source.');
|
|
296
|
+
await refresh();
|
|
297
|
+
return view();
|
|
298
|
+
}),
|
|
299
|
+
compare: (id) => compareSources(store.state, id, dependencies.diff),
|
|
300
|
+
// Delivers the oldest batch the agent has not replied to, so a lost response is redelivered.
|
|
301
|
+
deliver: () =>
|
|
302
|
+
serial(async () => {
|
|
303
|
+
const state = store.state;
|
|
304
|
+
const batch = state.batches.find(
|
|
305
|
+
(item) => deliverable(state, item) && state.activity?.[item.id]?.status !== 'replied',
|
|
306
|
+
);
|
|
307
|
+
// An ended review still redelivers a claimed batch until the agent replies to it.
|
|
308
|
+
if (!batch || (state.endedAt && batchStatus(state, batch.id) !== 'claimed')) return null;
|
|
309
|
+
if (batchStatus(state, batch.id) === 'queued') {
|
|
310
|
+
const at = new Date().toISOString();
|
|
311
|
+
state.queue ||= {};
|
|
312
|
+
state.queue[batch.id] = { status: 'claimed', at };
|
|
313
|
+
state.activity ||= {};
|
|
314
|
+
state.activity[batch.id] = { receivedAt: at, status: 'received', replies: [] };
|
|
315
|
+
finishPendingEnd(state);
|
|
316
|
+
state.revision++;
|
|
317
|
+
await store.commit(state);
|
|
318
|
+
notify({ type: 'activity', revision: state.revision });
|
|
319
|
+
if (state.endedAt) notify({ type: 'lifecycle', revision: state.revision, ended: true });
|
|
320
|
+
}
|
|
321
|
+
return feedback(state, batch.id);
|
|
322
|
+
}),
|
|
323
|
+
recordActivity: (sequence, input) =>
|
|
324
|
+
serial(async () => {
|
|
325
|
+
const state = store.state;
|
|
326
|
+
const batch =
|
|
327
|
+
sequence === undefined
|
|
328
|
+
? state.batches.find(
|
|
329
|
+
(item) =>
|
|
330
|
+
batchStatus(state, item.id) === 'claimed' &&
|
|
331
|
+
state.activity?.[item.id]?.status !== 'replied',
|
|
332
|
+
)
|
|
333
|
+
: state.batches.find((item) => item.sequence === sequence);
|
|
334
|
+
if (!batch)
|
|
335
|
+
throw new Error(
|
|
336
|
+
sequence === undefined
|
|
337
|
+
? 'No delivered batch is awaiting a reply. Pass --batch to reply to an earlier one.'
|
|
338
|
+
: `Unknown feedback batch: ${sequence}.`,
|
|
339
|
+
);
|
|
340
|
+
if (batchStatus(state, batch.id) !== 'claimed')
|
|
341
|
+
throw new Error('Batch has not been delivered to the agent.');
|
|
342
|
+
if (!['working', 'done'].includes(input.status))
|
|
343
|
+
throw new Error('Reply status must be working or done.');
|
|
344
|
+
const body = input.body ?? '';
|
|
345
|
+
if (
|
|
346
|
+
typeof body !== 'string' ||
|
|
347
|
+
body.length > 10000 ||
|
|
348
|
+
(input.status === 'done' && !body.trim())
|
|
349
|
+
)
|
|
350
|
+
throw new Error('A reply must contain 1–10,000 characters.');
|
|
351
|
+
const activity = state.activity[batch.id];
|
|
352
|
+
const previous = JSON.stringify(activity);
|
|
353
|
+
if (input.status === 'working' && activity.status !== 'replied') {
|
|
354
|
+
activity.startedAt ||= new Date().toISOString();
|
|
355
|
+
activity.status = 'working';
|
|
356
|
+
}
|
|
357
|
+
if (input.status === 'done' && activity.replies.at(-1)?.body !== body.trim()) {
|
|
358
|
+
activity.replies.push({
|
|
359
|
+
id: randomUUID(),
|
|
360
|
+
body: body.trim(),
|
|
361
|
+
at: new Date().toISOString(),
|
|
362
|
+
});
|
|
363
|
+
activity.status = 'replied';
|
|
364
|
+
}
|
|
365
|
+
if (previous !== JSON.stringify(activity)) {
|
|
366
|
+
state.revision++;
|
|
367
|
+
await store.commit(state);
|
|
368
|
+
notify({ type: 'activity', revision: state.revision });
|
|
369
|
+
}
|
|
370
|
+
return { batch: batch.sequence, activity: activity.status };
|
|
371
|
+
}),
|
|
372
|
+
mutate: (input) =>
|
|
373
|
+
serial(async () => {
|
|
374
|
+
const handler = actions[input.action];
|
|
375
|
+
if (!handler) throw new Error('Unknown review action.');
|
|
376
|
+
if (store.state.endedAt && !['resume', 'end'].includes(input.action))
|
|
377
|
+
throw new Error('Review ended. Resume explicitly to change feedback.');
|
|
378
|
+
if (metadataActions.has(input.action)) {
|
|
379
|
+
if (input.revision !== store.state.revision)
|
|
380
|
+
throw new Error('Revision conflict. Refresh review state and retry.');
|
|
381
|
+
} else await checkRevision(input);
|
|
382
|
+
if (input.action === 'resume' && store.state.endedAt) await refresh();
|
|
383
|
+
const state = store.state;
|
|
384
|
+
handler(state, input);
|
|
385
|
+
state.revision++;
|
|
386
|
+
await store.commit(state);
|
|
387
|
+
const lifecycle = ['end', 'resume'].includes(input.action) || Boolean(state.endedAt);
|
|
388
|
+
notify({
|
|
389
|
+
type: lifecycle ? 'lifecycle' : 'activity',
|
|
390
|
+
revision: state.revision,
|
|
391
|
+
ended: Boolean(state.endedAt),
|
|
392
|
+
});
|
|
393
|
+
if (input.action === 'submit')
|
|
394
|
+
notify({ type: 'batch', sequence: state.batches.at(-1).sequence });
|
|
395
|
+
return view();
|
|
396
|
+
}),
|
|
397
|
+
close: () => serial(() => store.close()),
|
|
398
|
+
get state() {
|
|
399
|
+
return store.state;
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|