spectater 0.1.0 → 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/README.md +86 -33
- package/bin/spectater.js +346 -306
- package/package.json +18 -9
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/discovery.js +4 -4
- package/src/hooks.js +145 -0
- package/src/review.js +195 -140
- package/src/server.js +143 -57
- package/src/storage.js +56 -9
- package/web/app.js +51 -11
- package/web/index.html +15 -2
- package/web/style.css +12 -0
- package/SECURITY.md +0 -11
- package/docs/licenses/open-design.txt +0 -201
- package/docs/security-review.md +0 -33
- package/docs/verification.md +0 -41
- package/docs/workflow.md +0 -65
- package/integration/spectater/SKILL.md +0 -17
- package/src/control.js +0 -236
package/src/server.js
CHANGED
|
@@ -4,7 +4,6 @@ import { readFile } from 'node:fs/promises';
|
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { startControl } from './control.js';
|
|
8
7
|
import { feedback, feedbackMarkdown } from './storage.js';
|
|
9
8
|
import { watchChange } from './watch.js';
|
|
10
9
|
|
|
@@ -15,16 +14,50 @@ const assets = new Map([
|
|
|
15
14
|
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
|
16
15
|
]);
|
|
17
16
|
const shell = await readFile(new URL('../web/index.html', import.meta.url), 'utf8');
|
|
17
|
+
const headers = {
|
|
18
|
+
'Content-Security-Policy':
|
|
19
|
+
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
20
|
+
'X-Content-Type-Options': 'nosniff',
|
|
21
|
+
'X-Frame-Options': 'DENY',
|
|
22
|
+
'Referrer-Policy': 'no-referrer',
|
|
23
|
+
'Cache-Control': 'no-store',
|
|
24
|
+
};
|
|
25
|
+
export const MAX_WAIT_MS = 3_600_000;
|
|
18
26
|
|
|
27
|
+
async function readJson(request) {
|
|
28
|
+
let bytes = 0;
|
|
29
|
+
const chunks = [];
|
|
30
|
+
for await (const chunk of request) {
|
|
31
|
+
bytes += chunk.length;
|
|
32
|
+
if (bytes > 65536) {
|
|
33
|
+
const error = new Error('Request exceeds 64 KiB.');
|
|
34
|
+
error.statusCode = 413;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
}
|
|
39
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
40
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
41
|
+
throw new Error('Expected a JSON object.');
|
|
42
|
+
return input;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Serves one review to the browser (capability in the URL fragment) and to agent commands
|
|
47
|
+
* (`/agent/*`, same capability). `onIdle` fires after `idleTimeout` ms with no browser stream
|
|
48
|
+
* and no waiting agent; `onStop` fires after an agent stop request is answered.
|
|
49
|
+
*/
|
|
19
50
|
export async function startServer(review, options = {}) {
|
|
51
|
+
const { watch = true, idleTimeout = 0, onIdle = () => {}, onStop = () => {} } = options;
|
|
20
52
|
const capability = randomBytes(32).toString('hex');
|
|
21
53
|
const route = `/review/${encodeURIComponent(review.view().change)}`;
|
|
22
54
|
let origin;
|
|
23
|
-
let listeners = 0;
|
|
24
55
|
let watchStatus = { automatic: false };
|
|
25
56
|
let watcher,
|
|
26
57
|
stopping = false,
|
|
27
|
-
watcherTask = Promise.resolve()
|
|
58
|
+
watcherTask = Promise.resolve(),
|
|
59
|
+
waiter = null,
|
|
60
|
+
idleTimer;
|
|
28
61
|
const clients = new Set();
|
|
29
62
|
const broadcast = (event) => {
|
|
30
63
|
for (const client of clients) {
|
|
@@ -34,6 +67,31 @@ export async function startServer(review, options = {}) {
|
|
|
34
67
|
} else client.write(`${JSON.stringify(event)}\n`);
|
|
35
68
|
}
|
|
36
69
|
};
|
|
70
|
+
const touch = () => {
|
|
71
|
+
clearTimeout(idleTimer);
|
|
72
|
+
if (!stopping && idleTimeout > 0 && !clients.size && !waiter)
|
|
73
|
+
idleTimer = setTimeout(onIdle, idleTimeout);
|
|
74
|
+
};
|
|
75
|
+
const presence = () => {
|
|
76
|
+
broadcast({ type: 'presence', listeners: waiter ? 1 : 0 });
|
|
77
|
+
touch();
|
|
78
|
+
};
|
|
79
|
+
const finishWait = (target, result) => {
|
|
80
|
+
if (!target || waiter !== target) return;
|
|
81
|
+
waiter = null;
|
|
82
|
+
clearTimeout(target.timer);
|
|
83
|
+
if (!target.response.destroyed) target.response.end(JSON.stringify(result));
|
|
84
|
+
presence();
|
|
85
|
+
};
|
|
86
|
+
const deliver = async (target) => {
|
|
87
|
+
try {
|
|
88
|
+
const batch = await review.deliver();
|
|
89
|
+
if (batch) finishWait(target, { status: 'batch', batch });
|
|
90
|
+
else if (review.state.endedAt) finishWait(target, { status: 'ended' });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
finishWait(target, { status: 'error', error: error.message });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
37
95
|
const reportWatch = (event) => {
|
|
38
96
|
watchStatus = { automatic: event.automatic, watchError: event.error };
|
|
39
97
|
broadcast(event);
|
|
@@ -46,25 +104,67 @@ export async function startServer(review, options = {}) {
|
|
|
46
104
|
await watcher?.close();
|
|
47
105
|
watcher = undefined;
|
|
48
106
|
reportWatch({ type: 'watch', automatic: false });
|
|
49
|
-
} else if (!watcher &&
|
|
50
|
-
watcher = await watchChange(review, reportWatch);
|
|
107
|
+
} else if (!watcher && watch) watcher = await watchChange(review, reportWatch);
|
|
51
108
|
})
|
|
52
109
|
.catch((error) => reportWatch({ type: 'watch', automatic: false, error: error.message }));
|
|
53
110
|
return watcherTask;
|
|
54
111
|
};
|
|
112
|
+
const refresh = () => (watcher ? watcher.retry() : review.refresh());
|
|
55
113
|
const unsubscribe = review.subscribe((event) => {
|
|
56
114
|
if (['refresh', 'activity', 'lifecycle'].includes(event.type)) broadcast(event);
|
|
57
|
-
if (event.type === 'lifecycle')
|
|
115
|
+
if (event.type === 'lifecycle') {
|
|
116
|
+
void syncWatcher();
|
|
117
|
+
// Re-check through deliver so a final Send & End batch reaches the waiter before ended.
|
|
118
|
+
if (event.ended && waiter) void deliver(waiter);
|
|
119
|
+
}
|
|
120
|
+
if (event.type === 'batch' && waiter) void deliver(waiter);
|
|
58
121
|
});
|
|
122
|
+
|
|
123
|
+
const agent = {
|
|
124
|
+
status: () => ({
|
|
125
|
+
status: 'active',
|
|
126
|
+
url,
|
|
127
|
+
ended: Boolean(review.state.endedAt),
|
|
128
|
+
listening: Boolean(waiter),
|
|
129
|
+
}),
|
|
130
|
+
refresh: async () => ({ status: 'refreshed', revision: (await refresh()).revision }),
|
|
131
|
+
end: async () => {
|
|
132
|
+
await review.mutate({ action: 'end', revision: review.state.revision });
|
|
133
|
+
return { status: 'ended' };
|
|
134
|
+
},
|
|
135
|
+
resume: async () => {
|
|
136
|
+
await review.mutate({ action: 'resume', revision: review.state.revision });
|
|
137
|
+
return { status: 'resumed' };
|
|
138
|
+
},
|
|
139
|
+
reply: async (input) => ({
|
|
140
|
+
status: 'recorded',
|
|
141
|
+
...(await review.recordActivity(input.batch, input)),
|
|
142
|
+
}),
|
|
143
|
+
stop: () => {
|
|
144
|
+
setImmediate(onStop);
|
|
145
|
+
return { status: 'stopping' };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function wait(input, response) {
|
|
150
|
+
const timeout = input.timeout ?? 540_000;
|
|
151
|
+
if (!Number.isInteger(timeout) || timeout < 0 || timeout > MAX_WAIT_MS)
|
|
152
|
+
throw new Error(`timeout must be 0–${MAX_WAIT_MS} ms.`);
|
|
153
|
+
if (waiter && !input.takeover) return { status: 'listener_active' };
|
|
154
|
+
finishWait(waiter, { status: 'listener_replaced' });
|
|
155
|
+
response.writeHead(200, { ...headers, 'Content-Type': 'application/json; charset=utf-8' });
|
|
156
|
+
const target = { response };
|
|
157
|
+
waiter = target;
|
|
158
|
+
presence();
|
|
159
|
+
response.once('close', () => finishWait(target, { status: 'disconnected' }));
|
|
160
|
+
// Queued feedback wins over a zero timeout, so the timer starts after the first delivery check.
|
|
161
|
+
void deliver(target).then(() => {
|
|
162
|
+
if (waiter === target)
|
|
163
|
+
target.timer = setTimeout(() => finishWait(target, { status: 'timeout' }), timeout);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
59
167
|
const server = http.createServer(async (request, response) => {
|
|
60
|
-
const headers = {
|
|
61
|
-
'Content-Security-Policy':
|
|
62
|
-
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
63
|
-
'X-Content-Type-Options': 'nosniff',
|
|
64
|
-
'X-Frame-Options': 'DENY',
|
|
65
|
-
'Referrer-Policy': 'no-referrer',
|
|
66
|
-
'Cache-Control': 'no-store',
|
|
67
|
-
};
|
|
68
168
|
const send = (code, data, type = 'application/json') => {
|
|
69
169
|
response.writeHead(code, { ...headers, 'Content-Type': `${type}; charset=utf-8` });
|
|
70
170
|
response.end(type === 'application/json' ? JSON.stringify(data) : data);
|
|
@@ -100,10 +200,14 @@ export async function startServer(review, options = {}) {
|
|
|
100
200
|
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
101
201
|
});
|
|
102
202
|
response.write(
|
|
103
|
-
`${JSON.stringify({ type: 'ready', revision: review.state.revision, listeners, ...watchStatus })}\n`,
|
|
203
|
+
`${JSON.stringify({ type: 'ready', revision: review.state.revision, listeners: waiter ? 1 : 0, ...watchStatus })}\n`,
|
|
104
204
|
);
|
|
105
205
|
clients.add(response);
|
|
106
|
-
|
|
206
|
+
touch();
|
|
207
|
+
response.once('close', () => {
|
|
208
|
+
clients.delete(response);
|
|
209
|
+
touch();
|
|
210
|
+
});
|
|
107
211
|
return;
|
|
108
212
|
}
|
|
109
213
|
if (request.method === 'GET' && url.pathname === `${route}/state`)
|
|
@@ -117,37 +221,33 @@ export async function startServer(review, options = {}) {
|
|
|
117
221
|
url.pathname.startsWith(`${route}/batch/`) && url.pathname.slice(`${route}/batch/`.length);
|
|
118
222
|
if (request.method === 'GET' && batchMatch && /^[a-f0-9-]{36}$/.test(batchMatch))
|
|
119
223
|
return send(200, { markdown: feedbackMarkdown(feedback(review.state, batchMatch)) });
|
|
224
|
+
const operation = url.pathname.startsWith('/agent/') && url.pathname.slice('/agent/'.length);
|
|
225
|
+
const browserMutation = [`${route}/refresh`, `${route}/action`].includes(url.pathname);
|
|
120
226
|
if (
|
|
121
227
|
request.method !== 'POST' ||
|
|
122
|
-
!
|
|
228
|
+
!(browserMutation || operation === 'wait' || Object.hasOwn(agent, operation))
|
|
123
229
|
)
|
|
124
230
|
return send(404, { error: 'Unknown route.' });
|
|
125
231
|
if (
|
|
126
|
-
request.headers.origin !== origin ||
|
|
232
|
+
(browserMutation && request.headers.origin !== origin) ||
|
|
127
233
|
request.headers['content-type'] !== 'application/json'
|
|
128
234
|
)
|
|
129
235
|
return send(403, { error: 'Mutation requires this review origin and JSON.' });
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
send(413, { error: 'Request exceeds 64 KiB.' });
|
|
136
|
-
request.resume();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
chunks.push(chunk);
|
|
140
|
-
}
|
|
141
|
-
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
142
|
-
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
143
|
-
throw new Error('Expected a JSON object.');
|
|
144
|
-
if (url.pathname.endsWith('/refresh')) {
|
|
145
|
-
const state = watcher ? await watcher.retry() : await review.refresh();
|
|
146
|
-
return send(200, { ...state, ...watchStatus });
|
|
236
|
+
const input = await readJson(request);
|
|
237
|
+
if (operation === 'wait') {
|
|
238
|
+
const refused = wait(input, response);
|
|
239
|
+
if (refused) send(200, refused);
|
|
240
|
+
return;
|
|
147
241
|
}
|
|
242
|
+
if (operation) return send(200, await agent[operation](input));
|
|
243
|
+
if (url.pathname.endsWith('/refresh'))
|
|
244
|
+
return send(200, { ...(await refresh()), ...watchStatus });
|
|
148
245
|
return send(200, await review.mutate(input));
|
|
149
246
|
} catch (error) {
|
|
150
|
-
|
|
247
|
+
if (response.headersSent) return;
|
|
248
|
+
send(error.statusCode || (/conflict/i.test(error.message) ? 409 : 400), {
|
|
249
|
+
error: error.message,
|
|
250
|
+
});
|
|
151
251
|
}
|
|
152
252
|
});
|
|
153
253
|
server.requestTimeout = 20000;
|
|
@@ -159,34 +259,20 @@ export async function startServer(review, options = {}) {
|
|
|
159
259
|
});
|
|
160
260
|
origin = `http://127.0.0.1:${server.address().port}`;
|
|
161
261
|
const url = `${origin}${route}#${capability}`;
|
|
162
|
-
let control;
|
|
163
|
-
try {
|
|
164
|
-
control = await startControl(
|
|
165
|
-
review,
|
|
166
|
-
url,
|
|
167
|
-
(count) => {
|
|
168
|
-
listeners = count;
|
|
169
|
-
broadcast({ type: 'presence', listeners });
|
|
170
|
-
},
|
|
171
|
-
() => (watcher ? watcher.retry() : review.refresh()),
|
|
172
|
-
);
|
|
173
|
-
} catch (error) {
|
|
174
|
-
unsubscribe();
|
|
175
|
-
server.closeAllConnections();
|
|
176
|
-
server.close();
|
|
177
|
-
throw error;
|
|
178
|
-
}
|
|
179
262
|
await syncWatcher();
|
|
263
|
+
touch();
|
|
180
264
|
return {
|
|
181
265
|
url,
|
|
182
266
|
origin,
|
|
183
267
|
route,
|
|
184
268
|
async close() {
|
|
185
269
|
stopping = true;
|
|
270
|
+
clearTimeout(idleTimer);
|
|
186
271
|
unsubscribe();
|
|
272
|
+
finishWait(waiter, { status: 'stopped' });
|
|
273
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
187
274
|
await watcherTask;
|
|
188
275
|
await watcher?.close();
|
|
189
|
-
await control.close();
|
|
190
276
|
for (const client of clients) client.end();
|
|
191
277
|
clients.clear();
|
|
192
278
|
server.closeAllConnections();
|
|
@@ -198,8 +284,8 @@ export async function startServer(review, options = {}) {
|
|
|
198
284
|
};
|
|
199
285
|
}
|
|
200
286
|
|
|
287
|
+
const openers = { darwin: 'open', linux: 'xdg-open' };
|
|
201
288
|
export async function openBrowser(url, run = promisify(execFile), platform = process.platform) {
|
|
202
|
-
if (platform
|
|
203
|
-
|
|
204
|
-
await run('open', [url], { timeout: 10000, maxBuffer: 65536 });
|
|
289
|
+
if (!openers[platform]) throw new Error('Automatic opening supports macOS and Linux only.');
|
|
290
|
+
await run(openers[platform], [url], { timeout: 10000, maxBuffer: 65536 });
|
|
205
291
|
}
|
package/src/storage.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { chmod, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { hash, within } from './discovery.js';
|
|
7
|
-
import { readLimited } from './files.js';
|
|
7
|
+
import { readFileLimited, readLimited } from './files.js';
|
|
8
8
|
|
|
9
9
|
export function stateDirectory() {
|
|
10
10
|
if (process.env.SPECTATER_STATE_DIR) return path.resolve(process.env.SPECTATER_STATE_DIR);
|
|
11
11
|
if (process.platform === 'darwin')
|
|
12
12
|
return path.join(os.homedir(), 'Library', 'Application Support', 'SpecTater');
|
|
13
|
-
if (process.platform === 'win32')
|
|
14
|
-
return path.join(process.env.LOCALAPPDATA || os.homedir(), 'SpecTater');
|
|
15
13
|
return path.join(
|
|
16
14
|
process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
|
|
17
15
|
'spectater',
|
|
@@ -56,6 +54,11 @@ function validate(state, id) {
|
|
|
56
54
|
(typeof state.endedAt !== 'string' || !Number.isFinite(Date.parse(state.endedAt)))
|
|
57
55
|
)
|
|
58
56
|
throw new Error('Corrupt review completion metadata.');
|
|
57
|
+
if (
|
|
58
|
+
state.endRequestedAt !== undefined &&
|
|
59
|
+
(typeof state.endRequestedAt !== 'string' || !Number.isFinite(Date.parse(state.endRequestedAt)))
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Corrupt review completion metadata.');
|
|
59
62
|
for (const [key, snapshot] of Object.entries(state.snapshots)) {
|
|
60
63
|
if (
|
|
61
64
|
typeof snapshot.source !== 'string' ||
|
|
@@ -97,7 +100,10 @@ function validate(state, id) {
|
|
|
97
100
|
typeof b.id !== 'string' ||
|
|
98
101
|
b.sequence !== i + 1 ||
|
|
99
102
|
typeof b.submittedAt !== 'string' ||
|
|
100
|
-
!Array.isArray(b.entries)
|
|
103
|
+
!Array.isArray(b.entries) ||
|
|
104
|
+
(b.message !== undefined &&
|
|
105
|
+
(typeof b.message !== 'string' || !b.message.trim() || b.message.length > 10000)) ||
|
|
106
|
+
(!b.entries.length && b.message === undefined)
|
|
101
107
|
)
|
|
102
108
|
throw new Error('Corrupt feedback batch. Restore a valid copy.');
|
|
103
109
|
});
|
|
@@ -212,6 +218,25 @@ export async function loadState(directory, id) {
|
|
|
212
218
|
}
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
export function alive(pid) {
|
|
222
|
+
try {
|
|
223
|
+
process.kill(pid, 0);
|
|
224
|
+
return true;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return error.code === 'EPERM';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A lock is abandoned only when it names a process that no longer exists.
|
|
231
|
+
async function abandoned(lock) {
|
|
232
|
+
try {
|
|
233
|
+
const { pid } = JSON.parse(new TextDecoder().decode(await readFileLimited(lock, 1024)));
|
|
234
|
+
return Number.isSafeInteger(pid) && pid > 0 && !alive(pid);
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
215
240
|
export async function openStore(review, directory = stateDirectory()) {
|
|
216
241
|
directory = path.resolve(directory);
|
|
217
242
|
if (directory === review.root || within(review.root, directory))
|
|
@@ -232,9 +257,29 @@ export async function openStore(review, directory = stateDirectory()) {
|
|
|
232
257
|
try {
|
|
233
258
|
lockHandle = await open(lock, 'wx', 0o600);
|
|
234
259
|
} catch (error) {
|
|
235
|
-
|
|
236
|
-
`Review writer lock unavailable (${error.code}).
|
|
260
|
+
const unavailable = new Error(
|
|
261
|
+
`Review writer lock unavailable (${error.code}). Another SpecTater process owns this review.`,
|
|
237
262
|
);
|
|
263
|
+
if (error.code !== 'EEXIST' || !(await abandoned(lock))) throw unavailable;
|
|
264
|
+
// Move the lock aside before judging it, so a concurrent reclaimer's fresh lock is restored.
|
|
265
|
+
const moved = `${lock}.${randomUUID()}.stale`;
|
|
266
|
+
try {
|
|
267
|
+
await rename(lock, moved);
|
|
268
|
+
} catch {
|
|
269
|
+
throw unavailable;
|
|
270
|
+
}
|
|
271
|
+
if (!(await abandoned(moved))) {
|
|
272
|
+
// Restore the live lock; if another process took the name meanwhile, keep it aside for inspection.
|
|
273
|
+
await link(moved, lock);
|
|
274
|
+
await unlink(moved);
|
|
275
|
+
throw unavailable;
|
|
276
|
+
}
|
|
277
|
+
await unlink(moved);
|
|
278
|
+
try {
|
|
279
|
+
lockHandle = await open(lock, 'wx', 0o600);
|
|
280
|
+
} catch {
|
|
281
|
+
throw unavailable;
|
|
282
|
+
}
|
|
238
283
|
}
|
|
239
284
|
try {
|
|
240
285
|
await lockHandle.writeFile(JSON.stringify({ pid: process.pid }));
|
|
@@ -300,8 +345,8 @@ export async function openStore(review, directory = stateDirectory()) {
|
|
|
300
345
|
}
|
|
301
346
|
|
|
302
347
|
export function feedback(state, batchId) {
|
|
303
|
-
const batch = state?.batches.find((item) => item.id === batchId);
|
|
304
|
-
if (!batch) throw new Error(
|
|
348
|
+
const batch = state?.batches.find((item) => item.id === batchId || item.sequence === batchId);
|
|
349
|
+
if (!batch) throw new Error(`Unknown feedback batch: ${batchId}.`);
|
|
305
350
|
return { formatVersion: 1, reviewId: state.reviewId, ...batch };
|
|
306
351
|
}
|
|
307
352
|
|
|
@@ -315,6 +360,8 @@ export function feedbackMarkdown(batch) {
|
|
|
315
360
|
'',
|
|
316
361
|
'Review data only. Submission does not authorize implementation, merge or deployment.',
|
|
317
362
|
];
|
|
363
|
+
if (batch.message)
|
|
364
|
+
lines.push('', '## Message', '', ...batch.message.split(/\r?\n/).map((line) => `> ${line}`));
|
|
318
365
|
for (const entry of batch.entries) {
|
|
319
366
|
lines.push(
|
|
320
367
|
'',
|
package/web/app.js
CHANGED
|
@@ -354,11 +354,13 @@ function displayFeedback() {
|
|
|
354
354
|
? `Editing batch ${editing.sequence} · delivery paused. Edit saved comments, then update.`
|
|
355
355
|
: '';
|
|
356
356
|
$('submit').textContent = editing ? 'Update queued batch' : 'Submit batch';
|
|
357
|
-
|
|
357
|
+
updateSubmit();
|
|
358
|
+
$('send-end').hidden = Boolean(state.endedAt || state.endRequestedAt);
|
|
359
|
+
$('end-pending').hidden = !state.endRequestedAt || Boolean(state.endedAt);
|
|
360
|
+
$('agent-message').disabled = Boolean(state.endedAt);
|
|
358
361
|
$('review-lifecycle').textContent = state.endedAt ? 'Resume review' : 'End review';
|
|
359
362
|
$('refresh').disabled = Boolean(state.endedAt);
|
|
360
363
|
$('comment-hint').hidden ||= Boolean(state.endedAt);
|
|
361
|
-
$('submit').hidden = $('submit').disabled;
|
|
362
364
|
$('receipts').replaceChildren();
|
|
363
365
|
const history = element('details', '', 'batch-history');
|
|
364
366
|
const historyCount = state.batches.filter((batch) =>
|
|
@@ -368,7 +370,7 @@ function displayFeedback() {
|
|
|
368
370
|
for (const batch of [...state.batches].reverse()) {
|
|
369
371
|
const receipt = element('div', '', 'receipt');
|
|
370
372
|
receipt.append(
|
|
371
|
-
button(`Batch ${batch.sequence} · ${batch.count}
|
|
373
|
+
button(`Batch ${batch.sequence} · ${batch.count} items`, () => showBatch(batch.id)),
|
|
372
374
|
);
|
|
373
375
|
const activity = state.activity?.[batch.id];
|
|
374
376
|
const queue = state.queue?.[batch.id];
|
|
@@ -452,24 +454,26 @@ async function loadComparison(id) {
|
|
|
452
454
|
}
|
|
453
455
|
}
|
|
454
456
|
async function operation(fn) {
|
|
455
|
-
if (busy) return;
|
|
457
|
+
if (busy) return false;
|
|
456
458
|
busy = true;
|
|
457
459
|
document.body.setAttribute('aria-busy', 'true');
|
|
458
460
|
try {
|
|
459
461
|
await fn();
|
|
462
|
+
return true;
|
|
460
463
|
} catch (error) {
|
|
461
464
|
announce(error.message, true);
|
|
462
465
|
if ($('comment-popover').matches(':popover-open')) {
|
|
463
466
|
$('composer-error').textContent = error.message;
|
|
464
467
|
$('composer-error').hidden = false;
|
|
465
468
|
}
|
|
469
|
+
return false;
|
|
466
470
|
} finally {
|
|
467
471
|
busy = false;
|
|
468
472
|
document.body.setAttribute('aria-busy', 'false');
|
|
469
473
|
}
|
|
470
474
|
}
|
|
471
475
|
async function action(input) {
|
|
472
|
-
|
|
476
|
+
return operation(async () => {
|
|
473
477
|
acceptState(await api('action', { ...input, revision: state.revision }));
|
|
474
478
|
if (input.action === 'comment' || input.action === 'edit') {
|
|
475
479
|
$('comment').value = '';
|
|
@@ -478,6 +482,7 @@ async function action(input) {
|
|
|
478
482
|
persistDraft();
|
|
479
483
|
}
|
|
480
484
|
if (input.action === 'delete') undoDeletedId = input.id;
|
|
485
|
+
if (input.action === 'submit') $('agent-message').value = '';
|
|
481
486
|
if (input.action === 'end') closeComposer();
|
|
482
487
|
display();
|
|
483
488
|
announce(
|
|
@@ -486,7 +491,11 @@ async function action(input) {
|
|
|
486
491
|
: input.action === 'resume'
|
|
487
492
|
? 'Review resumed.'
|
|
488
493
|
: input.action === 'submit'
|
|
489
|
-
?
|
|
494
|
+
? state.endedAt
|
|
495
|
+
? 'Review ended.'
|
|
496
|
+
: input.end
|
|
497
|
+
? 'Batch saved. The review ends once the agent receives it.'
|
|
498
|
+
: 'Batch saved.'
|
|
490
499
|
: input.action === 'cancel-batch'
|
|
491
500
|
? 'Submission cancelled. Comments kept.'
|
|
492
501
|
: input.action === 'edit-batch'
|
|
@@ -495,14 +504,16 @@ async function action(input) {
|
|
|
495
504
|
? 'Comment deleted.'
|
|
496
505
|
: 'Saved.',
|
|
497
506
|
);
|
|
498
|
-
if (input.action === 'submit'
|
|
507
|
+
if (input.action === 'submit' && state.batches.length)
|
|
508
|
+
await showBatch(state.batches.at(-1).id, false);
|
|
499
509
|
});
|
|
500
510
|
}
|
|
501
511
|
async function showBatch(id, open = true) {
|
|
502
512
|
try {
|
|
503
513
|
$('export').value = (await api(`batch/${id}`)).markdown;
|
|
514
|
+
const batch = state.batches.find((item) => item.id === id);
|
|
504
515
|
$('retrieval-command').textContent =
|
|
505
|
-
`spectater feedback
|
|
516
|
+
`spectater feedback ${state.change} --batch ${batch.sequence} --format markdown`;
|
|
506
517
|
$('export-details').hidden = false;
|
|
507
518
|
$('export-details').open = open;
|
|
508
519
|
} catch (error) {
|
|
@@ -599,10 +610,14 @@ document.addEventListener('keyup', (event) => {
|
|
|
599
610
|
)
|
|
600
611
|
captureSelection();
|
|
601
612
|
});
|
|
613
|
+
function saveComment() {
|
|
614
|
+
if (editingId) return action({ action: 'edit', id: editingId, body: $('comment').value });
|
|
615
|
+
if (selection) return action({ action: 'comment', ...selection, body: $('comment').value });
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
602
618
|
$('comment-form').addEventListener('submit', (event) => {
|
|
603
619
|
event.preventDefault();
|
|
604
|
-
|
|
605
|
-
else if (selection) action({ action: 'comment', ...selection, body: $('comment').value });
|
|
620
|
+
void saveComment();
|
|
606
621
|
});
|
|
607
622
|
$('comment').addEventListener('input', persistDraft);
|
|
608
623
|
$('comment-popover').addEventListener('toggle', updateDraftButton);
|
|
@@ -615,7 +630,32 @@ $('review-lifecycle').addEventListener('click', () => {
|
|
|
615
630
|
if (!state.endedAt) editingId = undefined;
|
|
616
631
|
void action({ action: state.endedAt ? 'resume' : 'end' });
|
|
617
632
|
});
|
|
618
|
-
|
|
633
|
+
function updateSubmit() {
|
|
634
|
+
$('submit').disabled =
|
|
635
|
+
Boolean(state.endedAt) || !(state.pendingCommentIds.length || $('agent-message').value.trim());
|
|
636
|
+
}
|
|
637
|
+
function submit(end = false) {
|
|
638
|
+
const message = $('agent-message').value.trim();
|
|
639
|
+
return action({ action: 'submit', ...(message ? { message } : {}), ...(end ? { end } : {}) });
|
|
640
|
+
}
|
|
641
|
+
$('submit').addEventListener('click', () => submit());
|
|
642
|
+
$('send-end').addEventListener('click', () => {
|
|
643
|
+
if ($('comment').value)
|
|
644
|
+
return announce('Save or clear the unsaved draft before ending the review.', true);
|
|
645
|
+
void submit(true);
|
|
646
|
+
});
|
|
647
|
+
$('agent-message').addEventListener('input', updateSubmit);
|
|
648
|
+
$('agent-message').addEventListener('keydown', (event) => {
|
|
649
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
if (!$('submit').disabled) void submit();
|
|
652
|
+
});
|
|
653
|
+
$('comment').addEventListener('keydown', async (event) => {
|
|
654
|
+
if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
|
|
655
|
+
event.preventDefault();
|
|
656
|
+
if ($('save').disabled || !(await saveComment())) return;
|
|
657
|
+
if ((event.metaKey || event.ctrlKey) && !$('submit').disabled) await submit();
|
|
658
|
+
});
|
|
619
659
|
$('refresh').addEventListener('click', () =>
|
|
620
660
|
operation(async () => {
|
|
621
661
|
acceptState(await api('refresh', {}));
|
package/web/index.html
CHANGED
|
@@ -98,7 +98,20 @@
|
|
|
98
98
|
</details>
|
|
99
99
|
<div class="submission">
|
|
100
100
|
<p id="queue-editing" class="note" hidden></p>
|
|
101
|
-
<
|
|
101
|
+
<label for="agent-message">Message to agent</label>
|
|
102
|
+
<textarea
|
|
103
|
+
id="agent-message"
|
|
104
|
+
rows="2"
|
|
105
|
+
maxlength="10000"
|
|
106
|
+
placeholder="Optional. Enter sends; Shift+Enter adds a line."
|
|
107
|
+
></textarea>
|
|
108
|
+
<div class="submit-actions">
|
|
109
|
+
<button id="submit" type="button" disabled>Submit batch</button
|
|
110
|
+
><button id="send-end" type="button">Send & End</button>
|
|
111
|
+
</div>
|
|
112
|
+
<p id="end-pending" class="note" hidden>
|
|
113
|
+
Ending after the agent receives the final batch.
|
|
114
|
+
</p>
|
|
102
115
|
<div id="receipts"></div>
|
|
103
116
|
<button id="review-lifecycle" type="button">End review</button>
|
|
104
117
|
<details id="export-details" hidden>
|
|
@@ -135,7 +148,7 @@
|
|
|
135
148
|
rows="3"
|
|
136
149
|
maxlength="10000"
|
|
137
150
|
required
|
|
138
|
-
placeholder="Add a comment…"
|
|
151
|
+
placeholder="Add a comment… Enter saves; Cmd/Ctrl+Enter saves and submits."
|
|
139
152
|
></textarea>
|
|
140
153
|
<button id="save" type="submit" disabled>Save comment</button>
|
|
141
154
|
</form>
|
package/web/style.css
CHANGED
|
@@ -411,6 +411,18 @@ textarea::placeholder {
|
|
|
411
411
|
.submission {
|
|
412
412
|
margin-top: var(--space-6);
|
|
413
413
|
}
|
|
414
|
+
.submission label {
|
|
415
|
+
display: block;
|
|
416
|
+
font-size: var(--text-xs);
|
|
417
|
+
color: var(--muted);
|
|
418
|
+
margin-bottom: var(--space-1);
|
|
419
|
+
}
|
|
420
|
+
.submit-actions {
|
|
421
|
+
display: flex;
|
|
422
|
+
flex-wrap: wrap;
|
|
423
|
+
gap: var(--space-2);
|
|
424
|
+
margin: var(--space-2) 0;
|
|
425
|
+
}
|
|
414
426
|
.receipt {
|
|
415
427
|
margin: var(--space-3) 0;
|
|
416
428
|
}
|
package/SECURITY.md
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# Security
|
|
2
|
-
|
|
3
|
-
SpecTater is a local tool. Verified platforms and limits are documented in README.md.
|
|
4
|
-
|
|
5
|
-
Do not disclose vulnerabilities, secrets or private artifact content in a public issue. Use GitHub's private vulnerability reporting for this repository when available. Otherwise contact the maintainer privately through the contact information on their GitHub profile before sharing technical details.
|
|
6
|
-
|
|
7
|
-
The implementation runs locally on loopback and serves only the selected change manifest. A per-process capability authorizes review requests; Host/Origin checks and CSP restrict browser access. Raw HTML remains escaped, external images do not load, and the reader never writes canonical Markdown. These boundaries do not isolate the process from local users who can modify it or its files.
|
|
8
|
-
|
|
9
|
-
Review snapshots may contain private source. Storage is owner-only outside the repository, remains until explicit removal, and is never uploaded by SpecTater. Keep capability URLs and review-state files private. Do not commit state, browser profiles or exported private feedback. File, request and state size limits fail visibly; no automatic retention cleanup runs.
|
|
10
|
-
|
|
11
|
-
Contract and browser tests exercise filesystem escape, active Markdown, foreign requests, bounded native reads, queue/lifecycle races and persistence failures. See [the security pass](docs/security-review.md) for the source-review scope and the independent release-review finding. Automated tests do not replace review.
|