mockpit 1.0.0 → 1.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 +12 -3
- package/bin/mockpit.js +47 -21
- package/dist/mcp/server.js +43 -4
- package/dist/server/apiViews.d.ts +11 -8
- package/dist/server/apiViews.js +21 -16
- package/dist/server/app.d.ts +3 -1
- package/dist/server/app.js +374 -67
- package/dist/server/appIcon.d.ts +22 -0
- package/dist/server/appIcon.js +43 -0
- package/dist/server/appIconPng.d.ts +1 -0
- package/dist/server/appIconPng.js +6 -0
- package/dist/server/askNudges.d.ts +3 -0
- package/dist/server/askNudges.js +40 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +8 -0
- package/dist/server/designGuide.js +15 -13
- package/dist/server/eventLog.d.ts +27 -0
- package/dist/server/eventLog.js +187 -0
- package/dist/server/feedbackBatch.d.ts +16 -4
- package/dist/server/feedbackBatch.js +59 -9
- package/dist/server/handoff.d.ts +6 -0
- package/dist/server/handoff.js +38 -0
- package/dist/server/index.js +12 -0
- package/dist/server/knobs.js +2 -3
- package/dist/server/logFile.d.ts +2 -0
- package/dist/server/logFile.js +27 -0
- package/dist/server/mcpHttp.d.ts +2 -2
- package/dist/server/mcpHttp.js +11 -5
- package/dist/server/mcpSpec.d.ts +8 -0
- package/dist/server/mcpSpec.js +26 -1
- package/dist/server/run.js +2 -2
- package/dist/server/runApi.d.ts +1 -1
- package/dist/server/runApi.js +1 -1
- package/dist/server/sqlStore.d.ts +3 -1
- package/dist/server/sqlStore.js +40 -8
- package/dist/server/types.d.ts +25 -2
- package/dist/server/types.js +47 -4
- package/extensions/mockpit.js +24 -9
- package/guide/topics/asks.md +19 -7
- package/guide/topics/feedback.md +17 -7
- package/guide/topics/knobs.md +3 -3
- package/guide/topics/surfaces.md +8 -8
- package/package.json +2 -2
- package/skills/mockpit/SKILL.md +4 -2
- package/viewer/dist/index.html +9 -9
package/dist/server/app.js
CHANGED
|
@@ -2,11 +2,15 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
3
|
import { getCookie, setCookie } from "hono/cookie";
|
|
4
4
|
import { streamSSE } from "hono/streaming";
|
|
5
|
+
import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
|
|
6
|
+
import { ICON_PNG_BASE64 } from "./appIconPng.js";
|
|
5
7
|
import { decodeBase64 } from "./base64.js";
|
|
6
8
|
import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
|
|
9
|
+
import { askNudges } from "./askNudges.js";
|
|
7
10
|
import { EventBus } from "./events.js";
|
|
8
11
|
import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
|
|
9
12
|
import { buildFeedbackBatches } from "./feedbackBatch.js";
|
|
13
|
+
import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
|
|
10
14
|
import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
|
|
11
15
|
import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
|
|
12
16
|
import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
|
|
@@ -16,10 +20,11 @@ import { addHook, addSubscription, isPushSubscription, listHooks, notify, remove
|
|
|
16
20
|
import { expandSlots, parseSlotTags } from "./slots.js";
|
|
17
21
|
import { registerMcp } from "./mcpHttp.js";
|
|
18
22
|
import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
|
|
23
|
+
import { logMiddleware, writeLog } from "./eventLog.js";
|
|
19
24
|
import { RUN_API } from "./runApi.js";
|
|
20
25
|
import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
21
26
|
import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
|
|
22
|
-
import {
|
|
27
|
+
import { builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, REACTIONS, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
|
|
23
28
|
import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
|
|
24
29
|
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
25
30
|
// A held long-poll (watch, the viewer) re-arms rather than pinning a socket
|
|
@@ -165,8 +170,14 @@ const MAX_LABEL = 120;
|
|
|
165
170
|
const MAX_ASKS = 20;
|
|
166
171
|
const MAX_OPTIONS = 20;
|
|
167
172
|
const MAX_DRAFT_COMMENTS = 50;
|
|
173
|
+
const APP_FILES = new Set([
|
|
174
|
+
"/manifest.webmanifest",
|
|
175
|
+
"/icon.svg",
|
|
176
|
+
"/apple-touch-icon.png",
|
|
177
|
+
...ICON_SIZES.map((s) => `/icon-${s}.png`),
|
|
178
|
+
]);
|
|
168
179
|
const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
|
|
169
|
-
export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, }) {
|
|
180
|
+
export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
|
|
170
181
|
const app = new Hono();
|
|
171
182
|
// The scripts topic carries the run API from runApi.ts, its one source.
|
|
172
183
|
if (topics.scripts)
|
|
@@ -188,6 +199,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
188
199
|
c.header("Referrer-Policy", "no-referrer");
|
|
189
200
|
return next();
|
|
190
201
|
});
|
|
202
|
+
if (log)
|
|
203
|
+
app.use("*", logMiddleware(log));
|
|
191
204
|
const bus = new EventBus();
|
|
192
205
|
if (onEvent) {
|
|
193
206
|
bus.subscribe((event) => {
|
|
@@ -203,9 +216,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
203
216
|
// Each holder increments on entry and releases exactly once via a guarded
|
|
204
217
|
// release() wired to every exit (stream abort, request abort, normal return).
|
|
205
218
|
let holdConnections = 0;
|
|
206
|
-
// Only the viewer holds /api/events (watch long-polls)
|
|
207
|
-
//
|
|
208
|
-
|
|
219
|
+
// Only the viewer holds /api/events (watch long-polls). Each tab names the
|
|
220
|
+
// mock it has on screen (`?viewing=`), so pending.viewerOpen is per mock: a
|
|
221
|
+
// tab on Home or on another mock doesn't read as "looking at this one".
|
|
222
|
+
const viewing = new Map();
|
|
209
223
|
const acquireHold = () => {
|
|
210
224
|
if (holdConnections >= maxHoldConnections)
|
|
211
225
|
return false;
|
|
@@ -327,31 +341,93 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
327
341
|
return value;
|
|
328
342
|
}
|
|
329
343
|
// --- shared flows (used by both the REST API and the MCP endpoint) ---
|
|
344
|
+
// Sessions with an author=user long-poll held open right now: a waiting
|
|
345
|
+
// `mockpit watch` is a live agent, whatever its agentActiveAt says.
|
|
346
|
+
const listening = new Map();
|
|
330
347
|
// User comments the agent has not seen yet ride along on its next write, so
|
|
331
348
|
// agents hear feedback without blocking on the long-poll. The cursor also
|
|
332
349
|
// advances past the agent's own comments to keep reads cheap.
|
|
333
|
-
async function collectFeedback(sessionId) {
|
|
350
|
+
async function collectFeedback(sessionId, base) {
|
|
351
|
+
// Touch before reading the cursor: a taker's handOff checks agentActiveAt,
|
|
352
|
+
// so it can't move this cursor under a read already in flight.
|
|
353
|
+
await store.touchAgent(sessionId);
|
|
334
354
|
const session = await store.getSession(sessionId);
|
|
335
355
|
if (!session)
|
|
336
356
|
return [];
|
|
357
|
+
const handedOff = await takeOrphaned(session);
|
|
337
358
|
const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
|
|
338
|
-
if (fresh.length
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
359
|
+
if (fresh.length > 0) {
|
|
360
|
+
await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
|
|
361
|
+
bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
|
|
362
|
+
}
|
|
342
363
|
const feedback = fresh.filter((cm) => cm.author === "user");
|
|
343
|
-
return feedback
|
|
364
|
+
return feedbackWithHandoffs(feedback, handedOff, base);
|
|
365
|
+
}
|
|
366
|
+
async function feedbackWithHandoffs(own, handedOff, base) {
|
|
367
|
+
const mine = own.length > 0 ? await buildFeedbackBatches(store, own, base) : [];
|
|
368
|
+
if (handedOff.length === 0)
|
|
369
|
+
return mine;
|
|
370
|
+
const theirs = await buildFeedbackBatches(store, handedOff, base);
|
|
371
|
+
return [...mine, ...theirs.map((b) => ({ ...b, handoff: HANDOFF_NOTE }))];
|
|
372
|
+
}
|
|
373
|
+
// The user comments still owed to sessions `taker` supersedes (handoff.ts),
|
|
374
|
+
// taken for `taker`: each owner's cursor moves past them in one conditional
|
|
375
|
+
// write, so delivery stays exactly-once across sessions.
|
|
376
|
+
async function takeOrphaned(taker) {
|
|
377
|
+
if (!taker.project)
|
|
378
|
+
return [];
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
const taken = [];
|
|
381
|
+
for (const owner of await store.listSessions(taker.project)) {
|
|
382
|
+
if (!supersedes(taker, owner, listening.has(owner.id)))
|
|
383
|
+
continue;
|
|
384
|
+
const owed = await store.listComments({ sessionId: owner.id, afterSeq: owner.agentSeq });
|
|
385
|
+
// Seq order is time order, so the ready comments are a prefix; stop at the
|
|
386
|
+
// first one still inside its grace and leave the rest owed.
|
|
387
|
+
let upto = owner.agentSeq;
|
|
388
|
+
const take = [];
|
|
389
|
+
for (const c of owed) {
|
|
390
|
+
if (c.author === "user" && !handoffReady(c, owner, taker, now))
|
|
391
|
+
break;
|
|
392
|
+
upto = c.seq;
|
|
393
|
+
if (c.author === "user")
|
|
394
|
+
take.push(c);
|
|
395
|
+
}
|
|
396
|
+
if (take.length === 0)
|
|
397
|
+
continue;
|
|
398
|
+
if (!(await store.handOff(owner.id, owner, upto)))
|
|
399
|
+
continue;
|
|
400
|
+
bus.broadcast({ type: "comment-delivered", sessionId: owner.id, seq: upto });
|
|
401
|
+
taken.push(...take);
|
|
402
|
+
}
|
|
403
|
+
return taken;
|
|
344
404
|
}
|
|
345
405
|
// Per-comment delivery state for the viewer: `delivered` once the session's
|
|
346
|
-
// agentSeq has passed the comment. Computed, never stored.
|
|
406
|
+
// agentSeq has passed the comment. Computed, never stored. `ownerGone` marks
|
|
407
|
+
// an undelivered user comment whose session a newer one already supersedes,
|
|
408
|
+
// so the Thread can say the next agent here will get it.
|
|
347
409
|
async function withDelivered(comments) {
|
|
348
|
-
const
|
|
410
|
+
const sessions = new Map();
|
|
349
411
|
for (const c of comments) {
|
|
350
|
-
if (!
|
|
351
|
-
|
|
412
|
+
if (!sessions.has(c.sessionId))
|
|
413
|
+
sessions.set(c.sessionId, await store.getSession(c.sessionId));
|
|
414
|
+
}
|
|
415
|
+
const gone = new Map();
|
|
416
|
+
const ownerGone = async (owner) => {
|
|
417
|
+
if (!gone.has(owner.id)) {
|
|
418
|
+
const peers = owner.project ? await store.listSessions(owner.project) : [];
|
|
419
|
+
gone.set(owner.id, peers.some((t) => supersedes(t, owner, listening.has(owner.id))));
|
|
352
420
|
}
|
|
421
|
+
return gone.get(owner.id);
|
|
422
|
+
};
|
|
423
|
+
const out = [];
|
|
424
|
+
for (const c of comments) {
|
|
425
|
+
const owner = sessions.get(c.sessionId);
|
|
426
|
+
const delivered = c.seq <= (owner?.agentSeq ?? 0);
|
|
427
|
+
const waiting = !delivered && !!owner && c.author === "user" && (await ownerGone(owner));
|
|
428
|
+
out.push({ ...c, delivered, ...(waiting ? { ownerGone: true } : {}) });
|
|
353
429
|
}
|
|
354
|
-
return
|
|
430
|
+
return out;
|
|
355
431
|
}
|
|
356
432
|
// The workspace's light/dark choice. Older workspaces stored a theme id under
|
|
357
433
|
// `theme`; that registry is gone, so they start from the default mode.
|
|
@@ -665,8 +741,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
665
741
|
const out = [];
|
|
666
742
|
for (const [path, config] of Object.entries(knobs)) {
|
|
667
743
|
const n = discreteChoices(config);
|
|
668
|
-
if (n !== null && n <= 3) {
|
|
669
|
-
out.push(`knob "${path}"
|
|
744
|
+
if (n !== null && n >= 2 && n <= 3) {
|
|
745
|
+
out.push(`knob "${path}": a 2–3 option knob is a choice. Ask with options that \`set\` it.`);
|
|
670
746
|
}
|
|
671
747
|
}
|
|
672
748
|
return out;
|
|
@@ -680,35 +756,67 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
680
756
|
const names = live(state);
|
|
681
757
|
if (names.length < 2)
|
|
682
758
|
return null;
|
|
759
|
+
const applies = (a) => a.scope !== "part" && !(a.scope === "state" && a.state !== state);
|
|
683
760
|
const bound = mock.asks.some((a) => {
|
|
684
|
-
if (
|
|
761
|
+
if (!applies(a))
|
|
685
762
|
return false;
|
|
686
763
|
const variants = new Set(a.options.map((o) => o.variant));
|
|
687
764
|
return names.every((n) => variants.has(n));
|
|
688
765
|
});
|
|
689
766
|
if (bound)
|
|
690
767
|
return null;
|
|
768
|
+
const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
|
|
691
769
|
const key = (list) => [...list].sort().join("\n");
|
|
692
770
|
const states = mock.states.length ? mock.states : [null];
|
|
693
771
|
const wide = states.every((s) => key(live(s)) === key(names));
|
|
772
|
+
// An ask that already picks between some of these variants is the question:
|
|
773
|
+
// a second, generic "Which one?" would make the user choose the look twice.
|
|
774
|
+
// A mock-wide one only fits while every state has the same variants; else a
|
|
775
|
+
// variant only some states have would leave the others no real choice.
|
|
776
|
+
const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
|
|
777
|
+
if (existing) {
|
|
778
|
+
const have = new Set(existing.options.map((o) => o.variant));
|
|
779
|
+
const missing = names.filter((n) => !have.has(n));
|
|
780
|
+
const ask = {
|
|
781
|
+
id: existing.id,
|
|
782
|
+
text: existing.text,
|
|
783
|
+
scope: existing.scope,
|
|
784
|
+
...(existing.state ? { state: existing.state } : {}),
|
|
785
|
+
...(existing.multi ? { multi: true } : {}),
|
|
786
|
+
options: [
|
|
787
|
+
...existing.options.map((o) => ({
|
|
788
|
+
id: o.id,
|
|
789
|
+
label: o.label,
|
|
790
|
+
...(o.variant ? { variant: o.variant } : {}),
|
|
791
|
+
...(o.set ? { set: o.set } : {}),
|
|
792
|
+
})),
|
|
793
|
+
...missing.map((v) => ({ label: v, variant: v })),
|
|
794
|
+
],
|
|
795
|
+
};
|
|
796
|
+
return {
|
|
797
|
+
nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing ask "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
|
|
798
|
+
ask,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
694
801
|
const options = names.map((v) => ({ label: v, variant: v }));
|
|
802
|
+
// Not the built-in's reserved id: a stored ask under it would collide with the
|
|
803
|
+
// built-in the viewer synthesizes once a new variant goes unbound. Nor any id
|
|
804
|
+
// already taken, which sending this would silently replace.
|
|
805
|
+
const base = wide ? "look" : `look-${slugify(state ?? "")}`;
|
|
806
|
+
let id = base;
|
|
807
|
+
for (let n = 2; mock.asks.some((a) => a.id === id); n++)
|
|
808
|
+
id = `${base}-${n}`;
|
|
695
809
|
const ask = wide
|
|
696
|
-
? { id
|
|
697
|
-
: {
|
|
698
|
-
id: `${BUILTIN_ASK_ID}-${slugify(state ?? "")}`,
|
|
699
|
-
text: "Which one?",
|
|
700
|
-
scope: "state",
|
|
701
|
-
state,
|
|
702
|
-
options,
|
|
703
|
-
};
|
|
704
|
-
const where = state === null ? mock.slug : `${mock.slug} state "${state}"`;
|
|
810
|
+
? { id, text: "Which one?", scope: "mock", options }
|
|
811
|
+
: { id, text: "Which one?", scope: "state", state, options };
|
|
705
812
|
return {
|
|
706
|
-
nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk
|
|
813
|
+
nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk. Never ask in chat.`,
|
|
707
814
|
ask,
|
|
708
815
|
};
|
|
709
816
|
}
|
|
710
817
|
// The answer to every write: what was published, the parts found per state,
|
|
711
|
-
// what moved since the previous version, and any feedback waiting
|
|
818
|
+
// what moved since the previous version, and any feedback waiting — for the
|
|
819
|
+
// session that wrote, which on a new version may not be the variant's own.
|
|
712
820
|
async function writeResult(mock, post, ctx, extra = {}) {
|
|
713
821
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
714
822
|
const detail = mockDetailView(mock, posts);
|
|
@@ -719,7 +827,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
719
827
|
partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
|
|
720
828
|
}
|
|
721
829
|
const warnings = await iconWarnings(mock.project, post.surfaces);
|
|
722
|
-
const feedback = await collectFeedback(post.sessionId);
|
|
830
|
+
const feedback = await collectFeedback(extra.writer?.id ?? post.sessionId, ctx.base);
|
|
723
831
|
return ok({
|
|
724
832
|
mock: {
|
|
725
833
|
id: mock.id,
|
|
@@ -943,7 +1051,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
943
1051
|
announcePost(mock, updated, false);
|
|
944
1052
|
if (prompt && ctx.request)
|
|
945
1053
|
notifyPublish(mock, updated, prompt, ctx);
|
|
946
|
-
return writeResult(mock, updated, ctx, {
|
|
1054
|
+
return writeResult(mock, updated, ctx, {
|
|
1055
|
+
previous: target.surfaces,
|
|
1056
|
+
applied,
|
|
1057
|
+
nudges,
|
|
1058
|
+
writer: session,
|
|
1059
|
+
});
|
|
947
1060
|
}
|
|
948
1061
|
// `parts`: new outer html per part ("name" or "name#key"), spliced into the
|
|
949
1062
|
// variant's html so a tweak costs the tweak rather than the whole document.
|
|
@@ -1227,7 +1340,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1227
1340
|
text: added.map((a) => a.text).join(" · "),
|
|
1228
1341
|
url: mockUrl(ctx.base, mock),
|
|
1229
1342
|
});
|
|
1230
|
-
const feedback = session ? await collectFeedback(session.id) : [];
|
|
1343
|
+
const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
|
|
1344
|
+
const nudges = askNudges(added, mock.asks);
|
|
1231
1345
|
return ok({
|
|
1232
1346
|
mock: mock.slug,
|
|
1233
1347
|
mockId: mock.id,
|
|
@@ -1235,11 +1349,12 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1235
1349
|
asks: added,
|
|
1236
1350
|
open: openCount(mock, posts),
|
|
1237
1351
|
url: mockUrl(ctx.base, mock),
|
|
1352
|
+
...(nudges.length ? { nudges } : {}),
|
|
1238
1353
|
feedback,
|
|
1239
1354
|
});
|
|
1240
1355
|
}
|
|
1241
1356
|
// --- drafts and replies (the user's side; viewer origin only) ---
|
|
1242
|
-
function sanitizePartComments(raw, mock) {
|
|
1357
|
+
function sanitizePartComments(raw, mock, posts) {
|
|
1243
1358
|
if (raw === undefined || raw === null)
|
|
1244
1359
|
return [];
|
|
1245
1360
|
if (!Array.isArray(raw))
|
|
@@ -1248,16 +1363,39 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1248
1363
|
return `at most ${MAX_DRAFT_COMMENTS} comments`;
|
|
1249
1364
|
const out = [];
|
|
1250
1365
|
for (const c of raw) {
|
|
1366
|
+
const reaction = c?.reaction;
|
|
1367
|
+
if (reaction !== undefined && !REACTIONS.includes(reaction))
|
|
1368
|
+
return `reaction must be one of ${REACTIONS.join(", ")}`;
|
|
1251
1369
|
const text = str(c?.text, MAX_COMMENT_TEXT);
|
|
1252
|
-
if (!text)
|
|
1370
|
+
if (!text && !reaction)
|
|
1253
1371
|
return "every comment needs text";
|
|
1254
1372
|
const state = stateArg(c.state) ?? null;
|
|
1255
1373
|
if (state !== null && !mock.states.includes(state))
|
|
1256
1374
|
return `${mock.slug} has no state "${state}"`;
|
|
1257
1375
|
const part = str(c.part, 200) ?? null;
|
|
1376
|
+
const variant = str(c.variant, 200);
|
|
1377
|
+
if (variant && !posts.some((p) => p.variant === variant && p.state === state))
|
|
1378
|
+
return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
|
|
1258
1379
|
const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
|
|
1259
1380
|
const anchor = a ? sanitizePartAnchor(a) : undefined;
|
|
1260
|
-
|
|
1381
|
+
const next = {
|
|
1382
|
+
part,
|
|
1383
|
+
state,
|
|
1384
|
+
text: text ?? "",
|
|
1385
|
+
...(anchor ? { anchor } : {}),
|
|
1386
|
+
...(reaction ? { reaction: reaction } : {}),
|
|
1387
|
+
...(variant ? { variant } : {}),
|
|
1388
|
+
};
|
|
1389
|
+
// One reaction per target: a later tap on the same part replaces the earlier one.
|
|
1390
|
+
if (reaction) {
|
|
1391
|
+
const same = out.findIndex((o) => o.reaction &&
|
|
1392
|
+
o.part === part &&
|
|
1393
|
+
o.state === state &&
|
|
1394
|
+
(o.variant ?? null) === (variant ?? null));
|
|
1395
|
+
if (same >= 0)
|
|
1396
|
+
out.splice(same, 1);
|
|
1397
|
+
}
|
|
1398
|
+
out.push(next);
|
|
1261
1399
|
}
|
|
1262
1400
|
return out;
|
|
1263
1401
|
}
|
|
@@ -1270,6 +1408,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1270
1408
|
: undefined;
|
|
1271
1409
|
const quote = str(a.quote, 200);
|
|
1272
1410
|
const selector = str(a.selector, 500);
|
|
1411
|
+
const width = typeof a.width === "number" && Number.isFinite(a.width) && a.width >= 1 && a.width <= 10000
|
|
1412
|
+
? Math.round(a.width)
|
|
1413
|
+
: undefined;
|
|
1273
1414
|
if (!offset && !box && !quote && !selector)
|
|
1274
1415
|
return undefined;
|
|
1275
1416
|
return {
|
|
@@ -1277,6 +1418,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1277
1418
|
...(quote ? { quote } : {}),
|
|
1278
1419
|
...(selector ? { selector } : {}),
|
|
1279
1420
|
...(box ? { box } : {}),
|
|
1421
|
+
...(width ? { width } : {}),
|
|
1280
1422
|
};
|
|
1281
1423
|
}
|
|
1282
1424
|
// A stored ask, else the viewer's built-in variant ask under its reserved id.
|
|
@@ -1325,6 +1467,37 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1325
1467
|
}
|
|
1326
1468
|
return out;
|
|
1327
1469
|
}
|
|
1470
|
+
// askId → attached image ids: known asks, ids of stored raster images only,
|
|
1471
|
+
// deduped and capped, empty lists dropped.
|
|
1472
|
+
async function sanitizeAskImages(raw, mock, posts, key) {
|
|
1473
|
+
if (raw === undefined || raw === null)
|
|
1474
|
+
return {};
|
|
1475
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1476
|
+
return `"${key}" must be an object`;
|
|
1477
|
+
const out = {};
|
|
1478
|
+
for (const [askId, value] of Object.entries(raw)) {
|
|
1479
|
+
if (!askById(mock, posts, askId))
|
|
1480
|
+
return `${mock.slug} has no ask "${askId}"`;
|
|
1481
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== "string")) {
|
|
1482
|
+
return `${key} "${askId}" must be a list of asset ids`;
|
|
1483
|
+
}
|
|
1484
|
+
const ids = [...new Set(value)];
|
|
1485
|
+
if (ids.length > MAX_ASK_IMAGES) {
|
|
1486
|
+
return `${key} "${askId}" takes at most ${MAX_ASK_IMAGES} images`;
|
|
1487
|
+
}
|
|
1488
|
+
for (const id of ids) {
|
|
1489
|
+
const asset = await store.getAsset(id);
|
|
1490
|
+
if (!asset)
|
|
1491
|
+
return `asset "${id}" not found`;
|
|
1492
|
+
if (!ATTACHABLE_IMAGE_TYPES.has(asset.contentType)) {
|
|
1493
|
+
return `asset "${id}" is not an attachable image`;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
if (ids.length)
|
|
1497
|
+
out[askId] = ids;
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1328
1501
|
function sanitizeMix(raw, posts) {
|
|
1329
1502
|
if (raw === undefined || raw === null)
|
|
1330
1503
|
return {};
|
|
@@ -1344,7 +1517,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1344
1517
|
}
|
|
1345
1518
|
// A draft, validated against the mock as it is now: unknown asks, options,
|
|
1346
1519
|
// variants, knob paths or out-of-range values are refused, never stored.
|
|
1347
|
-
function sanitizeDraft(raw, mock, posts, base) {
|
|
1520
|
+
async function sanitizeDraft(raw, mock, posts, base) {
|
|
1348
1521
|
const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
|
|
1349
1522
|
const answers = sanitizeAnswers(pick("answers"), mock, posts);
|
|
1350
1523
|
if (typeof answers === "string")
|
|
@@ -1355,7 +1528,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1355
1528
|
const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
|
|
1356
1529
|
if (!tuned.ok)
|
|
1357
1530
|
return tuned.error;
|
|
1358
|
-
const comments = sanitizePartComments(pick("comments"), mock);
|
|
1531
|
+
const comments = sanitizePartComments(pick("comments"), mock, posts);
|
|
1359
1532
|
if (typeof comments === "string")
|
|
1360
1533
|
return comments;
|
|
1361
1534
|
const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
|
|
@@ -1364,6 +1537,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1364
1537
|
const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
|
|
1365
1538
|
if (typeof notes === "string")
|
|
1366
1539
|
return notes;
|
|
1540
|
+
const otherImages = await sanitizeAskImages(pick("otherImages"), mock, posts, "otherImages");
|
|
1541
|
+
if (typeof otherImages === "string")
|
|
1542
|
+
return otherImages;
|
|
1543
|
+
const noteImages = await sanitizeAskImages(pick("noteImages"), mock, posts, "noteImages");
|
|
1544
|
+
if (typeof noteImages === "string")
|
|
1545
|
+
return noteImages;
|
|
1546
|
+
for (const askId of Object.keys(otherImages))
|
|
1547
|
+
others[askId] ??= "";
|
|
1548
|
+
for (const askId of Object.keys(noteImages))
|
|
1549
|
+
notes[askId] ??= "";
|
|
1367
1550
|
for (const askId of Object.keys(others)) {
|
|
1368
1551
|
const ask = askById(mock, posts, askId);
|
|
1369
1552
|
if (!ask?.multi && answers[askId] !== undefined)
|
|
@@ -1381,6 +1564,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1381
1564
|
comments,
|
|
1382
1565
|
others,
|
|
1383
1566
|
notes,
|
|
1567
|
+
...(Object.keys(otherImages).length ? { otherImages } : {}),
|
|
1568
|
+
...(Object.keys(noteImages).length ? { noteImages } : {}),
|
|
1384
1569
|
updatedAt: new Date().toISOString(),
|
|
1385
1570
|
};
|
|
1386
1571
|
}
|
|
@@ -1402,7 +1587,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1402
1587
|
if (isResult(mock))
|
|
1403
1588
|
return mock;
|
|
1404
1589
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
1405
|
-
const draft = sanitizeDraft(body, mock, posts, null);
|
|
1590
|
+
const draft = await sanitizeDraft(body, mock, posts, null);
|
|
1406
1591
|
if (typeof draft === "string")
|
|
1407
1592
|
return fail(400, draft);
|
|
1408
1593
|
await store.putDraft(mock.id, draft);
|
|
@@ -1487,13 +1672,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1487
1672
|
if (answer !== undefined || other !== undefined) {
|
|
1488
1673
|
delete next.answer;
|
|
1489
1674
|
delete next.other;
|
|
1675
|
+
delete next.otherImages;
|
|
1490
1676
|
if (answer !== undefined)
|
|
1491
1677
|
next.answer = answer;
|
|
1492
1678
|
if (other !== undefined)
|
|
1493
1679
|
next.other = other;
|
|
1680
|
+
const images = draft.otherImages?.[a.id];
|
|
1681
|
+
if (other !== undefined && images)
|
|
1682
|
+
next.otherImages = images;
|
|
1494
1683
|
}
|
|
1495
|
-
if (note !== undefined)
|
|
1684
|
+
if (note !== undefined) {
|
|
1496
1685
|
next.note = note;
|
|
1686
|
+
delete next.noteImages;
|
|
1687
|
+
const images = draft.noteImages?.[a.id];
|
|
1688
|
+
if (images)
|
|
1689
|
+
next.noteImages = images;
|
|
1690
|
+
}
|
|
1497
1691
|
return next;
|
|
1498
1692
|
}
|
|
1499
1693
|
// Send: the user's one batched answer becomes one kind:"reply" comment on the
|
|
@@ -1506,7 +1700,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1506
1700
|
if (isResult(mock))
|
|
1507
1701
|
return mock;
|
|
1508
1702
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
1509
|
-
const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
|
|
1703
|
+
const draft = await sanitizeDraft(body ?? {}, mock, posts, mock.draft);
|
|
1510
1704
|
if (typeof draft === "string")
|
|
1511
1705
|
return fail(400, draft);
|
|
1512
1706
|
const text = str(body?.text, MAX_COMMENT_TEXT);
|
|
@@ -1536,6 +1730,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1536
1730
|
comments: draft.comments,
|
|
1537
1731
|
...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
|
|
1538
1732
|
...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
|
|
1733
|
+
...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
|
|
1734
|
+
...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
|
|
1539
1735
|
...(text ? { text } : {}),
|
|
1540
1736
|
...(decision ? { decision } : {}),
|
|
1541
1737
|
};
|
|
@@ -1861,7 +2057,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1861
2057
|
}
|
|
1862
2058
|
// The agent's plain-text message in a mock's thread. Stored as text and
|
|
1863
2059
|
// rendered as a text node, never as HTML. A write, so feedback rides along.
|
|
1864
|
-
async function sayFlow(ref, body) {
|
|
2060
|
+
async function sayFlow(ref, body, ctx) {
|
|
1865
2061
|
if (!body || typeof body !== "object")
|
|
1866
2062
|
return fail(400, "invalid JSON body");
|
|
1867
2063
|
const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
|
|
@@ -1885,10 +2081,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1885
2081
|
if (!comment)
|
|
1886
2082
|
return fail(404, "session not found");
|
|
1887
2083
|
announceComment(comment);
|
|
1888
|
-
return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
|
|
2084
|
+
return ok({ feedback: await collectFeedback(comment.sessionId, ctx.base) }, 201);
|
|
1889
2085
|
}
|
|
1890
2086
|
// Long-poll: resolves as soon as a matching comment lands, or at timeout.
|
|
1891
2087
|
async function waitForComments(q, signal) {
|
|
2088
|
+
// The agent listening for its feedback: it is alive, and it may take
|
|
2089
|
+
// replies owed to a session it superseded (handoff.ts).
|
|
2090
|
+
const listener = q.agent && q.author === "user" ? q.sessionId : undefined;
|
|
2091
|
+
if (listener)
|
|
2092
|
+
await store.touchAgent(listener);
|
|
1892
2093
|
// An author=user session wait with no explicit cursor resumes from the
|
|
1893
2094
|
// session's agentSeq — "where the agent left off" lives server-side so the
|
|
1894
2095
|
// CLI, both MCP transports, and piggyback share one exactly-once stream.
|
|
@@ -1896,12 +2097,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1896
2097
|
if (afterSeq === undefined && q.author === "user" && q.sessionId) {
|
|
1897
2098
|
afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
|
|
1898
2099
|
}
|
|
2100
|
+
const takeOrphanedFor = async (id) => {
|
|
2101
|
+
const taker = id ? await store.getSession(id) : null;
|
|
2102
|
+
return taker ? await takeOrphaned(taker) : [];
|
|
2103
|
+
};
|
|
2104
|
+
let handedOff = await takeOrphanedFor(listener);
|
|
1899
2105
|
const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
|
|
1900
2106
|
const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
|
|
1901
2107
|
const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
|
|
1902
2108
|
let all = await store.listComments(query);
|
|
1903
2109
|
let comments = matches(all);
|
|
1904
|
-
if (comments.length === 0 && wait > 0) {
|
|
2110
|
+
if (comments.length === 0 && handedOff.length === 0 && wait > 0) {
|
|
2111
|
+
if (listener)
|
|
2112
|
+
listening.set(listener, (listening.get(listener) ?? 0) + 1);
|
|
1905
2113
|
await new Promise((resolve) => {
|
|
1906
2114
|
const timer = setTimeout(done, wait * 1000);
|
|
1907
2115
|
const unsubscribe = bus.subscribe((event) => {
|
|
@@ -1930,10 +2138,20 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1930
2138
|
resolve();
|
|
1931
2139
|
}
|
|
1932
2140
|
});
|
|
2141
|
+
if (listener) {
|
|
2142
|
+
const n = (listening.get(listener) ?? 1) - 1;
|
|
2143
|
+
if (n > 0)
|
|
2144
|
+
listening.set(listener, n);
|
|
2145
|
+
else
|
|
2146
|
+
listening.delete(listener);
|
|
2147
|
+
}
|
|
1933
2148
|
// A caller that gave up can't receive this batch, so delivering it would
|
|
1934
2149
|
// advance the cursor past feedback no agent ever saw.
|
|
1935
2150
|
if (signal?.aborted)
|
|
1936
|
-
return { comments: [], lastSeq: afterSeq ?? 0 };
|
|
2151
|
+
return { comments: [], lastSeq: afterSeq ?? 0, handedOff: [] };
|
|
2152
|
+
if (listener)
|
|
2153
|
+
await store.touchAgent(listener);
|
|
2154
|
+
handedOff = await takeOrphanedFor(listener);
|
|
1937
2155
|
all = await store.listComments(query);
|
|
1938
2156
|
comments = matches(all);
|
|
1939
2157
|
}
|
|
@@ -1946,13 +2164,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1946
2164
|
await store.markAgentSeen(q.sessionId, lastSeq);
|
|
1947
2165
|
bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
|
|
1948
2166
|
}
|
|
1949
|
-
return { comments, lastSeq };
|
|
2167
|
+
return { comments, lastSeq, handedOff };
|
|
1950
2168
|
}
|
|
1951
2169
|
// The watch long-poll's answer: comments plus the batched feedback built
|
|
1952
2170
|
// from them.
|
|
1953
|
-
async function listenFlow(q, signal) {
|
|
1954
|
-
const result = await waitForComments({ ...q, agent: true }, signal);
|
|
1955
|
-
const feedback = await
|
|
2171
|
+
async function listenFlow(q, base, signal) {
|
|
2172
|
+
const { handedOff, ...result } = await waitForComments({ ...q, agent: true }, signal);
|
|
2173
|
+
const feedback = await feedbackWithHandoffs(result.comments, handedOff, base);
|
|
1956
2174
|
return ok({ ...result, feedback });
|
|
1957
2175
|
}
|
|
1958
2176
|
// What the user is doing right now, per mock: derived from the SSE registry
|
|
@@ -1960,7 +2178,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1960
2178
|
// tells the agent something ("2 of 3 answered", or nobody is looking).
|
|
1961
2179
|
const pendingOf = (mock, posts) => ({
|
|
1962
2180
|
mock: mock.slug,
|
|
1963
|
-
viewerOpen:
|
|
2181
|
+
viewerOpen: viewing.has(mock.id),
|
|
1964
2182
|
draft: mock.draft
|
|
1965
2183
|
? {
|
|
1966
2184
|
answered: Object.keys(mock.draft.answers).length,
|
|
@@ -1972,14 +2190,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1972
2190
|
});
|
|
1973
2191
|
// The feedback verb: never blocks. Takes what the session has not heard yet
|
|
1974
2192
|
// (the same cursor as piggyback and watch) plus `pending` for the project.
|
|
1975
|
-
async function feedbackFlow(query) {
|
|
2193
|
+
async function feedbackFlow(query, ctx) {
|
|
1976
2194
|
if (typeof query.session !== "string" || !query.session) {
|
|
1977
2195
|
return fail(400, 'provide "session": the session id a write returned');
|
|
1978
2196
|
}
|
|
1979
2197
|
const session = await store.getSession(query.session);
|
|
1980
2198
|
if (!session)
|
|
1981
2199
|
return fail(404, `session "${query.session}" not found`);
|
|
1982
|
-
const { comments } = await waitForComments({
|
|
2200
|
+
const { comments, handedOff } = await waitForComments({
|
|
1983
2201
|
sessionId: session.id,
|
|
1984
2202
|
author: "user",
|
|
1985
2203
|
waitSeconds: 0,
|
|
@@ -1987,13 +2205,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1987
2205
|
});
|
|
1988
2206
|
const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
|
|
1989
2207
|
return ok({
|
|
1990
|
-
feedback: await
|
|
2208
|
+
feedback: await feedbackWithHandoffs(comments, handedOff, ctx.base),
|
|
1991
2209
|
pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
|
|
1992
2210
|
});
|
|
1993
2211
|
}
|
|
1994
2212
|
// Store an uploaded blob. An explicit session is validated and a missing one
|
|
1995
|
-
// is auto-created so an upload can precede the first publish.
|
|
1996
|
-
//
|
|
2213
|
+
// is auto-created so an upload can precede the first publish. An image the
|
|
2214
|
+
// user attaches names its mock instead, and belongs to the session that mock
|
|
2215
|
+
// replies to. The asset's data is dropped from the result (it's bytes).
|
|
1997
2216
|
async function uploadAsset(input) {
|
|
1998
2217
|
if (input.data.byteLength === 0)
|
|
1999
2218
|
return { error: "empty upload", status: 400 };
|
|
@@ -2001,6 +2220,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2001
2220
|
return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
|
|
2002
2221
|
}
|
|
2003
2222
|
let sessionId = input.session;
|
|
2223
|
+
if (input.mock !== undefined) {
|
|
2224
|
+
if (!ATTACHABLE_IMAGE_TYPES.has(input.contentType)) {
|
|
2225
|
+
return { error: "attach a png, jpeg, webp or gif image", status: 415 };
|
|
2226
|
+
}
|
|
2227
|
+
const mock = await store.getMock(input.mock);
|
|
2228
|
+
if (!mock)
|
|
2229
|
+
return { error: `mock "${input.mock}" not found`, status: 404 };
|
|
2230
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2231
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2232
|
+
sessionId = mock.sessionId ?? latest?.sessionId;
|
|
2233
|
+
if (!sessionId)
|
|
2234
|
+
return { error: `${mock.slug} has no agent session`, status: 409 };
|
|
2235
|
+
}
|
|
2004
2236
|
if (sessionId && !(await store.getSession(sessionId))) {
|
|
2005
2237
|
return { error: `session "${sessionId}" not found`, status: 404 };
|
|
2006
2238
|
}
|
|
@@ -2058,6 +2290,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2058
2290
|
return next();
|
|
2059
2291
|
if (path.startsWith(STATIC_ASSET_PREFIX))
|
|
2060
2292
|
return next();
|
|
2293
|
+
// The installable-app files carry no workspace data, and a browser may
|
|
2294
|
+
// fetch them without the cookie; the manifest adds the key only for a
|
|
2295
|
+
// caller that already holds it.
|
|
2296
|
+
if (APP_FILES.has(path))
|
|
2297
|
+
return next();
|
|
2061
2298
|
const key = c.req.query("key");
|
|
2062
2299
|
if (key === authToken) {
|
|
2063
2300
|
setCookie(c, "mockpit_key", authToken, {
|
|
@@ -2131,6 +2368,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2131
2368
|
].join("");
|
|
2132
2369
|
return injectHead(text, `<script>${config}</script>`);
|
|
2133
2370
|
};
|
|
2371
|
+
// The tags that make the viewer installable to a home screen. `use-credentials`
|
|
2372
|
+
// sends the cookie with the manifest fetch, which browsers otherwise omit.
|
|
2373
|
+
const withAppHead = (request, mode) => (text) => {
|
|
2374
|
+
const base = escapeHtml(requestBasePath(request));
|
|
2375
|
+
return injectHead(text, [
|
|
2376
|
+
`<link rel="manifest" href="${base}/manifest.webmanifest" crossorigin="use-credentials">`,
|
|
2377
|
+
`<link rel="icon" href="${base}/icon.svg" type="image/svg+xml">`,
|
|
2378
|
+
`<link rel="apple-touch-icon" href="${base}/apple-touch-icon.png">`,
|
|
2379
|
+
`<meta name="theme-color" content="${themeColor(mode)}">`,
|
|
2380
|
+
`<meta name="mobile-web-app-capable" content="yes">`,
|
|
2381
|
+
`<meta name="apple-mobile-web-app-capable" content="yes">`,
|
|
2382
|
+
`<meta name="apple-mobile-web-app-title" content="mockpit">`,
|
|
2383
|
+
`<meta name="apple-mobile-web-app-status-bar-style" content="default">`,
|
|
2384
|
+
].join("\n"));
|
|
2385
|
+
};
|
|
2134
2386
|
// --- viewer pages ---
|
|
2135
2387
|
// Link previews for a mock page: the first open variant's first surface,
|
|
2136
2388
|
// with every pixel-affecting input pinned in the image URL so the Worker can
|
|
@@ -2171,13 +2423,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2171
2423
|
// sandboxed surface documents at /s/:id are *meant* to be framed and carry
|
|
2172
2424
|
// their own `sandbox` CSP header instead, so they never pass through here.
|
|
2173
2425
|
c.header("Content-Security-Policy", "frame-ancestors 'self'");
|
|
2174
|
-
const html = withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title);
|
|
2426
|
+
const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, { req: { url: c.req.url } }), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
|
|
2175
2427
|
if (!opts.preview)
|
|
2176
2428
|
return html;
|
|
2177
2429
|
const { mock, post } = opts.preview;
|
|
2178
2430
|
return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
|
|
2179
2431
|
};
|
|
2180
2432
|
app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
|
|
2433
|
+
// --- the installable app: manifest and icons ---
|
|
2434
|
+
app.get("/manifest.webmanifest", async (c) => {
|
|
2435
|
+
// A tokened workspace bakes the key into start_url for a caller that holds
|
|
2436
|
+
// it (the viewer links the manifest with credentials), so a home-screen
|
|
2437
|
+
// launch, which may not share the browser's cookies, signs itself in.
|
|
2438
|
+
const keyed = !!authToken && isAuthenticated(c);
|
|
2439
|
+
const manifest = webManifest(await workspaceMode(), keyed ? `./?key=${encodeURIComponent(authToken)}` : "./");
|
|
2440
|
+
c.header("Content-Type", "application/manifest+json; charset=utf-8");
|
|
2441
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2442
|
+
c.header("Cache-Control", keyed ? "private, no-store" : "no-cache");
|
|
2443
|
+
return c.body(JSON.stringify(manifest));
|
|
2444
|
+
});
|
|
2445
|
+
app.get("/icon.svg", (c) => {
|
|
2446
|
+
c.header("Content-Type", "image/svg+xml");
|
|
2447
|
+
// Our own art, but SVG is a document: opened top-level it runs at this origin.
|
|
2448
|
+
c.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
|
2449
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2450
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
2451
|
+
return c.body(ICON_SVG);
|
|
2452
|
+
});
|
|
2453
|
+
const iconPng = (size) => (c) => {
|
|
2454
|
+
c.header("Content-Type", "image/png");
|
|
2455
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2456
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
2457
|
+
return c.body(decodeBase64(ICON_PNG_BASE64[size]).buffer);
|
|
2458
|
+
};
|
|
2459
|
+
for (const size of ICON_SIZES)
|
|
2460
|
+
app.get(`/icon-${size}.png`, iconPng(size));
|
|
2461
|
+
app.get("/apple-touch-icon.png", iconPng(180));
|
|
2181
2462
|
// The engine reads the project/mock out of the URL itself; these render the
|
|
2182
2463
|
// same shell as "/".
|
|
2183
2464
|
app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
|
|
@@ -2225,8 +2506,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2225
2506
|
const cwd = str(body.cwd, 4096);
|
|
2226
2507
|
const key = str(body.key, MAX_TITLE);
|
|
2227
2508
|
const existing = key ? await store.getSessionByKey(key) : null;
|
|
2228
|
-
if (existing)
|
|
2229
|
-
|
|
2509
|
+
if (existing) {
|
|
2510
|
+
// A resumed conversation is that agent back, not a new one superseding it.
|
|
2511
|
+
await store.touchAgent(existing.id);
|
|
2512
|
+
return c.json((await store.getSession(existing.id)) ?? existing, 200);
|
|
2513
|
+
}
|
|
2230
2514
|
const session = await store.createSession({
|
|
2231
2515
|
agent: str(body.agent, MAX_TITLE) ?? "agent",
|
|
2232
2516
|
title: str(body.title, MAX_TITLE),
|
|
@@ -2297,8 +2581,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2297
2581
|
app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2298
2582
|
app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
|
|
2299
2583
|
app.post("/api/mocks/:id/variants/:postId/restore", async (c) => send(c, await restoreVersionFlow(c.req.param("id"), c.req.param("postId"), await jsonBody(c), flowCtx(c))));
|
|
2300
|
-
app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c))));
|
|
2301
|
-
app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") })));
|
|
2584
|
+
app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2585
|
+
app.get("/api/feedback", async (c) => send(c, await feedbackFlow({ session: c.req.query("session"), project: c.req.query("project") }, flowCtx(c))));
|
|
2302
2586
|
// --- comments ---
|
|
2303
2587
|
app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
|
|
2304
2588
|
app.delete("/api/comments/:id", async (c) => {
|
|
@@ -2363,8 +2647,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2363
2647
|
query.agent = isAgentRead;
|
|
2364
2648
|
const respond = async (signal) => {
|
|
2365
2649
|
if (isAgentRead)
|
|
2366
|
-
return send(c, await listenFlow(query, signal));
|
|
2367
|
-
const result = await waitForComments(query, signal);
|
|
2650
|
+
return send(c, await listenFlow(query, flowCtx(c).base, signal));
|
|
2651
|
+
const { handedOff: _none, ...result } = await waitForComments(query, signal);
|
|
2368
2652
|
return c.json({ ...result, comments: await withDelivered(result.comments) });
|
|
2369
2653
|
};
|
|
2370
2654
|
if (waitSeconds <= 0)
|
|
@@ -2931,11 +3215,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2931
3215
|
kind: isAssetKind(kindQ) ? kindQ : undefined,
|
|
2932
3216
|
session: c.req.query("session"),
|
|
2933
3217
|
agent: c.req.query("agent"),
|
|
3218
|
+
mock: c.req.query("mock"),
|
|
2934
3219
|
};
|
|
2935
3220
|
}
|
|
2936
3221
|
catch (err) {
|
|
2937
3222
|
return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
|
|
2938
3223
|
}
|
|
3224
|
+
// An attachment on a mock is the user's, so only the viewer may make one.
|
|
3225
|
+
if (body.mock !== undefined && !flowCtx(c).viewer) {
|
|
3226
|
+
return c.json({ error: "only the viewer can attach images to a mock" }, 403);
|
|
3227
|
+
}
|
|
2939
3228
|
const result = await uploadAsset(body);
|
|
2940
3229
|
if ("error" in result)
|
|
2941
3230
|
return c.json({ error: result.error }, result.status);
|
|
@@ -2986,12 +3275,30 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2986
3275
|
}
|
|
2987
3276
|
if (!acquireHold())
|
|
2988
3277
|
return c.json({ error: "too many concurrent connections" }, 503);
|
|
2989
|
-
|
|
3278
|
+
const openedAt = Date.now();
|
|
3279
|
+
const viewingId = str(c.req.query("viewing"), MAX_TITLE);
|
|
3280
|
+
if (viewingId)
|
|
3281
|
+
viewing.set(viewingId, (viewing.get(viewingId) ?? 0) + 1);
|
|
2990
3282
|
let attached = true;
|
|
2991
3283
|
const releaseHold = makeRelease();
|
|
2992
3284
|
const release = () => {
|
|
2993
|
-
if (attached)
|
|
2994
|
-
|
|
3285
|
+
if (attached) {
|
|
3286
|
+
if (viewingId) {
|
|
3287
|
+
const left = (viewing.get(viewingId) ?? 1) - 1;
|
|
3288
|
+
if (left > 0)
|
|
3289
|
+
viewing.set(viewingId, left);
|
|
3290
|
+
else
|
|
3291
|
+
viewing.delete(viewingId);
|
|
3292
|
+
}
|
|
3293
|
+
writeLog(log, {
|
|
3294
|
+
t: new Date().toISOString(),
|
|
3295
|
+
op: "sse close",
|
|
3296
|
+
client: "viewer",
|
|
3297
|
+
status: 200,
|
|
3298
|
+
ms: Date.now() - openedAt,
|
|
3299
|
+
...(viewingId ? { viewing: viewingId } : {}),
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
2995
3302
|
attached = false;
|
|
2996
3303
|
releaseHold();
|
|
2997
3304
|
};
|
|
@@ -3078,8 +3385,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3078
3385
|
get: (ref, query) => getMockFlow(ref, query),
|
|
3079
3386
|
ask: (ref, body, ctx) => askFlow(ref, body, ctx),
|
|
3080
3387
|
exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
|
|
3081
|
-
say: (ref, body) => sayFlow(ref, body),
|
|
3082
|
-
feedback: (query) => feedbackFlow(query),
|
|
3388
|
+
say: (ref, body, ctx) => sayFlow(ref, body, ctx),
|
|
3389
|
+
feedback: (query, ctx) => feedbackFlow(query, ctx),
|
|
3083
3390
|
};
|
|
3084
3391
|
const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
|
|
3085
3392
|
// --- run: an agent script over the same flows (server/run.ts) ---
|