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/server.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { feedback, feedbackMarkdown } from './storage.js';
|
|
8
|
+
import { watchChange } from './watch.js';
|
|
9
|
+
|
|
10
|
+
const assets = new Map([
|
|
11
|
+
['/app.js', ['../web/app.js', 'text/javascript']],
|
|
12
|
+
['/theme.js', ['../web/theme.js', 'text/javascript']],
|
|
13
|
+
['/style.css', ['../web/style.css', 'text/css']],
|
|
14
|
+
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
|
15
|
+
]);
|
|
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;
|
|
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
|
+
*/
|
|
50
|
+
export async function startServer(review, options = {}) {
|
|
51
|
+
const { watch = true, idleTimeout = 0, onIdle = () => {}, onStop = () => {} } = options;
|
|
52
|
+
const capability = randomBytes(32).toString('hex');
|
|
53
|
+
const route = `/review/${encodeURIComponent(review.view().change)}`;
|
|
54
|
+
let origin;
|
|
55
|
+
let watchStatus = { automatic: false };
|
|
56
|
+
let watcher,
|
|
57
|
+
stopping = false,
|
|
58
|
+
watcherTask = Promise.resolve(),
|
|
59
|
+
waiter = null,
|
|
60
|
+
idleTimer;
|
|
61
|
+
const clients = new Set();
|
|
62
|
+
const broadcast = (event) => {
|
|
63
|
+
for (const client of clients) {
|
|
64
|
+
if (client.destroyed || client.writableLength > 65536) {
|
|
65
|
+
clients.delete(client);
|
|
66
|
+
client.destroy();
|
|
67
|
+
} else client.write(`${JSON.stringify(event)}\n`);
|
|
68
|
+
}
|
|
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
|
+
};
|
|
95
|
+
const reportWatch = (event) => {
|
|
96
|
+
watchStatus = { automatic: event.automatic, watchError: event.error };
|
|
97
|
+
broadcast(event);
|
|
98
|
+
};
|
|
99
|
+
const syncWatcher = () => {
|
|
100
|
+
watcherTask = watcherTask
|
|
101
|
+
.then(async () => {
|
|
102
|
+
if (stopping) return;
|
|
103
|
+
if (review.state.endedAt) {
|
|
104
|
+
await watcher?.close();
|
|
105
|
+
watcher = undefined;
|
|
106
|
+
reportWatch({ type: 'watch', automatic: false });
|
|
107
|
+
} else if (!watcher && watch) watcher = await watchChange(review, reportWatch);
|
|
108
|
+
})
|
|
109
|
+
.catch((error) => reportWatch({ type: 'watch', automatic: false, error: error.message }));
|
|
110
|
+
return watcherTask;
|
|
111
|
+
};
|
|
112
|
+
const refresh = () => (watcher ? watcher.retry() : review.refresh());
|
|
113
|
+
const unsubscribe = review.subscribe((event) => {
|
|
114
|
+
if (['refresh', 'activity', 'lifecycle'].includes(event.type)) broadcast(event);
|
|
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);
|
|
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
|
+
|
|
167
|
+
const server = http.createServer(async (request, response) => {
|
|
168
|
+
const send = (code, data, type = 'application/json') => {
|
|
169
|
+
response.writeHead(code, { ...headers, 'Content-Type': `${type}; charset=utf-8` });
|
|
170
|
+
response.end(type === 'application/json' ? JSON.stringify(data) : data);
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
if (request.headers.host !== new URL(origin).host)
|
|
174
|
+
return send(403, { error: 'Invalid Host.' });
|
|
175
|
+
if (request.headers.origin && request.headers.origin !== origin)
|
|
176
|
+
return send(403, { error: 'Foreign origin refused.' });
|
|
177
|
+
if (request.headers['sec-fetch-site'] === 'cross-site')
|
|
178
|
+
return send(403, { error: 'Cross-site request refused.' });
|
|
179
|
+
const url = new URL(request.url, origin);
|
|
180
|
+
if (url.search) return send(404, { error: 'Unknown route.' });
|
|
181
|
+
if (request.method === 'GET' && url.pathname === route) return send(200, shell, 'text/html');
|
|
182
|
+
if (request.method === 'GET' && assets.has(url.pathname)) {
|
|
183
|
+
const [file, type] = assets.get(url.pathname);
|
|
184
|
+
return send(
|
|
185
|
+
200,
|
|
186
|
+
await readFile(fileURLToPath(new URL(file, import.meta.url)), 'utf8'),
|
|
187
|
+
type,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const token = request.headers.authorization || '';
|
|
191
|
+
const expected = `Bearer ${capability}`;
|
|
192
|
+
if (
|
|
193
|
+
token.length !== expected.length ||
|
|
194
|
+
!timingSafeEqual(Buffer.from(token), Buffer.from(expected))
|
|
195
|
+
)
|
|
196
|
+
return send(403, { error: 'Review capability required. Reopen the printed URL.' });
|
|
197
|
+
if (request.method === 'GET' && url.pathname === `${route}/events`) {
|
|
198
|
+
response.writeHead(200, {
|
|
199
|
+
...headers,
|
|
200
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
201
|
+
});
|
|
202
|
+
response.write(
|
|
203
|
+
`${JSON.stringify({ type: 'ready', revision: review.state.revision, listeners: waiter ? 1 : 0, ...watchStatus })}\n`,
|
|
204
|
+
);
|
|
205
|
+
clients.add(response);
|
|
206
|
+
touch();
|
|
207
|
+
response.once('close', () => {
|
|
208
|
+
clients.delete(response);
|
|
209
|
+
touch();
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (request.method === 'GET' && url.pathname === `${route}/state`)
|
|
214
|
+
return send(200, { ...review.view(), ...watchStatus });
|
|
215
|
+
const comparison =
|
|
216
|
+
url.pathname.startsWith(`${route}/changes/`) &&
|
|
217
|
+
url.pathname.slice(`${route}/changes/`.length);
|
|
218
|
+
if (request.method === 'GET' && comparison && /^[a-f0-9]{24}$/.test(comparison))
|
|
219
|
+
return send(200, await review.compare(comparison));
|
|
220
|
+
const batchMatch =
|
|
221
|
+
url.pathname.startsWith(`${route}/batch/`) && url.pathname.slice(`${route}/batch/`.length);
|
|
222
|
+
if (request.method === 'GET' && batchMatch && /^[a-f0-9-]{36}$/.test(batchMatch))
|
|
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);
|
|
226
|
+
if (
|
|
227
|
+
request.method !== 'POST' ||
|
|
228
|
+
!(browserMutation || operation === 'wait' || Object.hasOwn(agent, operation))
|
|
229
|
+
)
|
|
230
|
+
return send(404, { error: 'Unknown route.' });
|
|
231
|
+
if (
|
|
232
|
+
(browserMutation && request.headers.origin !== origin) ||
|
|
233
|
+
request.headers['content-type'] !== 'application/json'
|
|
234
|
+
)
|
|
235
|
+
return send(403, { error: 'Mutation requires this review origin and JSON.' });
|
|
236
|
+
const input = await readJson(request);
|
|
237
|
+
if (operation === 'wait') {
|
|
238
|
+
const refused = wait(input, response);
|
|
239
|
+
if (refused) send(200, refused);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (operation) return send(200, await agent[operation](input));
|
|
243
|
+
if (url.pathname.endsWith('/refresh'))
|
|
244
|
+
return send(200, { ...(await refresh()), ...watchStatus });
|
|
245
|
+
return send(200, await review.mutate(input));
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (response.headersSent) return;
|
|
248
|
+
send(error.statusCode || (/conflict/i.test(error.message) ? 409 : 400), {
|
|
249
|
+
error: error.message,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
server.requestTimeout = 20000;
|
|
254
|
+
server.headersTimeout = 10000;
|
|
255
|
+
server.maxConnections = 32;
|
|
256
|
+
await new Promise((resolve, reject) => {
|
|
257
|
+
server.once('error', reject);
|
|
258
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
259
|
+
});
|
|
260
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
261
|
+
const url = `${origin}${route}#${capability}`;
|
|
262
|
+
await syncWatcher();
|
|
263
|
+
touch();
|
|
264
|
+
return {
|
|
265
|
+
url,
|
|
266
|
+
origin,
|
|
267
|
+
route,
|
|
268
|
+
async close() {
|
|
269
|
+
stopping = true;
|
|
270
|
+
clearTimeout(idleTimer);
|
|
271
|
+
unsubscribe();
|
|
272
|
+
finishWait(waiter, { status: 'stopped' });
|
|
273
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
274
|
+
await watcherTask;
|
|
275
|
+
await watcher?.close();
|
|
276
|
+
for (const client of clients) client.end();
|
|
277
|
+
clients.clear();
|
|
278
|
+
server.closeAllConnections();
|
|
279
|
+
await new Promise((resolve, reject) =>
|
|
280
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
281
|
+
);
|
|
282
|
+
await review.close();
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const openers = { darwin: 'open', linux: 'xdg-open' };
|
|
288
|
+
export async function openBrowser(url, run = promisify(execFile), platform = process.platform) {
|
|
289
|
+
if (!openers[platform]) throw new Error('Automatic opening supports macOS and Linux only.');
|
|
290
|
+
await run(openers[platform], [url], { timeout: 10000, maxBuffer: 65536 });
|
|
291
|
+
}
|
package/src/storage.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, realpath, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { hash, within } from './discovery.js';
|
|
7
|
+
import { readFileLimited, readLimited } from './files.js';
|
|
8
|
+
|
|
9
|
+
export function stateDirectory() {
|
|
10
|
+
if (process.env.SPECTATER_STATE_DIR) return path.resolve(process.env.SPECTATER_STATE_DIR);
|
|
11
|
+
if (process.platform === 'darwin')
|
|
12
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'SpecTater');
|
|
13
|
+
return path.join(
|
|
14
|
+
process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'),
|
|
15
|
+
'spectater',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function privateDirectory(directory) {
|
|
20
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
21
|
+
const stat = await lstat(directory);
|
|
22
|
+
if (
|
|
23
|
+
!stat.isDirectory() ||
|
|
24
|
+
stat.isSymbolicLink() ||
|
|
25
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
26
|
+
)
|
|
27
|
+
throw new Error('Review storage must be an owned private directory.');
|
|
28
|
+
if (process.platform === 'win32')
|
|
29
|
+
throw new Error(
|
|
30
|
+
'Private Windows storage is not verified; this release supports macOS/Linux permissions only.',
|
|
31
|
+
);
|
|
32
|
+
if ((await realpath(directory)) !== path.resolve(directory))
|
|
33
|
+
throw new Error('Review storage must not use symlinked directories.');
|
|
34
|
+
await chmod(directory, 0o700);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validate(state, id) {
|
|
38
|
+
if (
|
|
39
|
+
state?.formatVersion !== 1 ||
|
|
40
|
+
state.reviewId !== id ||
|
|
41
|
+
!Number.isSafeInteger(state.revision) ||
|
|
42
|
+
state.revision < 0 ||
|
|
43
|
+
!Array.isArray(state.comments) ||
|
|
44
|
+
!Array.isArray(state.batches) ||
|
|
45
|
+
!state.snapshots ||
|
|
46
|
+
!Array.isArray(state.documentIds) ||
|
|
47
|
+
typeof state.root !== 'string' ||
|
|
48
|
+
typeof state.changeRoot !== 'string' ||
|
|
49
|
+
typeof state.schemaName !== 'string'
|
|
50
|
+
)
|
|
51
|
+
throw new Error('Invalid or unsupported review state; preserve it and restore a valid copy.');
|
|
52
|
+
if (
|
|
53
|
+
state.endedAt !== undefined &&
|
|
54
|
+
(typeof state.endedAt !== 'string' || !Number.isFinite(Date.parse(state.endedAt)))
|
|
55
|
+
)
|
|
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.');
|
|
62
|
+
for (const [key, snapshot] of Object.entries(state.snapshots)) {
|
|
63
|
+
if (
|
|
64
|
+
typeof snapshot.source !== 'string' ||
|
|
65
|
+
snapshot.hash !== hash(Buffer.from(snapshot.source)) ||
|
|
66
|
+
key !== snapshot.hash ||
|
|
67
|
+
typeof snapshot.path !== 'string'
|
|
68
|
+
)
|
|
69
|
+
throw new Error('Corrupt review snapshot. Restore a valid copy.');
|
|
70
|
+
}
|
|
71
|
+
for (const c of [
|
|
72
|
+
...state.comments,
|
|
73
|
+
...state.batches.flatMap((b) => (Array.isArray(b.entries) ? b.entries : [null])),
|
|
74
|
+
]) {
|
|
75
|
+
const snapshot = c && state.snapshots[c.hash];
|
|
76
|
+
if (
|
|
77
|
+
c?.deletedAt !== undefined &&
|
|
78
|
+
(typeof c.deletedAt !== 'string' || !Number.isFinite(Date.parse(c.deletedAt)))
|
|
79
|
+
)
|
|
80
|
+
throw new Error('Corrupt comment deletion metadata.');
|
|
81
|
+
if (
|
|
82
|
+
!snapshot ||
|
|
83
|
+
typeof c.id !== 'string' ||
|
|
84
|
+
typeof c.body !== 'string' ||
|
|
85
|
+
typeof c.documentId !== 'string' ||
|
|
86
|
+
typeof c.path !== 'string' ||
|
|
87
|
+
!Number.isInteger(c.start) ||
|
|
88
|
+
!Number.isInteger(c.end) ||
|
|
89
|
+
c.start < 0 ||
|
|
90
|
+
c.end <= c.start ||
|
|
91
|
+
c.end > snapshot.source.length ||
|
|
92
|
+
snapshot.source.slice(c.start, c.end) !== c.slice ||
|
|
93
|
+
!['exact', 'enclosing'].includes(c.precision) ||
|
|
94
|
+
!['open', 'resolved'].includes(c.state)
|
|
95
|
+
)
|
|
96
|
+
throw new Error('Corrupt review comment. Restore a valid copy.');
|
|
97
|
+
}
|
|
98
|
+
state.batches.forEach((b, i) => {
|
|
99
|
+
if (
|
|
100
|
+
typeof b.id !== 'string' ||
|
|
101
|
+
b.sequence !== i + 1 ||
|
|
102
|
+
typeof b.submittedAt !== 'string' ||
|
|
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)
|
|
107
|
+
)
|
|
108
|
+
throw new Error('Corrupt feedback batch. Restore a valid copy.');
|
|
109
|
+
});
|
|
110
|
+
if (state.activity !== undefined) {
|
|
111
|
+
if (!state.activity || typeof state.activity !== 'object' || Array.isArray(state.activity))
|
|
112
|
+
throw new Error('Corrupt feedback activity.');
|
|
113
|
+
const timestamp = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
114
|
+
for (const [batchId, activity] of Object.entries(state.activity)) {
|
|
115
|
+
if (
|
|
116
|
+
!state.batches.some((batch) => batch.id === batchId) ||
|
|
117
|
+
!activity ||
|
|
118
|
+
!['received', 'working', 'replied'].includes(activity.status) ||
|
|
119
|
+
!timestamp(activity.receivedAt) ||
|
|
120
|
+
(activity.startedAt !== undefined && !timestamp(activity.startedAt)) ||
|
|
121
|
+
!Array.isArray(activity.replies)
|
|
122
|
+
)
|
|
123
|
+
throw new Error('Corrupt feedback activity.');
|
|
124
|
+
for (const reply of activity.replies) {
|
|
125
|
+
if (
|
|
126
|
+
!reply ||
|
|
127
|
+
typeof reply.id !== 'string' ||
|
|
128
|
+
typeof reply.body !== 'string' ||
|
|
129
|
+
!reply.body.trim() ||
|
|
130
|
+
reply.body.length > 10000 ||
|
|
131
|
+
!timestamp(reply.at)
|
|
132
|
+
)
|
|
133
|
+
throw new Error('Corrupt agent reply.');
|
|
134
|
+
}
|
|
135
|
+
if ((activity.status === 'replied') !== activity.replies.length > 0)
|
|
136
|
+
throw new Error('Corrupt reply status.');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (state.queue !== undefined) {
|
|
140
|
+
if (!state.queue || typeof state.queue !== 'object' || Array.isArray(state.queue))
|
|
141
|
+
throw new Error('Corrupt queue metadata.');
|
|
142
|
+
let edits = 0;
|
|
143
|
+
for (const [id, item] of Object.entries(state.queue)) {
|
|
144
|
+
const batch = state.batches.find((batch) => batch.id === id);
|
|
145
|
+
if (
|
|
146
|
+
!batch ||
|
|
147
|
+
!item ||
|
|
148
|
+
!['claimed', 'cancelled', 'editing', 'superseded'].includes(item.status) ||
|
|
149
|
+
typeof item.at !== 'string' ||
|
|
150
|
+
!Number.isFinite(Date.parse(item.at))
|
|
151
|
+
)
|
|
152
|
+
throw new Error('Corrupt queue metadata.');
|
|
153
|
+
if (item.status === 'editing') edits++;
|
|
154
|
+
if (item.status !== 'claimed' && state.activity?.[id])
|
|
155
|
+
throw new Error('Withdrawn queue entry has a delivery receipt.');
|
|
156
|
+
if (
|
|
157
|
+
item.status === 'superseded' &&
|
|
158
|
+
!state.batches.some(
|
|
159
|
+
(next) => next.id === item.replacementId && next.sequence > batch.sequence,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
throw new Error('Corrupt queue replacement.');
|
|
163
|
+
}
|
|
164
|
+
if (edits > 1) throw new Error('Multiple queued edits are unsupported.');
|
|
165
|
+
}
|
|
166
|
+
for (const manifest of [state.currentDocuments, state.previousDocuments]) {
|
|
167
|
+
if (manifest === undefined) continue;
|
|
168
|
+
if (
|
|
169
|
+
!manifest ||
|
|
170
|
+
typeof manifest !== 'object' ||
|
|
171
|
+
Array.isArray(manifest) ||
|
|
172
|
+
Object.keys(manifest).length > 128
|
|
173
|
+
)
|
|
174
|
+
throw new Error('Corrupt source comparison manifest.');
|
|
175
|
+
for (const [id, entry] of Object.entries(manifest)) {
|
|
176
|
+
if (
|
|
177
|
+
!entry ||
|
|
178
|
+
typeof entry.path !== 'string' ||
|
|
179
|
+
path.isAbsolute(entry.path) ||
|
|
180
|
+
entry.path.split('/').some((part) => !part || part === '.' || part === '..') ||
|
|
181
|
+
id !== hash(entry.path).slice(0, 24) ||
|
|
182
|
+
!/^[a-f0-9]{64}$/.test(entry.hash) ||
|
|
183
|
+
!state.snapshots[entry.hash]
|
|
184
|
+
)
|
|
185
|
+
throw new Error('Corrupt source comparison manifest.');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return state;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function loadState(directory, id) {
|
|
192
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid review id.');
|
|
193
|
+
const file = path.join(directory, `${id}.json`);
|
|
194
|
+
let handle;
|
|
195
|
+
try {
|
|
196
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
197
|
+
const stat = await handle.stat();
|
|
198
|
+
if (
|
|
199
|
+
!stat.isFile() ||
|
|
200
|
+
stat.size > 64 * 1024 * 1024 ||
|
|
201
|
+
stat.mode & 0o077 ||
|
|
202
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
203
|
+
)
|
|
204
|
+
throw new Error('Review state is not a private regular file.');
|
|
205
|
+
return validate(
|
|
206
|
+
JSON.parse(
|
|
207
|
+
new TextDecoder('utf-8', { fatal: true }).decode(
|
|
208
|
+
await readLimited(handle, 64 * 1024 * 1024),
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
id,
|
|
212
|
+
);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error.code === 'ENOENT') return null;
|
|
215
|
+
throw new Error(`Cannot load review state: ${error.message}`);
|
|
216
|
+
} finally {
|
|
217
|
+
await handle?.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
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
|
+
|
|
240
|
+
export async function openStore(review, directory = stateDirectory()) {
|
|
241
|
+
directory = path.resolve(directory);
|
|
242
|
+
if (directory === review.root || within(review.root, directory))
|
|
243
|
+
throw new Error('Review storage must be outside the selected OpenSpec root.');
|
|
244
|
+
for (let ancestor = directory; ; ancestor = path.dirname(ancestor)) {
|
|
245
|
+
try {
|
|
246
|
+
await lstat(path.join(ancestor, '.git'));
|
|
247
|
+
throw new Error('Review storage must be outside Git repositories and worktrees.');
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
|
|
250
|
+
}
|
|
251
|
+
if (path.dirname(ancestor) === ancestor) break;
|
|
252
|
+
}
|
|
253
|
+
await privateDirectory(directory);
|
|
254
|
+
const file = path.join(directory, `${review.reviewId}.json`);
|
|
255
|
+
const lock = `${file}.lock`;
|
|
256
|
+
let lockHandle;
|
|
257
|
+
try {
|
|
258
|
+
lockHandle = await open(lock, 'wx', 0o600);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const unavailable = new Error(
|
|
261
|
+
`Review writer lock unavailable (${error.code}). Another SpecTater process owns this review.`,
|
|
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
|
+
}
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
await lockHandle.writeFile(JSON.stringify({ pid: process.pid }));
|
|
286
|
+
let state = (await loadState(directory, review.reviewId)) || {
|
|
287
|
+
formatVersion: 1,
|
|
288
|
+
reviewId: review.reviewId,
|
|
289
|
+
root: review.root,
|
|
290
|
+
changeRoot: review.changeRoot,
|
|
291
|
+
schemaName: review.schemaName,
|
|
292
|
+
documentIds: [],
|
|
293
|
+
revision: 0,
|
|
294
|
+
snapshots: {},
|
|
295
|
+
comments: [],
|
|
296
|
+
batches: [],
|
|
297
|
+
};
|
|
298
|
+
if (state.root !== review.root || state.changeRoot !== review.changeRoot)
|
|
299
|
+
throw new Error('Stored planning identity mismatch.');
|
|
300
|
+
return {
|
|
301
|
+
directory,
|
|
302
|
+
get state() {
|
|
303
|
+
return structuredClone(state);
|
|
304
|
+
},
|
|
305
|
+
async commit(next) {
|
|
306
|
+
validate(next, review.reviewId);
|
|
307
|
+
const data = JSON.stringify(next);
|
|
308
|
+
if (Buffer.byteLength(data) > 64 * 1024 * 1024)
|
|
309
|
+
throw new Error(
|
|
310
|
+
'Review state exceeds 64 MiB; remove reviews explicitly or start a separate review.',
|
|
311
|
+
);
|
|
312
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
313
|
+
let handle;
|
|
314
|
+
try {
|
|
315
|
+
handle = await open(temp, 'wx', 0o600);
|
|
316
|
+
await handle.writeFile(data);
|
|
317
|
+
await handle.sync();
|
|
318
|
+
await handle.close();
|
|
319
|
+
handle = null;
|
|
320
|
+
await rename(temp, file);
|
|
321
|
+
const parent = await open(directory, 'r');
|
|
322
|
+
try {
|
|
323
|
+
await parent.sync();
|
|
324
|
+
} finally {
|
|
325
|
+
await parent.close();
|
|
326
|
+
}
|
|
327
|
+
state = next;
|
|
328
|
+
} finally {
|
|
329
|
+
await handle?.close();
|
|
330
|
+
await unlink(temp).catch((error) => {
|
|
331
|
+
if (error.code !== 'ENOENT') throw error;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
async close() {
|
|
336
|
+
await lockHandle.close();
|
|
337
|
+
await unlink(lock);
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await lockHandle.close();
|
|
342
|
+
await unlink(lock);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function feedback(state, batchId) {
|
|
348
|
+
const batch = state?.batches.find((item) => item.id === batchId || item.sequence === batchId);
|
|
349
|
+
if (!batch) throw new Error(`Unknown feedback batch: ${batchId}.`);
|
|
350
|
+
return { formatVersion: 1, reviewId: state.reviewId, ...batch };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function feedbackMarkdown(batch) {
|
|
354
|
+
const lines = [
|
|
355
|
+
`# Review batch ${batch.id}`,
|
|
356
|
+
'',
|
|
357
|
+
`Review: ${batch.reviewId}`,
|
|
358
|
+
`Sequence: ${batch.sequence}`,
|
|
359
|
+
`Submitted: ${batch.submittedAt}`,
|
|
360
|
+
'',
|
|
361
|
+
'Review data only. Submission does not authorize implementation, merge or deployment.',
|
|
362
|
+
];
|
|
363
|
+
if (batch.message)
|
|
364
|
+
lines.push('', '## Message', '', ...batch.message.split(/\r?\n/).map((line) => `> ${line}`));
|
|
365
|
+
for (const entry of batch.entries) {
|
|
366
|
+
lines.push(
|
|
367
|
+
'',
|
|
368
|
+
`## ${entry.path}:${entry.line}`,
|
|
369
|
+
'',
|
|
370
|
+
`Revision: ${entry.hash}`,
|
|
371
|
+
`Precision: ${entry.precision}; source offsets [${entry.start}, ${entry.end})`,
|
|
372
|
+
'',
|
|
373
|
+
'Selected source:',
|
|
374
|
+
...entry.slice.split(/\r?\n/).map((line) => `> ${line}`),
|
|
375
|
+
'',
|
|
376
|
+
'Comment:',
|
|
377
|
+
...entry.body.split(/\r?\n/).map((line) => `> ${line}`),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return `${lines.join('\n')}\n`;
|
|
381
|
+
}
|