mockpit 0.0.0-stage → 1.0.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 +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- package/viewer/dist/index.html +1922 -0
|
@@ -0,0 +1,3112 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
import { decodeBase64 } from "./base64.js";
|
|
6
|
+
import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
|
|
7
|
+
import { EventBus } from "./events.js";
|
|
8
|
+
import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
|
|
9
|
+
import { buildFeedbackBatches } from "./feedbackBatch.js";
|
|
10
|
+
import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
|
|
11
|
+
import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
|
|
12
|
+
import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
|
|
13
|
+
import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
|
|
14
|
+
import { postToMarkdown } from "./postMarkdown.js";
|
|
15
|
+
import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
|
|
16
|
+
import { expandSlots, parseSlotTags } from "./slots.js";
|
|
17
|
+
import { registerMcp } from "./mcpHttp.js";
|
|
18
|
+
import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
|
|
19
|
+
import { RUN_API } from "./runApi.js";
|
|
20
|
+
import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
21
|
+
import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
|
|
22
|
+
import { BUILTIN_ASK_ID, builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
|
|
23
|
+
import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
|
|
24
|
+
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
// A held long-poll (watch, the viewer) re-arms rather than pinning a socket
|
|
26
|
+
// for good; proxies and serverless hosts drop longer requests anyway.
|
|
27
|
+
const MAX_WAIT_SECONDS = 230;
|
|
28
|
+
// Hard ceiling on any request body, applied globally. Every write endpoint
|
|
29
|
+
// reads its body with an unbounded `c.req.json()` (and /mcp likewise), so
|
|
30
|
+
// without this a single oversize POST is an out-of-memory flood — and the local
|
|
31
|
+
// default ships with no auth token, so those endpoints are reachable
|
|
32
|
+
// unauthenticated. Sized to clear the largest legitimate body — a base64 asset
|
|
33
|
+
// uploaded over MCP, ~4/3 of the 5 MiB asset cap — while still bounding a flood.
|
|
34
|
+
// The /api/assets route's own 5 MiB streaming cap is stricter and still applies.
|
|
35
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
36
|
+
// A comment's text and a surface's title both ride the feedback channel back to
|
|
37
|
+
// the agent in feedback batches, re-sent on every poll — so cap them at the
|
|
38
|
+
// edge to keep one oversize value from bloating the agent's context forever.
|
|
39
|
+
const MAX_COMMENT_TEXT = 8000;
|
|
40
|
+
const MAX_TITLE = 500;
|
|
41
|
+
// Ceiling on concurrently-held SSE + long-poll connections. Both are GETs that
|
|
42
|
+
// pin a connection open (the event stream indefinitely, /api/comments?wait up
|
|
43
|
+
// to MAX_WAIT_SECONDS); on a publicRead workspace they're reachable unauthenticated,
|
|
44
|
+
// so without a cap a flood exhausts sockets. One app instance is one workspace
|
|
45
|
+
// (a single Durable Object), and one workspace is one user — so legitimate
|
|
46
|
+
// concurrent holds are small but not tiny: each open viewer tab holds one SSE,
|
|
47
|
+
// and each active agent holds a long-poll (and possibly its own SSE). A
|
|
48
|
+
// multi-agent session with 5 agents plus a few viewer tabs can legitimately
|
|
49
|
+
// reach ~15. 32 clears that with headroom while still bounding a flood on a
|
|
50
|
+
// no-token local workspace — and a real flood is orders of magnitude bigger, so
|
|
51
|
+
// rejecting at 32 vs 16 makes no difference to flood protection, only to
|
|
52
|
+
// legitimate use. Configurable so deployments can tune it and tests can
|
|
53
|
+
// exercise the cap cheaply.
|
|
54
|
+
const DEFAULT_MAX_HOLD_CONNECTIONS = 32;
|
|
55
|
+
// Asset serving policy: only raster images are served inline; everything else
|
|
56
|
+
// (incl. svg, json, text, the octet-stream catch-all) is an attachment, so a
|
|
57
|
+
// top-level open of /a/:id can never execute an uploaded document as a live
|
|
58
|
+
// same-origin script. <img>/fetch ignore Content-Disposition, so embedding
|
|
59
|
+
// keeps working regardless.
|
|
60
|
+
const INLINE_IMAGE_TYPES = new Set([
|
|
61
|
+
"image/png",
|
|
62
|
+
"image/jpeg",
|
|
63
|
+
"image/gif",
|
|
64
|
+
"image/webp",
|
|
65
|
+
"image/avif",
|
|
66
|
+
]);
|
|
67
|
+
const ATTACH_SAFE_TYPES = new Set([
|
|
68
|
+
"image/svg+xml",
|
|
69
|
+
"application/json",
|
|
70
|
+
"application/x-ndjson",
|
|
71
|
+
"text/plain",
|
|
72
|
+
"text/csv",
|
|
73
|
+
]);
|
|
74
|
+
function assetServeHeaders(asset) {
|
|
75
|
+
if (INLINE_IMAGE_TYPES.has(asset.contentType)) {
|
|
76
|
+
return { contentType: asset.contentType, disposition: "inline" };
|
|
77
|
+
}
|
|
78
|
+
const contentType = ATTACH_SAFE_TYPES.has(asset.contentType)
|
|
79
|
+
? asset.contentType
|
|
80
|
+
: "application/octet-stream";
|
|
81
|
+
const name = (asset.filename || asset.id).replace(/[^\w.-]/g, "_");
|
|
82
|
+
return { contentType, disposition: `attachment; filename="${name}"` };
|
|
83
|
+
}
|
|
84
|
+
// Pick an AssetKind when the caller didn't specify one.
|
|
85
|
+
function inferAssetKind(contentType) {
|
|
86
|
+
return contentType.startsWith("image/") ? "image" : "file";
|
|
87
|
+
}
|
|
88
|
+
const isAssetKind = (v) => v === "image" || v === "file";
|
|
89
|
+
// Docs and onboarding snippets are written against the local default; serve
|
|
90
|
+
// them with the real origin so a deployed instance shows copy-pasteable URLs.
|
|
91
|
+
const LOCAL_ORIGIN = "http://localhost:8228";
|
|
92
|
+
// Newer-than for plain x.y.z strings; prerelease suffixes compare as their
|
|
93
|
+
// base version, and garbage compares as "not newer".
|
|
94
|
+
function versionGt(a, b) {
|
|
95
|
+
const pa = a.split("-")[0].split(".").map(Number);
|
|
96
|
+
const pb = b.split("-")[0].split(".").map(Number);
|
|
97
|
+
for (let i = 0; i < 3; i++) {
|
|
98
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
99
|
+
if (d !== 0)
|
|
100
|
+
return d > 0;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
// Latest published version from npm, release notes from the matching GitHub
|
|
105
|
+
// release. Notes are garnish: if GitHub is unreachable the version alone
|
|
106
|
+
// still makes a usable notice.
|
|
107
|
+
async function fetchLatestFromRegistry() {
|
|
108
|
+
const res = await fetch("https://registry.npmjs.org/mockpit/latest");
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
return null;
|
|
111
|
+
const pkg = (await res.json());
|
|
112
|
+
if (typeof pkg.version !== "string")
|
|
113
|
+
return null;
|
|
114
|
+
let notes;
|
|
115
|
+
try {
|
|
116
|
+
const gh = await fetch(`https://api.github.com/repos/fabiogaliano/mockpit/releases/tags/v${pkg.version}`, { headers: { "user-agent": "mockpit", accept: "application/vnd.github+json" } });
|
|
117
|
+
if (gh.ok) {
|
|
118
|
+
const rel = (await gh.json());
|
|
119
|
+
if (typeof rel.body === "string")
|
|
120
|
+
notes = rel.body;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore — see above
|
|
125
|
+
}
|
|
126
|
+
return { version: pkg.version, notes };
|
|
127
|
+
}
|
|
128
|
+
const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
129
|
+
// In "session" mode only reads addressed by an unguessable id are public: a
|
|
130
|
+
// mock id, a post id (its documents), an asset id, a session's comments/feed.
|
|
131
|
+
// Anything addressed by NAME (projects, mock slugs, the mock list) would let a
|
|
132
|
+
// single shared link enumerate the whole workspace, so it stays private.
|
|
133
|
+
function isPublicReadAllowed(path, mode, query) {
|
|
134
|
+
if (mode === "full")
|
|
135
|
+
return true;
|
|
136
|
+
if (path.startsWith("/project/"))
|
|
137
|
+
return true;
|
|
138
|
+
if (path.startsWith("/s/"))
|
|
139
|
+
return true;
|
|
140
|
+
if (path.startsWith("/a/"))
|
|
141
|
+
return true;
|
|
142
|
+
if (/^\/api\/mocks\/[^/]+(\/export)?$/.test(path)) {
|
|
143
|
+
return !path.startsWith("/api/mocks/recent") && !query.has("project");
|
|
144
|
+
}
|
|
145
|
+
if (path === "/api/comments")
|
|
146
|
+
return true;
|
|
147
|
+
if (path === "/api/events")
|
|
148
|
+
return true;
|
|
149
|
+
if (path === "/api/theme")
|
|
150
|
+
return true;
|
|
151
|
+
if (path === "/api/version")
|
|
152
|
+
return true;
|
|
153
|
+
// A project's kits are named by project, so only the bundled list is public.
|
|
154
|
+
if (path === "/api/kits")
|
|
155
|
+
return !query.has("project");
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const ok = (body, status = 200) => ({ status, body });
|
|
159
|
+
const fail = (status, error, extra = {}) => ({
|
|
160
|
+
status,
|
|
161
|
+
body: { error, ...extra },
|
|
162
|
+
});
|
|
163
|
+
const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
|
|
164
|
+
const MAX_LABEL = 120;
|
|
165
|
+
const MAX_ASKS = 20;
|
|
166
|
+
const MAX_OPTIONS = 20;
|
|
167
|
+
const MAX_DRAFT_COMMENTS = 50;
|
|
168
|
+
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, }) {
|
|
170
|
+
const app = new Hono();
|
|
171
|
+
// The scripts topic carries the run API from runApi.ts, its one source.
|
|
172
|
+
if (topics.scripts)
|
|
173
|
+
topics = {
|
|
174
|
+
...topics,
|
|
175
|
+
scripts: topics.scripts.replace("<!-- run-api -->", `\`\`\`ts\n${RUN_API}\n\`\`\``),
|
|
176
|
+
};
|
|
177
|
+
// `?key=` bootstraps cookie auth, so never let a workspace URL disclose that
|
|
178
|
+
// credential to another origin through an outbound Referer header. Set this
|
|
179
|
+
// before auth so denied and public routes carry the same policy.
|
|
180
|
+
// The origin of the most recent request. A surface document bakes its origin
|
|
181
|
+
// in (CSP, <base>, asset URLs), so the publish-time pre-warm needs one — and a
|
|
182
|
+
// write arrives over the same origin the viewer is about to read from. It is
|
|
183
|
+
// only ever a cache-key input: an origin that turns out to be wrong wastes a
|
|
184
|
+
// warm entry, it can never be served to the wrong caller (renderKey includes it).
|
|
185
|
+
let lastOrigin = null;
|
|
186
|
+
app.use("*", (c, next) => {
|
|
187
|
+
lastOrigin = new URL(c.req.url).origin;
|
|
188
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
189
|
+
return next();
|
|
190
|
+
});
|
|
191
|
+
const bus = new EventBus();
|
|
192
|
+
if (onEvent) {
|
|
193
|
+
bus.subscribe((event) => {
|
|
194
|
+
try {
|
|
195
|
+
onEvent(event);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
console.warn("[mockpit] onEvent listener failed", err);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// Live count of held SSE + long-poll connections, gated by maxHoldConnections.
|
|
203
|
+
// Each holder increments on entry and releases exactly once via a guarded
|
|
204
|
+
// release() wired to every exit (stream abort, request abort, normal return).
|
|
205
|
+
let holdConnections = 0;
|
|
206
|
+
// Only the viewer holds /api/events (watch long-polls), so an attached SSE
|
|
207
|
+
// client means someone has the workspace open.
|
|
208
|
+
let viewerClients = 0;
|
|
209
|
+
const acquireHold = () => {
|
|
210
|
+
if (holdConnections >= maxHoldConnections)
|
|
211
|
+
return false;
|
|
212
|
+
holdConnections++;
|
|
213
|
+
return true;
|
|
214
|
+
};
|
|
215
|
+
const makeRelease = () => {
|
|
216
|
+
let released = false;
|
|
217
|
+
return () => {
|
|
218
|
+
if (released)
|
|
219
|
+
return;
|
|
220
|
+
released = true;
|
|
221
|
+
holdConnections--;
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
// Rendered-document cache for /s/:id rich surfaces. Rendering a markdown/code/
|
|
225
|
+
// diff surface runs shiki / @pierre-diffs SSR, which is non-trivial (a big diff
|
|
226
|
+
// is tens of ms + tens of KB), so memoize the finished document string. The
|
|
227
|
+
// key pins everything the output depends on — post id, surface index, the
|
|
228
|
+
// RESOLVED version number, theme, mode — and a version's content is immutable,
|
|
229
|
+
// so a hit is always correct (a post edit bumps the version → a new key).
|
|
230
|
+
// Bounded by BYTES and LRU-evicted: a dropped entry costs a re-render, never
|
|
231
|
+
// correctness. Bounding the entry COUNT was the wrong axis — a document can
|
|
232
|
+
// weigh anything from 2 KB to ~900 KB, so 512 entries could mean 450 MB in an
|
|
233
|
+
// isolate with a 128 MB budget. The DurableObject is single-instance per
|
|
234
|
+
// workspace, so this in-memory cache is authoritative; a multi-instance deploy
|
|
235
|
+
// could back it with KV/Cache API behind the same key without changing callers.
|
|
236
|
+
const MAX_RENDER_CACHE_BYTES = 32 * 1024 * 1024;
|
|
237
|
+
const renderCache = new Map();
|
|
238
|
+
let renderCacheBytes = 0;
|
|
239
|
+
// UTF-16 code units × 2 is the retained JS string size, which is what this
|
|
240
|
+
// bound is protecting — not the transferred UTF-8 length.
|
|
241
|
+
const docBytes = (doc) => doc.length * 2;
|
|
242
|
+
const dropCacheEntry = (key) => {
|
|
243
|
+
const doc = renderCache.get(key);
|
|
244
|
+
if (doc === undefined)
|
|
245
|
+
return;
|
|
246
|
+
renderCacheBytes -= docBytes(doc);
|
|
247
|
+
renderCache.delete(key);
|
|
248
|
+
};
|
|
249
|
+
const clearRenderCache = () => {
|
|
250
|
+
renderCache.clear();
|
|
251
|
+
renderCacheBytes = 0;
|
|
252
|
+
};
|
|
253
|
+
// The response headers every surface document carries, on the cache-first
|
|
254
|
+
// path and the render path alike.
|
|
255
|
+
//
|
|
256
|
+
// The `sandbox` CSP sandboxes the document however it is loaded. The viewer
|
|
257
|
+
// embeds it in an iframe whose `sandbox="allow-scripts"` attribute gives it an
|
|
258
|
+
// opaque origin, but the document is served from the workspace's own origin —
|
|
259
|
+
// so a TOP-LEVEL load (a user opening /s/:id in a new tab, an agent-shared
|
|
260
|
+
// link) would otherwise run the agent's script in the workspace origin, where
|
|
261
|
+
// it could reach same-origin storage or window.open('/') the real viewer. A
|
|
262
|
+
// `sandbox` CSP can only be set as a response header (not the meta tag the
|
|
263
|
+
// page carries), and it forces the same opaque-origin sandbox on a direct
|
|
264
|
+
// navigation: allow-scripts so the bridge still runs, but no allow-same-origin,
|
|
265
|
+
// so agent code can never touch the workspace origin. Mirrors the iframe's
|
|
266
|
+
// sandbox flags.
|
|
267
|
+
//
|
|
268
|
+
// Version-pinned + themed requests (what the viewer always sends) are
|
|
269
|
+
// immutable, so they allow long-lived shared caching; an unpinned direct load
|
|
270
|
+
// is not.
|
|
271
|
+
function surfaceDocHeaders(c, immutable) {
|
|
272
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
273
|
+
c.header("Content-Security-Policy", "sandbox allow-scripts");
|
|
274
|
+
c.header("Cache-Control", immutable ? "public, max-age=31536000, immutable" : "private, no-cache");
|
|
275
|
+
}
|
|
276
|
+
function renderCacheHit(key) {
|
|
277
|
+
const hit = renderCache.get(key);
|
|
278
|
+
if (hit === undefined)
|
|
279
|
+
return undefined;
|
|
280
|
+
// refresh LRU recency
|
|
281
|
+
renderCache.delete(key);
|
|
282
|
+
renderCache.set(key, hit);
|
|
283
|
+
return hit;
|
|
284
|
+
}
|
|
285
|
+
async function cachedRender(key, build) {
|
|
286
|
+
const hit = renderCacheHit(key);
|
|
287
|
+
if (hit !== undefined)
|
|
288
|
+
return hit;
|
|
289
|
+
const doc = await build();
|
|
290
|
+
renderCache.set(key, doc);
|
|
291
|
+
renderCacheBytes += docBytes(doc);
|
|
292
|
+
while (renderCacheBytes > MAX_RENDER_CACHE_BYTES && renderCache.size > 1) {
|
|
293
|
+
const oldest = renderCache.keys().next().value;
|
|
294
|
+
if (oldest === undefined)
|
|
295
|
+
break;
|
|
296
|
+
dropCacheEntry(oldest);
|
|
297
|
+
}
|
|
298
|
+
return doc;
|
|
299
|
+
}
|
|
300
|
+
// Last-resort safety net: any handler that throws (rather than returning a
|
|
301
|
+
// status) becomes a clean JSON 500 instead of leaking a stack or a bare crash.
|
|
302
|
+
// Validation rejects bad input with 4xx before this, so reaching here means an
|
|
303
|
+
// unexpected bug — log it so it isn't swallowed silently.
|
|
304
|
+
app.onError((err, c) => {
|
|
305
|
+
console.error("mockpit: unhandled error", err);
|
|
306
|
+
return c.json({ error: "internal error" }, 500);
|
|
307
|
+
});
|
|
308
|
+
const normalizeBasePath = (value) => {
|
|
309
|
+
if (!value || value === "/")
|
|
310
|
+
return "";
|
|
311
|
+
const withLeading = value.startsWith("/") ? value : `/${value}`;
|
|
312
|
+
let end = withLeading.length;
|
|
313
|
+
while (end > 0 && withLeading.charCodeAt(end - 1) === 47)
|
|
314
|
+
end--;
|
|
315
|
+
return withLeading.slice(0, end);
|
|
316
|
+
};
|
|
317
|
+
const requestBasePath = (request) => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath);
|
|
318
|
+
// Cached, fail-silent update lookup: being offline or rate-limited must
|
|
319
|
+
// cost nothing but the absence of the notice. Failures are cached too, so
|
|
320
|
+
// a dead network doesn't retry on every viewer load.
|
|
321
|
+
let updateCache = null;
|
|
322
|
+
async function latestRelease() {
|
|
323
|
+
if (updateCache && Date.now() - updateCache.at < UPDATE_CHECK_TTL_MS)
|
|
324
|
+
return updateCache.value;
|
|
325
|
+
const value = await (fetchLatestRelease ?? fetchLatestFromRegistry)().catch(() => null);
|
|
326
|
+
updateCache = { at: Date.now(), value };
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
// --- shared flows (used by both the REST API and the MCP endpoint) ---
|
|
330
|
+
// User comments the agent has not seen yet ride along on its next write, so
|
|
331
|
+
// agents hear feedback without blocking on the long-poll. The cursor also
|
|
332
|
+
// advances past the agent's own comments to keep reads cheap.
|
|
333
|
+
async function collectFeedback(sessionId) {
|
|
334
|
+
const session = await store.getSession(sessionId);
|
|
335
|
+
if (!session)
|
|
336
|
+
return [];
|
|
337
|
+
const fresh = await store.listComments({ sessionId, afterSeq: session.agentSeq });
|
|
338
|
+
if (fresh.length === 0)
|
|
339
|
+
return [];
|
|
340
|
+
await store.markAgentSeen(sessionId, fresh[fresh.length - 1].seq);
|
|
341
|
+
bus.broadcast({ type: "comment-delivered", sessionId, seq: fresh[fresh.length - 1].seq });
|
|
342
|
+
const feedback = fresh.filter((cm) => cm.author === "user");
|
|
343
|
+
return feedback.length > 0 ? await buildFeedbackBatches(store, feedback) : [];
|
|
344
|
+
}
|
|
345
|
+
// Per-comment delivery state for the viewer: `delivered` once the session's
|
|
346
|
+
// agentSeq has passed the comment. Computed, never stored.
|
|
347
|
+
async function withDelivered(comments) {
|
|
348
|
+
const cursors = new Map();
|
|
349
|
+
for (const c of comments) {
|
|
350
|
+
if (!cursors.has(c.sessionId)) {
|
|
351
|
+
cursors.set(c.sessionId, (await store.getSession(c.sessionId))?.agentSeq ?? 0);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return comments.map((c) => ({ ...c, delivered: c.seq <= (cursors.get(c.sessionId) ?? 0) }));
|
|
355
|
+
}
|
|
356
|
+
// The workspace's light/dark choice. Older workspaces stored a theme id under
|
|
357
|
+
// `theme`; that registry is gone, so they start from the default mode.
|
|
358
|
+
async function workspaceMode() {
|
|
359
|
+
const stored = await store.getSetting("mode");
|
|
360
|
+
return isMode(stored) ? stored : DEFAULT_MODE;
|
|
361
|
+
}
|
|
362
|
+
// A project's imported design system (settings key `design:<project>`), or
|
|
363
|
+
// null when `mockpit init` has never run for it.
|
|
364
|
+
async function designFor(project) {
|
|
365
|
+
const raw = await store.getSetting(`design:${project}`);
|
|
366
|
+
if (!raw)
|
|
367
|
+
return null;
|
|
368
|
+
try {
|
|
369
|
+
const design = JSON.parse(raw);
|
|
370
|
+
return {
|
|
371
|
+
...design,
|
|
372
|
+
tailwindCss: typeof design.tailwindCss === "string" ? design.tailwindCss : "",
|
|
373
|
+
strippedImports: Array.isArray(design.strippedImports) ? design.strippedImports : [],
|
|
374
|
+
iconSets: Array.isArray(design.iconSets) ? design.iconSets : [],
|
|
375
|
+
projectKits: Array.isArray(design.projectKits) ? design.projectKits : [],
|
|
376
|
+
designFiles: design.designFiles ?? null,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Parsed installed icon sets by asset id. Asset bytes are immutable (the id
|
|
384
|
+
// is their hash), so an entry never goes stale; the bound only caps memory.
|
|
385
|
+
const iconSetCache = new Map();
|
|
386
|
+
const ICON_SET_CACHE_MAX = 16;
|
|
387
|
+
async function installedIconSet(assetId) {
|
|
388
|
+
if (iconSetCache.has(assetId))
|
|
389
|
+
return iconSetCache.get(assetId);
|
|
390
|
+
const asset = await store.getAsset(assetId);
|
|
391
|
+
// A missing asset is not cached: it may be uploaded a moment later.
|
|
392
|
+
if (!asset)
|
|
393
|
+
return null;
|
|
394
|
+
const set = parseIconSet(new TextDecoder().decode(asset.data));
|
|
395
|
+
if (iconSetCache.size >= ICON_SET_CACHE_MAX) {
|
|
396
|
+
iconSetCache.delete(iconSetCache.keys().next().value);
|
|
397
|
+
}
|
|
398
|
+
iconSetCache.set(assetId, set);
|
|
399
|
+
return set;
|
|
400
|
+
}
|
|
401
|
+
// The project's installed sets first, so installing a set under a bundled
|
|
402
|
+
// prefix (a newer lucide) overrides the bundled copy.
|
|
403
|
+
async function iconSetsFor(design) {
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const ref of design?.iconSets ?? []) {
|
|
406
|
+
const set = await installedIconSet(ref.assetId);
|
|
407
|
+
if (set)
|
|
408
|
+
out.push({ prefix: ref.prefix, set, source: "installed" });
|
|
409
|
+
}
|
|
410
|
+
for (const [prefix, set] of await bundledIconSets()) {
|
|
411
|
+
if (!out.some((s) => s.prefix === prefix))
|
|
412
|
+
out.push({ prefix, set, source: "bundled" });
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
async function iconResolverFor(design) {
|
|
417
|
+
const sets = await iconSetsFor(design);
|
|
418
|
+
return { resolve: resolverFor(sets.map((s) => s.set)), prefixes: sets.map((s) => s.prefix) };
|
|
419
|
+
}
|
|
420
|
+
// Unknown icon names in a write's html surfaces, said at write time so the
|
|
421
|
+
// agent learns without rendering.
|
|
422
|
+
async function iconWarnings(project, surfaces) {
|
|
423
|
+
const html = surfaces.flatMap((s) => s.kind === "html" && mayHaveIcons(s.html) ? [s.html] : []);
|
|
424
|
+
if (html.length === 0)
|
|
425
|
+
return [];
|
|
426
|
+
const { resolve, prefixes } = await iconResolverFor(await designFor(project));
|
|
427
|
+
const unknown = new Set();
|
|
428
|
+
for (const h of html)
|
|
429
|
+
for (const u of expandIcons(h, resolve).unknown)
|
|
430
|
+
unknown.add(u);
|
|
431
|
+
return [...unknown].map((u) => `unknown icon ${u} (sets: ${prefixes.join(", ")})`);
|
|
432
|
+
}
|
|
433
|
+
// An agent asking for instructions must never get an error page, so a store
|
|
434
|
+
// failure degrades to the brief without project settings.
|
|
435
|
+
async function briefFor(project) {
|
|
436
|
+
try {
|
|
437
|
+
const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
|
|
438
|
+
return renderBriefGuide(await designFor(name));
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
console.warn("[mockpit] project design unavailable for the brief", err);
|
|
442
|
+
return renderBriefGuide(null);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// Push + webhooks. Detached on purpose: a dead push endpoint must never fail
|
|
446
|
+
// (or slow) the write that triggered it.
|
|
447
|
+
function fireNotify(payload) {
|
|
448
|
+
void notify(store, payload).catch((err) => console.warn("[mockpit] notify failed", err));
|
|
449
|
+
}
|
|
450
|
+
const mockUrl = (base, mock, post) => {
|
|
451
|
+
const url = `${base}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
|
|
452
|
+
if (!post)
|
|
453
|
+
return url;
|
|
454
|
+
const q = new URLSearchParams();
|
|
455
|
+
if (post.state !== null)
|
|
456
|
+
q.set("state", post.state);
|
|
457
|
+
q.set("variant", post.variant);
|
|
458
|
+
return `${url}?${q}`;
|
|
459
|
+
};
|
|
460
|
+
// Project resolution for a session: explicit wins, then the cwd's basename,
|
|
461
|
+
// then the single-workspace fallback.
|
|
462
|
+
const resolveProject = (project, cwd) => project?.trim() || projectFromCwd(cwd) || DEFAULT_PROJECT;
|
|
463
|
+
// A mock reference is its id, or its slug within a project. Without a
|
|
464
|
+
// project, a slug that names exactly one mock in the workspace still resolves —
|
|
465
|
+
// an agent rarely has more than one repo with the same mock name.
|
|
466
|
+
async function resolveMock(ref, project, sessionId) {
|
|
467
|
+
const name = typeof ref === "string" ? ref.trim() : "";
|
|
468
|
+
if (!name)
|
|
469
|
+
return fail(400, 'provide a "mock"');
|
|
470
|
+
const byId = await store.getMock(name);
|
|
471
|
+
if (byId)
|
|
472
|
+
return byId;
|
|
473
|
+
const slug = slugify(name);
|
|
474
|
+
let proj = typeof project === "string" && project.trim() ? project.trim() : undefined;
|
|
475
|
+
if (!proj && typeof sessionId === "string") {
|
|
476
|
+
const session = await store.getSession(sessionId);
|
|
477
|
+
proj = session?.project ?? undefined;
|
|
478
|
+
}
|
|
479
|
+
if (proj) {
|
|
480
|
+
const mock = await store.findMock(proj, slug);
|
|
481
|
+
return mock ?? fail(404, `${proj} has no mock "${slug}"`);
|
|
482
|
+
}
|
|
483
|
+
const matches = (await store.listMocks()).filter((m) => m.slug === slug);
|
|
484
|
+
if (matches.length === 1)
|
|
485
|
+
return matches[0];
|
|
486
|
+
if (matches.length === 0)
|
|
487
|
+
return fail(404, `no mock "${slug}"`);
|
|
488
|
+
return fail(400, `"${slug}" exists in several projects; pass project`, {
|
|
489
|
+
projects: matches.map((m) => m.project),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const isResult = (v) => !!v && typeof v === "object" && "status" in v && "body" in v;
|
|
493
|
+
// A state argument: undefined = not given, null = the single unnamed state.
|
|
494
|
+
const stateArg = (v) => v === undefined ? undefined : v === null || v === "" ? null : str(v, MAX_LABEL);
|
|
495
|
+
const variantLabel = (p) => (p.state === null ? p.variant : `${p.state}/${p.variant}`);
|
|
496
|
+
// Which variant a call means. Explicit wins; one candidate is unambiguous;
|
|
497
|
+
// several is an error that names them rather than guessing.
|
|
498
|
+
function chooseVariant(mock, posts, stateIn, variant) {
|
|
499
|
+
let candidates = posts;
|
|
500
|
+
if (stateIn !== undefined) {
|
|
501
|
+
if (stateIn !== null && !mock.states.includes(stateIn)) {
|
|
502
|
+
return fail(404, `${mock.slug} has no state "${stateIn}"`, { states: mock.states });
|
|
503
|
+
}
|
|
504
|
+
candidates = candidates.filter((p) => p.state === stateIn);
|
|
505
|
+
}
|
|
506
|
+
if (variant)
|
|
507
|
+
candidates = candidates.filter((p) => p.variant === variant);
|
|
508
|
+
if (candidates.length === 1)
|
|
509
|
+
return candidates[0];
|
|
510
|
+
if (candidates.length === 0) {
|
|
511
|
+
return fail(404, `${mock.slug} has no variant ${variant ? `"${variant}"` : ""}`.trim(), {
|
|
512
|
+
variants: posts.map(variantLabel),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
const live = candidates.filter((p) => p.status !== "archived");
|
|
516
|
+
if (live.length === 1 && !variant)
|
|
517
|
+
return live[0];
|
|
518
|
+
return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
|
|
519
|
+
variants: candidates.map(variantLabel),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
// The kit ids a surface of this project may name beyond the bundled ones.
|
|
523
|
+
const kitScope = async (project) => ({
|
|
524
|
+
projectKits: ((await designFor(project))?.projectKits ?? []).map((k) => k.id),
|
|
525
|
+
});
|
|
526
|
+
async function parseSurfaceInput(body, ctx, scope, base) {
|
|
527
|
+
if (body.html !== undefined) {
|
|
528
|
+
if (typeof body.html !== "string" || !body.html.trim()) {
|
|
529
|
+
return fail(400, 'body must include non-empty "html" string');
|
|
530
|
+
}
|
|
531
|
+
const raw = [htmlSurface(body.html, body.kits)];
|
|
532
|
+
if (!ctx.strict)
|
|
533
|
+
return coerceSurfaces(raw, scope);
|
|
534
|
+
const parsed = await validateSurfaces(raw, scope);
|
|
535
|
+
return parsed.ok ? parsed.surfaces : fail(400, parsed.error, surfaceIssues(parsed));
|
|
536
|
+
}
|
|
537
|
+
if (!Array.isArray(body.surfaces))
|
|
538
|
+
return fail(400, '"surfaces" must be an array');
|
|
539
|
+
return surfaceList(body.surfaces, ctx, scope, base);
|
|
540
|
+
}
|
|
541
|
+
// The full ordered list replaces the variant's surfaces, so one shape covers
|
|
542
|
+
// add, edit, remove and reorder: an entry that is only `{id}` keeps that
|
|
543
|
+
// surface, an id with content replaces it in place, an entry without an id is
|
|
544
|
+
// new, and an id left out is removed.
|
|
545
|
+
async function surfaceList(raw, ctx, scope, base) {
|
|
546
|
+
const out = [];
|
|
547
|
+
const used = new Set();
|
|
548
|
+
const issues = [];
|
|
549
|
+
for (const [i, entry] of raw.entries()) {
|
|
550
|
+
const record = entry && typeof entry === "object" && !Array.isArray(entry)
|
|
551
|
+
? entry
|
|
552
|
+
: null;
|
|
553
|
+
const id = typeof record?.id === "string" ? record.id : undefined;
|
|
554
|
+
if (id !== undefined) {
|
|
555
|
+
if (used.has(id))
|
|
556
|
+
return fail(400, `surface "${id}" appears twice in "surfaces"`);
|
|
557
|
+
used.add(id);
|
|
558
|
+
const current = base();
|
|
559
|
+
if (isResult(current))
|
|
560
|
+
return current;
|
|
561
|
+
const kept = current.find((s) => s.id === id);
|
|
562
|
+
if (!kept) {
|
|
563
|
+
return fail(404, `surface "${id}" not found`, { surfaces: current.map(surfaceRef) });
|
|
564
|
+
}
|
|
565
|
+
if (record?.kind === undefined) {
|
|
566
|
+
out.push(kept);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const { id: _id, ...content } = record ?? {};
|
|
571
|
+
const one = record ? [content] : [entry];
|
|
572
|
+
if (!ctx.strict) {
|
|
573
|
+
const [surface] = await coerceSurfaces(one, scope);
|
|
574
|
+
if (surface)
|
|
575
|
+
out.push(id ? { ...surface, id } : surface);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const parsed = await validateSurfaces(one, scope);
|
|
579
|
+
if (!parsed.ok) {
|
|
580
|
+
for (const issue of parsed.issues) {
|
|
581
|
+
issues.push({
|
|
582
|
+
...issue,
|
|
583
|
+
requestPath: issue.requestPath.replace(/^surfaces\[0\]/, `surfaces[${i}]`),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
out.push(id ? { ...parsed.surfaces[0], id } : parsed.surfaces[0]);
|
|
589
|
+
}
|
|
590
|
+
if (issues.length) {
|
|
591
|
+
return fail(400, issues.map((x) => `${x.requestPath}: ${x.message}`).join("; "), {
|
|
592
|
+
code: "surface_validation_failed",
|
|
593
|
+
issues,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
const surfaceIssues = (failure) => ({
|
|
599
|
+
code: failure.code,
|
|
600
|
+
issues: failure.issues,
|
|
601
|
+
});
|
|
602
|
+
function checkSurfaces(surfaces) {
|
|
603
|
+
if (surfaces.length === 0)
|
|
604
|
+
return fail(400, "a variant needs at least one surface");
|
|
605
|
+
if (surfacesByteLength(surfaces) > MAX_SURFACE_BYTES) {
|
|
606
|
+
return fail(413, `surface exceeds ${MAX_SURFACE_BYTES} bytes`);
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
// A page's slot list, with each missing version pinned to the referenced
|
|
611
|
+
// component's current one (snapshot semantics).
|
|
612
|
+
async function pageSlots(project, surfaces) {
|
|
613
|
+
const html = surfaces.find((s) => s.kind === "html");
|
|
614
|
+
if (!html || html.kind !== "html")
|
|
615
|
+
return [];
|
|
616
|
+
const slots = [];
|
|
617
|
+
for (const tag of parseSlotTags(html.html)) {
|
|
618
|
+
let version = tag.version;
|
|
619
|
+
if (version == null)
|
|
620
|
+
version = (await slotTarget(project, tag.slug, tag.variant))?.version ?? null;
|
|
621
|
+
if (version != null)
|
|
622
|
+
slots.push({ slug: tag.slug, variant: tag.variant, version });
|
|
623
|
+
}
|
|
624
|
+
return slots;
|
|
625
|
+
}
|
|
626
|
+
// A slot names a component mock by slug; it renders that mock's first state.
|
|
627
|
+
async function slotTarget(project, slug, variant) {
|
|
628
|
+
const mock = await store.findMock(project, slug);
|
|
629
|
+
if (!mock)
|
|
630
|
+
return null;
|
|
631
|
+
return store.findPost(mock.id, mock.states[0] ?? null, variant);
|
|
632
|
+
}
|
|
633
|
+
// Inline each `<mockpit-slot>` with the referenced variant version's first
|
|
634
|
+
// html surface body. The stored `slots` list (resolved at publish) wins over
|
|
635
|
+
// a bare tag, so a page keeps rendering the versions it was composed from.
|
|
636
|
+
async function expandPageHtml(project, post, html) {
|
|
637
|
+
const pinned = new Map(post.slots.map((s) => [`${s.slug}::${s.variant}`, s.version]));
|
|
638
|
+
const bodies = new Map();
|
|
639
|
+
for (const tag of parseSlotTags(html)) {
|
|
640
|
+
const key = `${tag.slug}::${tag.variant}`;
|
|
641
|
+
const version = tag.version ?? pinned.get(key) ?? null;
|
|
642
|
+
const cacheKey = `${key}::${version}`;
|
|
643
|
+
if (bodies.has(cacheKey))
|
|
644
|
+
continue;
|
|
645
|
+
const target = await slotTarget(project, tag.slug, tag.variant);
|
|
646
|
+
if (!target) {
|
|
647
|
+
bodies.set(cacheKey, null);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
const surfaces = version == null || version === target.version
|
|
651
|
+
? target.surfaces
|
|
652
|
+
: (target.history.find((h) => h.version === version)?.surfaces ?? null);
|
|
653
|
+
const body = surfaces?.find((s) => s.kind === "html");
|
|
654
|
+
bodies.set(cacheKey, body && body.kind === "html" ? body.html : null);
|
|
655
|
+
}
|
|
656
|
+
return expandSlots(html, ({ slug, variant, version }) => {
|
|
657
|
+
const key = `${slug}::${variant}`;
|
|
658
|
+
const pinnedVersion = version ?? pinned.get(key) ?? null;
|
|
659
|
+
return bodies.get(`${key}::${pinnedVersion}`) ?? null;
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
// Knobs with a handful of discrete choices are usually decisions dressed as
|
|
663
|
+
// controls: the user can't compare options they only see one at a time.
|
|
664
|
+
function knobNudges(knobs) {
|
|
665
|
+
const out = [];
|
|
666
|
+
for (const [path, config] of Object.entries(knobs)) {
|
|
667
|
+
const n = discreteChoices(config);
|
|
668
|
+
if (n !== null && n <= 3) {
|
|
669
|
+
out.push(`knob "${path}" has ${n} discrete options: if each option needs its own render, ask instead (ask with options bound to variants); keep a knob when one render plus a control shows it`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
// Ask discipline: two live variants of a state with no ask binding them all
|
|
675
|
+
// leave the user nothing to answer in one Send, and the agent tends to ask in
|
|
676
|
+
// chat instead. The nudge carries a ready ask: mock-wide when every state has
|
|
677
|
+
// the same variant names, else for this state.
|
|
678
|
+
function unboundVariants(mock, posts, state) {
|
|
679
|
+
const live = (s) => posts.filter((p) => p.state === s && p.status !== "archived").map((p) => p.variant);
|
|
680
|
+
const names = live(state);
|
|
681
|
+
if (names.length < 2)
|
|
682
|
+
return null;
|
|
683
|
+
const bound = mock.asks.some((a) => {
|
|
684
|
+
if (a.scope === "part" || (a.scope === "state" && a.state !== state))
|
|
685
|
+
return false;
|
|
686
|
+
const variants = new Set(a.options.map((o) => o.variant));
|
|
687
|
+
return names.every((n) => variants.has(n));
|
|
688
|
+
});
|
|
689
|
+
if (bound)
|
|
690
|
+
return null;
|
|
691
|
+
const key = (list) => [...list].sort().join("\n");
|
|
692
|
+
const states = mock.states.length ? mock.states : [null];
|
|
693
|
+
const wide = states.every((s) => key(live(s)) === key(names));
|
|
694
|
+
const options = names.map((v) => ({ label: v, variant: v }));
|
|
695
|
+
const ask = wide
|
|
696
|
+
? { id: BUILTIN_ASK_ID, text: "Which one?", scope: "mock", options }
|
|
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}"`;
|
|
705
|
+
return {
|
|
706
|
+
nudge: `${where} has ${names.length} variants and no ask binds them: call ask with suggestedAsk (or your own question with options bound to the variants) so the user can choose in one Send; never ask in chat`,
|
|
707
|
+
ask,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
// The answer to every write: what was published, the parts found per state,
|
|
711
|
+
// what moved since the previous version, and any feedback waiting.
|
|
712
|
+
async function writeResult(mock, post, ctx, extra = {}) {
|
|
713
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
714
|
+
const detail = mockDetailView(mock, posts);
|
|
715
|
+
const unbound = unboundVariants(mock, posts, post.state);
|
|
716
|
+
const nudges = [...(extra.nudges ?? []), ...(unbound ? [unbound.nudge] : [])];
|
|
717
|
+
let partChanges;
|
|
718
|
+
if (extra.previous) {
|
|
719
|
+
partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
|
|
720
|
+
}
|
|
721
|
+
const warnings = await iconWarnings(mock.project, post.surfaces);
|
|
722
|
+
const feedback = await collectFeedback(post.sessionId);
|
|
723
|
+
return ok({
|
|
724
|
+
mock: {
|
|
725
|
+
id: mock.id,
|
|
726
|
+
project: mock.project,
|
|
727
|
+
slug: mock.slug,
|
|
728
|
+
title: mock.title,
|
|
729
|
+
kind: mock.kind,
|
|
730
|
+
states: mock.states,
|
|
731
|
+
},
|
|
732
|
+
post: {
|
|
733
|
+
id: post.id,
|
|
734
|
+
state: post.state,
|
|
735
|
+
variant: post.variant,
|
|
736
|
+
version: post.version,
|
|
737
|
+
status: post.status,
|
|
738
|
+
surfaces: post.surfaces.map(surfaceRef),
|
|
739
|
+
},
|
|
740
|
+
sessionId: post.sessionId,
|
|
741
|
+
url: mockUrl(ctx.base, mock, post),
|
|
742
|
+
parts: detail.parts,
|
|
743
|
+
...(extra.applied ? { applied: extra.applied } : {}),
|
|
744
|
+
...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
|
|
745
|
+
? { partChanges }
|
|
746
|
+
: {}),
|
|
747
|
+
...(nudges.length ? { nudges } : {}),
|
|
748
|
+
...(unbound ? { suggestedAsk: unbound.ask } : {}),
|
|
749
|
+
...(warnings.length ? { warnings } : {}),
|
|
750
|
+
feedback,
|
|
751
|
+
}, extra.status ?? 200);
|
|
752
|
+
}
|
|
753
|
+
function announcePost(mock, post, created, by = "agent") {
|
|
754
|
+
bus.broadcast({
|
|
755
|
+
type: created ? "post-created" : "post-updated",
|
|
756
|
+
id: post.id,
|
|
757
|
+
mockId: mock.id,
|
|
758
|
+
sessionId: post.sessionId,
|
|
759
|
+
version: post.version,
|
|
760
|
+
by,
|
|
761
|
+
});
|
|
762
|
+
warmPost(post, mock);
|
|
763
|
+
}
|
|
764
|
+
// The one write: publish one variant of one state of a mock. An existing
|
|
765
|
+
// (mock, state, variant) becomes a new version; anything else is created.
|
|
766
|
+
// Exactly one body: `html`, the full ordered `surfaces` list, or `parts`
|
|
767
|
+
// spliced into the variant's latest (or `from`) version.
|
|
768
|
+
async function publishFlow(body, ctx) {
|
|
769
|
+
if (!body || typeof body !== "object")
|
|
770
|
+
return fail(400, "invalid JSON body");
|
|
771
|
+
const slugIn = str(body.mock, MAX_TITLE);
|
|
772
|
+
if (!slugIn)
|
|
773
|
+
return fail(400, 'provide "mock": the mock slug, e.g. "writer"');
|
|
774
|
+
const given = ["html", "surfaces", "parts"].filter((k) => body[k] !== undefined);
|
|
775
|
+
if (given.length === 0)
|
|
776
|
+
return fail(400, 'provide "html", "surfaces" or "parts"');
|
|
777
|
+
if (given.length > 1) {
|
|
778
|
+
return fail(400, `pass one of "html", "surfaces" or "parts", not ${given.join(" and ")}`);
|
|
779
|
+
}
|
|
780
|
+
const partEdits = partEditsArg(body);
|
|
781
|
+
if (isResult(partEdits))
|
|
782
|
+
return partEdits;
|
|
783
|
+
const knobs = checkKnobs(body.knobs);
|
|
784
|
+
if (!knobs.ok)
|
|
785
|
+
return fail(400, knobs.error);
|
|
786
|
+
const variantKnobs = checkKnobs(body.variantKnobs);
|
|
787
|
+
if (!variantKnobs.ok)
|
|
788
|
+
return fail(400, variantKnobs.error);
|
|
789
|
+
const stateIn = stateArg(body.state);
|
|
790
|
+
const variantIn = str(body.variant, MAX_LABEL);
|
|
791
|
+
const kind = body.kind === "page" || body.kind === "component" ? body.kind : undefined;
|
|
792
|
+
const title = str(body.title, MAX_TITLE);
|
|
793
|
+
const fromN = Number(body.from);
|
|
794
|
+
const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
|
|
795
|
+
const prompt = str(body.prompt, MAX_COMMENT_TEXT);
|
|
796
|
+
const author = str(body.author, MAX_TITLE);
|
|
797
|
+
// An existing mock is addressable by id before a session exists.
|
|
798
|
+
let mock = null;
|
|
799
|
+
if (typeof body.mock === "string")
|
|
800
|
+
mock = await store.getMock(body.mock);
|
|
801
|
+
let session = null;
|
|
802
|
+
if (typeof body.session === "string" && body.session) {
|
|
803
|
+
session = await store.getSession(body.session);
|
|
804
|
+
if (!session)
|
|
805
|
+
return fail(404, `session "${body.session}" not found`);
|
|
806
|
+
}
|
|
807
|
+
const project = mock?.project ??
|
|
808
|
+
(str(body.project, MAX_TITLE) ||
|
|
809
|
+
session?.project ||
|
|
810
|
+
projectFromCwd(session?.cwd) ||
|
|
811
|
+
resolveProject(undefined, str(body.cwd, 4096)));
|
|
812
|
+
const slug = mock?.slug ?? slugify(slugIn);
|
|
813
|
+
mock ??= await store.findMock(project, slug);
|
|
814
|
+
const posts = mock ? await store.listPosts({ mockId: mock.id }) : [];
|
|
815
|
+
let state;
|
|
816
|
+
if (stateIn === undefined || stateIn === null) {
|
|
817
|
+
if (mock && mock.states.length > 0) {
|
|
818
|
+
return fail(400, `${slug} has states; pass state`, { states: mock.states });
|
|
819
|
+
}
|
|
820
|
+
state = null;
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
if (mock && mock.states.length === 0 && posts.length > 0) {
|
|
824
|
+
return fail(409, `${slug} is a single-state mock; its variants have no state name. Publish a multi-state design as a new mock and name every state`);
|
|
825
|
+
}
|
|
826
|
+
state = stateIn;
|
|
827
|
+
}
|
|
828
|
+
const inState = posts.filter((p) => p.state === state);
|
|
829
|
+
let variant = variantIn;
|
|
830
|
+
if (!variant) {
|
|
831
|
+
const live = inState.filter((p) => p.status !== "archived");
|
|
832
|
+
if (inState.length === 0)
|
|
833
|
+
variant = DEFAULT_VARIANT;
|
|
834
|
+
else if (inState.length === 1)
|
|
835
|
+
variant = inState[0].variant;
|
|
836
|
+
else if (live.length === 1)
|
|
837
|
+
variant = live[0].variant;
|
|
838
|
+
else {
|
|
839
|
+
return fail(400, `${slug} has several variants; pass variant`, {
|
|
840
|
+
variants: inState.map(variantLabel),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const target = inState.find((p) => p.variant === variant) ?? null;
|
|
845
|
+
const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
|
|
846
|
+
variants: posts.map(variantLabel),
|
|
847
|
+
});
|
|
848
|
+
// What `parts` and kept surface ids refer to: the variant's current
|
|
849
|
+
// version, or the one `from` names.
|
|
850
|
+
const base = () => {
|
|
851
|
+
if (!target)
|
|
852
|
+
return missing();
|
|
853
|
+
if (from === undefined || from === target.version)
|
|
854
|
+
return target.surfaces;
|
|
855
|
+
return (target.history.find((h) => h.version === from)?.surfaces ??
|
|
856
|
+
fail(404, `${slug} has no version ${from}`));
|
|
857
|
+
};
|
|
858
|
+
let surfaces;
|
|
859
|
+
let applied;
|
|
860
|
+
if (partEdits) {
|
|
861
|
+
const current = base();
|
|
862
|
+
if (isResult(current))
|
|
863
|
+
return current;
|
|
864
|
+
const result = spliceParts(current, partEdits);
|
|
865
|
+
if (!result.ok)
|
|
866
|
+
return fail(400, result.error);
|
|
867
|
+
surfaces = result.surfaces;
|
|
868
|
+
applied = result.applied;
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
const parsed = await parseSurfaceInput(body, ctx, await kitScope(project), base);
|
|
872
|
+
if (isResult(parsed))
|
|
873
|
+
return parsed;
|
|
874
|
+
surfaces = parsed;
|
|
875
|
+
}
|
|
876
|
+
const bad = checkSurfaces(surfaces);
|
|
877
|
+
if (bad)
|
|
878
|
+
return bad;
|
|
879
|
+
const nudges = knobNudges({ ...knobs.value, ...variantKnobs.value });
|
|
880
|
+
if (!target) {
|
|
881
|
+
// A new variant: carried by the session the agent is writing from.
|
|
882
|
+
if (!session) {
|
|
883
|
+
session = await store.createSession({
|
|
884
|
+
agent: str(body.agent, MAX_TITLE) ?? "agent",
|
|
885
|
+
title: str(body.sessionTitle, MAX_TITLE),
|
|
886
|
+
cwd: str(body.cwd, 4096),
|
|
887
|
+
project,
|
|
888
|
+
});
|
|
889
|
+
bus.broadcast({ type: "session-created", id: session.id });
|
|
890
|
+
}
|
|
891
|
+
if (!mock) {
|
|
892
|
+
mock = await store.createMock({
|
|
893
|
+
project,
|
|
894
|
+
slug,
|
|
895
|
+
title: title ?? titleFromSlug(slug),
|
|
896
|
+
kind: kind ?? "component",
|
|
897
|
+
states: state === null ? [] : [state],
|
|
898
|
+
knobs: knobs.value,
|
|
899
|
+
sessionId: session.id,
|
|
900
|
+
});
|
|
901
|
+
bus.broadcast({ type: "mock-created", id: mock.id, project });
|
|
902
|
+
}
|
|
903
|
+
const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
|
|
904
|
+
const created = await store.createPost({
|
|
905
|
+
sessionId: session.id,
|
|
906
|
+
mock: mock.id,
|
|
907
|
+
state,
|
|
908
|
+
variant,
|
|
909
|
+
title: title ?? mock.title,
|
|
910
|
+
surfaces,
|
|
911
|
+
...(Object.keys(variantKnobs.value).length ? { knobs: variantKnobs.value } : {}),
|
|
912
|
+
slots,
|
|
913
|
+
from,
|
|
914
|
+
prompt,
|
|
915
|
+
author,
|
|
916
|
+
});
|
|
917
|
+
if (!created)
|
|
918
|
+
return fail(404, "session not found");
|
|
919
|
+
mock = await updateMockAfterWrite(mock, { state, kind, title, knobs: knobs.value, session });
|
|
920
|
+
announcePost(mock, created, true);
|
|
921
|
+
if (prompt && ctx.request)
|
|
922
|
+
notifyPublish(mock, created, prompt, ctx);
|
|
923
|
+
return writeResult(mock, created, ctx, { nudges, status: 201 });
|
|
924
|
+
}
|
|
925
|
+
if (!mock)
|
|
926
|
+
return missing();
|
|
927
|
+
// A new version is written by whoever writes it; without an explicit
|
|
928
|
+
// session the variant's own session carries it.
|
|
929
|
+
session ??= await store.getSession(target.sessionId);
|
|
930
|
+
const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
|
|
931
|
+
const updated = await store.updatePost(target.id, {
|
|
932
|
+
surfaces,
|
|
933
|
+
title,
|
|
934
|
+
...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
|
|
935
|
+
slots,
|
|
936
|
+
from,
|
|
937
|
+
prompt,
|
|
938
|
+
author,
|
|
939
|
+
});
|
|
940
|
+
if (!updated)
|
|
941
|
+
return fail(404, "variant not found");
|
|
942
|
+
mock = await updateMockAfterWrite(mock, { state, kind, knobs: knobs.value, session });
|
|
943
|
+
announcePost(mock, updated, false);
|
|
944
|
+
if (prompt && ctx.request)
|
|
945
|
+
notifyPublish(mock, updated, prompt, ctx);
|
|
946
|
+
return writeResult(mock, updated, ctx, { previous: target.surfaces, applied, nudges });
|
|
947
|
+
}
|
|
948
|
+
// `parts`: new outer html per part ("name" or "name#key"), spliced into the
|
|
949
|
+
// variant's html so a tweak costs the tweak rather than the whole document.
|
|
950
|
+
function partEditsArg(body) {
|
|
951
|
+
if (body?.parts === undefined)
|
|
952
|
+
return undefined;
|
|
953
|
+
const parts = body.parts;
|
|
954
|
+
if (!parts || typeof parts !== "object" || Array.isArray(parts)) {
|
|
955
|
+
return fail(400, '"parts" must be an object: { "<part name>": "<html for that part>" }');
|
|
956
|
+
}
|
|
957
|
+
const entries = Object.entries(parts);
|
|
958
|
+
if (entries.length === 0)
|
|
959
|
+
return fail(400, '"parts" is empty; name at least one part');
|
|
960
|
+
if (entries.some(([, html]) => typeof html !== "string")) {
|
|
961
|
+
return fail(400, 'each "parts" value must be the html string for that part');
|
|
962
|
+
}
|
|
963
|
+
return parts;
|
|
964
|
+
}
|
|
965
|
+
// A write may add a state, retitle the mock, declare knobs, and makes its
|
|
966
|
+
// session the one a reply is delivered to.
|
|
967
|
+
async function updateMockAfterWrite(mock, w) {
|
|
968
|
+
const states = w.state !== null && !mock.states.includes(w.state) ? [...mock.states, w.state] : undefined;
|
|
969
|
+
const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
|
|
970
|
+
const updated = await store.updateMock(mock.id, {
|
|
971
|
+
...(states ? { states } : {}),
|
|
972
|
+
...(w.kind ? { kind: w.kind } : {}),
|
|
973
|
+
...(w.title ? { title: w.title } : {}),
|
|
974
|
+
...(knobs ? { knobs } : {}),
|
|
975
|
+
...(w.session ? { sessionId: w.session.id } : {}),
|
|
976
|
+
});
|
|
977
|
+
if (!updated)
|
|
978
|
+
return mock;
|
|
979
|
+
bus.broadcast({ type: "mock-updated", id: updated.id, project: updated.project });
|
|
980
|
+
return updated;
|
|
981
|
+
}
|
|
982
|
+
function notifyPublish(mock, post, text, ctx) {
|
|
983
|
+
fireNotify({
|
|
984
|
+
event: "publish",
|
|
985
|
+
project: mock.project,
|
|
986
|
+
slug: mock.slug,
|
|
987
|
+
variant: post.variant,
|
|
988
|
+
version: post.version,
|
|
989
|
+
text,
|
|
990
|
+
url: mockUrl(ctx.base, mock, post),
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
async function listMocksFlow(query) {
|
|
994
|
+
const project = str(query.project, MAX_TITLE);
|
|
995
|
+
const mocks = await store.listMocks(project);
|
|
996
|
+
const rows = [];
|
|
997
|
+
const pending = [];
|
|
998
|
+
for (const m of mocks) {
|
|
999
|
+
const posts = await store.listPosts({ mockId: m.id });
|
|
1000
|
+
rows.push(mockSummaryView(m, posts));
|
|
1001
|
+
pending.push(pendingOf(m, posts));
|
|
1002
|
+
}
|
|
1003
|
+
const open = rows.reduce((n, r) => n + r.open, 0);
|
|
1004
|
+
return ok({
|
|
1005
|
+
...(project ? { project } : {}),
|
|
1006
|
+
mocks: rows,
|
|
1007
|
+
open,
|
|
1008
|
+
openMocks: rows.filter((r) => r.open > 0).length,
|
|
1009
|
+
pending,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
// The tuned values of the mock's latest reply: what the user last sent.
|
|
1013
|
+
async function lastTuned(mockId) {
|
|
1014
|
+
const replies = (await store.listComments({ mockId })).filter((c) => c.kind === "reply");
|
|
1015
|
+
return replies[replies.length - 1]?.payload?.tuned ?? {};
|
|
1016
|
+
}
|
|
1017
|
+
async function getMockFlow(ref, query) {
|
|
1018
|
+
const mock = await resolveMock(ref, query.project, query.session);
|
|
1019
|
+
if (isResult(mock))
|
|
1020
|
+
return mock;
|
|
1021
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1022
|
+
return ok({
|
|
1023
|
+
...mockDetailView(mock, posts, {
|
|
1024
|
+
body: query.body,
|
|
1025
|
+
history: query.history,
|
|
1026
|
+
tuned: await lastTuned(mock.id),
|
|
1027
|
+
}),
|
|
1028
|
+
pending: pendingOf(mock, posts),
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
async function exportFlow(ref, query, ctx) {
|
|
1032
|
+
const mock = await resolveMock(ref, query.project);
|
|
1033
|
+
if (isResult(mock))
|
|
1034
|
+
return mock;
|
|
1035
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1036
|
+
const wantedState = stateArg(query.state);
|
|
1037
|
+
const wantedVariant = str(query.variant, MAX_LABEL);
|
|
1038
|
+
const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
|
|
1039
|
+
if (states.length === 0)
|
|
1040
|
+
return fail(404, `${mock.slug} has no state "${wantedState}"`);
|
|
1041
|
+
const entries = [];
|
|
1042
|
+
for (const state of states) {
|
|
1043
|
+
const inState = posts.filter((p) => p.state === state);
|
|
1044
|
+
const post = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
|
|
1045
|
+
inState.find((p) => p.status === "accepted") ||
|
|
1046
|
+
inState.find((p) => p.status === "open") ||
|
|
1047
|
+
inState[0];
|
|
1048
|
+
if (!post)
|
|
1049
|
+
continue;
|
|
1050
|
+
if (wantedVariant && post.variant !== wantedVariant)
|
|
1051
|
+
continue;
|
|
1052
|
+
const html = post.surfaces.find((s) => s.kind === "html");
|
|
1053
|
+
entries.push({
|
|
1054
|
+
state,
|
|
1055
|
+
variant: post.variant,
|
|
1056
|
+
postId: post.id,
|
|
1057
|
+
version: post.version,
|
|
1058
|
+
status: post.status,
|
|
1059
|
+
html: html && html.kind === "html" ? html.html : "",
|
|
1060
|
+
markdown: postToMarkdown(post, {
|
|
1061
|
+
postUrl: `${ctx.base}/s/${post.id}`,
|
|
1062
|
+
assetBase: ctx.base,
|
|
1063
|
+
}),
|
|
1064
|
+
history: [
|
|
1065
|
+
...[...post.history]
|
|
1066
|
+
.sort((a, b) => a.version - b.version)
|
|
1067
|
+
.map((h) => ({
|
|
1068
|
+
version: h.version,
|
|
1069
|
+
at: h.at,
|
|
1070
|
+
from: h.from ?? null,
|
|
1071
|
+
prompt: h.prompt ?? "",
|
|
1072
|
+
})),
|
|
1073
|
+
{
|
|
1074
|
+
version: post.version,
|
|
1075
|
+
at: post.updatedAt,
|
|
1076
|
+
from: post.from ?? null,
|
|
1077
|
+
prompt: post.prompt ?? "",
|
|
1078
|
+
},
|
|
1079
|
+
],
|
|
1080
|
+
screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
if (entries.length === 0) {
|
|
1084
|
+
return fail(404, `${mock.slug} has no variant "${wantedVariant}"`, {
|
|
1085
|
+
variants: posts.map(variantLabel),
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
const replies = (await store.listComments({ mockId: mock.id })).filter((c) => c.kind === "reply");
|
|
1089
|
+
return ok({
|
|
1090
|
+
project: mock.project,
|
|
1091
|
+
mock: mock.slug,
|
|
1092
|
+
title: mock.title,
|
|
1093
|
+
kind: mock.kind,
|
|
1094
|
+
states: entries,
|
|
1095
|
+
knobs: mock.knobs,
|
|
1096
|
+
reply: replies[replies.length - 1]?.payload ?? null,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
// --- asks ---
|
|
1100
|
+
function sanitizeAsk(raw, mock, posts, taken) {
|
|
1101
|
+
if (!raw || typeof raw !== "object")
|
|
1102
|
+
return "each ask must be an object";
|
|
1103
|
+
const text = str(raw.text, MAX_COMMENT_TEXT);
|
|
1104
|
+
if (!text)
|
|
1105
|
+
return 'each ask needs "text"';
|
|
1106
|
+
const scope = raw.scope === "state" || raw.scope === "part" ? raw.scope : "mock";
|
|
1107
|
+
const state = str(raw.state, MAX_LABEL);
|
|
1108
|
+
if (scope === "state") {
|
|
1109
|
+
if (!state)
|
|
1110
|
+
return `ask "${text}" has scope "state" but no state`;
|
|
1111
|
+
if (!mock.states.includes(state))
|
|
1112
|
+
return `${mock.slug} has no state "${state}"`;
|
|
1113
|
+
}
|
|
1114
|
+
else if (state && !mock.states.includes(state)) {
|
|
1115
|
+
return `${mock.slug} has no state "${state}"`;
|
|
1116
|
+
}
|
|
1117
|
+
const part = str(raw.part, 200);
|
|
1118
|
+
if (scope === "part" && !part)
|
|
1119
|
+
return `ask "${text}" has scope "part" but no part`;
|
|
1120
|
+
if (!Array.isArray(raw.options) || raw.options.length === 0) {
|
|
1121
|
+
return `ask "${text}" needs options`;
|
|
1122
|
+
}
|
|
1123
|
+
if (raw.options.length > MAX_OPTIONS)
|
|
1124
|
+
return `ask "${text}" has more than ${MAX_OPTIONS} options`;
|
|
1125
|
+
const variants = new Set(posts.map((p) => p.variant));
|
|
1126
|
+
const options = [];
|
|
1127
|
+
// Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
|
|
1128
|
+
const optionIds = new Set([OTHER_ID]);
|
|
1129
|
+
for (const o of raw.options) {
|
|
1130
|
+
const opt = typeof o === "string" ? { label: o } : o;
|
|
1131
|
+
const label = str(opt?.label, 200);
|
|
1132
|
+
if (!label)
|
|
1133
|
+
return `every option of "${text}" needs a label`;
|
|
1134
|
+
if (str(opt.id, 64) && slugify(str(opt.id, 64)) === OTHER_ID) {
|
|
1135
|
+
return `option id "${OTHER_ID}" is reserved: the viewer adds its own "Other…" write-in to every ask`;
|
|
1136
|
+
}
|
|
1137
|
+
let id = slugify(str(opt.id, 64) ?? label);
|
|
1138
|
+
for (let n = 2; optionIds.has(id); n++)
|
|
1139
|
+
id = `${slugify(str(opt.id, 64) ?? label)}-${n}`;
|
|
1140
|
+
optionIds.add(id);
|
|
1141
|
+
const variant = str(opt.variant, MAX_LABEL);
|
|
1142
|
+
if (variant && !variants.has(variant)) {
|
|
1143
|
+
return `option "${label}" names variant "${variant}", which ${mock.slug} does not have`;
|
|
1144
|
+
}
|
|
1145
|
+
let set;
|
|
1146
|
+
if (opt.set !== undefined) {
|
|
1147
|
+
const checked = checkKnobValues(opt.set, mock, posts, `option "${label}"`);
|
|
1148
|
+
if (!checked.ok)
|
|
1149
|
+
return checked.error;
|
|
1150
|
+
set = checked.value;
|
|
1151
|
+
}
|
|
1152
|
+
options.push({ id, label, ...(variant ? { variant } : {}), ...(set ? { set } : {}) });
|
|
1153
|
+
}
|
|
1154
|
+
let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
|
|
1155
|
+
if (!str(raw.id, 64))
|
|
1156
|
+
for (let n = 2; taken.has(id); n++)
|
|
1157
|
+
id = `${slugify(text).slice(0, 36)}-${n}`;
|
|
1158
|
+
return {
|
|
1159
|
+
id,
|
|
1160
|
+
text,
|
|
1161
|
+
scope,
|
|
1162
|
+
...(state ? { state } : {}),
|
|
1163
|
+
...(part ? { part } : {}),
|
|
1164
|
+
options,
|
|
1165
|
+
...(raw.multi === true ? { multi: true } : {}),
|
|
1166
|
+
at: new Date().toISOString(),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
// Ask the user structured questions. An ask whose id matches an existing one
|
|
1170
|
+
// replaces it (and reopens it); anything else is appended.
|
|
1171
|
+
async function askFlow(ref, body, ctx) {
|
|
1172
|
+
if (!body || typeof body !== "object")
|
|
1173
|
+
return fail(400, "invalid JSON body");
|
|
1174
|
+
const resolved = await resolveMock(ref, body.project, body.session);
|
|
1175
|
+
if (isResult(resolved))
|
|
1176
|
+
return resolved;
|
|
1177
|
+
let mock = resolved;
|
|
1178
|
+
const raw = Array.isArray(body.asks) ? body.asks : body.text ? [body] : null;
|
|
1179
|
+
if (!raw || raw.length === 0)
|
|
1180
|
+
return fail(400, 'provide "asks": [{text, options}]');
|
|
1181
|
+
if (raw.length > MAX_ASKS)
|
|
1182
|
+
return fail(400, `at most ${MAX_ASKS} asks per call`);
|
|
1183
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1184
|
+
const asks = [...mock.asks];
|
|
1185
|
+
const taken = new Set(asks.map((a) => a.id));
|
|
1186
|
+
const added = [];
|
|
1187
|
+
for (const entry of raw) {
|
|
1188
|
+
const ask = sanitizeAsk(entry, mock, posts, taken);
|
|
1189
|
+
if (typeof ask === "string")
|
|
1190
|
+
return fail(400, ask);
|
|
1191
|
+
const at = asks.findIndex((a) => a.id === ask.id);
|
|
1192
|
+
if (at >= 0)
|
|
1193
|
+
asks[at] = ask;
|
|
1194
|
+
else
|
|
1195
|
+
asks.push(ask);
|
|
1196
|
+
taken.add(ask.id);
|
|
1197
|
+
added.push(ask);
|
|
1198
|
+
}
|
|
1199
|
+
let sessionId = mock.sessionId;
|
|
1200
|
+
if (typeof body.session === "string" && (await store.getSession(body.session))) {
|
|
1201
|
+
sessionId = body.session;
|
|
1202
|
+
}
|
|
1203
|
+
const updated = await store.updateMock(mock.id, { asks, sessionId });
|
|
1204
|
+
if (!updated)
|
|
1205
|
+
return fail(404, "mock not found");
|
|
1206
|
+
mock = updated;
|
|
1207
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
1208
|
+
// The questions also land in the thread, so the user reads them where they answer.
|
|
1209
|
+
const session = sessionId ? await store.getSession(sessionId) : null;
|
|
1210
|
+
if (session) {
|
|
1211
|
+
const comment = await store.createComment({
|
|
1212
|
+
sessionId: session.id,
|
|
1213
|
+
mockId: mock.id,
|
|
1214
|
+
author: reservedAgent(session.agent),
|
|
1215
|
+
text: added.map((a) => a.text).join("\n"),
|
|
1216
|
+
kind: "ask",
|
|
1217
|
+
});
|
|
1218
|
+
if (comment)
|
|
1219
|
+
announceComment(comment);
|
|
1220
|
+
}
|
|
1221
|
+
fireNotify({
|
|
1222
|
+
event: "ask",
|
|
1223
|
+
project: mock.project,
|
|
1224
|
+
slug: mock.slug,
|
|
1225
|
+
variant: "",
|
|
1226
|
+
version: Math.max(1, ...posts.map((p) => p.version)),
|
|
1227
|
+
text: added.map((a) => a.text).join(" · "),
|
|
1228
|
+
url: mockUrl(ctx.base, mock),
|
|
1229
|
+
});
|
|
1230
|
+
const feedback = session ? await collectFeedback(session.id) : [];
|
|
1231
|
+
return ok({
|
|
1232
|
+
mock: mock.slug,
|
|
1233
|
+
mockId: mock.id,
|
|
1234
|
+
project: mock.project,
|
|
1235
|
+
asks: added,
|
|
1236
|
+
open: openCount(mock, posts),
|
|
1237
|
+
url: mockUrl(ctx.base, mock),
|
|
1238
|
+
feedback,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
// --- drafts and replies (the user's side; viewer origin only) ---
|
|
1242
|
+
function sanitizePartComments(raw, mock) {
|
|
1243
|
+
if (raw === undefined || raw === null)
|
|
1244
|
+
return [];
|
|
1245
|
+
if (!Array.isArray(raw))
|
|
1246
|
+
return '"comments" must be an array';
|
|
1247
|
+
if (raw.length > MAX_DRAFT_COMMENTS)
|
|
1248
|
+
return `at most ${MAX_DRAFT_COMMENTS} comments`;
|
|
1249
|
+
const out = [];
|
|
1250
|
+
for (const c of raw) {
|
|
1251
|
+
const text = str(c?.text, MAX_COMMENT_TEXT);
|
|
1252
|
+
if (!text)
|
|
1253
|
+
return "every comment needs text";
|
|
1254
|
+
const state = stateArg(c.state) ?? null;
|
|
1255
|
+
if (state !== null && !mock.states.includes(state))
|
|
1256
|
+
return `${mock.slug} has no state "${state}"`;
|
|
1257
|
+
const part = str(c.part, 200) ?? null;
|
|
1258
|
+
const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
|
|
1259
|
+
const anchor = a ? sanitizePartAnchor(a) : undefined;
|
|
1260
|
+
out.push({ part, state, text, ...(anchor ? { anchor } : {}) });
|
|
1261
|
+
}
|
|
1262
|
+
return out;
|
|
1263
|
+
}
|
|
1264
|
+
function sanitizePartAnchor(a) {
|
|
1265
|
+
const offset = Array.isArray(a.offset) && a.offset.length === 2 && a.offset.every((n) => Number.isFinite(n))
|
|
1266
|
+
? [Number(a.offset[0]), Number(a.offset[1])]
|
|
1267
|
+
: undefined;
|
|
1268
|
+
const box = Array.isArray(a.box) && a.box.length === 4 && a.box.every((n) => Number.isFinite(n))
|
|
1269
|
+
? a.box.map(Number)
|
|
1270
|
+
: undefined;
|
|
1271
|
+
const quote = str(a.quote, 200);
|
|
1272
|
+
const selector = str(a.selector, 500);
|
|
1273
|
+
if (!offset && !box && !quote && !selector)
|
|
1274
|
+
return undefined;
|
|
1275
|
+
return {
|
|
1276
|
+
...(offset ? { offset } : {}),
|
|
1277
|
+
...(quote ? { quote } : {}),
|
|
1278
|
+
...(selector ? { selector } : {}),
|
|
1279
|
+
...(box ? { box } : {}),
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
// A stored ask, else the viewer's built-in variant ask under its reserved id.
|
|
1283
|
+
const askById = (mock, posts, id) => mock.asks.find((a) => a.id === id) ?? builtinAsk(mock, posts, id);
|
|
1284
|
+
function sanitizeAnswers(raw, mock, posts) {
|
|
1285
|
+
if (raw === undefined || raw === null)
|
|
1286
|
+
return {};
|
|
1287
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1288
|
+
return '"answers" must be an object';
|
|
1289
|
+
const out = {};
|
|
1290
|
+
for (const [askId, value] of Object.entries(raw)) {
|
|
1291
|
+
const ask = askById(mock, posts, askId);
|
|
1292
|
+
if (!ask)
|
|
1293
|
+
return `${mock.slug} has no ask "${askId}"`;
|
|
1294
|
+
const ids = Array.isArray(value) ? value : [value];
|
|
1295
|
+
if (ids.length === 0)
|
|
1296
|
+
continue;
|
|
1297
|
+
if (!ask.multi && ids.length > 1)
|
|
1298
|
+
return `ask "${askId}" takes one answer`;
|
|
1299
|
+
for (const id of ids) {
|
|
1300
|
+
if (typeof id !== "string" || !ask.options.some((o) => o.id === id)) {
|
|
1301
|
+
return `ask "${askId}" has no option "${String(id)}"`;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
out[askId] = ask.multi ? ids : ids[0];
|
|
1305
|
+
}
|
|
1306
|
+
return out;
|
|
1307
|
+
}
|
|
1308
|
+
// askId → text, for the write-ins and the notes: known asks only, text capped
|
|
1309
|
+
// like a comment, blanks dropped.
|
|
1310
|
+
function sanitizeAskTexts(raw, mock, posts, key) {
|
|
1311
|
+
if (raw === undefined || raw === null)
|
|
1312
|
+
return {};
|
|
1313
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1314
|
+
return `"${key}" must be an object`;
|
|
1315
|
+
const out = {};
|
|
1316
|
+
for (const [askId, value] of Object.entries(raw)) {
|
|
1317
|
+
if (!askById(mock, posts, askId))
|
|
1318
|
+
return `${mock.slug} has no ask "${askId}"`;
|
|
1319
|
+
if (value !== undefined && value !== null && typeof value !== "string") {
|
|
1320
|
+
return `${key} "${askId}" must be text`;
|
|
1321
|
+
}
|
|
1322
|
+
const text = str(value, MAX_COMMENT_TEXT);
|
|
1323
|
+
if (text)
|
|
1324
|
+
out[askId] = text;
|
|
1325
|
+
}
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
function sanitizeMix(raw, posts) {
|
|
1329
|
+
if (raw === undefined || raw === null)
|
|
1330
|
+
return {};
|
|
1331
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1332
|
+
return '"mix" must be an object';
|
|
1333
|
+
const variants = new Set(posts.map((p) => p.variant));
|
|
1334
|
+
const out = {};
|
|
1335
|
+
for (const [part, variant] of Object.entries(raw)) {
|
|
1336
|
+
if (!part || part.length > 200)
|
|
1337
|
+
return "mix keys are part names";
|
|
1338
|
+
if (typeof variant !== "string" || !variants.has(variant)) {
|
|
1339
|
+
return `mix "${part}" names a variant that does not exist`;
|
|
1340
|
+
}
|
|
1341
|
+
out[part] = variant;
|
|
1342
|
+
}
|
|
1343
|
+
return out;
|
|
1344
|
+
}
|
|
1345
|
+
// A draft, validated against the mock as it is now: unknown asks, options,
|
|
1346
|
+
// variants, knob paths or out-of-range values are refused, never stored.
|
|
1347
|
+
function sanitizeDraft(raw, mock, posts, base) {
|
|
1348
|
+
const pick = (key) => (raw?.[key] !== undefined ? raw[key] : base?.[key]);
|
|
1349
|
+
const answers = sanitizeAnswers(pick("answers"), mock, posts);
|
|
1350
|
+
if (typeof answers === "string")
|
|
1351
|
+
return answers;
|
|
1352
|
+
const mix = sanitizeMix(pick("mix"), posts);
|
|
1353
|
+
if (typeof mix === "string")
|
|
1354
|
+
return mix;
|
|
1355
|
+
const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
|
|
1356
|
+
if (!tuned.ok)
|
|
1357
|
+
return tuned.error;
|
|
1358
|
+
const comments = sanitizePartComments(pick("comments"), mock);
|
|
1359
|
+
if (typeof comments === "string")
|
|
1360
|
+
return comments;
|
|
1361
|
+
const others = sanitizeAskTexts(pick("others"), mock, posts, "others");
|
|
1362
|
+
if (typeof others === "string")
|
|
1363
|
+
return others;
|
|
1364
|
+
const notes = sanitizeAskTexts(pick("notes"), mock, posts, "notes");
|
|
1365
|
+
if (typeof notes === "string")
|
|
1366
|
+
return notes;
|
|
1367
|
+
for (const askId of Object.keys(others)) {
|
|
1368
|
+
const ask = askById(mock, posts, askId);
|
|
1369
|
+
if (!ask?.multi && answers[askId] !== undefined)
|
|
1370
|
+
return `ask "${askId}" takes one answer`;
|
|
1371
|
+
}
|
|
1372
|
+
const versionN = Number(pick("version"));
|
|
1373
|
+
const version = Number.isInteger(versionN) && versionN > 0
|
|
1374
|
+
? versionN
|
|
1375
|
+
: Math.max(1, ...posts.map((p) => p.version));
|
|
1376
|
+
return {
|
|
1377
|
+
version,
|
|
1378
|
+
answers,
|
|
1379
|
+
mix,
|
|
1380
|
+
tuned: tuned.value,
|
|
1381
|
+
comments,
|
|
1382
|
+
others,
|
|
1383
|
+
notes,
|
|
1384
|
+
updatedAt: new Date().toISOString(),
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
const viewerOnly = () => fail(403, "only the viewer can do this");
|
|
1388
|
+
async function getDraftFlow(ref, ctx) {
|
|
1389
|
+
if (!ctx.viewer)
|
|
1390
|
+
return viewerOnly();
|
|
1391
|
+
const mock = await resolveMock(ref);
|
|
1392
|
+
if (isResult(mock))
|
|
1393
|
+
return mock;
|
|
1394
|
+
return ok({ draft: mock.draft });
|
|
1395
|
+
}
|
|
1396
|
+
async function putDraftFlow(ref, body, ctx) {
|
|
1397
|
+
if (!ctx.viewer)
|
|
1398
|
+
return viewerOnly();
|
|
1399
|
+
if (!body || typeof body !== "object")
|
|
1400
|
+
return fail(400, "invalid JSON body");
|
|
1401
|
+
const mock = await resolveMock(ref);
|
|
1402
|
+
if (isResult(mock))
|
|
1403
|
+
return mock;
|
|
1404
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1405
|
+
const draft = sanitizeDraft(body, mock, posts, null);
|
|
1406
|
+
if (typeof draft === "string")
|
|
1407
|
+
return fail(400, draft);
|
|
1408
|
+
await store.putDraft(mock.id, draft);
|
|
1409
|
+
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
1410
|
+
return ok({ draft });
|
|
1411
|
+
}
|
|
1412
|
+
async function deleteDraftFlow(ref, ctx) {
|
|
1413
|
+
if (!ctx.viewer)
|
|
1414
|
+
return viewerOnly();
|
|
1415
|
+
const mock = await resolveMock(ref);
|
|
1416
|
+
if (isResult(mock))
|
|
1417
|
+
return mock;
|
|
1418
|
+
await store.putDraft(mock.id, null);
|
|
1419
|
+
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
1420
|
+
return ok({ draft: null });
|
|
1421
|
+
}
|
|
1422
|
+
function sanitizeDecision(raw, mock, posts) {
|
|
1423
|
+
if (raw === undefined || raw === null)
|
|
1424
|
+
return undefined;
|
|
1425
|
+
const kind = raw.kind;
|
|
1426
|
+
if (kind !== "accept" && kind !== "revise" && kind !== "drop") {
|
|
1427
|
+
return 'decision.kind must be "accept", "revise" or "drop"';
|
|
1428
|
+
}
|
|
1429
|
+
const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
|
|
1430
|
+
if (isResult(chosen))
|
|
1431
|
+
return chosen.body.error;
|
|
1432
|
+
return { kind, state: chosen.state, variant: chosen.variant };
|
|
1433
|
+
}
|
|
1434
|
+
// Which variants a reply accepts and archives. A variant-bound answer accepts
|
|
1435
|
+
// the chosen variant(s) in every state the ask covers and archives their
|
|
1436
|
+
// siblings there; a part-scoped pick is a mix, not a verdict. The built-in
|
|
1437
|
+
// variant asks flip the same way, per state.
|
|
1438
|
+
function replyFlips(mock, posts, answers, decision) {
|
|
1439
|
+
const accept = new Set();
|
|
1440
|
+
const archive = new Set();
|
|
1441
|
+
const settle = (inState, chosen) => {
|
|
1442
|
+
if (!inState.some((p) => chosen.has(p.variant)))
|
|
1443
|
+
return;
|
|
1444
|
+
for (const p of inState) {
|
|
1445
|
+
if (chosen.has(p.variant))
|
|
1446
|
+
accept.add(p.id);
|
|
1447
|
+
else if (p.status !== "archived")
|
|
1448
|
+
archive.add(p.id);
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
const builtins = Object.keys(answers)
|
|
1452
|
+
.filter((id) => !mock.asks.some((a) => a.id === id))
|
|
1453
|
+
.flatMap((id) => builtinAsk(mock, posts, id) ?? []);
|
|
1454
|
+
for (const ask of [...mock.asks, ...builtins]) {
|
|
1455
|
+
const answer = answers[ask.id];
|
|
1456
|
+
if (answer === undefined || ask.scope === "part")
|
|
1457
|
+
continue;
|
|
1458
|
+
const ids = Array.isArray(answer) ? answer : [answer];
|
|
1459
|
+
const chosen = new Set(ask.options.filter((o) => ids.includes(o.id) && o.variant).map((o) => o.variant));
|
|
1460
|
+
if (chosen.size === 0)
|
|
1461
|
+
continue;
|
|
1462
|
+
const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
|
|
1463
|
+
for (const state of states)
|
|
1464
|
+
settle(posts.filter((p) => p.state === state), chosen);
|
|
1465
|
+
}
|
|
1466
|
+
if (decision?.kind === "accept") {
|
|
1467
|
+
settle(posts.filter((p) => p.state === decision.state), new Set([decision.variant]));
|
|
1468
|
+
}
|
|
1469
|
+
else if (decision?.kind === "drop") {
|
|
1470
|
+
const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
|
|
1471
|
+
if (post)
|
|
1472
|
+
archive.add(post.id);
|
|
1473
|
+
}
|
|
1474
|
+
for (const id of accept)
|
|
1475
|
+
archive.delete(id);
|
|
1476
|
+
return { accept, archive };
|
|
1477
|
+
}
|
|
1478
|
+
// An ask as a reply leaves it. A pick or a write-in replaces whatever was
|
|
1479
|
+
// answered before; a note alone keeps that answer and adds to it.
|
|
1480
|
+
function sentAsk(a, draft) {
|
|
1481
|
+
const answer = draft.answers[a.id];
|
|
1482
|
+
const other = draft.others?.[a.id];
|
|
1483
|
+
const note = draft.notes?.[a.id];
|
|
1484
|
+
if (answer === undefined && other === undefined && note === undefined)
|
|
1485
|
+
return a;
|
|
1486
|
+
const next = { ...a };
|
|
1487
|
+
if (answer !== undefined || other !== undefined) {
|
|
1488
|
+
delete next.answer;
|
|
1489
|
+
delete next.other;
|
|
1490
|
+
if (answer !== undefined)
|
|
1491
|
+
next.answer = answer;
|
|
1492
|
+
if (other !== undefined)
|
|
1493
|
+
next.other = other;
|
|
1494
|
+
}
|
|
1495
|
+
if (note !== undefined)
|
|
1496
|
+
next.note = note;
|
|
1497
|
+
return next;
|
|
1498
|
+
}
|
|
1499
|
+
// Send: the user's one batched answer becomes one kind:"reply" comment on the
|
|
1500
|
+
// mock's session, delivered through the same cursor as everything else. The
|
|
1501
|
+
// draft is cleared and statuses flip in the same store transaction.
|
|
1502
|
+
async function replyFlow(ref, body, ctx) {
|
|
1503
|
+
if (!ctx.viewer)
|
|
1504
|
+
return viewerOnly();
|
|
1505
|
+
const mock = await resolveMock(ref);
|
|
1506
|
+
if (isResult(mock))
|
|
1507
|
+
return mock;
|
|
1508
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1509
|
+
const draft = sanitizeDraft(body ?? {}, mock, posts, mock.draft);
|
|
1510
|
+
if (typeof draft === "string")
|
|
1511
|
+
return fail(400, draft);
|
|
1512
|
+
const text = str(body?.text, MAX_COMMENT_TEXT);
|
|
1513
|
+
const decision = sanitizeDecision(body?.decision, mock, posts);
|
|
1514
|
+
if (typeof decision === "string")
|
|
1515
|
+
return fail(400, decision);
|
|
1516
|
+
const empty = !text &&
|
|
1517
|
+
!decision &&
|
|
1518
|
+
!Object.keys(draft.answers).length &&
|
|
1519
|
+
!Object.keys(draft.mix).length &&
|
|
1520
|
+
!Object.keys(draft.tuned).length &&
|
|
1521
|
+
!draft.comments.length &&
|
|
1522
|
+
!Object.keys(draft.others ?? {}).length &&
|
|
1523
|
+
!Object.keys(draft.notes ?? {}).length;
|
|
1524
|
+
if (empty)
|
|
1525
|
+
return fail(400, "nothing to send");
|
|
1526
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
1527
|
+
const sessionId = mock.sessionId ?? latest?.sessionId;
|
|
1528
|
+
if (!sessionId)
|
|
1529
|
+
return fail(409, `${mock.slug} has no agent session to reply to`);
|
|
1530
|
+
const payload = {
|
|
1531
|
+
mockId: mock.id,
|
|
1532
|
+
version: draft.version,
|
|
1533
|
+
answers: draft.answers,
|
|
1534
|
+
mix: draft.mix,
|
|
1535
|
+
tuned: draft.tuned,
|
|
1536
|
+
comments: draft.comments,
|
|
1537
|
+
...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
|
|
1538
|
+
...(Object.keys(draft.notes ?? {}).length ? { notes: draft.notes } : {}),
|
|
1539
|
+
...(text ? { text } : {}),
|
|
1540
|
+
...(decision ? { decision } : {}),
|
|
1541
|
+
};
|
|
1542
|
+
const asks = mock.asks.map((a) => sentAsk(a, draft));
|
|
1543
|
+
const flips = replyFlips(mock, posts, draft.answers, decision);
|
|
1544
|
+
const comment = await store.commitReply({
|
|
1545
|
+
mockId: mock.id,
|
|
1546
|
+
sessionId,
|
|
1547
|
+
text: text ?? "",
|
|
1548
|
+
payload,
|
|
1549
|
+
asks,
|
|
1550
|
+
accept: [...flips.accept],
|
|
1551
|
+
archive: [...flips.archive],
|
|
1552
|
+
});
|
|
1553
|
+
if (!comment)
|
|
1554
|
+
return fail(409, `${mock.slug} has no agent session to reply to`);
|
|
1555
|
+
announceComment(comment);
|
|
1556
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
1557
|
+
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
1558
|
+
const after = await store.listPosts({ mockId: mock.id });
|
|
1559
|
+
for (const p of after) {
|
|
1560
|
+
if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
|
|
1561
|
+
bus.broadcast({
|
|
1562
|
+
type: "post-updated",
|
|
1563
|
+
id: p.id,
|
|
1564
|
+
mockId: mock.id,
|
|
1565
|
+
sessionId: p.sessionId,
|
|
1566
|
+
version: p.version,
|
|
1567
|
+
by: "user",
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
fireNotify({
|
|
1572
|
+
event: "decision",
|
|
1573
|
+
project: mock.project,
|
|
1574
|
+
slug: mock.slug,
|
|
1575
|
+
variant: decision?.variant ?? "",
|
|
1576
|
+
version: draft.version,
|
|
1577
|
+
text: text || decision?.kind || "reply",
|
|
1578
|
+
url: mockUrl(ctx.base, mock),
|
|
1579
|
+
});
|
|
1580
|
+
const [delivered] = await withDelivered([comment]);
|
|
1581
|
+
return ok({
|
|
1582
|
+
reply: delivered,
|
|
1583
|
+
accepted: after
|
|
1584
|
+
.filter((p) => flips.accept.has(p.id))
|
|
1585
|
+
.map((p) => ({ state: p.state, variant: p.variant })),
|
|
1586
|
+
archived: after
|
|
1587
|
+
.filter((p) => flips.archive.has(p.id))
|
|
1588
|
+
.map((p) => ({ state: p.state, variant: p.variant })),
|
|
1589
|
+
}, 201);
|
|
1590
|
+
}
|
|
1591
|
+
async function restoreFlow(ref, body) {
|
|
1592
|
+
const mock = await resolveMock(ref, body?.project);
|
|
1593
|
+
if (isResult(mock))
|
|
1594
|
+
return mock;
|
|
1595
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1596
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
1597
|
+
if (isResult(chosen))
|
|
1598
|
+
return chosen;
|
|
1599
|
+
const updated = await store.setPostStatus(chosen.id, "open");
|
|
1600
|
+
if (!updated)
|
|
1601
|
+
return fail(404, "variant not found");
|
|
1602
|
+
bus.broadcast({
|
|
1603
|
+
type: "post-updated",
|
|
1604
|
+
id: updated.id,
|
|
1605
|
+
mockId: mock.id,
|
|
1606
|
+
sessionId: updated.sessionId,
|
|
1607
|
+
version: updated.version,
|
|
1608
|
+
by: "user",
|
|
1609
|
+
});
|
|
1610
|
+
return ok({ state: updated.state, variant: updated.variant, status: updated.status });
|
|
1611
|
+
}
|
|
1612
|
+
// D8 "restore as vN": the user brings an older version back as the newest
|
|
1613
|
+
// one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
|
|
1614
|
+
// because the version is authored by the user; an agent restoring publishes.
|
|
1615
|
+
async function restoreVersionFlow(ref, postId, body, ctx) {
|
|
1616
|
+
if (!ctx.viewer)
|
|
1617
|
+
return viewerOnly();
|
|
1618
|
+
const mock = await resolveMock(ref);
|
|
1619
|
+
if (isResult(mock))
|
|
1620
|
+
return mock;
|
|
1621
|
+
const post = await store.getPost(postId);
|
|
1622
|
+
if (!post || post.mock !== mock.id)
|
|
1623
|
+
return fail(404, `${mock.slug} has no variant "${postId}"`);
|
|
1624
|
+
const version = Number(body?.version);
|
|
1625
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
1626
|
+
return fail(400, '"version" must be a positive integer');
|
|
1627
|
+
}
|
|
1628
|
+
if (version === post.version)
|
|
1629
|
+
return fail(409, `v${version} is already the current version`);
|
|
1630
|
+
const old = post.history.find((h) => h.version === version);
|
|
1631
|
+
if (!old)
|
|
1632
|
+
return fail(404, `v${version} is not available`);
|
|
1633
|
+
const updated = await store.updatePost(post.id, {
|
|
1634
|
+
surfaces: old.surfaces,
|
|
1635
|
+
title: old.title,
|
|
1636
|
+
from: version,
|
|
1637
|
+
prompt: `restored v${version}`,
|
|
1638
|
+
author: "user",
|
|
1639
|
+
});
|
|
1640
|
+
if (!updated)
|
|
1641
|
+
return fail(404, "variant not found");
|
|
1642
|
+
const touched = (await store.updateMock(mock.id, {})) ?? mock;
|
|
1643
|
+
bus.broadcast({ type: "mock-updated", id: touched.id, project: touched.project });
|
|
1644
|
+
announcePost(touched, updated, false, "user");
|
|
1645
|
+
return ok({
|
|
1646
|
+
state: updated.state,
|
|
1647
|
+
variant: updated.variant,
|
|
1648
|
+
version: updated.version,
|
|
1649
|
+
from: version,
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
async function removeMockFlow(ref, query) {
|
|
1653
|
+
const mock = await resolveMock(ref, query.project);
|
|
1654
|
+
if (isResult(mock))
|
|
1655
|
+
return mock;
|
|
1656
|
+
await store.removeMock(mock.id);
|
|
1657
|
+
bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
|
|
1658
|
+
return ok({ ok: true });
|
|
1659
|
+
}
|
|
1660
|
+
// --- comments ---
|
|
1661
|
+
function numberInRange(value, min, max) {
|
|
1662
|
+
const n = Number(value);
|
|
1663
|
+
return Number.isFinite(n) && n >= min && n <= max ? n : null;
|
|
1664
|
+
}
|
|
1665
|
+
function sanitizeCommentAnchor(raw, mock, post) {
|
|
1666
|
+
if (!raw || typeof raw !== "object")
|
|
1667
|
+
return undefined;
|
|
1668
|
+
const input = raw;
|
|
1669
|
+
if (input.kind === "part") {
|
|
1670
|
+
const part = str(input.part, 200);
|
|
1671
|
+
if (!part)
|
|
1672
|
+
return undefined;
|
|
1673
|
+
const state = stateArg(input.state) ?? post?.state ?? null;
|
|
1674
|
+
if (state !== null && !mock.states.includes(state))
|
|
1675
|
+
return undefined;
|
|
1676
|
+
return { kind: "part", part, state, ...sanitizePartAnchor(input) };
|
|
1677
|
+
}
|
|
1678
|
+
if (!post)
|
|
1679
|
+
return undefined;
|
|
1680
|
+
const kind = input.kind === "rect" || input.kind === "lineRange" ? input.kind : "point";
|
|
1681
|
+
let surfaceIndex = Number(input.surfaceIndex);
|
|
1682
|
+
if (!Number.isInteger(surfaceIndex) ||
|
|
1683
|
+
surfaceIndex < 0 ||
|
|
1684
|
+
surfaceIndex >= post.surfaces.length) {
|
|
1685
|
+
const surfaceId = typeof input.surfaceId === "string" ? input.surfaceId : undefined;
|
|
1686
|
+
surfaceIndex = post.surfaces.findIndex((s) => s.id === surfaceId);
|
|
1687
|
+
}
|
|
1688
|
+
if (surfaceIndex < 0 || surfaceIndex >= post.surfaces.length)
|
|
1689
|
+
return undefined;
|
|
1690
|
+
const surface = post.surfaces[surfaceIndex];
|
|
1691
|
+
const base = {
|
|
1692
|
+
surfaceIndex,
|
|
1693
|
+
...(surface.id && { surfaceId: surface.id }),
|
|
1694
|
+
surfaceKind: surface.kind,
|
|
1695
|
+
// The server pins anchors to the current stored version instead of trusting
|
|
1696
|
+
// the client-supplied value.
|
|
1697
|
+
postVersion: post.version,
|
|
1698
|
+
};
|
|
1699
|
+
if (kind === "lineRange") {
|
|
1700
|
+
const startLine = Number(input.startLine);
|
|
1701
|
+
const endLine = Number(input.endLine);
|
|
1702
|
+
if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1) {
|
|
1703
|
+
return undefined;
|
|
1704
|
+
}
|
|
1705
|
+
return {
|
|
1706
|
+
kind,
|
|
1707
|
+
...base,
|
|
1708
|
+
startLine,
|
|
1709
|
+
endLine: Math.max(startLine, endLine),
|
|
1710
|
+
...(typeof input.file === "string" && { file: input.file.slice(0, MAX_TITLE) }),
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
const x = numberInRange(input.x, 0, 1);
|
|
1714
|
+
const y = numberInRange(input.y, 0, 1);
|
|
1715
|
+
if (x == null || y == null)
|
|
1716
|
+
return undefined;
|
|
1717
|
+
if (kind === "rect") {
|
|
1718
|
+
const w = numberInRange(input.w, 0, 1);
|
|
1719
|
+
const h = numberInRange(input.h, 0, 1);
|
|
1720
|
+
if (w == null || h == null)
|
|
1721
|
+
return undefined;
|
|
1722
|
+
return { kind, ...base, x, y, w, h };
|
|
1723
|
+
}
|
|
1724
|
+
return { kind: "point", ...base, x, y };
|
|
1725
|
+
}
|
|
1726
|
+
// Viewport presets the stage lays out at; anything else is dropped rather
|
|
1727
|
+
// than echoed back to the agent as a made-up width.
|
|
1728
|
+
const VIEWPORTS = new Set([390, 820, 1280]);
|
|
1729
|
+
const sanitizeViewport = (raw) => {
|
|
1730
|
+
const n = Number(raw);
|
|
1731
|
+
return VIEWPORTS.has(n) ? n : null;
|
|
1732
|
+
};
|
|
1733
|
+
const sanitizePostVersion = (raw, post) => {
|
|
1734
|
+
const n = Number(raw);
|
|
1735
|
+
return Number.isInteger(n) && n >= 1 && n <= post.version ? n : post.version;
|
|
1736
|
+
};
|
|
1737
|
+
// Markers are DATA: every field is re-derived or range-checked here, so
|
|
1738
|
+
// whatever the overlay (or a forged request) sends, the agent only ever
|
|
1739
|
+
// receives a bounded shape it can render as text.
|
|
1740
|
+
const MAX_ANCHORS = 20;
|
|
1741
|
+
const MAX_ANCHOR_TEXT = 200;
|
|
1742
|
+
function sanitizeAnchors(raw, post, viewport) {
|
|
1743
|
+
if (!post || !Array.isArray(raw))
|
|
1744
|
+
return [];
|
|
1745
|
+
const out = [];
|
|
1746
|
+
for (const entry of raw.slice(0, MAX_ANCHORS)) {
|
|
1747
|
+
if (!entry || typeof entry !== "object")
|
|
1748
|
+
continue;
|
|
1749
|
+
const a = entry;
|
|
1750
|
+
const shape = a.shape === "rect" || a.shape === "circle" ? a.shape : "pin";
|
|
1751
|
+
const box = Array.isArray(a.box) ? a.box.map((n) => numberInRange(n, 0, 1)) : [];
|
|
1752
|
+
const need = shape === "pin" ? 2 : 4;
|
|
1753
|
+
if (box.length < need || box.slice(0, need).some((n) => n == null))
|
|
1754
|
+
continue;
|
|
1755
|
+
let surfaceIndex = Number(a.surfaceIndex);
|
|
1756
|
+
if (!Number.isInteger(surfaceIndex) ||
|
|
1757
|
+
surfaceIndex < 0 ||
|
|
1758
|
+
surfaceIndex >= post.surfaces.length) {
|
|
1759
|
+
surfaceIndex = 0;
|
|
1760
|
+
}
|
|
1761
|
+
const ref = typeof a.ref === "string" && /^@\d{1,3}$/.test(a.ref) ? a.ref : `@${out.length + 1}`;
|
|
1762
|
+
out.push({
|
|
1763
|
+
ref,
|
|
1764
|
+
shape,
|
|
1765
|
+
box: box.slice(0, need),
|
|
1766
|
+
surfaceIndex,
|
|
1767
|
+
postVersion: sanitizePostVersion(a.postVersion, post),
|
|
1768
|
+
...(typeof a.path === "string" && { path: a.path.slice(0, MAX_ANCHOR_TEXT) }),
|
|
1769
|
+
...(typeof a.text === "string" && { text: a.text.slice(0, MAX_ANCHOR_TEXT) }),
|
|
1770
|
+
...(viewport === null ? {} : { viewport }),
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
return out;
|
|
1774
|
+
}
|
|
1775
|
+
function announceComment(comment) {
|
|
1776
|
+
bus.broadcast({
|
|
1777
|
+
type: "comment-created",
|
|
1778
|
+
id: comment.id,
|
|
1779
|
+
sessionId: comment.sessionId,
|
|
1780
|
+
mockId: comment.mockId,
|
|
1781
|
+
postId: comment.postId,
|
|
1782
|
+
seq: comment.seq,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
// Where a thread comment lands: a mock, optionally one variant of it, and the
|
|
1786
|
+
// session whose stream carries it.
|
|
1787
|
+
async function commentTarget(body) {
|
|
1788
|
+
let post = null;
|
|
1789
|
+
let mock = null;
|
|
1790
|
+
if (typeof body.post === "string" && body.post) {
|
|
1791
|
+
post = await store.getPost(body.post);
|
|
1792
|
+
if (!post)
|
|
1793
|
+
return fail(404, "post not found");
|
|
1794
|
+
mock = await store.getMock(post.mock);
|
|
1795
|
+
}
|
|
1796
|
+
else if (body.mock !== undefined) {
|
|
1797
|
+
const resolved = await resolveMock(body.mock, body.project, body.session);
|
|
1798
|
+
if (isResult(resolved))
|
|
1799
|
+
return resolved;
|
|
1800
|
+
mock = resolved;
|
|
1801
|
+
if (body.variant !== undefined || body.state !== undefined) {
|
|
1802
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
1803
|
+
const chosen = chooseVariant(mock, posts, stateArg(body.state), str(body.variant, MAX_LABEL));
|
|
1804
|
+
if (isResult(chosen))
|
|
1805
|
+
return chosen;
|
|
1806
|
+
post = chosen;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
else {
|
|
1810
|
+
return fail(400, 'provide "mock" (or "post")');
|
|
1811
|
+
}
|
|
1812
|
+
if (!mock)
|
|
1813
|
+
return fail(404, "mock not found");
|
|
1814
|
+
let session = null;
|
|
1815
|
+
if (typeof body.session === "string" && body.session) {
|
|
1816
|
+
session = await store.getSession(body.session);
|
|
1817
|
+
if (!session)
|
|
1818
|
+
return fail(404, `session "${body.session}" not found`);
|
|
1819
|
+
}
|
|
1820
|
+
if (!session) {
|
|
1821
|
+
const sid = mock.sessionId ?? post?.sessionId;
|
|
1822
|
+
session = sid ? await store.getSession(sid) : null;
|
|
1823
|
+
}
|
|
1824
|
+
if (!session)
|
|
1825
|
+
return fail(409, `${mock.slug} has no agent session`);
|
|
1826
|
+
return { mock, post, session };
|
|
1827
|
+
}
|
|
1828
|
+
// The user's comment in a thread: the trusted viewer's verb only. Sandboxed
|
|
1829
|
+
// surfaces have opaque origins, so their bridge is stamped "surface" by the
|
|
1830
|
+
// viewer rather than by contained code. An agent writes with say.
|
|
1831
|
+
async function commentFlow(body, ctx) {
|
|
1832
|
+
if (!ctx.viewer)
|
|
1833
|
+
return fail(403, "only the viewer can do this; an agent posts with say");
|
|
1834
|
+
if (!body || typeof body !== "object")
|
|
1835
|
+
return fail(400, "invalid JSON body");
|
|
1836
|
+
const text = str(body.text, MAX_COMMENT_TEXT);
|
|
1837
|
+
if (!text)
|
|
1838
|
+
return fail(400, 'provide non-empty "text"');
|
|
1839
|
+
const target = await commentTarget(body);
|
|
1840
|
+
if (isResult(target))
|
|
1841
|
+
return target;
|
|
1842
|
+
const { mock, post, session } = target;
|
|
1843
|
+
const viewport = sanitizeViewport(body.viewport);
|
|
1844
|
+
const comment = await store.createComment({
|
|
1845
|
+
sessionId: session.id,
|
|
1846
|
+
mockId: mock.id,
|
|
1847
|
+
postId: post?.id ?? null,
|
|
1848
|
+
author: body.author === "surface" ? "surface" : "user",
|
|
1849
|
+
text,
|
|
1850
|
+
anchor: sanitizeCommentAnchor(body.anchor, mock, post),
|
|
1851
|
+
kind: "comment",
|
|
1852
|
+
anchors: sanitizeAnchors(body.anchors, post, viewport),
|
|
1853
|
+
postVersion: post ? sanitizePostVersion(body.postVersion, post) : null,
|
|
1854
|
+
viewport,
|
|
1855
|
+
});
|
|
1856
|
+
if (!comment)
|
|
1857
|
+
return fail(404, "session not found");
|
|
1858
|
+
announceComment(comment);
|
|
1859
|
+
const [delivered] = await withDelivered([comment]);
|
|
1860
|
+
return ok(delivered, 201);
|
|
1861
|
+
}
|
|
1862
|
+
// The agent's plain-text message in a mock's thread. Stored as text and
|
|
1863
|
+
// rendered as a text node, never as HTML. A write, so feedback rides along.
|
|
1864
|
+
async function sayFlow(ref, body) {
|
|
1865
|
+
if (!body || typeof body !== "object")
|
|
1866
|
+
return fail(400, "invalid JSON body");
|
|
1867
|
+
const text = typeof body.message === "string" ? str(body.message, MAX_COMMENT_TEXT) : undefined;
|
|
1868
|
+
if (!text)
|
|
1869
|
+
return fail(400, 'provide non-empty "message" text');
|
|
1870
|
+
const target = await commentTarget({ ...body, mock: ref, post: undefined });
|
|
1871
|
+
if (isResult(target))
|
|
1872
|
+
return target;
|
|
1873
|
+
const { mock, post, session } = target;
|
|
1874
|
+
const comment = await store.createComment({
|
|
1875
|
+
sessionId: session.id,
|
|
1876
|
+
mockId: mock.id,
|
|
1877
|
+
postId: post?.id ?? null,
|
|
1878
|
+
author: reservedAgent(session.agent),
|
|
1879
|
+
text,
|
|
1880
|
+
kind: "comment",
|
|
1881
|
+
anchors: [],
|
|
1882
|
+
postVersion: post ? post.version : null,
|
|
1883
|
+
viewport: null,
|
|
1884
|
+
});
|
|
1885
|
+
if (!comment)
|
|
1886
|
+
return fail(404, "session not found");
|
|
1887
|
+
announceComment(comment);
|
|
1888
|
+
return ok({ feedback: await collectFeedback(comment.sessionId) }, 201);
|
|
1889
|
+
}
|
|
1890
|
+
// Long-poll: resolves as soon as a matching comment lands, or at timeout.
|
|
1891
|
+
async function waitForComments(q, signal) {
|
|
1892
|
+
// An author=user session wait with no explicit cursor resumes from the
|
|
1893
|
+
// session's agentSeq — "where the agent left off" lives server-side so the
|
|
1894
|
+
// CLI, both MCP transports, and piggyback share one exactly-once stream.
|
|
1895
|
+
let afterSeq = q.afterSeq;
|
|
1896
|
+
if (afterSeq === undefined && q.author === "user" && q.sessionId) {
|
|
1897
|
+
afterSeq = (await store.getSession(q.sessionId))?.agentSeq;
|
|
1898
|
+
}
|
|
1899
|
+
const query = { sessionId: q.sessionId, mockId: q.mockId, postId: q.postId, afterSeq };
|
|
1900
|
+
const matches = (list) => q.author ? list.filter((cm) => cm.author === q.author) : list;
|
|
1901
|
+
const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS);
|
|
1902
|
+
let all = await store.listComments(query);
|
|
1903
|
+
let comments = matches(all);
|
|
1904
|
+
if (comments.length === 0 && wait > 0) {
|
|
1905
|
+
await new Promise((resolve) => {
|
|
1906
|
+
const timer = setTimeout(done, wait * 1000);
|
|
1907
|
+
const unsubscribe = bus.subscribe((event) => {
|
|
1908
|
+
if (event.type !== "comment-created")
|
|
1909
|
+
return;
|
|
1910
|
+
if (q.sessionId && event.sessionId !== q.sessionId)
|
|
1911
|
+
return;
|
|
1912
|
+
if (q.mockId && event.mockId !== q.mockId)
|
|
1913
|
+
return;
|
|
1914
|
+
if (q.postId && event.postId !== q.postId)
|
|
1915
|
+
return;
|
|
1916
|
+
done();
|
|
1917
|
+
});
|
|
1918
|
+
const onAbort = () => done();
|
|
1919
|
+
let settled = false;
|
|
1920
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1921
|
+
if (signal?.aborted)
|
|
1922
|
+
done();
|
|
1923
|
+
function done() {
|
|
1924
|
+
if (settled)
|
|
1925
|
+
return;
|
|
1926
|
+
settled = true;
|
|
1927
|
+
clearTimeout(timer);
|
|
1928
|
+
unsubscribe();
|
|
1929
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1930
|
+
resolve();
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
// A caller that gave up can't receive this batch, so delivering it would
|
|
1934
|
+
// advance the cursor past feedback no agent ever saw.
|
|
1935
|
+
if (signal?.aborted)
|
|
1936
|
+
return { comments: [], lastSeq: afterSeq ?? 0 };
|
|
1937
|
+
all = await store.listComments(query);
|
|
1938
|
+
comments = matches(all);
|
|
1939
|
+
}
|
|
1940
|
+
// The cursor advances past every comment in the window — not just the
|
|
1941
|
+
// filtered ones — so the next call doesn't re-read the agent's own comments.
|
|
1942
|
+
const lastSeq = all.length > 0 ? all[all.length - 1].seq : (afterSeq ?? 0);
|
|
1943
|
+
// An author=user query is the agent listening (the viewer never filters by
|
|
1944
|
+
// author) — what it receives here must not be re-delivered as piggyback.
|
|
1945
|
+
if (q.agent && q.author === "user" && q.sessionId && all.length > 0) {
|
|
1946
|
+
await store.markAgentSeen(q.sessionId, lastSeq);
|
|
1947
|
+
bus.broadcast({ type: "comment-delivered", sessionId: q.sessionId, seq: lastSeq });
|
|
1948
|
+
}
|
|
1949
|
+
return { comments, lastSeq };
|
|
1950
|
+
}
|
|
1951
|
+
// The watch long-poll's answer: comments plus the batched feedback built
|
|
1952
|
+
// from them.
|
|
1953
|
+
async function listenFlow(q, signal) {
|
|
1954
|
+
const result = await waitForComments({ ...q, agent: true }, signal);
|
|
1955
|
+
const feedback = await buildFeedbackBatches(store, result.comments);
|
|
1956
|
+
return ok({ ...result, feedback });
|
|
1957
|
+
}
|
|
1958
|
+
// What the user is doing right now, per mock: derived from the SSE registry
|
|
1959
|
+
// and the server-side draft, never stored. It is why an empty `feedback` still
|
|
1960
|
+
// tells the agent something ("2 of 3 answered", or nobody is looking).
|
|
1961
|
+
const pendingOf = (mock, posts) => ({
|
|
1962
|
+
mock: mock.slug,
|
|
1963
|
+
viewerOpen: viewerClients > 0,
|
|
1964
|
+
draft: mock.draft
|
|
1965
|
+
? {
|
|
1966
|
+
answered: Object.keys(mock.draft.answers).length,
|
|
1967
|
+
of: openCount(mock, posts),
|
|
1968
|
+
comments: mock.draft.comments.length,
|
|
1969
|
+
touchedAt: mock.draft.updatedAt,
|
|
1970
|
+
}
|
|
1971
|
+
: null,
|
|
1972
|
+
});
|
|
1973
|
+
// The feedback verb: never blocks. Takes what the session has not heard yet
|
|
1974
|
+
// (the same cursor as piggyback and watch) plus `pending` for the project.
|
|
1975
|
+
async function feedbackFlow(query) {
|
|
1976
|
+
if (typeof query.session !== "string" || !query.session) {
|
|
1977
|
+
return fail(400, 'provide "session": the session id a write returned');
|
|
1978
|
+
}
|
|
1979
|
+
const session = await store.getSession(query.session);
|
|
1980
|
+
if (!session)
|
|
1981
|
+
return fail(404, `session "${query.session}" not found`);
|
|
1982
|
+
const { comments } = await waitForComments({
|
|
1983
|
+
sessionId: session.id,
|
|
1984
|
+
author: "user",
|
|
1985
|
+
waitSeconds: 0,
|
|
1986
|
+
agent: true,
|
|
1987
|
+
});
|
|
1988
|
+
const project = str(query.project, MAX_TITLE) ?? session.project ?? undefined;
|
|
1989
|
+
return ok({
|
|
1990
|
+
feedback: await buildFeedbackBatches(store, comments),
|
|
1991
|
+
pending: await Promise.all((await store.listMocks(project)).map(async (m) => pendingOf(m, await store.listPosts({ mockId: m.id })))),
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
// 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. The asset's
|
|
1996
|
+
// data is dropped from the result (it's bytes).
|
|
1997
|
+
async function uploadAsset(input) {
|
|
1998
|
+
if (input.data.byteLength === 0)
|
|
1999
|
+
return { error: "empty upload", status: 400 };
|
|
2000
|
+
if (input.data.byteLength > MAX_ASSET_BYTES) {
|
|
2001
|
+
return { error: `asset exceeds ${MAX_ASSET_BYTES} bytes`, status: 413 };
|
|
2002
|
+
}
|
|
2003
|
+
let sessionId = input.session;
|
|
2004
|
+
if (sessionId && !(await store.getSession(sessionId))) {
|
|
2005
|
+
return { error: `session "${sessionId}" not found`, status: 404 };
|
|
2006
|
+
}
|
|
2007
|
+
if (!sessionId) {
|
|
2008
|
+
const session = await store.createSession({ agent: input.agent ?? "agent" });
|
|
2009
|
+
bus.broadcast({ type: "session-created", id: session.id });
|
|
2010
|
+
sessionId = session.id;
|
|
2011
|
+
}
|
|
2012
|
+
const asset = await store.putAsset({
|
|
2013
|
+
sessionId,
|
|
2014
|
+
kind: input.kind ?? inferAssetKind(input.contentType),
|
|
2015
|
+
contentType: input.contentType || "application/octet-stream",
|
|
2016
|
+
filename: input.filename,
|
|
2017
|
+
data: input.data,
|
|
2018
|
+
});
|
|
2019
|
+
if (!asset)
|
|
2020
|
+
return { error: "session not found", status: 404 };
|
|
2021
|
+
const { data: _data, ...meta } = asset;
|
|
2022
|
+
return { asset: meta };
|
|
2023
|
+
}
|
|
2024
|
+
// --- auth ---
|
|
2025
|
+
const isAuthenticated = (c) => {
|
|
2026
|
+
if (!authToken)
|
|
2027
|
+
return true;
|
|
2028
|
+
if (c.req.header("authorization") === `Bearer ${authToken}`)
|
|
2029
|
+
return true;
|
|
2030
|
+
if (getCookie(c, "mockpit_key") === authToken)
|
|
2031
|
+
return true;
|
|
2032
|
+
return c.req.query("key") === authToken;
|
|
2033
|
+
};
|
|
2034
|
+
const isUnauthenticatedSessionRead = (c) => publicRead === "session" && !isAuthenticated(c);
|
|
2035
|
+
app.use("*", async (c, next) => {
|
|
2036
|
+
const path = new URL(c.req.url).pathname;
|
|
2037
|
+
// Content-hashed static assets (the surface bridge script and the static
|
|
2038
|
+
// stylesheets) are served unauthenticated on purpose: they are our own code,
|
|
2039
|
+
// carry no workspace data, and the sandboxed frames that load them run at an
|
|
2040
|
+
// opaque origin, so their subresource requests carry no SameSite cookie and
|
|
2041
|
+
// could not authenticate even on a tokened workspace.
|
|
2042
|
+
if (path.startsWith(STATIC_ASSET_PREFIX))
|
|
2043
|
+
return next();
|
|
2044
|
+
if (authenticate) {
|
|
2045
|
+
const result = await authenticate(c.req.raw);
|
|
2046
|
+
if (result === true)
|
|
2047
|
+
return next();
|
|
2048
|
+
if (result instanceof Response)
|
|
2049
|
+
return result;
|
|
2050
|
+
if (path.startsWith("/api") || path === "/mcp") {
|
|
2051
|
+
return c.json({ error: "unauthorized" }, 401);
|
|
2052
|
+
}
|
|
2053
|
+
return c.text("unauthorized", 401);
|
|
2054
|
+
}
|
|
2055
|
+
if (!authToken)
|
|
2056
|
+
return next();
|
|
2057
|
+
if (path === "/guide" || path === "/setup" || path === "/agent-howto")
|
|
2058
|
+
return next();
|
|
2059
|
+
if (path.startsWith(STATIC_ASSET_PREFIX))
|
|
2060
|
+
return next();
|
|
2061
|
+
const key = c.req.query("key");
|
|
2062
|
+
if (key === authToken) {
|
|
2063
|
+
setCookie(c, "mockpit_key", authToken, {
|
|
2064
|
+
httpOnly: true,
|
|
2065
|
+
sameSite: "Lax",
|
|
2066
|
+
secure: new URL(c.req.url).protocol === "https:",
|
|
2067
|
+
maxAge: 60 * 60 * 24 * 90,
|
|
2068
|
+
path: "/",
|
|
2069
|
+
});
|
|
2070
|
+
return next();
|
|
2071
|
+
}
|
|
2072
|
+
if (publicRead &&
|
|
2073
|
+
c.req.method === "GET" &&
|
|
2074
|
+
isPublicReadAllowed(path, publicRead, new URL(c.req.url).searchParams)) {
|
|
2075
|
+
return next();
|
|
2076
|
+
}
|
|
2077
|
+
if (isAuthenticated(c))
|
|
2078
|
+
return next();
|
|
2079
|
+
if (path.startsWith("/api") || path === "/mcp") {
|
|
2080
|
+
return c.json({ error: "unauthorized — send Authorization: Bearer <token>" }, 401);
|
|
2081
|
+
}
|
|
2082
|
+
return c.text("unauthorized — open this page as /?key=<your token>", 401);
|
|
2083
|
+
});
|
|
2084
|
+
// Cap every request body. Runs after auth, so an unauthenticated request on a
|
|
2085
|
+
// token-protected workspace is rejected (401) before its body is ever read; on a
|
|
2086
|
+
// no-token workspace it still bounds the body. bodyLimit short-circuits on an
|
|
2087
|
+
// oversize Content-Length and otherwise streams-and-aborts at the cap, so a
|
|
2088
|
+
// chunked body (no Content-Length) can't slip past either. /api/assets is
|
|
2089
|
+
// exempt here because it applies its own, stricter cap (limitAssetBody below).
|
|
2090
|
+
const limitBody = bodyLimit({
|
|
2091
|
+
maxSize: MAX_BODY_BYTES,
|
|
2092
|
+
onError: (c) => c.json({ error: "request body too large" }, 413),
|
|
2093
|
+
});
|
|
2094
|
+
app.use("*", (c, next) => (c.req.path === "/api/assets" ? next() : limitBody(c, next)));
|
|
2095
|
+
// The asset route's own (tighter) body cap. Keeps the asset limit and its
|
|
2096
|
+
// wording, and bounds the upload before it is read — bodyLimit refuses an
|
|
2097
|
+
// oversize Content-Length outright and aborts a chunked stream at the cap.
|
|
2098
|
+
const limitAssetBody = bodyLimit({
|
|
2099
|
+
maxSize: MAX_ASSET_BYTES,
|
|
2100
|
+
onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
|
|
2101
|
+
});
|
|
2102
|
+
const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin);
|
|
2103
|
+
const injectHead = (text, head) => {
|
|
2104
|
+
const headClose = text.lastIndexOf("</head>");
|
|
2105
|
+
return headClose >= 0
|
|
2106
|
+
? `${text.slice(0, headClose)}${head}${text.slice(headClose)}`
|
|
2107
|
+
: `${head}${text}`;
|
|
2108
|
+
};
|
|
2109
|
+
const withDocumentTitle = (text, title) => {
|
|
2110
|
+
if (!title)
|
|
2111
|
+
return text;
|
|
2112
|
+
const escaped = escapeHtml(title);
|
|
2113
|
+
const titleTag = `<title>${escaped}</title>`;
|
|
2114
|
+
const titleStart = text.indexOf("<title>");
|
|
2115
|
+
if (titleStart < 0)
|
|
2116
|
+
return injectHead(text, titleTag);
|
|
2117
|
+
const titleEnd = text.indexOf("</title>", titleStart + "<title>".length);
|
|
2118
|
+
if (titleEnd < 0)
|
|
2119
|
+
return injectHead(text, titleTag);
|
|
2120
|
+
return `${text.slice(0, titleStart)}${titleTag}${text.slice(titleEnd + "</title>".length)}`;
|
|
2121
|
+
};
|
|
2122
|
+
const withViewerConfig = (text, request, isReadonly, pageTitle) => {
|
|
2123
|
+
const config = [
|
|
2124
|
+
`window.__MOCKPIT_BASE_PATH__=${JSON.stringify(requestBasePath(request))};`,
|
|
2125
|
+
pageTitle ? `window.__MOCKPIT_PAGE_TITLE__=${JSON.stringify(pageTitle)};` : "",
|
|
2126
|
+
isReadonly ? "window.__MOCKPIT_READONLY__=true;" : "",
|
|
2127
|
+
isReadonly && publicRead
|
|
2128
|
+
? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
|
|
2129
|
+
: "",
|
|
2130
|
+
screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
|
|
2131
|
+
].join("");
|
|
2132
|
+
return injectHead(text, `<script>${config}</script>`);
|
|
2133
|
+
};
|
|
2134
|
+
// --- viewer pages ---
|
|
2135
|
+
// Link previews for a mock page: the first open variant's first surface,
|
|
2136
|
+
// with every pixel-affecting input pinned in the image URL so the Worker can
|
|
2137
|
+
// cache it at the edge.
|
|
2138
|
+
const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
|
|
2139
|
+
const origin = new URL(request.url).origin;
|
|
2140
|
+
const publicBasePath = requestBasePath(request);
|
|
2141
|
+
const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
|
|
2142
|
+
const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
|
|
2143
|
+
imageUrl.searchParams.set("card", "1");
|
|
2144
|
+
imageUrl.searchParams.set("theme", DEFAULT_THEME_ID);
|
|
2145
|
+
imageUrl.searchParams.set("mode", mode);
|
|
2146
|
+
imageUrl.searchParams.set("v", String(post.version));
|
|
2147
|
+
imageUrl.searchParams.set("g", rendererGeneration);
|
|
2148
|
+
const image = imageUrl.toString();
|
|
2149
|
+
const title = escapeHtml(mock.title);
|
|
2150
|
+
const description = "A https://mockpit.sh mock";
|
|
2151
|
+
return [
|
|
2152
|
+
`<link rel="canonical" href="${escapeHtml(canonical)}">`,
|
|
2153
|
+
`<meta property="og:type" content="website">`,
|
|
2154
|
+
`<meta property="og:title" content="${title}">`,
|
|
2155
|
+
`<meta property="og:description" content="${description}">`,
|
|
2156
|
+
`<meta property="og:url" content="${escapeHtml(canonical)}">`,
|
|
2157
|
+
`<meta property="og:image" content="${escapeHtml(image)}">`,
|
|
2158
|
+
`<meta property="og:image:width" content="1200">`,
|
|
2159
|
+
`<meta property="og:image:height" content="630">`,
|
|
2160
|
+
`<meta name="twitter:card" content="summary_large_image">`,
|
|
2161
|
+
`<meta name="twitter:title" content="${title}">`,
|
|
2162
|
+
`<meta name="twitter:description" content="${description}">`,
|
|
2163
|
+
`<meta name="twitter:image" content="${escapeHtml(image)}">`,
|
|
2164
|
+
].join("\n");
|
|
2165
|
+
};
|
|
2166
|
+
const configuredViewerHtml = async (c, opts = {}) => {
|
|
2167
|
+
// The viewer HTML is the trusted app origin — it shares that origin with the
|
|
2168
|
+
// authenticated API and the comment→agent channel, so a cross-origin page
|
|
2169
|
+
// that frames it could clickjack actions or the prompt-injection channel.
|
|
2170
|
+
// Refuse cross-origin framing (same-origin embedding still allowed). The
|
|
2171
|
+
// sandboxed surface documents at /s/:id are *meant* to be framed and carry
|
|
2172
|
+
// their own `sandbox` CSP header instead, so they never pass through here.
|
|
2173
|
+
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);
|
|
2175
|
+
if (!opts.preview)
|
|
2176
|
+
return html;
|
|
2177
|
+
const { mock, post } = opts.preview;
|
|
2178
|
+
return injectHead(html, mockPreviewHead(mock, post, c.req.raw, await workspaceMode(), version ?? "dev"));
|
|
2179
|
+
};
|
|
2180
|
+
app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
|
|
2181
|
+
// The engine reads the project/mock out of the URL itself; these render the
|
|
2182
|
+
// same shell as "/".
|
|
2183
|
+
app.get("/project/:name", async (c) => c.html(await configuredViewerHtml(c, { title: c.req.param("name") })));
|
|
2184
|
+
app.get("/project/:name/:slug", async (c) => {
|
|
2185
|
+
const mock = await store.findMock(c.req.param("name"), c.req.param("slug"));
|
|
2186
|
+
if (!mock)
|
|
2187
|
+
return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
|
|
2188
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2189
|
+
const post = posts.find((p) => p.status !== "archived") ?? posts[0];
|
|
2190
|
+
return c.html(await configuredViewerHtml(c, {
|
|
2191
|
+
title: mock.title,
|
|
2192
|
+
...(post ? { preview: { mock, post } } : {}),
|
|
2193
|
+
}));
|
|
2194
|
+
});
|
|
2195
|
+
// --- REST plumbing for the shared flows ---
|
|
2196
|
+
const flowCtx = (c, strict = true) => ({
|
|
2197
|
+
base: `${new URL(c.req.url).origin}${requestBasePath(c.req.raw)}`,
|
|
2198
|
+
// The browser sets Fetch Metadata on same-origin requests; only the trusted
|
|
2199
|
+
// viewer may act as the user (drafts, replies, user-authored comments).
|
|
2200
|
+
viewer: c.req.header("sec-fetch-site") === "same-origin",
|
|
2201
|
+
strict,
|
|
2202
|
+
request: c.req.raw,
|
|
2203
|
+
signal: c.req.raw.signal,
|
|
2204
|
+
});
|
|
2205
|
+
const send = (c, result) => c.json(result.body, result.status);
|
|
2206
|
+
const jsonBody = (c) => c.req.json().catch(() => null);
|
|
2207
|
+
const flag = (c, key) => c.req.query(key) === "1";
|
|
2208
|
+
// --- sessions ---
|
|
2209
|
+
app.get("/api/sessions", async (c) => {
|
|
2210
|
+
const [sessions, counts] = await Promise.all([
|
|
2211
|
+
store.listSessions(),
|
|
2212
|
+
store.countPostsBySession(),
|
|
2213
|
+
]);
|
|
2214
|
+
return c.json(sessions.map((s) => sessionRowView(s, counts.get(s.id) ?? 0)));
|
|
2215
|
+
});
|
|
2216
|
+
app.get("/api/sessions/:id", async (c) => {
|
|
2217
|
+
const session = await store.getSession(c.req.param("id"));
|
|
2218
|
+
if (!session)
|
|
2219
|
+
return c.json({ error: "session not found" }, 404);
|
|
2220
|
+
const posts = await store.listPosts({ sessionId: session.id });
|
|
2221
|
+
return c.json(sessionRowView(session, posts.length));
|
|
2222
|
+
});
|
|
2223
|
+
app.post("/api/sessions", async (c) => {
|
|
2224
|
+
const body = (await jsonBody(c)) ?? {};
|
|
2225
|
+
const cwd = str(body.cwd, 4096);
|
|
2226
|
+
const key = str(body.key, MAX_TITLE);
|
|
2227
|
+
const existing = key ? await store.getSessionByKey(key) : null;
|
|
2228
|
+
if (existing)
|
|
2229
|
+
return c.json(existing, 200);
|
|
2230
|
+
const session = await store.createSession({
|
|
2231
|
+
agent: str(body.agent, MAX_TITLE) ?? "agent",
|
|
2232
|
+
title: str(body.title, MAX_TITLE),
|
|
2233
|
+
cwd,
|
|
2234
|
+
// Resolved once here so every mock this session publishes lands in the
|
|
2235
|
+
// same project.
|
|
2236
|
+
project: resolveProject(str(body.project, MAX_TITLE), cwd),
|
|
2237
|
+
key,
|
|
2238
|
+
});
|
|
2239
|
+
bus.broadcast({ type: "session-created", id: session.id });
|
|
2240
|
+
return c.json(session, 201);
|
|
2241
|
+
});
|
|
2242
|
+
app.patch("/api/sessions/:id", async (c) => {
|
|
2243
|
+
const body = await jsonBody(c);
|
|
2244
|
+
if (!body || typeof body.title !== "string") {
|
|
2245
|
+
return c.json({ error: 'body must include "title" string' }, 400);
|
|
2246
|
+
}
|
|
2247
|
+
const session = await store.renameSession(c.req.param("id"), body.title.slice(0, MAX_TITLE));
|
|
2248
|
+
if (!session)
|
|
2249
|
+
return c.json({ error: "session not found" }, 404);
|
|
2250
|
+
bus.broadcast({ type: "session-updated", id: session.id });
|
|
2251
|
+
return c.json(session);
|
|
2252
|
+
});
|
|
2253
|
+
app.delete("/api/sessions/:id", async (c) => {
|
|
2254
|
+
const id = c.req.param("id");
|
|
2255
|
+
if (!(await store.removeSession(id)))
|
|
2256
|
+
return c.json({ error: "session not found" }, 404);
|
|
2257
|
+
bus.broadcast({ type: "session-deleted", id });
|
|
2258
|
+
return c.json({ ok: true });
|
|
2259
|
+
});
|
|
2260
|
+
// --- projects and mocks ---
|
|
2261
|
+
app.get("/api/projects", async (c) => c.json(await store.listProjects()));
|
|
2262
|
+
app.get("/api/mocks", async (c) => send(c, await listMocksFlow({ project: c.req.query("project") })));
|
|
2263
|
+
app.post("/api/mocks", async (c) => send(c, await publishFlow(await jsonBody(c), flowCtx(c))));
|
|
2264
|
+
// A session-scoped public read may only address a mock by its unguessable id.
|
|
2265
|
+
const guardPublicMock = async (c) => {
|
|
2266
|
+
if (!isUnauthenticatedSessionRead(c))
|
|
2267
|
+
return null;
|
|
2268
|
+
return (await store.getMock(c.req.param("id") ?? ""))
|
|
2269
|
+
? null
|
|
2270
|
+
: c.json({ error: "mock not found" }, 404);
|
|
2271
|
+
};
|
|
2272
|
+
app.get("/api/mocks/:id", async (c) => {
|
|
2273
|
+
const denied = await guardPublicMock(c);
|
|
2274
|
+
if (denied)
|
|
2275
|
+
return denied;
|
|
2276
|
+
return send(c, await getMockFlow(c.req.param("id"), {
|
|
2277
|
+
project: c.req.query("project"),
|
|
2278
|
+
body: flag(c, "body"),
|
|
2279
|
+
history: flag(c, "history"),
|
|
2280
|
+
}));
|
|
2281
|
+
});
|
|
2282
|
+
app.get("/api/mocks/:id/export", async (c) => {
|
|
2283
|
+
const denied = await guardPublicMock(c);
|
|
2284
|
+
if (denied)
|
|
2285
|
+
return denied;
|
|
2286
|
+
return send(c, await exportFlow(c.req.param("id"), {
|
|
2287
|
+
project: c.req.query("project"),
|
|
2288
|
+
state: c.req.query("state"),
|
|
2289
|
+
variant: c.req.query("variant"),
|
|
2290
|
+
}, flowCtx(c)));
|
|
2291
|
+
});
|
|
2292
|
+
app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
|
|
2293
|
+
app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2294
|
+
app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
|
|
2295
|
+
app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2296
|
+
app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
|
|
2297
|
+
app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2298
|
+
app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
|
|
2299
|
+
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") })));
|
|
2302
|
+
// --- comments ---
|
|
2303
|
+
app.post("/api/comments", async (c) => send(c, await commentFlow(await jsonBody(c), flowCtx(c))));
|
|
2304
|
+
app.delete("/api/comments/:id", async (c) => {
|
|
2305
|
+
const comment = await store.removeComment(c.req.param("id"));
|
|
2306
|
+
if (!comment)
|
|
2307
|
+
return c.json({ error: "comment not found" }, 404);
|
|
2308
|
+
bus.broadcast({ type: "comment-deleted", id: comment.id, sessionId: comment.sessionId });
|
|
2309
|
+
return c.json({ ok: true });
|
|
2310
|
+
});
|
|
2311
|
+
// The viewer's update notice: running version vs latest published release.
|
|
2312
|
+
app.get("/api/version", async (c) => {
|
|
2313
|
+
if (!version)
|
|
2314
|
+
return c.json({ current: null, latest: null, updateAvailable: false });
|
|
2315
|
+
const latest = await latestRelease();
|
|
2316
|
+
const updateAvailable = latest !== null && versionGt(latest.version, version);
|
|
2317
|
+
return c.json({
|
|
2318
|
+
current: version,
|
|
2319
|
+
latest: latest?.version ?? null,
|
|
2320
|
+
updateAvailable,
|
|
2321
|
+
upgradeCommand: updateAvailable ? (upgradeCommand ?? null) : null,
|
|
2322
|
+
notes: updateAvailable ? (latest?.notes ?? null) : null,
|
|
2323
|
+
});
|
|
2324
|
+
});
|
|
2325
|
+
// Long-poll friendly: ?wait=N holds the request open up to N seconds until
|
|
2326
|
+
// a matching comment arrives. Plumbing for `mockpit watch` and the viewer;
|
|
2327
|
+
// agents read with GET /api/feedback, which never blocks.
|
|
2328
|
+
// A wait counts against the connection cap (it pins a socket just like SSE);
|
|
2329
|
+
// an instant ?wait=0 read does not.
|
|
2330
|
+
app.get("/api/comments", async (c) => {
|
|
2331
|
+
const sessionId = c.req.query("session");
|
|
2332
|
+
const mockId = c.req.query("mock");
|
|
2333
|
+
const postId = c.req.query("post");
|
|
2334
|
+
if (isUnauthenticatedSessionRead(c)) {
|
|
2335
|
+
if (!sessionId && !mockId && !postId) {
|
|
2336
|
+
return c.json({ error: "session, mock or post required" }, 401);
|
|
2337
|
+
}
|
|
2338
|
+
if (sessionId && !(await store.getSession(sessionId))) {
|
|
2339
|
+
return c.json({ error: "session not found" }, 404);
|
|
2340
|
+
}
|
|
2341
|
+
if (mockId && !(await store.getMock(mockId)))
|
|
2342
|
+
return c.json({ error: "mock not found" }, 404);
|
|
2343
|
+
if (postId && !(await store.getPost(postId)))
|
|
2344
|
+
return c.json({ error: "post not found" }, 404);
|
|
2345
|
+
}
|
|
2346
|
+
const waitSeconds = Number(c.req.query("wait") ?? 0) || 0;
|
|
2347
|
+
const author = c.req.query("author");
|
|
2348
|
+
const after = c.req.query("after");
|
|
2349
|
+
const query = {
|
|
2350
|
+
sessionId,
|
|
2351
|
+
mockId,
|
|
2352
|
+
postId,
|
|
2353
|
+
author,
|
|
2354
|
+
afterSeq: after ? Number(after) : undefined,
|
|
2355
|
+
waitSeconds,
|
|
2356
|
+
};
|
|
2357
|
+
// An `author=user` read (or any wait) is the agent listening; anything else
|
|
2358
|
+
// is the viewer reading a thread, which gets per-comment delivery state.
|
|
2359
|
+
// Only an authenticated caller can be the agent: on a public-read workspace
|
|
2360
|
+
// an anonymous reader must never advance the feedback cursor, or the agent
|
|
2361
|
+
// would silently lose the comments that reader "took".
|
|
2362
|
+
const isAgentRead = isAuthenticated(c) && (author === "user" || waitSeconds > 0);
|
|
2363
|
+
query.agent = isAgentRead;
|
|
2364
|
+
const respond = async (signal) => {
|
|
2365
|
+
if (isAgentRead)
|
|
2366
|
+
return send(c, await listenFlow(query, signal));
|
|
2367
|
+
const result = await waitForComments(query, signal);
|
|
2368
|
+
return c.json({ ...result, comments: await withDelivered(result.comments) });
|
|
2369
|
+
};
|
|
2370
|
+
if (waitSeconds <= 0)
|
|
2371
|
+
return respond();
|
|
2372
|
+
if (!acquireHold())
|
|
2373
|
+
return c.json({ error: "too many concurrent connections" }, 503);
|
|
2374
|
+
const release = makeRelease();
|
|
2375
|
+
// If the client disconnects mid-wait, release the slot promptly.
|
|
2376
|
+
c.req.raw.signal.addEventListener("abort", release, { once: true });
|
|
2377
|
+
try {
|
|
2378
|
+
return await respond(c.req.raw.signal);
|
|
2379
|
+
}
|
|
2380
|
+
finally {
|
|
2381
|
+
release();
|
|
2382
|
+
}
|
|
2383
|
+
});
|
|
2384
|
+
// Content-hashed bridge/token assets the surface documents reference, so
|
|
2385
|
+
// every surface doesn't re-inline the same ~11 KB. Without this route the
|
|
2386
|
+
// in-frame bridge never loads and iframes never report their height.
|
|
2387
|
+
app.get("/asset/:file", (c) => {
|
|
2388
|
+
const asset = staticAsset(`/asset/${c.req.param("file")}`);
|
|
2389
|
+
if (!asset)
|
|
2390
|
+
return c.text("Not found", 404);
|
|
2391
|
+
c.header("Content-Type", asset.contentType);
|
|
2392
|
+
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2393
|
+
return c.body(asset.body);
|
|
2394
|
+
});
|
|
2395
|
+
app.get("/guide", (c) => c.text(withOrigin(topics.html ?? "", c)));
|
|
2396
|
+
app.get("/setup", (c) => c.text(withOrigin(setupText, c)));
|
|
2397
|
+
// The brief without `?topic=`, one reference topic with it. Unknown topics
|
|
2398
|
+
// are a 400 that names the real ones, so an agent can correct itself.
|
|
2399
|
+
app.get("/agent-howto", async (c) => {
|
|
2400
|
+
const topic = c.req.query("topic");
|
|
2401
|
+
if (topic === undefined)
|
|
2402
|
+
return c.text(withOrigin(await briefFor(c.req.query("project")), c));
|
|
2403
|
+
if (!isGuideTopic(topic))
|
|
2404
|
+
return c.json({ error: unknownTopicMessage(topic), topics: GUIDE_TOPICS }, 400);
|
|
2405
|
+
return c.text(withOrigin(topics[topic] ?? "", c));
|
|
2406
|
+
});
|
|
2407
|
+
// Opt-in html kits available on this workspace (id, label, summary, classes) —
|
|
2408
|
+
// for discovery (`mockpit kits`); the CSS/JS payloads are server-only. With
|
|
2409
|
+
// `?project=`, that project's own kits follow the bundled ones.
|
|
2410
|
+
app.get("/api/kits", async (c) => {
|
|
2411
|
+
const project = c.req.query("project");
|
|
2412
|
+
return c.json(kitSummaries(project ? ((await designFor(project))?.projectKits ?? []) : []));
|
|
2413
|
+
});
|
|
2414
|
+
// --- theme: one palette, so the workspace setting is just its mode ---
|
|
2415
|
+
app.get("/api/theme", async (c) => c.json({ mode: await workspaceMode() }));
|
|
2416
|
+
app.put("/api/theme", async (c) => {
|
|
2417
|
+
const body = await c.req.json().catch(() => null);
|
|
2418
|
+
const mode = body?.mode;
|
|
2419
|
+
if (!isMode(mode))
|
|
2420
|
+
return c.json({ error: 'mode must be "dark" or "light"' }, 400);
|
|
2421
|
+
await store.setSetting("mode", mode);
|
|
2422
|
+
bus.broadcast({ type: "theme-changed", mode });
|
|
2423
|
+
return c.json({ mode });
|
|
2424
|
+
});
|
|
2425
|
+
// The project's design system, imported by `mockpit init` and injected into
|
|
2426
|
+
// every html surface of the project (see renderHtmlPage).
|
|
2427
|
+
app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
|
|
2428
|
+
app.put("/api/projects/:name/design", async (c) => {
|
|
2429
|
+
const body = await c.req.json().catch(() => null);
|
|
2430
|
+
if (!body || typeof body !== "object")
|
|
2431
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
2432
|
+
const project = c.req.param("name");
|
|
2433
|
+
// `mockpit init` re-detects the repo and PUTs without iconSets; that must
|
|
2434
|
+
// not uninstall what `mockpit icons add` installed.
|
|
2435
|
+
const previous = await designFor(project);
|
|
2436
|
+
let iconSets = previous?.iconSets ?? [];
|
|
2437
|
+
if (body.iconSets !== undefined) {
|
|
2438
|
+
const checked = await checkIconSets(body.iconSets);
|
|
2439
|
+
if (typeof checked === "string")
|
|
2440
|
+
return c.json({ error: checked }, 400);
|
|
2441
|
+
iconSets = checked;
|
|
2442
|
+
}
|
|
2443
|
+
// Same rule as iconSets: a PUT that omits projectKits keeps them.
|
|
2444
|
+
let projectKits = previous?.projectKits ?? [];
|
|
2445
|
+
if (body.projectKits !== undefined) {
|
|
2446
|
+
const checked = checkProjectKits(body.projectKits);
|
|
2447
|
+
if (typeof checked === "string")
|
|
2448
|
+
return c.json({ error: checked }, 400);
|
|
2449
|
+
projectKits = checked;
|
|
2450
|
+
}
|
|
2451
|
+
const kitIds = ["tailwind", "none", ...KIT_IDS, ...projectKits.map((k) => k.id)];
|
|
2452
|
+
const kit = body.kit === undefined || body.kit === null ? "none" : body.kit;
|
|
2453
|
+
if (typeof kit !== "string" || !kitIds.includes(kit))
|
|
2454
|
+
return c.json({ error: `unknown kit "${String(kit)}" — known: ${kitIds.join(", ")}` }, 400);
|
|
2455
|
+
const designFiles = checkDesignFiles(body.designFiles);
|
|
2456
|
+
if (typeof designFiles === "string")
|
|
2457
|
+
return c.json({ error: designFiles }, 400);
|
|
2458
|
+
const design = {
|
|
2459
|
+
detected: body.detected && typeof body.detected === "object"
|
|
2460
|
+
? {
|
|
2461
|
+
tailwind: body.detected.tailwind === true,
|
|
2462
|
+
shadcn: body.detected.shadcn === true,
|
|
2463
|
+
cssVars: Number(body.detected.cssVars) || 0,
|
|
2464
|
+
fonts: Array.isArray(body.detected.fonts)
|
|
2465
|
+
? body.detected.fonts.filter((f) => typeof f === "string").slice(0, 20)
|
|
2466
|
+
: [],
|
|
2467
|
+
}
|
|
2468
|
+
: null,
|
|
2469
|
+
palette: body.palette && typeof body.palette === "object" && body.palette.light && body.palette.dark
|
|
2470
|
+
? body.palette
|
|
2471
|
+
: null,
|
|
2472
|
+
kit,
|
|
2473
|
+
cssVars: typeof body.cssVars === "string" ? body.cssVars.slice(0, 64_000) : "",
|
|
2474
|
+
// A cut stylesheet would not compile, so an oversized one is dropped
|
|
2475
|
+
// whole and the frame falls back to `cssVars`.
|
|
2476
|
+
tailwindCss: typeof body.tailwindCss === "string" && body.tailwindCss.length <= 128_000
|
|
2477
|
+
? body.tailwindCss
|
|
2478
|
+
: "",
|
|
2479
|
+
strippedImports: Array.isArray(body.strippedImports)
|
|
2480
|
+
? body.strippedImports
|
|
2481
|
+
.filter((s) => typeof s === "string")
|
|
2482
|
+
.map((s) => s.slice(0, 200))
|
|
2483
|
+
.slice(0, 50)
|
|
2484
|
+
: [],
|
|
2485
|
+
iconSets,
|
|
2486
|
+
projectKits,
|
|
2487
|
+
designFiles,
|
|
2488
|
+
updatedAt: new Date().toISOString(),
|
|
2489
|
+
};
|
|
2490
|
+
return c.json(await saveDesign(project, design));
|
|
2491
|
+
});
|
|
2492
|
+
async function saveDesign(project, design) {
|
|
2493
|
+
await store.setSetting(`design:${project}`, JSON.stringify(design));
|
|
2494
|
+
// Every rendered surface bakes the design into its document string, so a
|
|
2495
|
+
// design change invalidates them all.
|
|
2496
|
+
clearRenderCache();
|
|
2497
|
+
bus.broadcast({ type: "theme-changed", mode: await workspaceMode() });
|
|
2498
|
+
return design;
|
|
2499
|
+
}
|
|
2500
|
+
const MAX_PROJECT_KITS = 16;
|
|
2501
|
+
function checkProjectKits(raw) {
|
|
2502
|
+
if (!Array.isArray(raw))
|
|
2503
|
+
return "projectKits must be an array";
|
|
2504
|
+
if (raw.length > MAX_PROJECT_KITS)
|
|
2505
|
+
return `at most ${MAX_PROJECT_KITS} project kits`;
|
|
2506
|
+
const kits = [];
|
|
2507
|
+
for (const item of raw) {
|
|
2508
|
+
const kit = checkProjectKit(item);
|
|
2509
|
+
if (typeof kit === "string")
|
|
2510
|
+
return kit;
|
|
2511
|
+
if (kits.some((k) => k.id === kit.id))
|
|
2512
|
+
return `kit "${kit.id}" is listed twice`;
|
|
2513
|
+
kits.push(kit);
|
|
2514
|
+
}
|
|
2515
|
+
return kits;
|
|
2516
|
+
}
|
|
2517
|
+
const emptyDesign = () => ({
|
|
2518
|
+
detected: null,
|
|
2519
|
+
palette: null,
|
|
2520
|
+
kit: "none",
|
|
2521
|
+
cssVars: "",
|
|
2522
|
+
tailwindCss: "",
|
|
2523
|
+
strippedImports: [],
|
|
2524
|
+
iconSets: [],
|
|
2525
|
+
projectKits: [],
|
|
2526
|
+
updatedAt: new Date().toISOString(),
|
|
2527
|
+
});
|
|
2528
|
+
// Add or replace one project kit (`mockpit kit add`). A project that never ran
|
|
2529
|
+
// init gets a design holding just the kit.
|
|
2530
|
+
app.put("/api/projects/:name/kits/:id", async (c) => {
|
|
2531
|
+
const body = await c.req.json().catch(() => null);
|
|
2532
|
+
if (!body || typeof body !== "object")
|
|
2533
|
+
return c.json({ error: "invalid JSON body" }, 400);
|
|
2534
|
+
const project = c.req.param("name");
|
|
2535
|
+
const kit = checkProjectKit({ ...body, id: c.req.param("id") });
|
|
2536
|
+
if (typeof kit === "string")
|
|
2537
|
+
return c.json({ error: kit }, 400);
|
|
2538
|
+
const design = (await designFor(project)) ?? emptyDesign();
|
|
2539
|
+
const others = design.projectKits.filter((k) => k.id !== kit.id);
|
|
2540
|
+
if (others.length >= MAX_PROJECT_KITS)
|
|
2541
|
+
return c.json({ error: `at most ${MAX_PROJECT_KITS} project kits` }, 400);
|
|
2542
|
+
const saved = await saveDesign(project, {
|
|
2543
|
+
...design,
|
|
2544
|
+
projectKits: [...others, kit],
|
|
2545
|
+
updatedAt: new Date().toISOString(),
|
|
2546
|
+
});
|
|
2547
|
+
return c.json(saved);
|
|
2548
|
+
});
|
|
2549
|
+
// Removing the project's default kit falls back to no kit rather than leaving
|
|
2550
|
+
// design.kit naming something that no longer resolves.
|
|
2551
|
+
app.delete("/api/projects/:name/kits/:id", async (c) => {
|
|
2552
|
+
const project = c.req.param("name");
|
|
2553
|
+
const id = c.req.param("id");
|
|
2554
|
+
const design = await designFor(project);
|
|
2555
|
+
if (!design?.projectKits.some((k) => k.id === id))
|
|
2556
|
+
return c.json({ error: `${project} has no kit "${id}"` }, 404);
|
|
2557
|
+
const saved = await saveDesign(project, {
|
|
2558
|
+
...design,
|
|
2559
|
+
kit: design.kit === id ? "none" : design.kit,
|
|
2560
|
+
projectKits: design.projectKits.filter((k) => k.id !== id),
|
|
2561
|
+
updatedAt: new Date().toISOString(),
|
|
2562
|
+
});
|
|
2563
|
+
return c.json(saved);
|
|
2564
|
+
});
|
|
2565
|
+
// Every set an html surface of this project can name, installed first.
|
|
2566
|
+
app.get("/api/projects/:name/icons", async (c) => {
|
|
2567
|
+
const sets = await iconSetsFor(await designFor(c.req.param("name")));
|
|
2568
|
+
return c.json({
|
|
2569
|
+
sets: sets.map((s) => ({ prefix: s.prefix, count: iconCount(s.set), source: s.source })),
|
|
2570
|
+
});
|
|
2571
|
+
});
|
|
2572
|
+
// The repo's design files as init read them: every map and list is cut to
|
|
2573
|
+
// the reader's own caps, then the whole is refused past 32k so one project
|
|
2574
|
+
// cannot bloat every brief and every settings read.
|
|
2575
|
+
const DESIGN_FILES_MAX = 32_000;
|
|
2576
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
2577
|
+
function checkDesignFiles(raw) {
|
|
2578
|
+
if (raw === undefined || raw === null)
|
|
2579
|
+
return null;
|
|
2580
|
+
if (!isRecord(raw))
|
|
2581
|
+
return "designFiles must be an object";
|
|
2582
|
+
const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
2583
|
+
const strs = (v, n, max) => Array.isArray(v)
|
|
2584
|
+
? v
|
|
2585
|
+
.filter((s) => typeof s === "string")
|
|
2586
|
+
.map((s) => s.slice(0, max))
|
|
2587
|
+
.slice(0, n)
|
|
2588
|
+
: [];
|
|
2589
|
+
const flat = (v, n) => Object.fromEntries(Object.entries(isRecord(v) ? v : {})
|
|
2590
|
+
.filter((e) => typeof e[1] === "string")
|
|
2591
|
+
.slice(0, n)
|
|
2592
|
+
.map(([k, x]) => [k.slice(0, 80), x.slice(0, 120)]));
|
|
2593
|
+
const nested = (v, n) => Object.fromEntries(Object.entries(isRecord(v) ? v : {})
|
|
2594
|
+
.filter(([, x]) => isRecord(x))
|
|
2595
|
+
.slice(0, n)
|
|
2596
|
+
.map(([k, x]) => [k.slice(0, 80), flat(x, 12)]));
|
|
2597
|
+
const out = {};
|
|
2598
|
+
const md = raw.designMd;
|
|
2599
|
+
if (isRecord(md)) {
|
|
2600
|
+
out.designMd = {
|
|
2601
|
+
name: str(md.name, 120),
|
|
2602
|
+
colors: flat(md.colors, 100),
|
|
2603
|
+
typography: nested(md.typography, 40),
|
|
2604
|
+
rounded: flat(md.rounded, 30),
|
|
2605
|
+
spacing: flat(md.spacing, 30),
|
|
2606
|
+
components: nested(md.components, 60),
|
|
2607
|
+
headings: strs(md.headings, 20, 120),
|
|
2608
|
+
dos: str(md.dos, 1_500),
|
|
2609
|
+
};
|
|
2610
|
+
}
|
|
2611
|
+
const tokens = raw.tokens;
|
|
2612
|
+
if (isRecord(tokens)) {
|
|
2613
|
+
out.tokens = {
|
|
2614
|
+
files: strs(tokens.files, 20, 200),
|
|
2615
|
+
count: Math.max(0, Math.floor(Number(tokens.count) || 0)),
|
|
2616
|
+
cssVars: tokens.cssVars === true,
|
|
2617
|
+
values: flat(tokens.values, 400),
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
const shadcn = raw.shadcn;
|
|
2621
|
+
if (isRecord(shadcn)) {
|
|
2622
|
+
out.shadcn = {
|
|
2623
|
+
style: str(shadcn.style, 60),
|
|
2624
|
+
baseColor: str(shadcn.baseColor, 60),
|
|
2625
|
+
iconLibrary: str(shadcn.iconLibrary, 60),
|
|
2626
|
+
components: strs(shadcn.components, 80, 60),
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
const size = JSON.stringify(out).length;
|
|
2630
|
+
if (size > DESIGN_FILES_MAX) {
|
|
2631
|
+
return `designFiles is ${size} characters serialized; at most ${DESIGN_FILES_MAX}`;
|
|
2632
|
+
}
|
|
2633
|
+
return Object.keys(out).length ? out : null;
|
|
2634
|
+
}
|
|
2635
|
+
// The installed sets a design PUT names: each must be an uploaded Iconify
|
|
2636
|
+
// JSON set whose prefix matches, so a typo fails here rather than as blank
|
|
2637
|
+
// icons on every render. The count is read off the set, not trusted.
|
|
2638
|
+
const MAX_ICON_SETS = 32;
|
|
2639
|
+
async function checkIconSets(raw) {
|
|
2640
|
+
if (!Array.isArray(raw))
|
|
2641
|
+
return "iconSets must be an array of {prefix, assetId}";
|
|
2642
|
+
if (raw.length > MAX_ICON_SETS)
|
|
2643
|
+
return `at most ${MAX_ICON_SETS} icon sets`;
|
|
2644
|
+
const byPrefix = new Map();
|
|
2645
|
+
for (const entry of raw) {
|
|
2646
|
+
const prefix = entry?.prefix;
|
|
2647
|
+
const assetId = entry?.assetId;
|
|
2648
|
+
if (!isIconName(prefix) || typeof assetId !== "string" || !assetId) {
|
|
2649
|
+
return "each icon set needs a prefix (lowercase, e.g. lucide) and an assetId";
|
|
2650
|
+
}
|
|
2651
|
+
const set = await installedIconSet(assetId);
|
|
2652
|
+
if (!set)
|
|
2653
|
+
return `icon set ${prefix}: asset ${assetId} is not an Iconify JSON set`;
|
|
2654
|
+
if (set.prefix !== prefix) {
|
|
2655
|
+
return `icon set ${prefix}: asset ${assetId} holds the ${set.prefix} set`;
|
|
2656
|
+
}
|
|
2657
|
+
byPrefix.set(prefix, { prefix, assetId, count: iconCount(set) });
|
|
2658
|
+
}
|
|
2659
|
+
return [...byPrefix.values()];
|
|
2660
|
+
}
|
|
2661
|
+
// --- push and webhooks ---
|
|
2662
|
+
app.get("/api/push/vapid", async (c) => c.json({ publicKey: (await vapidKeys(store)).publicKey }));
|
|
2663
|
+
app.post("/api/push/subscribe", async (c) => {
|
|
2664
|
+
const body = await c.req.json().catch(() => null);
|
|
2665
|
+
const subscription = body?.subscription ?? body;
|
|
2666
|
+
if (!isPushSubscription(subscription)) {
|
|
2667
|
+
return c.json({ error: "invalid push subscription" }, 400);
|
|
2668
|
+
}
|
|
2669
|
+
await addSubscription(store, subscription);
|
|
2670
|
+
return c.body(null, 204);
|
|
2671
|
+
});
|
|
2672
|
+
app.get("/api/hooks", async (c) => c.json(await listHooks(store)));
|
|
2673
|
+
app.post("/api/hooks", async (c) => {
|
|
2674
|
+
const body = await c.req.json().catch(() => null);
|
|
2675
|
+
const url = typeof body?.url === "string" ? body.url : "";
|
|
2676
|
+
if (!/^https?:\/\//.test(url))
|
|
2677
|
+
return c.json({ error: 'body must include an http(s) "url"' }, 400);
|
|
2678
|
+
const events = (Array.isArray(body.events) ? body.events : []).filter((e) => e === "ask" || e === "publish" || e === "decision");
|
|
2679
|
+
if (events.length === 0) {
|
|
2680
|
+
return c.json({ error: '"events" must list "ask", "publish" and/or "decision"' }, 400);
|
|
2681
|
+
}
|
|
2682
|
+
const hook = { id: newId(), url, events };
|
|
2683
|
+
await addHook(store, hook);
|
|
2684
|
+
return c.json({ id: hook.id }, 201);
|
|
2685
|
+
});
|
|
2686
|
+
app.delete("/api/hooks/:id", async (c) => {
|
|
2687
|
+
if (!(await removeHook(store, c.req.param("id")))) {
|
|
2688
|
+
return c.json({ error: "hook not found" }, 404);
|
|
2689
|
+
}
|
|
2690
|
+
return c.json({ ok: true });
|
|
2691
|
+
});
|
|
2692
|
+
// --- rendering ---
|
|
2693
|
+
// Serves one surface of a post as a themed, sandboxed document. The viewer
|
|
2694
|
+
// points an iframe here for every surface kind that becomes HTML — html surfaces
|
|
2695
|
+
// (author markup) and the rich kinds (markdown/code/diff/terminal rendered
|
|
2696
|
+
// server-side; mermaid as a self-rendering CDN doc). Image/json surfaces
|
|
2697
|
+
// are data the viewer renders natively (text nodes / <img> / JSX), so they
|
|
2698
|
+
// never reach here.
|
|
2699
|
+
// Everything a rendered document depends on. The resolved version makes a
|
|
2700
|
+
// version's content immutable, and `origin` is in the key because it is baked
|
|
2701
|
+
// into the document (CSP, <base>, asset URLs) — a pre-warm triggered by an
|
|
2702
|
+
// agent publishing over one origin must never be served to a viewer on another.
|
|
2703
|
+
// `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
|
|
2704
|
+
// spellings of the same values share an entry.
|
|
2705
|
+
const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}`;
|
|
2706
|
+
// The document itself. Shared by the GET below and the publish-time pre-warm,
|
|
2707
|
+
// so both produce byte-identical output for one key.
|
|
2708
|
+
async function buildSurfaceDoc(args) {
|
|
2709
|
+
const { surface, mode, origin } = args;
|
|
2710
|
+
const theme = themeById(DEFAULT_THEME_ID);
|
|
2711
|
+
const themeId = theme.id;
|
|
2712
|
+
if (surface.kind === "html") {
|
|
2713
|
+
// A page mock composes published components by reference; the tags
|
|
2714
|
+
// are expanded here, server-side, so the whole page is still ONE
|
|
2715
|
+
// sandboxed document rather than nested frames.
|
|
2716
|
+
const html = args.mock?.kind === "page"
|
|
2717
|
+
? await expandPageHtml(args.mock.project, args.post, surface.html)
|
|
2718
|
+
: surface.html;
|
|
2719
|
+
return renderHtmlPage({
|
|
2720
|
+
title: args.title,
|
|
2721
|
+
html,
|
|
2722
|
+
origin,
|
|
2723
|
+
theme,
|
|
2724
|
+
mode,
|
|
2725
|
+
kits: surface.kits,
|
|
2726
|
+
design: args.design,
|
|
2727
|
+
version: args.version,
|
|
2728
|
+
knobs: args.knobs,
|
|
2729
|
+
resolveIcon: mayHaveIcons(html) ? (await iconResolverFor(args.design)).resolve : undefined,
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
if (surface.kind === "mermaid") {
|
|
2733
|
+
return renderMermaidPage({ mermaid: surface.mermaid, origin, theme, mode });
|
|
2734
|
+
}
|
|
2735
|
+
// Load the rich renderers on first use, not at module load. richRender.ts
|
|
2736
|
+
// pulls in shiki, @pierre/diffs, markdown-it and ansi_up — measured at ~48 MB
|
|
2737
|
+
// of RSS and ~240 ms of import time (`npm run bench:all`, process suite), which
|
|
2738
|
+
// every server paid at boot whether or not it ever rendered a rich surface.
|
|
2739
|
+
// Deferring it past the html and mermaid branches above means an html-only
|
|
2740
|
+
// workspace never loads any of it.
|
|
2741
|
+
//
|
|
2742
|
+
// The runtime's module cache makes every later call cheap, so there's no memo
|
|
2743
|
+
// here to keep in sync. On the Worker the module is already inside the
|
|
2744
|
+
// deployed bundle — the import defers evaluating it, not fetching it — so this
|
|
2745
|
+
// needs no network at runtime. test/workerIntegration covers that on real
|
|
2746
|
+
// workerd, because a dynamic import resolving differently there is exactly the
|
|
2747
|
+
// way this optimization could break in production and nowhere else.
|
|
2748
|
+
const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
|
|
2749
|
+
const rendered = surface.kind === "markdown"
|
|
2750
|
+
? await renderMarkdown(surface, { theme: themeId, mode })
|
|
2751
|
+
: surface.kind === "code"
|
|
2752
|
+
? await renderCode(surface, { theme: themeId, mode })
|
|
2753
|
+
: surface.kind === "terminal"
|
|
2754
|
+
? renderTerminal(surface)
|
|
2755
|
+
: await renderDiff(surface, { theme: themeId, mode }).catch((e) => ({
|
|
2756
|
+
body: `<div class="rich-error">Couldn’t render diff — ${escapeHtml(e instanceof Error ? e.message : "render error")}</div>`,
|
|
2757
|
+
css: `.rich-error{color:var(--danger);font:13px/1.5 ui-monospace,monospace;padding:8px 12px;}`,
|
|
2758
|
+
}));
|
|
2759
|
+
return renderSandboxedPart({ body: rendered.body, css: rendered.css, origin, theme, mode });
|
|
2760
|
+
}
|
|
2761
|
+
// Render the current version of a post into the cache, off the write path.
|
|
2762
|
+
// The first thing the viewer does after a publish is load an iframe per
|
|
2763
|
+
// surface; warming both schemes of the workspace theme turns that cold render
|
|
2764
|
+
// (up to a second for a large code surface) into a cache hit. Detached on
|
|
2765
|
+
// purpose: a render failure must never fail — or delay — the write. (The DO
|
|
2766
|
+
// stays alive for the duration; there is no ExecutionContext to hand this to.)
|
|
2767
|
+
const PREWARM_SURFACE_LIMIT = 4;
|
|
2768
|
+
function warmPost(post, mock) {
|
|
2769
|
+
const origin = lastOrigin;
|
|
2770
|
+
if (!origin)
|
|
2771
|
+
return;
|
|
2772
|
+
void (async () => {
|
|
2773
|
+
const design = await designFor(mock.project);
|
|
2774
|
+
let warmed = 0;
|
|
2775
|
+
for (const [idx, surface] of post.surfaces.entries()) {
|
|
2776
|
+
if (warmed >= PREWARM_SURFACE_LIMIT)
|
|
2777
|
+
break;
|
|
2778
|
+
if (!isSandboxedSurfaceKind(surface.kind))
|
|
2779
|
+
continue;
|
|
2780
|
+
warmed++;
|
|
2781
|
+
for (const mode of ["light", "dark"]) {
|
|
2782
|
+
const key = renderKey({ postId: post.id, idx, version: post.version, mode, origin });
|
|
2783
|
+
if (renderCache.has(key))
|
|
2784
|
+
continue;
|
|
2785
|
+
await cachedRender(key, () => buildSurfaceDoc({
|
|
2786
|
+
post,
|
|
2787
|
+
mock,
|
|
2788
|
+
surface,
|
|
2789
|
+
title: post.title,
|
|
2790
|
+
version: post.version,
|
|
2791
|
+
mode,
|
|
2792
|
+
origin,
|
|
2793
|
+
design,
|
|
2794
|
+
}));
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
})().catch((err) => console.warn("[mockpit] render pre-warm failed", err));
|
|
2798
|
+
}
|
|
2799
|
+
// `?k=` arrives URL-encoded; past this it is not a set of knob values.
|
|
2800
|
+
const MAX_KNOB_QUERY = 8 * 1024;
|
|
2801
|
+
const renderPostPage = async (c) => {
|
|
2802
|
+
const surfaceParam = c.req.query("surface") ?? "0";
|
|
2803
|
+
const ver = c.req.query("ver");
|
|
2804
|
+
const modeParam = c.req.query("mode");
|
|
2805
|
+
const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
|
|
2806
|
+
const kParam = c.req.query("k");
|
|
2807
|
+
const origin = new URL(c.req.url).origin;
|
|
2808
|
+
// Cache-first. A hit needs nothing from the post row, and the row is the
|
|
2809
|
+
// expensive part of this route (surfaces + every retained version). Only a
|
|
2810
|
+
// version-pinned request can take this path: without `ver` the current
|
|
2811
|
+
// version — and so the key — is unknown until the row is read. A `?k=`
|
|
2812
|
+
// request never does: its values are checked against the mock's knobs,
|
|
2813
|
+
// which can change under a pinned version.
|
|
2814
|
+
const pinned = Number(ver);
|
|
2815
|
+
if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined) {
|
|
2816
|
+
const hit = renderCacheHit(renderKey({
|
|
2817
|
+
postId: c.req.param("id"),
|
|
2818
|
+
idx: Number(surfaceParam),
|
|
2819
|
+
version: pinned,
|
|
2820
|
+
mode,
|
|
2821
|
+
origin,
|
|
2822
|
+
}));
|
|
2823
|
+
if (hit !== undefined) {
|
|
2824
|
+
surfaceDocHeaders(c, true);
|
|
2825
|
+
return c.html(hit);
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
const post = await store.getPost(c.req.param("id"));
|
|
2829
|
+
if (!post)
|
|
2830
|
+
return c.text("Post not found", 404);
|
|
2831
|
+
const mock = await store.getMock(post.mock);
|
|
2832
|
+
let title = post.title;
|
|
2833
|
+
let surfaces = post.surfaces;
|
|
2834
|
+
let version = post.version;
|
|
2835
|
+
if (ver && Number(ver) !== post.version) {
|
|
2836
|
+
const old = post.history.find((h) => h.version === Number(ver));
|
|
2837
|
+
if (!old)
|
|
2838
|
+
return c.text(`Version ${ver} not available`, 404);
|
|
2839
|
+
title = old.title;
|
|
2840
|
+
surfaces = old.surfaces;
|
|
2841
|
+
version = old.version;
|
|
2842
|
+
}
|
|
2843
|
+
const idx = Number(surfaceParam ?? 0);
|
|
2844
|
+
const surface = surfaces[idx];
|
|
2845
|
+
// Only the kinds that become HTML are served here. Image/json render
|
|
2846
|
+
// natively in the viewer and must not be reachable as a document.
|
|
2847
|
+
if (!surface || !isSandboxedSurfaceKind(surface.kind)) {
|
|
2848
|
+
return c.text("No renderable surface at that index", 404);
|
|
2849
|
+
}
|
|
2850
|
+
// Knob values are agent/user data: only values the mock (or this variant's
|
|
2851
|
+
// per-part overrides) declares, within their schema, ever reach the document.
|
|
2852
|
+
let knobs;
|
|
2853
|
+
let knobsKey;
|
|
2854
|
+
if (kParam !== undefined) {
|
|
2855
|
+
const bad = (error) => {
|
|
2856
|
+
surfaceDocHeaders(c, false);
|
|
2857
|
+
return c.text(error, 400);
|
|
2858
|
+
};
|
|
2859
|
+
if (kParam.length > MAX_KNOB_QUERY)
|
|
2860
|
+
return bad("k is too long");
|
|
2861
|
+
let raw;
|
|
2862
|
+
try {
|
|
2863
|
+
raw = JSON.parse(kParam);
|
|
2864
|
+
}
|
|
2865
|
+
catch {
|
|
2866
|
+
return bad("k must be URL-encoded JSON of {path: value}");
|
|
2867
|
+
}
|
|
2868
|
+
const checked = checkKnobValues(raw, mock ?? { knobs: {} }, [post], "k");
|
|
2869
|
+
if (!checked.ok)
|
|
2870
|
+
return bad(checked.error);
|
|
2871
|
+
const paths = Object.keys(checked.value).sort();
|
|
2872
|
+
if (paths.length > 0 && surface.kind === "html") {
|
|
2873
|
+
knobs = Object.fromEntries(paths.map((p) => [p, checked.value[p]]));
|
|
2874
|
+
knobsKey = JSON.stringify(knobs);
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
// Every input is in the URL (the palette is fixed, the mode is a query
|
|
2878
|
+
// param), so a version-pinned document never changes.
|
|
2879
|
+
surfaceDocHeaders(c, ver != null);
|
|
2880
|
+
// Cache the finished document under the same key the pre-warm uses; the
|
|
2881
|
+
// resolved `version` makes it immutable, so a hit is always correct.
|
|
2882
|
+
// A page's slots are pinned at publish, so they are a function of
|
|
2883
|
+
// (id, version) too and need no separate key component.
|
|
2884
|
+
const cacheKey = renderKey({ postId: post.id, idx, version, mode, origin, knobs: knobsKey });
|
|
2885
|
+
const design = mock ? await designFor(mock.project) : null;
|
|
2886
|
+
const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({ post, mock, surface, title, version, mode, origin, design, knobs }));
|
|
2887
|
+
return c.html(doc);
|
|
2888
|
+
};
|
|
2889
|
+
app.get("/s/:id", renderPostPage);
|
|
2890
|
+
// --- assets (agent-uploaded images and files) ---
|
|
2891
|
+
// Accepts raw bytes (the asset's own Content-Type, metadata via query) or a
|
|
2892
|
+
// JSON envelope { data: base64, contentType, ... } — so curl --data-binary
|
|
2893
|
+
// and a JSON client both work, and MCP can ride base64. The body is read once
|
|
2894
|
+
// and only treated as an envelope when it is application/json carrying a
|
|
2895
|
+
// base64 `data` string; a raw JSON asset (no top-level `data`) stays raw.
|
|
2896
|
+
app.post("/api/assets", limitAssetBody, async (c) => {
|
|
2897
|
+
const mime = (c.req.header("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
2898
|
+
// limitAssetBody has already bounded the body to MAX_ASSET_BYTES, so this
|
|
2899
|
+
// read is safe. The post-decode cap in uploadAsset still applies (a base64
|
|
2900
|
+
// envelope decodes to ~3/4), enforcing the true asset limit on the bytes.
|
|
2901
|
+
const buf = new Uint8Array(await c.req.arrayBuffer());
|
|
2902
|
+
let envelope = null;
|
|
2903
|
+
if (mime === "application/json") {
|
|
2904
|
+
try {
|
|
2905
|
+
const j = JSON.parse(new TextDecoder().decode(buf));
|
|
2906
|
+
if (j && typeof j.data === "string")
|
|
2907
|
+
envelope = j;
|
|
2908
|
+
}
|
|
2909
|
+
catch {
|
|
2910
|
+
// not an envelope — fall through to the raw path
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
const kindQ = c.req.query("kind");
|
|
2914
|
+
let body;
|
|
2915
|
+
try {
|
|
2916
|
+
body = envelope
|
|
2917
|
+
? {
|
|
2918
|
+
data: decodeBase64(envelope.data),
|
|
2919
|
+
contentType: typeof envelope.contentType === "string"
|
|
2920
|
+
? envelope.contentType
|
|
2921
|
+
: "application/octet-stream",
|
|
2922
|
+
filename: typeof envelope.filename === "string" ? envelope.filename : undefined,
|
|
2923
|
+
kind: isAssetKind(envelope.kind) ? envelope.kind : undefined,
|
|
2924
|
+
session: typeof envelope.session === "string" ? envelope.session : undefined,
|
|
2925
|
+
agent: typeof envelope.agent === "string" ? envelope.agent : undefined,
|
|
2926
|
+
}
|
|
2927
|
+
: {
|
|
2928
|
+
data: buf,
|
|
2929
|
+
contentType: mime || "application/octet-stream",
|
|
2930
|
+
filename: c.req.query("filename"),
|
|
2931
|
+
kind: isAssetKind(kindQ) ? kindQ : undefined,
|
|
2932
|
+
session: c.req.query("session"),
|
|
2933
|
+
agent: c.req.query("agent"),
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
catch (err) {
|
|
2937
|
+
return c.json({ error: err instanceof Error ? err.message : "invalid upload" }, 400);
|
|
2938
|
+
}
|
|
2939
|
+
const result = await uploadAsset(body);
|
|
2940
|
+
if ("error" in result)
|
|
2941
|
+
return c.json({ error: result.error }, result.status);
|
|
2942
|
+
const origin = new URL(c.req.url).origin;
|
|
2943
|
+
const publicBasePath = requestBasePath(c.req.raw);
|
|
2944
|
+
const assetPath = encodeURIComponent(result.asset.id);
|
|
2945
|
+
return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
|
|
2946
|
+
});
|
|
2947
|
+
app.get("/a/:id", async (c) => {
|
|
2948
|
+
const id = c.req.param("id");
|
|
2949
|
+
let asset = await store.getAsset(id);
|
|
2950
|
+
// Optimistic uploads: an agent can derive an asset's URL from its content
|
|
2951
|
+
// hash and publish a surface referencing it before (or while) the bytes are
|
|
2952
|
+
// uploaded. Rather than 404 in that window, briefly wait for the bytes —
|
|
2953
|
+
// but only when a live surface actually points at this id, so unknown ids
|
|
2954
|
+
// still fail fast.
|
|
2955
|
+
if (!asset && (await store.isAssetReferenced(id))) {
|
|
2956
|
+
for (let i = 0; i < 20 && !asset; i++) {
|
|
2957
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
2958
|
+
asset = await store.getAsset(id);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
if (!asset)
|
|
2962
|
+
return c.text("Asset not found", 404);
|
|
2963
|
+
await store.touchAsset(asset.id);
|
|
2964
|
+
const { contentType, disposition } = assetServeHeaders(asset);
|
|
2965
|
+
c.header("Content-Type", contentType);
|
|
2966
|
+
c.header("Content-Disposition", disposition);
|
|
2967
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
2968
|
+
// Short revalidating cache (not immutable) so touch-on-serve keeps firing
|
|
2969
|
+
// and the LRU clock reflects real views; asset ids are unique anyway.
|
|
2970
|
+
c.header("Cache-Control", "private, max-age=60");
|
|
2971
|
+
return c.body(asset.data);
|
|
2972
|
+
});
|
|
2973
|
+
// --- live feed ---
|
|
2974
|
+
app.get("/api/events", async (c) => {
|
|
2975
|
+
const sessionId = c.req.query("session");
|
|
2976
|
+
const mockId = c.req.query("mock");
|
|
2977
|
+
if (isUnauthenticatedSessionRead(c)) {
|
|
2978
|
+
if (!sessionId && !mockId)
|
|
2979
|
+
return c.json({ error: "session or mock required" }, 401);
|
|
2980
|
+
if (sessionId && !(await store.getSession(sessionId))) {
|
|
2981
|
+
return c.json({ error: "session not found" }, 404);
|
|
2982
|
+
}
|
|
2983
|
+
if (mockId && !(await store.getMock(mockId))) {
|
|
2984
|
+
return c.json({ error: "mock not found" }, 404);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
if (!acquireHold())
|
|
2988
|
+
return c.json({ error: "too many concurrent connections" }, 503);
|
|
2989
|
+
viewerClients++;
|
|
2990
|
+
let attached = true;
|
|
2991
|
+
const releaseHold = makeRelease();
|
|
2992
|
+
const release = () => {
|
|
2993
|
+
if (attached)
|
|
2994
|
+
viewerClients--;
|
|
2995
|
+
attached = false;
|
|
2996
|
+
releaseHold();
|
|
2997
|
+
};
|
|
2998
|
+
// Safety net: if the client disconnects before the stream callback opens,
|
|
2999
|
+
// the request abort still releases the slot. close() below is guarded so a
|
|
3000
|
+
// later abort firing release again is a no-op.
|
|
3001
|
+
c.req.raw.signal.addEventListener("abort", release, { once: true });
|
|
3002
|
+
const eventSessionId = (event) => {
|
|
3003
|
+
if ("sessionId" in event)
|
|
3004
|
+
return event.sessionId;
|
|
3005
|
+
if (event.type === "session-created" ||
|
|
3006
|
+
event.type === "session-updated" ||
|
|
3007
|
+
event.type === "session-deleted") {
|
|
3008
|
+
return event.id;
|
|
3009
|
+
}
|
|
3010
|
+
return undefined;
|
|
3011
|
+
};
|
|
3012
|
+
const eventMockId = (event) => {
|
|
3013
|
+
if ("mockId" in event)
|
|
3014
|
+
return event.mockId;
|
|
3015
|
+
if (event.type === "mock-created" ||
|
|
3016
|
+
event.type === "mock-updated" ||
|
|
3017
|
+
event.type === "mock-deleted") {
|
|
3018
|
+
return event.id;
|
|
3019
|
+
}
|
|
3020
|
+
return undefined;
|
|
3021
|
+
};
|
|
3022
|
+
return streamSSE(c, async (stream) => {
|
|
3023
|
+
const queue = [];
|
|
3024
|
+
let wake = null;
|
|
3025
|
+
const unsubscribe = bus.subscribe((event) => {
|
|
3026
|
+
if (sessionId && eventSessionId(event) !== sessionId)
|
|
3027
|
+
return;
|
|
3028
|
+
if (mockId && eventMockId(event) !== mockId)
|
|
3029
|
+
return;
|
|
3030
|
+
queue.push(event);
|
|
3031
|
+
wake?.();
|
|
3032
|
+
});
|
|
3033
|
+
let open = true;
|
|
3034
|
+
let closed = false;
|
|
3035
|
+
const close = () => {
|
|
3036
|
+
if (closed)
|
|
3037
|
+
return;
|
|
3038
|
+
closed = true;
|
|
3039
|
+
open = false;
|
|
3040
|
+
unsubscribe();
|
|
3041
|
+
wake?.();
|
|
3042
|
+
release();
|
|
3043
|
+
};
|
|
3044
|
+
stream.onAbort(close);
|
|
3045
|
+
c.req.raw.signal.addEventListener("abort", close, { once: true });
|
|
3046
|
+
try {
|
|
3047
|
+
await stream.writeSSE({ event: "hello", data: "{}" });
|
|
3048
|
+
while (open) {
|
|
3049
|
+
while (queue.length > 0) {
|
|
3050
|
+
await stream.writeSSE({ data: JSON.stringify(queue.shift()) });
|
|
3051
|
+
}
|
|
3052
|
+
let pingTimer = null;
|
|
3053
|
+
await Promise.race([
|
|
3054
|
+
new Promise((resolve) => {
|
|
3055
|
+
wake = resolve;
|
|
3056
|
+
}),
|
|
3057
|
+
new Promise((resolve) => {
|
|
3058
|
+
pingTimer = setTimeout(resolve, 15000);
|
|
3059
|
+
}),
|
|
3060
|
+
]);
|
|
3061
|
+
wake = null;
|
|
3062
|
+
if (pingTimer)
|
|
3063
|
+
clearTimeout(pingTimer);
|
|
3064
|
+
if (open && queue.length === 0) {
|
|
3065
|
+
await stream.writeSSE({ event: "ping", data: "{}" });
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
finally {
|
|
3070
|
+
close();
|
|
3071
|
+
}
|
|
3072
|
+
});
|
|
3073
|
+
});
|
|
3074
|
+
// --- MCP over streamable HTTP (works locally and deployed) ---
|
|
3075
|
+
const mcpFlows = {
|
|
3076
|
+
publish: (body, ctx) => publishFlow(body, ctx),
|
|
3077
|
+
list: (query) => listMocksFlow(query),
|
|
3078
|
+
get: (ref, query) => getMockFlow(ref, query),
|
|
3079
|
+
ask: (ref, body, ctx) => askFlow(ref, body, ctx),
|
|
3080
|
+
exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
|
|
3081
|
+
say: (ref, body) => sayFlow(ref, body),
|
|
3082
|
+
feedback: (query) => feedbackFlow(query),
|
|
3083
|
+
};
|
|
3084
|
+
const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
|
|
3085
|
+
// --- run: an agent script over the same flows (server/run.ts) ---
|
|
3086
|
+
const runFlow = createRunFlow({
|
|
3087
|
+
store,
|
|
3088
|
+
executor,
|
|
3089
|
+
limits: { ...DEFAULT_RUN_LIMITS, ...runLimits },
|
|
3090
|
+
flows: mcpFlows,
|
|
3091
|
+
guide,
|
|
3092
|
+
uploadAsset,
|
|
3093
|
+
async createSession({ agent, project }) {
|
|
3094
|
+
const session = await store.createSession({ agent, project: resolveProject(project) });
|
|
3095
|
+
bus.broadcast({ type: "session-created", id: session.id });
|
|
3096
|
+
return session.id;
|
|
3097
|
+
},
|
|
3098
|
+
});
|
|
3099
|
+
// Never the viewer's verb: a script acts as the agent whatever origin sent it.
|
|
3100
|
+
app.post("/api/run", async (c) => send(c, await runFlow(await jsonBody(c), flowCtx(c, false))));
|
|
3101
|
+
registerMcp(app, {
|
|
3102
|
+
store,
|
|
3103
|
+
basePath: requestBasePath,
|
|
3104
|
+
flows: mcpFlows,
|
|
3105
|
+
run: runFlow,
|
|
3106
|
+
uploadAsset,
|
|
3107
|
+
// Every feature works on every tier: a remote MCP agent needs the project's
|
|
3108
|
+
// real palette and kit, and the topics, as much as a shell one does.
|
|
3109
|
+
guide,
|
|
3110
|
+
});
|
|
3111
|
+
return app;
|
|
3112
|
+
}
|