@khanglvm/relay 0.14.1 → 0.15.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 +15 -10
- package/docs/AGENT.md +25 -17
- package/package.json +1 -1
- package/skills/relay/SKILL.md +21 -8
- package/src/cli.js +30 -13
- package/src/server.js +222 -119
- package/src/ui/annotate.js +16 -7
- package/src/ui/app.js +77 -32
- package/src/ui/blocks.css +5 -3
- package/src/ui/blocks.js +113 -20
package/src/server.js
CHANGED
|
@@ -162,17 +162,24 @@ function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
|
|
|
162
162
|
};
|
|
163
163
|
// Prefill from the LIVE draft at request time (drafts autosave in real time),
|
|
164
164
|
// so a mid-fill page reload restores everything the user already entered.
|
|
165
|
-
|
|
165
|
+
// Reviewer answers are an independent, reference-only draft. They must never
|
|
166
|
+
// prefill from or overwrite the owner's final-answer draft. Read-only viewers
|
|
167
|
+
// see the owner's current state but cannot mutate it.
|
|
168
|
+
const reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
|
|
169
|
+
const sourceDraft = access && access.role === 'review'
|
|
170
|
+
? (reviewDrafts[access.reviewSessionId] || null)
|
|
171
|
+
: record.draft;
|
|
172
|
+
const prefill = sourceDraft
|
|
166
173
|
? {
|
|
167
|
-
answers:
|
|
168
|
-
comment:
|
|
169
|
-
notes:
|
|
170
|
-
annotations:
|
|
171
|
-
blockEdits:
|
|
174
|
+
answers: sourceDraft.answers || {},
|
|
175
|
+
comment: sourceDraft.comment || '',
|
|
176
|
+
notes: sourceDraft.notes || {},
|
|
177
|
+
annotations: sourceDraft.annotations || [],
|
|
178
|
+
blockEdits: sourceDraft.blockEdits || {},
|
|
172
179
|
// The server draft's save time, so the client can pick the NEWER of this
|
|
173
180
|
// vs. its localStorage mirror (a tab that kept typing while the server
|
|
174
181
|
// was unreachable holds fresher input than the last server save).
|
|
175
|
-
updatedAt:
|
|
182
|
+
updatedAt: sourceDraft.updatedAt || null,
|
|
176
183
|
}
|
|
177
184
|
: null;
|
|
178
185
|
const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
|
|
@@ -196,6 +203,45 @@ function buildLockedPage(title = 'Relay board') {
|
|
|
196
203
|
'</head><body><div class="box"><div class="title">Share link not active</div><div class="sub">Ask the board owner to use Share and choose a permission before opening this board from another device.</div></div></body></html>';
|
|
197
204
|
}
|
|
198
205
|
|
|
206
|
+
const SHARE_STATE_VERSION = 2;
|
|
207
|
+
const REVIEW_SESSION_COOKIE = 'relay_review_session';
|
|
208
|
+
|
|
209
|
+
function reviewSessionId(req) {
|
|
210
|
+
if (req._relayReviewSessionId) return req._relayReviewSessionId;
|
|
211
|
+
const explicit = String(req.headers['x-relay-review-session'] || '');
|
|
212
|
+
const cookies = String(req.headers.cookie || '').split(';').map((s) => s.trim());
|
|
213
|
+
const cookie = cookies.find((s) => s.startsWith(REVIEW_SESSION_COOKIE + '='));
|
|
214
|
+
let value = explicit || (cookie ? decodeURIComponent(cookie.slice(REVIEW_SESSION_COOKIE.length + 1)) : '');
|
|
215
|
+
if (!/^[A-Za-z0-9_-]{8,80}$/.test(value)) value = 'rv-' + crypto.randomBytes(12).toString('hex');
|
|
216
|
+
req._relayReviewSessionId = value;
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function reviewSessionCookie(sessionId) {
|
|
221
|
+
return `${REVIEW_SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sideReviewState(record) {
|
|
225
|
+
const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
|
|
226
|
+
const submissions = Array.isArray(record.sideReviews) ? record.sideReviews : [];
|
|
227
|
+
return {
|
|
228
|
+
referenceOnly: true,
|
|
229
|
+
note: 'Side reviews are reference only. Wait for the board owner (or owner-authorized collaborator) to submit the final answer.',
|
|
230
|
+
submissions,
|
|
231
|
+
drafts,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function pruneReviewDrafts(record, max = 50) {
|
|
236
|
+
const drafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
|
|
237
|
+
const entries = Object.entries(drafts).sort((a, b) => {
|
|
238
|
+
const at = Date.parse(a[1] && a[1].updatedAt || '') || 0;
|
|
239
|
+
const bt = Date.parse(b[1] && b[1].updatedAt || '') || 0;
|
|
240
|
+
return bt - at;
|
|
241
|
+
});
|
|
242
|
+
record.reviewDrafts = Object.fromEntries(entries.slice(0, max));
|
|
243
|
+
}
|
|
244
|
+
|
|
199
245
|
// Validates + sanitizes one annotation's threaded replies. Keeps only
|
|
200
246
|
// well-formed {author, text, createdAt} entries; caps at 50; coerces author.
|
|
201
247
|
function sanitizeReplies(value) {
|
|
@@ -231,50 +277,6 @@ function sanitizeAnnotations(value) {
|
|
|
231
277
|
return out;
|
|
232
278
|
}
|
|
233
279
|
|
|
234
|
-
function nextAnnotationId(existing, used) {
|
|
235
|
-
let max = 0;
|
|
236
|
-
for (const a of existing) {
|
|
237
|
-
const m = /^a(\d+)$/.exec(String(a && a.id || ''));
|
|
238
|
-
if (m) max = Math.max(max, Number.parseInt(m[1], 10) || 0);
|
|
239
|
-
}
|
|
240
|
-
let id;
|
|
241
|
-
do {
|
|
242
|
-
id = 'a' + (++max);
|
|
243
|
-
} while (used.has(id));
|
|
244
|
-
used.add(id);
|
|
245
|
-
return id;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function sameReply(a, b) {
|
|
249
|
-
return a && b && a.author === b.author && a.text === b.text && a.createdAt === b.createdAt;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function mergeReviewAnnotations(existingValue, incomingValue) {
|
|
253
|
-
const existing = sanitizeAnnotations(existingValue);
|
|
254
|
-
const incoming = sanitizeAnnotations(incomingValue);
|
|
255
|
-
const out = existing.map((a) => ({ ...a, replies: Array.isArray(a.replies) ? [...a.replies] : [] }));
|
|
256
|
-
const byId = new Map(out.map((a) => [String(a.id || ''), a]));
|
|
257
|
-
const used = new Set(out.map((a) => String(a.id || '')).filter(Boolean));
|
|
258
|
-
for (const ann of incoming) {
|
|
259
|
-
const id = String(ann.id || '');
|
|
260
|
-
const current = byId.get(id);
|
|
261
|
-
if (!current) {
|
|
262
|
-
const next = { ...ann, id: id && !used.has(id) ? id : nextAnnotationId(out, used) };
|
|
263
|
-
next.replies = Array.isArray(next.replies) ? next.replies : [];
|
|
264
|
-
out.push(next);
|
|
265
|
-
byId.set(next.id, next);
|
|
266
|
-
used.add(next.id);
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
const replies = Array.isArray(current.replies) ? current.replies : (current.replies = []);
|
|
270
|
-
for (const r of Array.isArray(ann.replies) ? ann.replies : []) {
|
|
271
|
-
if (replies.length >= 50) break;
|
|
272
|
-
if (!replies.some((x) => sameReply(x, r))) replies.push(r);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
return out.slice(0, 500);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
280
|
function limitString(v, max) {
|
|
279
281
|
return typeof v === 'string' ? v.slice(0, max) : '';
|
|
280
282
|
}
|
|
@@ -499,8 +501,8 @@ function sendJson(res, code, obj) {
|
|
|
499
501
|
res.end(JSON.stringify(obj));
|
|
500
502
|
}
|
|
501
503
|
|
|
502
|
-
function sendHtml(res, body) {
|
|
503
|
-
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
504
|
+
function sendHtml(res, body, headers = {}) {
|
|
505
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
|
504
506
|
res.end(body);
|
|
505
507
|
}
|
|
506
508
|
|
|
@@ -597,14 +599,15 @@ function injectBeforeBodyEnd(html, snippet) {
|
|
|
597
599
|
// Custom-HTML fragments (no <html> tag) get wrapped in a minimal document that
|
|
598
600
|
// matches the user's theme, so e.g. "<b>hi</b>" doesn't paint a stark white
|
|
599
601
|
// block in dark mode. Full documents are served verbatim — their authors can
|
|
600
|
-
// read the ?theme=light|dark query param themselves.
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
602
|
+
// read the ?theme=light|dark query param themselves. The annotate bootstrap is
|
|
603
|
+
// omitted for read-only shares so their iframe content has no false comment
|
|
604
|
+
// affordances; existing feedback remains visible in the parent comments rail.
|
|
605
|
+
function wrapFragment(content, theme, annotate = true) {
|
|
606
|
+
if (/<html[\s>]/i.test(content)) return annotate ? injectBeforeBodyEnd(content, ANNOTATE_BOOTSTRAP) : content;
|
|
604
607
|
const dark = theme === 'dark';
|
|
605
608
|
const bg = dark ? '#282624' : '#ffffff';
|
|
606
609
|
const fg = dark ? '#edeae4' : '#1c1b19';
|
|
607
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${ANNOTATE_BOOTSTRAP}</body></html>`;
|
|
610
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>:root{color-scheme:${dark ? 'dark' : 'light'}}body{margin:12px;font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:${bg};color:${fg}}</style></head><body>${content}${annotate ? ANNOTATE_BOOTSTRAP : ''}</body></html>`;
|
|
608
611
|
}
|
|
609
612
|
|
|
610
613
|
function readBody(req, limit = 5 * 1024 * 1024) {
|
|
@@ -689,8 +692,17 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
689
692
|
updatedAt: new Date().toISOString(),
|
|
690
693
|
};
|
|
691
694
|
}
|
|
692
|
-
|
|
695
|
+
// A soft-timeout result may predate side reviews submitted while the board
|
|
696
|
+
// stayed live. Refresh that reference-only snapshot before archiving so a
|
|
697
|
+
// later reopen does not discard late reviews/drafts from the historical run.
|
|
698
|
+
const archivedResult = { ...record.result, sideReviews: sideReviewState(record) };
|
|
699
|
+
record.pastResults = [...(record.pastResults || []), archivedResult].slice(-10);
|
|
693
700
|
record.result = null;
|
|
701
|
+
// Side reviews belong to the archived run. A reopened board starts a fresh
|
|
702
|
+
// review round; carrying them forward would present stale reference input
|
|
703
|
+
// as current and duplicate it into the next final result.
|
|
704
|
+
record.sideReviews = [];
|
|
705
|
+
record.reviewDrafts = {};
|
|
694
706
|
saveBoard(record);
|
|
695
707
|
}
|
|
696
708
|
|
|
@@ -698,11 +710,22 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
698
710
|
// Mutation token: only `rly update` (which reads the running-file record)
|
|
699
711
|
// can authenticate to POST /api/update. Never embedded in the page/boot.
|
|
700
712
|
const token = crypto.randomBytes(16).toString('hex');
|
|
713
|
+
const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
|
|
714
|
+
const savedShareVersion = Number(savedShares.version) || 1;
|
|
701
715
|
const shareTokens = {
|
|
702
|
-
collab: crypto.randomBytes(16).toString('hex'),
|
|
703
|
-
|
|
716
|
+
collab: typeof savedShares.collab?.token === 'string' ? savedShares.collab.token : crypto.randomBytes(16).toString('hex'),
|
|
717
|
+
// v1 reviewer links were comments-only. Rotate + deactivate them instead of
|
|
718
|
+
// silently granting answer/side-submit permission after this upgrade.
|
|
719
|
+
review: savedShareVersion >= SHARE_STATE_VERSION && typeof savedShares.review?.token === 'string'
|
|
720
|
+
? savedShares.review.token
|
|
721
|
+
: crypto.randomBytes(16).toString('hex'),
|
|
722
|
+
read: typeof savedShares.read?.token === 'string' ? savedShares.read.token : crypto.randomBytes(16).toString('hex'),
|
|
723
|
+
};
|
|
724
|
+
const activeShares = {
|
|
725
|
+
collab: savedShares.collab?.active === true,
|
|
726
|
+
review: savedShareVersion >= SHARE_STATE_VERSION && savedShares.review?.active === true,
|
|
727
|
+
read: savedShares.read?.active === true,
|
|
704
728
|
};
|
|
705
|
-
const activeShares = { collab: false, review: false };
|
|
706
729
|
let rev = 1;
|
|
707
730
|
let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
|
|
708
731
|
let status = 'open';
|
|
@@ -712,8 +735,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
712
735
|
// working and still submit. Surfaced via /api/status so the page can show a
|
|
713
736
|
// calm "agent stopped waiting" note instead of disconnecting.
|
|
714
737
|
let softTimedOut = false;
|
|
715
|
-
//
|
|
716
|
-
|
|
738
|
+
// Presence that can lead to the terminal answer. Reviewer/read-only activity
|
|
739
|
+
// must not keep an agent waiting for a submission those roles cannot finalize.
|
|
740
|
+
const presenceByRole = { owner: null, collab: null, review: null, read: null };
|
|
717
741
|
let actualPort = 0;
|
|
718
742
|
let url = '';
|
|
719
743
|
const advertisedHost = shareHost();
|
|
@@ -728,6 +752,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
728
752
|
canComment: true,
|
|
729
753
|
canEditComments: true,
|
|
730
754
|
canDeleteComments: true,
|
|
755
|
+
canEditBlocks: true,
|
|
756
|
+
canFinalize: true,
|
|
731
757
|
};
|
|
732
758
|
}
|
|
733
759
|
const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
|
|
@@ -741,18 +767,37 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
741
767
|
canComment: true,
|
|
742
768
|
canEditComments: true,
|
|
743
769
|
canDeleteComments: true,
|
|
770
|
+
canEditBlocks: true,
|
|
771
|
+
canFinalize: true,
|
|
744
772
|
};
|
|
745
773
|
}
|
|
746
774
|
if (activeShares.review && tokenValue === shareTokens.review) {
|
|
747
775
|
return {
|
|
748
776
|
role: 'review',
|
|
749
777
|
token: shareTokens.review,
|
|
778
|
+
reviewSessionId: reviewSessionId(req),
|
|
779
|
+
canShare: false,
|
|
780
|
+
canSubmit: true,
|
|
781
|
+
canEditAnswers: true,
|
|
782
|
+
canComment: true,
|
|
783
|
+
canEditComments: false,
|
|
784
|
+
canDeleteComments: false,
|
|
785
|
+
canEditBlocks: false,
|
|
786
|
+
canFinalize: false,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (activeShares.read && tokenValue === shareTokens.read) {
|
|
790
|
+
return {
|
|
791
|
+
role: 'read',
|
|
792
|
+
token: shareTokens.read,
|
|
750
793
|
canShare: false,
|
|
751
794
|
canSubmit: false,
|
|
752
795
|
canEditAnswers: false,
|
|
753
|
-
canComment:
|
|
796
|
+
canComment: false,
|
|
754
797
|
canEditComments: false,
|
|
755
798
|
canDeleteComments: false,
|
|
799
|
+
canEditBlocks: false,
|
|
800
|
+
canFinalize: false,
|
|
756
801
|
};
|
|
757
802
|
}
|
|
758
803
|
return {
|
|
@@ -763,13 +808,29 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
763
808
|
canComment: false,
|
|
764
809
|
canEditComments: false,
|
|
765
810
|
canDeleteComments: false,
|
|
811
|
+
canEditBlocks: false,
|
|
812
|
+
canFinalize: false,
|
|
766
813
|
};
|
|
767
814
|
};
|
|
768
815
|
const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
|
|
816
|
+
const persistShareState = () => {
|
|
817
|
+
record.share = {
|
|
818
|
+
version: SHARE_STATE_VERSION,
|
|
819
|
+
collab: { active: activeShares.collab === true, token: shareTokens.collab },
|
|
820
|
+
review: { active: activeShares.review === true, token: shareTokens.review },
|
|
821
|
+
read: { active: activeShares.read === true, token: shareTokens.read },
|
|
822
|
+
updatedAt: new Date().toISOString(),
|
|
823
|
+
};
|
|
824
|
+
saveBoard(record);
|
|
825
|
+
};
|
|
769
826
|
let resolveDone;
|
|
770
827
|
const done = new Promise((r) => {
|
|
771
828
|
resolveDone = r;
|
|
772
829
|
});
|
|
830
|
+
const finalizerPresence = () => {
|
|
831
|
+
const candidates = [presenceByRole.owner, presenceByRole.collab].filter(Boolean);
|
|
832
|
+
return candidates.sort((a, b) => b.atMs - a.atMs)[0] || null;
|
|
833
|
+
};
|
|
773
834
|
|
|
774
835
|
const server = http.createServer(async (req, res) => {
|
|
775
836
|
const reqUrl = new URL(req.url, 'http://localhost');
|
|
@@ -781,17 +842,41 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
781
842
|
if (access.role === 'locked') {
|
|
782
843
|
sendHtml(res, buildLockedPage(record.spec.title));
|
|
783
844
|
} else {
|
|
784
|
-
|
|
845
|
+
const accessDraftRev = access.role === 'review'
|
|
846
|
+
? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
|
|
847
|
+
: draftRev;
|
|
848
|
+
const headers = access.role === 'review'
|
|
849
|
+
? { 'set-cookie': reviewSessionCookie(access.reviewSessionId) }
|
|
850
|
+
: {};
|
|
851
|
+
sendHtml(res, buildPage(record, rev, { access, draftRev: accessDraftRev }), headers);
|
|
785
852
|
}
|
|
786
853
|
} else if (req.method === 'GET' && pathname === '/api/board') {
|
|
787
|
-
|
|
788
|
-
sendJson(res,
|
|
854
|
+
const access = accessFor(req, reqUrl);
|
|
855
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
856
|
+
const sourceDraft = access.role === 'review'
|
|
857
|
+
? (record.reviewDrafts?.[access.reviewSessionId] || null)
|
|
858
|
+
: record.draft;
|
|
859
|
+
sendJson(res, 200, { id: record.id, spec: record.spec, draft: sourceDraft, result: record.result });
|
|
789
860
|
} else if (req.method === 'GET' && pathname === '/api/status') {
|
|
790
|
-
|
|
861
|
+
const access = accessFor(req, reqUrl);
|
|
862
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
863
|
+
const accessDraftRev = access.role === 'review'
|
|
864
|
+
? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
|
|
865
|
+
: draftRev;
|
|
866
|
+
sendJson(res, 200, { status, rev, draftRev: accessDraftRev, softTimedOut });
|
|
791
867
|
} else if (req.method === 'GET' && pathname === '/api/draft') {
|
|
792
|
-
|
|
793
|
-
sendJson(res,
|
|
868
|
+
const access = accessFor(req, reqUrl);
|
|
869
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
870
|
+
const sourceDraft = access.role === 'review'
|
|
871
|
+
? (record.reviewDrafts?.[access.reviewSessionId] || null)
|
|
872
|
+
: record.draft;
|
|
873
|
+
const accessDraftRev = access.role === 'review'
|
|
874
|
+
? Number(sourceDraft?.draftRev) || 0
|
|
875
|
+
: draftRev;
|
|
876
|
+
sendJson(res, 200, { draft: sourceDraft || null, draftRev: accessDraftRev });
|
|
794
877
|
} else if (req.method === 'POST' && pathname === '/api/ping') {
|
|
878
|
+
const access = accessFor(req, reqUrl);
|
|
879
|
+
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
795
880
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
796
881
|
// Validate body shape: visible/focused booleans, idleMs finite >= 0.
|
|
797
882
|
if (
|
|
@@ -800,10 +885,11 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
800
885
|
Number.isFinite(body.idleMs) &&
|
|
801
886
|
body.idleMs >= 0
|
|
802
887
|
) {
|
|
803
|
-
|
|
888
|
+
presenceByRole[access.role] = { atMs: Date.now(), visible: body.visible, focused: body.focused, idleMs: body.idleMs };
|
|
804
889
|
}
|
|
805
890
|
sendJson(res, 200, { ok: true });
|
|
806
891
|
} else if (req.method === 'GET' && pathname === '/api/presence') {
|
|
892
|
+
const presence = finalizerPresence();
|
|
807
893
|
if (!presence) {
|
|
808
894
|
sendJson(res, 200, { open: true, seen: false });
|
|
809
895
|
} else {
|
|
@@ -846,7 +932,8 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
846
932
|
const blockId = decodeURIComponent(pathname.slice('/html/b/'.length));
|
|
847
933
|
const block = findHtmlBlock(record.spec, blockId);
|
|
848
934
|
if (!block) return sendJson(res, 404, { error: `no html block "${blockId}"` });
|
|
849
|
-
|
|
935
|
+
const access = accessFor(req, reqUrl);
|
|
936
|
+
sendHtml(res, wrapFragment(block.html || '', theme, access.canComment === true));
|
|
850
937
|
} else if (req.method === 'GET' && pathname.startsWith('/img/b/')) {
|
|
851
938
|
// Embedded image bytes (image blocks authored from local files).
|
|
852
939
|
const blockId = decodeURIComponent(pathname.slice('/img/b/'.length));
|
|
@@ -870,13 +957,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
870
957
|
} else if (req.method === 'GET' && pathname === '/html/board') {
|
|
871
958
|
// Legacy alias → the board's first html block.
|
|
872
959
|
const block = firstBoardHtml(record.spec);
|
|
873
|
-
|
|
960
|
+
const access = accessFor(req, reqUrl);
|
|
961
|
+
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
|
|
874
962
|
} else if (req.method === 'GET' && pathname.startsWith('/html/q/')) {
|
|
875
963
|
const qid = decodeURIComponent(pathname.slice('/html/q/'.length));
|
|
876
964
|
const q = record.spec.questions.find((q) => q.id === qid);
|
|
877
965
|
if (!q) return sendJson(res, 404, { error: `no question "${qid}"` });
|
|
878
966
|
const block = firstQuestionHtml(q);
|
|
879
|
-
|
|
967
|
+
const access = accessFor(req, reqUrl);
|
|
968
|
+
sendHtml(res, wrapFragment((block && block.html) || '', theme, access.canComment === true));
|
|
880
969
|
} else if (req.method === 'POST' && pathname === '/api/pref') {
|
|
881
970
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
882
971
|
if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
|
|
@@ -911,17 +1000,23 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
911
1000
|
} else if (req.method === 'POST' && pathname === '/api/draft') {
|
|
912
1001
|
const access = accessFor(req, reqUrl);
|
|
913
1002
|
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
1003
|
+
if (!access.canEditAnswers && !access.canComment && !access.canEditBlocks) {
|
|
1004
|
+
return sendJson(res, 403, { error: 'this share is read only' });
|
|
1005
|
+
}
|
|
914
1006
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
915
1007
|
if (access.role === 'review') {
|
|
916
|
-
|
|
917
|
-
record.
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1008
|
+
record.reviewDrafts = record.reviewDrafts && typeof record.reviewDrafts === 'object' ? record.reviewDrafts : {};
|
|
1009
|
+
const priorRev = Number(record.reviewDrafts[access.reviewSessionId]?.draftRev) || 0;
|
|
1010
|
+
record.reviewDrafts[access.reviewSessionId] = {
|
|
1011
|
+
answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
|
|
1012
|
+
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1013
|
+
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1014
|
+
annotations: sanitizeAnnotations(body.annotations),
|
|
1015
|
+
blockEdits: {},
|
|
1016
|
+
draftRev: priorRev + 1,
|
|
923
1017
|
updatedAt: new Date().toISOString(),
|
|
924
1018
|
};
|
|
1019
|
+
pruneReviewDrafts(record);
|
|
925
1020
|
} else {
|
|
926
1021
|
record.draft = {
|
|
927
1022
|
answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
|
|
@@ -932,17 +1027,48 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
932
1027
|
updatedAt: new Date().toISOString(),
|
|
933
1028
|
};
|
|
934
1029
|
}
|
|
935
|
-
record.draftRev = ++draftRev;
|
|
1030
|
+
if (access.role !== 'review') record.draftRev = ++draftRev;
|
|
936
1031
|
saveBoard(record);
|
|
937
|
-
|
|
1032
|
+
const accessDraftRev = access.role === 'review'
|
|
1033
|
+
? Number(record.reviewDrafts?.[access.reviewSessionId]?.draftRev) || 0
|
|
1034
|
+
: draftRev;
|
|
1035
|
+
sendJson(res, 200, { ok: true, draftRev: accessDraftRev });
|
|
938
1036
|
} else if (req.method === 'POST' && pathname === '/api/submit') {
|
|
939
1037
|
const access = accessFor(req, reqUrl);
|
|
940
1038
|
if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
|
|
941
|
-
if (!access.canSubmit) return sendJson(res, 403, { error: 'this share
|
|
1039
|
+
if (!access.canSubmit) return sendJson(res, 403, { error: 'this share is read only' });
|
|
942
1040
|
if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
|
|
943
1041
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
944
1042
|
const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
|
|
945
1043
|
const skipped = record.spec.questions.filter((q) => !(q.id in answers)).map((q) => q.id);
|
|
1044
|
+
if (access.role === 'review') {
|
|
1045
|
+
const review = {
|
|
1046
|
+
id: 'sr-' + Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex'),
|
|
1047
|
+
status: 'side-review',
|
|
1048
|
+
final: false,
|
|
1049
|
+
referenceOnly: true,
|
|
1050
|
+
reviewSessionId: access.reviewSessionId,
|
|
1051
|
+
answers,
|
|
1052
|
+
skipped,
|
|
1053
|
+
comment: typeof body.comment === 'string' ? body.comment : '',
|
|
1054
|
+
notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
|
|
1055
|
+
annotations: sanitizeAnnotations(body.annotations),
|
|
1056
|
+
blockEdits: {},
|
|
1057
|
+
submittedAt: new Date().toISOString(),
|
|
1058
|
+
};
|
|
1059
|
+
record.sideReviews = [...(Array.isArray(record.sideReviews) ? record.sideReviews : []), review].slice(-50);
|
|
1060
|
+
if (record.reviewDrafts && typeof record.reviewDrafts === 'object') delete record.reviewDrafts[access.reviewSessionId];
|
|
1061
|
+
saveBoard(record);
|
|
1062
|
+
// Deliberately no finish(), record.result write, on-result hook, or
|
|
1063
|
+
// server close: a side review never wakes/completes the waiting agent.
|
|
1064
|
+
return sendJson(res, 200, {
|
|
1065
|
+
ok: true,
|
|
1066
|
+
sideReview: true,
|
|
1067
|
+
final: false,
|
|
1068
|
+
reviewId: review.id,
|
|
1069
|
+
message: 'Saved as a reference-only side review. The board still awaits the owner final answer.',
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
946
1072
|
sendJson(res, 200, { ok: true });
|
|
947
1073
|
finish({
|
|
948
1074
|
status: record.spec.questions.length ? 'submitted' : 'acknowledged',
|
|
@@ -956,7 +1082,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
956
1082
|
} else if (req.method === 'GET' && pathname === '/api/share') {
|
|
957
1083
|
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
|
|
958
1084
|
const roles = {};
|
|
959
|
-
for (const role of ['collab', 'review']) {
|
|
1085
|
+
for (const role of ['collab', 'review', 'read']) {
|
|
960
1086
|
roles[role] = {
|
|
961
1087
|
active: activeShares[role] === true,
|
|
962
1088
|
url: activeShares[role] === true ? shareUrlFor(role) : null,
|
|
@@ -967,21 +1093,24 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
967
1093
|
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
|
|
968
1094
|
if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
|
|
969
1095
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
970
|
-
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
|
|
971
|
-
if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "
|
|
1096
|
+
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : null;
|
|
1097
|
+
if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "read"' });
|
|
972
1098
|
activeShares[role] = true;
|
|
1099
|
+
persistShareState();
|
|
973
1100
|
sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
|
|
974
1101
|
} else if (req.method === 'DELETE' && pathname === '/api/share') {
|
|
975
1102
|
if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
|
|
976
1103
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
977
|
-
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'all' ? 'all' : null;
|
|
978
|
-
if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "all"' });
|
|
1104
|
+
const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'read' ? 'read' : body.role === 'all' ? 'all' : null;
|
|
1105
|
+
if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", "read", or "all"' });
|
|
979
1106
|
if (role === 'all') {
|
|
980
1107
|
activeShares.collab = false;
|
|
981
1108
|
activeShares.review = false;
|
|
1109
|
+
activeShares.read = false;
|
|
982
1110
|
} else {
|
|
983
1111
|
activeShares[role] = false;
|
|
984
1112
|
}
|
|
1113
|
+
persistShareState();
|
|
985
1114
|
sendJson(res, 200, { ok: true, role, active: false });
|
|
986
1115
|
} else {
|
|
987
1116
|
sendJson(res, 404, { error: 'not found' });
|
|
@@ -1040,15 +1169,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1040
1169
|
});
|
|
1041
1170
|
|
|
1042
1171
|
let timer = null;
|
|
1043
|
-
let idleTimer = null;
|
|
1044
1172
|
// The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
|
|
1045
1173
|
// we hand a `timeout` result back to the waiting agent (so `rly wait` returns
|
|
1046
1174
|
// with the autosaved draft) but keep the server listening, so the user can
|
|
1047
1175
|
// keep working and still submit. A late submit overwrites the result with
|
|
1048
1176
|
// `submitted` and re-fires the push-wake; the board only truly closes on
|
|
1049
|
-
// submit
|
|
1050
|
-
//
|
|
1051
|
-
// its timeout stays hard (close + resolve, exit 2).
|
|
1177
|
+
// submit or an explicit stop. A BLOCKING `rly ask` has no separate waiter to
|
|
1178
|
+
// hand back to, so its timeout stays hard (close + resolve, exit 2).
|
|
1052
1179
|
if (timeoutSec > 0) {
|
|
1053
1180
|
timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
|
|
1054
1181
|
}
|
|
@@ -1078,6 +1205,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1078
1205
|
notes: partial.notes ?? (record.draft?.notes || {}),
|
|
1079
1206
|
annotations: partial.annotations ?? (record.draft?.annotations || []),
|
|
1080
1207
|
blockEdits,
|
|
1208
|
+
sideReviews: sideReviewState(record),
|
|
1081
1209
|
createdAt: record.createdAt,
|
|
1082
1210
|
finishedAt: new Date().toISOString(),
|
|
1083
1211
|
durationMs: Date.now() - startedAt,
|
|
@@ -1097,7 +1225,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1097
1225
|
function closeServer(result) {
|
|
1098
1226
|
removeRunning(record.id);
|
|
1099
1227
|
if (timer) clearTimeout(timer);
|
|
1100
|
-
if (idleTimer) clearInterval(idleTimer);
|
|
1101
1228
|
process.removeListener('SIGINT', onSignal);
|
|
1102
1229
|
process.removeListener('SIGTERM', onSignal);
|
|
1103
1230
|
setTimeout(() => {
|
|
@@ -1131,30 +1258,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
|
|
|
1131
1258
|
softTimedOut = true;
|
|
1132
1259
|
const result = persistResult({ status: 'timeout' });
|
|
1133
1260
|
runOnResult(record.onResult, result, { quiet });
|
|
1134
|
-
startIdleWatchdog();
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// After a soft timeout, close the board for real once the user has clearly
|
|
1138
|
-
// left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
|
|
1139
|
-
// an abandoned board doesn't keep a server alive forever. Re-persists the
|
|
1140
|
-
// latest draft as the final `timeout` result; no double push-wake.
|
|
1141
|
-
function startIdleWatchdog() {
|
|
1142
|
-
const IDLE_CLOSE_MS = 15 * 60 * 1000;
|
|
1143
|
-
const HARD_CAP_MS = 6 * 60 * 60 * 1000;
|
|
1144
|
-
const softAt = Date.now();
|
|
1145
|
-
idleTimer = setInterval(() => {
|
|
1146
|
-
if (finished) {
|
|
1147
|
-
clearInterval(idleTimer);
|
|
1148
|
-
return;
|
|
1149
|
-
}
|
|
1150
|
-
const lastSeen = presence ? presence.atMs : softAt;
|
|
1151
|
-
if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
|
|
1152
|
-
finished = true;
|
|
1153
|
-
clearInterval(idleTimer);
|
|
1154
|
-
closeServer(persistResult({ status: 'timeout' }));
|
|
1155
|
-
}
|
|
1156
|
-
}, 60 * 1000);
|
|
1157
|
-
idleTimer.unref?.();
|
|
1158
1261
|
}
|
|
1159
1262
|
|
|
1160
1263
|
if (open) openUrl(url);
|
package/src/ui/annotate.js
CHANGED
|
@@ -393,7 +393,7 @@
|
|
|
393
393
|
|
|
394
394
|
// ---------- hover pin ----------
|
|
395
395
|
function showPin(entry) {
|
|
396
|
-
if (disabled) return;
|
|
396
|
+
if (disabled || !permissions.add) return;
|
|
397
397
|
clearTimeout(pinTimer);
|
|
398
398
|
pinEntry = entry;
|
|
399
399
|
const rect = entry.el.getBoundingClientRect();
|
|
@@ -547,6 +547,11 @@
|
|
|
547
547
|
function openPopover(info, anchorEl) {
|
|
548
548
|
if (disabled) return;
|
|
549
549
|
ensureDom();
|
|
550
|
+
const hasExisting = matching(info).length > 0;
|
|
551
|
+
// Read-only viewers can open existing threads from their badges, but a
|
|
552
|
+
// target with no feedback has nothing to show and must not expose an empty
|
|
553
|
+
// add-comment dialog.
|
|
554
|
+
if (!permissions.add && !hasExisting) return;
|
|
550
555
|
closePopover();
|
|
551
556
|
hidePin();
|
|
552
557
|
const pop = dom.pop;
|
|
@@ -586,7 +591,10 @@
|
|
|
586
591
|
ta.focus();
|
|
587
592
|
});
|
|
588
593
|
// agent replies aren't editable here — only the top-level user comment
|
|
589
|
-
const actions = el('div', { class: 'ann-thread-actions' },
|
|
594
|
+
const actions = el('div', { class: 'ann-thread-actions' },
|
|
595
|
+
permissions.edit && a.author === 'user' ? edit : null,
|
|
596
|
+
permissions.delete ? del : null
|
|
597
|
+
);
|
|
590
598
|
const thread = el('div', { class: 'ann-thread' },
|
|
591
599
|
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), actions),
|
|
592
600
|
textEl
|
|
@@ -616,7 +624,7 @@
|
|
|
616
624
|
submitReply();
|
|
617
625
|
}
|
|
618
626
|
});
|
|
619
|
-
thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
627
|
+
if (permissions.reply) thread.append(el('div', { class: 'ann-reply-form' }, input, btn));
|
|
620
628
|
existingWrap.append(thread);
|
|
621
629
|
}
|
|
622
630
|
};
|
|
@@ -633,7 +641,7 @@
|
|
|
633
641
|
};
|
|
634
642
|
save.addEventListener('click', () => popSave && popSave());
|
|
635
643
|
cancel.addEventListener('click', closePopover);
|
|
636
|
-
pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
|
|
644
|
+
if (permissions.add) pop.append(ta, el('div', { class: 'ann-pop-actions' }, save, cancel));
|
|
637
645
|
|
|
638
646
|
// Position: prefer below the anchor, flip above when out of room,
|
|
639
647
|
// clamp to the viewport with an 8px margin. Fixed positioning, so we
|
|
@@ -652,7 +660,8 @@
|
|
|
652
660
|
pop.style.visibility = '';
|
|
653
661
|
popOpen = true;
|
|
654
662
|
popScrollY = window.scrollY;
|
|
655
|
-
ta.focus();
|
|
663
|
+
if (permissions.add) ta.focus();
|
|
664
|
+
else popClose.focus();
|
|
656
665
|
}
|
|
657
666
|
|
|
658
667
|
// ---------- mutations ----------
|
|
@@ -742,7 +751,7 @@
|
|
|
742
751
|
}
|
|
743
752
|
|
|
744
753
|
function maybeShowSelBtn(rootEl, baseInfo) {
|
|
745
|
-
if (disabled) return hideSelBtn();
|
|
754
|
+
if (disabled || !permissions.add) return hideSelBtn();
|
|
746
755
|
const sel = window.getSelection();
|
|
747
756
|
if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
|
|
748
757
|
if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
|
|
@@ -821,7 +830,7 @@
|
|
|
821
830
|
el('div', { class: 'ann-sum-text' }, a.text),
|
|
822
831
|
meta.length ? el('div', { class: 'ann-sum-meta' }, meta) : null
|
|
823
832
|
),
|
|
824
|
-
del
|
|
833
|
+
permissions.delete ? del : null
|
|
825
834
|
);
|
|
826
835
|
// Clicking a row jumps to (and flashes) the matching element: a
|
|
827
836
|
// registered block element, or the inline highlight for a text comment.
|