mockpit 1.2.0 → 2.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/README.md +69 -48
- package/bin/demoData.js +3 -3
- package/bin/mockpit.js +426 -51
- package/dist/mcp/server.js +45 -7
- package/dist/server/apiViews.d.ts +407 -21
- package/dist/server/apiViews.js +110 -11
- package/dist/server/app.d.ts +5 -1
- package/dist/server/app.js +1454 -192
- package/dist/server/codeWorker.js +2 -1
- package/dist/server/decisions.d.ts +23 -0
- package/dist/server/decisions.js +100 -0
- package/dist/server/designGuide.d.ts +6 -2
- package/dist/server/designGuide.js +96 -27
- package/dist/server/embeds.d.ts +26 -0
- package/dist/server/embeds.js +144 -0
- package/dist/server/eventLog.js +2 -1
- package/dist/server/feedbackBatch.d.ts +15 -4
- package/dist/server/feedbackBatch.js +31 -7
- package/dist/server/fills.d.ts +41 -0
- package/dist/server/fills.js +328 -0
- package/dist/server/guidance.d.ts +23 -0
- package/dist/server/guidance.js +71 -0
- package/dist/server/index.js +25 -2
- package/dist/server/knobs.d.ts +6 -2
- package/dist/server/knobs.js +11 -1
- package/dist/server/mcpHttp.d.ts +6 -0
- package/dist/server/mcpHttp.js +35 -0
- package/dist/server/mcpSpec.d.ts +14 -13
- package/dist/server/mcpSpec.js +133 -18
- package/dist/server/postSurfaces.js +6 -12
- package/dist/server/richRender.js +3 -1
- package/dist/server/run.js +10 -1
- package/dist/server/runApi.d.ts +3 -3
- package/dist/server/runApi.js +18 -13
- package/dist/server/sqlStore.d.ts +97 -5
- package/dist/server/sqlStore.js +367 -27
- package/dist/server/surfacePage.d.ts +6 -2
- package/dist/server/surfacePage.js +142 -10
- package/dist/server/themes.d.ts +2 -0
- package/dist/server/themes.js +23 -3
- package/dist/server/types.d.ts +107 -16
- package/dist/server/types.js +73 -10
- package/dist/server/viewerRequest.d.ts +2 -0
- package/dist/server/viewerRequest.js +14 -0
- package/extensions/mockpit.js +179 -16
- package/guide/packs/design-craft.md +35 -0
- package/guide/topics/asks.md +82 -31
- package/guide/topics/feedback.md +151 -43
- package/guide/topics/html.md +8 -10
- package/guide/topics/http.md +8 -4
- package/guide/topics/knobs.md +4 -3
- package/guide/topics/scripts.md +2 -2
- package/guide/topics/surfaces.md +45 -11
- package/package.json +3 -15
- package/skills/launch-video/SKILL.md +2 -2
- package/skills/mockpit/SKILL.md +23 -7
- package/viewer/dist/index.html +9 -8
- package/dist/server/slots.d.ts +0 -14
- package/dist/server/slots.js +0 -71
package/dist/server/app.js
CHANGED
|
@@ -5,27 +5,31 @@ import { streamSSE } from "hono/streaming";
|
|
|
5
5
|
import { ICON_SIZES, ICON_SVG, themeColor, webManifest } from "./appIcon.js";
|
|
6
6
|
import { ICON_PNG_BASE64 } from "./appIconPng.js";
|
|
7
7
|
import { decodeBase64 } from "./base64.js";
|
|
8
|
-
import { mockDetailView, mockSummaryView, sessionRowView, surfaceRef } from "./apiViews.js";
|
|
8
|
+
import { mockDetailView, treeNodeView, mockSummaryView, placeOf, sessionRowView, surfaceRef, } from "./apiViews.js";
|
|
9
9
|
import { askNudges } from "./askNudges.js";
|
|
10
10
|
import { EventBus } from "./events.js";
|
|
11
11
|
import { GUIDE_TOPICS, isGuideTopic, renderBriefGuide, unknownTopicMessage, } from "./designGuide.js";
|
|
12
12
|
import { buildFeedbackBatches } from "./feedbackBatch.js";
|
|
13
|
+
import { guidanceSections, MAX_RULES_CHARS, parseRulesFile, RULES_SETTING, USER_RULES_SETTING, } from "./guidance.js";
|
|
13
14
|
import { HANDOFF_NOTE, handoffReady, supersedes } from "./handoff.js";
|
|
14
15
|
import { bundledIconSets, expandIcons, iconCount, isIconName, mayHaveIcons, parseIconSet, resolverFor, } from "./icons.js";
|
|
15
16
|
import { checkProjectKit, KIT_IDS, kitSummaries } from "./kits.js";
|
|
17
|
+
import { chosenFills, decisionsFor, settledValues } from "./decisions.js";
|
|
18
|
+
import { composeFills, slotsInSurfaces, spliceFills } from "./fills.js";
|
|
16
19
|
import { checkKnobs, checkKnobValues, discreteChoices } from "./knobs.js";
|
|
17
20
|
import { diffParts, partsInSurfaces, spliceParts } from "./parts.js";
|
|
18
21
|
import { postToMarkdown } from "./postMarkdown.js";
|
|
19
22
|
import { addHook, addSubscription, isPushSubscription, listHooks, notify, removeHook, vapidKeys, } from "./push.js";
|
|
20
|
-
import {
|
|
23
|
+
import { embedsKey, expandHtml, hasEmbedTags, parseEmbedTags, } from "./embeds.js";
|
|
21
24
|
import { registerMcp } from "./mcpHttp.js";
|
|
22
25
|
import { createRunFlow, DEFAULT_RUN_LIMITS } from "./run.js";
|
|
23
26
|
import { logMiddleware, writeLog } from "./eventLog.js";
|
|
24
27
|
import { RUN_API } from "./runApi.js";
|
|
25
|
-
import { escapeHtml, renderHtmlPage, renderMermaidPage, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
28
|
+
import { escapeHtml, renderHtmlPage, renderMermaidPage, knobAttributes, renderSandboxedPart, STATIC_ASSET_PREFIX, staticAsset, } from "./surfacePage.js";
|
|
26
29
|
import { DEFAULT_MODE, DEFAULT_THEME_ID, isMode, themeById } from "./themes.js";
|
|
27
|
-
import { builtinAsk, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, newId, openCount, OTHER_ID, REACTIONS, projectFromCwd, reservedAgent, slugify, surfacesByteLength, } from "./types.js";
|
|
30
|
+
import { askAnswered, builtinAsk, builtinAskIds, DEFAULT_PROJECT, DEFAULT_VARIANT, htmlSurface, isSandboxedSurfaceKind, ATTACHABLE_IMAGE_TYPES, MAX_ASK_IMAGES, MAX_ASSET_BYTES, inUseVersion, isLive, isMockKind, momentOf, NORMAL_SITUATION, optionState, newId, openAsks, openCount, OTHER_ID, REACTIONS, projectFromCwd, AREA_ORDER_SETTING, stripNul, postAtVersion, versionAt, variantStatus, reservedAgent, sameScreen, situationKey, slugify, surfacesByteLength, } from "./types.js";
|
|
28
31
|
import { coerceSurfaces, validateSurfaces, } from "./postSurfaces.js";
|
|
32
|
+
import { isViewerRequest } from "./viewerRequest.js";
|
|
29
33
|
const MAX_SURFACE_BYTES = 2 * 1024 * 1024;
|
|
30
34
|
// A held long-poll (watch, the viewer) re-arms rather than pinning a socket
|
|
31
35
|
// for good; proxies and serverless hosts drop longer requests anyway.
|
|
@@ -165,11 +169,34 @@ const fail = (status, error, extra = {}) => ({
|
|
|
165
169
|
status,
|
|
166
170
|
body: { error, ...extra },
|
|
167
171
|
});
|
|
172
|
+
const MAX_NOTE = 400;
|
|
173
|
+
const MAX_SOURCE = 1024;
|
|
174
|
+
const MAX_WIDTHS = 4;
|
|
175
|
+
const MIN_WIDTH = 280;
|
|
176
|
+
const MAX_WIDTH = 2560;
|
|
168
177
|
const str = (v, max) => typeof v === "string" && v.trim() ? v.trim().slice(0, max) : undefined;
|
|
169
178
|
const MAX_LABEL = 120;
|
|
179
|
+
const MAX_AREAS = 50;
|
|
180
|
+
const MAX_FLOWS = 50;
|
|
181
|
+
const MAX_FLOW_STEPS = 100;
|
|
170
182
|
const MAX_ASKS = 20;
|
|
171
183
|
const MAX_OPTIONS = 20;
|
|
172
184
|
const MAX_DRAFT_COMMENTS = 50;
|
|
185
|
+
// A title that names a step of the agent's process instead of what the mock
|
|
186
|
+
// is: it reads as noise on Home, where titles are seen out of context.
|
|
187
|
+
const PROCESS_TITLE = /^\s*(round\s*\d+|flow\s*\d+\s*[·:\-–—]|v\d+\b|prototype\b)/i;
|
|
188
|
+
// Once, on a variant's first version: a canvas page runs live in every
|
|
189
|
+
// overview card, and a browser holds only about 16 WebGL contexts.
|
|
190
|
+
const posterNudges = (surfaces) => surfaces.some((s) => s.kind === "html" && !s.poster && /<canvas[\s>]/i.test(s.html))
|
|
191
|
+
? [
|
|
192
|
+
"this page draws on a <canvas> and has no poster, so Studio and Home run it live; send a still of it as poster (upload the image, or CLI --poster shot.png) with each version",
|
|
193
|
+
]
|
|
194
|
+
: [];
|
|
195
|
+
const titleNudges = (title) => title && PROCESS_TITLE.test(title)
|
|
196
|
+
? [
|
|
197
|
+
`title "${title}" names your process; title the mock by what it is ("Hold menu"), as states are named by what the user sees`,
|
|
198
|
+
]
|
|
199
|
+
: [];
|
|
173
200
|
const APP_FILES = new Set([
|
|
174
201
|
"/manifest.webmanifest",
|
|
175
202
|
"/icon.svg",
|
|
@@ -177,7 +204,7 @@ const APP_FILES = new Set([
|
|
|
177
204
|
...ICON_SIZES.map((s) => `/icon-${s}.png`),
|
|
178
205
|
]);
|
|
179
206
|
const titleFromSlug = (slug) => slug.replace(/-/g, " ").replace(/^./, (ch) => ch.toUpperCase());
|
|
180
|
-
export function createApp({ store, viewerHtml, setupText, topics = {}, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
|
|
207
|
+
export function createApp({ store, viewerHtml, setupText, topics = {}, packs = {}, userRules, authenticate, authToken, basePath, publicRead, screenshots, version, upgradeCommand, renderRev, fetchLatestRelease, onEvent, maxHoldConnections = DEFAULT_MAX_HOLD_CONNECTIONS, executor, runLimits, log, }) {
|
|
181
208
|
const app = new Hono();
|
|
182
209
|
// The scripts topic carries the run API from runApi.ts, its one source.
|
|
183
210
|
if (topics.scripts)
|
|
@@ -195,7 +222,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
195
222
|
// warm entry, it can never be served to the wrong caller (renderKey includes it).
|
|
196
223
|
let lastOrigin = null;
|
|
197
224
|
app.use("*", (c, next) => {
|
|
198
|
-
lastOrigin =
|
|
225
|
+
lastOrigin = requestOrigin(c.req.raw);
|
|
199
226
|
c.header("Referrer-Policy", "no-referrer");
|
|
200
227
|
return next();
|
|
201
228
|
});
|
|
@@ -285,7 +312,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
285
312
|
function surfaceDocHeaders(c, immutable) {
|
|
286
313
|
c.header("X-Content-Type-Options", "nosniff");
|
|
287
314
|
c.header("Content-Security-Policy", "sandbox allow-scripts");
|
|
288
|
-
|
|
315
|
+
const current = !renderRev || c.req.query("r") === renderRev;
|
|
316
|
+
c.header("Cache-Control", immutable && current ? "public, max-age=31536000, immutable" : "private, no-cache");
|
|
289
317
|
}
|
|
290
318
|
function renderCacheHit(key) {
|
|
291
319
|
const hit = renderCache.get(key);
|
|
@@ -329,6 +357,19 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
329
357
|
return withLeading.slice(0, end);
|
|
330
358
|
};
|
|
331
359
|
const requestBasePath = (request) => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath);
|
|
360
|
+
// Behind a TLS-terminating proxy (tailscale serve, a tunnel) the request
|
|
361
|
+
// reaches us as http while the browser is on https. A surface bakes this
|
|
362
|
+
// origin into its script URLs and CSP, and an http script inside an https
|
|
363
|
+
// page is blocked as mixed content: the frame then never runs its bridge, so
|
|
364
|
+
// live knobs and part reports silently stop. Trusting the header is safe
|
|
365
|
+
// here: it only shapes the caller's own response.
|
|
366
|
+
const requestOrigin = (request) => {
|
|
367
|
+
const url = new URL(request.url);
|
|
368
|
+
const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
369
|
+
if (proto === "https" || proto === "http")
|
|
370
|
+
url.protocol = `${proto}:`;
|
|
371
|
+
return url.origin;
|
|
372
|
+
};
|
|
332
373
|
// Cached, fail-silent update lookup: being offline or rate-limited must
|
|
333
374
|
// cost nothing but the absence of the notice. Failures are cached too, so
|
|
334
375
|
// a dead network doesn't retry on every viewer load.
|
|
@@ -511,13 +552,42 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
511
552
|
async function briefFor(project) {
|
|
512
553
|
try {
|
|
513
554
|
const name = project ?? (await store.listProjects())[0]?.name ?? DEFAULT_PROJECT;
|
|
514
|
-
return renderBriefGuide(await designFor(name));
|
|
555
|
+
return renderBriefGuide(await designFor(name), await guidanceFor(name));
|
|
515
556
|
}
|
|
516
557
|
catch (err) {
|
|
517
558
|
console.warn("[mockpit] project design unavailable for the brief", err);
|
|
518
559
|
return renderBriefGuide(null);
|
|
519
560
|
}
|
|
520
561
|
}
|
|
562
|
+
// The user's file wins over the stored copy when the host can read it: the
|
|
563
|
+
// copy exists for hosts that can't.
|
|
564
|
+
async function userRulesText() {
|
|
565
|
+
const file = userRules ? await userRules().catch(() => null) : null;
|
|
566
|
+
return file ?? (await store.getSetting(USER_RULES_SETTING));
|
|
567
|
+
}
|
|
568
|
+
async function guidanceFor(project) {
|
|
569
|
+
return guidanceSections({
|
|
570
|
+
packs,
|
|
571
|
+
user: await userRulesText(),
|
|
572
|
+
project: await store.getSetting(`${RULES_SETTING}:${project}`),
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
// A rules file as sent by `mockpit rules`: stored whole, "" clears it. The
|
|
576
|
+
// answer says what the brief will make of it, so a mistyped pack name shows
|
|
577
|
+
// at once rather than as a quietly missing layer.
|
|
578
|
+
async function saveRules(key, body) {
|
|
579
|
+
if (!body || typeof body !== "object" || typeof body.text !== "string") {
|
|
580
|
+
return fail(400, 'provide "text", the rules file ("" clears it)');
|
|
581
|
+
}
|
|
582
|
+
const text = stripNul(body.text).trim();
|
|
583
|
+
if (text.length > MAX_RULES_CHARS) {
|
|
584
|
+
return fail(400, `a rules file is at most ${MAX_RULES_CHARS} characters`);
|
|
585
|
+
}
|
|
586
|
+
await store.setSetting(key, text);
|
|
587
|
+
const parsed = parseRulesFile(text);
|
|
588
|
+
const unknown = parsed.packs.filter((p) => !(p in packs));
|
|
589
|
+
return ok({ packs: parsed.packs, unknown, rules: parsed.body.length > 0, cleared: !text });
|
|
590
|
+
}
|
|
521
591
|
// Push + webhooks. Detached on purpose: a dead push endpoint must never fail
|
|
522
592
|
// (or slow) the write that triggered it.
|
|
523
593
|
function fireNotify(payload) {
|
|
@@ -588,7 +658,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
588
658
|
variants: posts.map(variantLabel),
|
|
589
659
|
});
|
|
590
660
|
}
|
|
591
|
-
const live = candidates.filter((p) => p.status
|
|
661
|
+
const live = candidates.filter((p) => isLive(p.status));
|
|
592
662
|
if (live.length === 1 && !variant)
|
|
593
663
|
return live[0];
|
|
594
664
|
return fail(400, `${mock.slug} has several variants; pass ${variant ? "state" : "variant"}`, {
|
|
@@ -675,6 +745,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
675
745
|
code: failure.code,
|
|
676
746
|
issues: failure.issues,
|
|
677
747
|
});
|
|
748
|
+
// A poster is a still of the html beside it. Carried over unchanged onto
|
|
749
|
+
// changed html (a parts or fills splice, a fork with edits) it would show an
|
|
750
|
+
// old page, so it goes unless the write brings one; `poster` names the still
|
|
751
|
+
// of the page this write produces.
|
|
752
|
+
async function withPoster(surfaces, before, poster) {
|
|
753
|
+
const given = poster === undefined ? undefined : str(poster, MAX_LABEL);
|
|
754
|
+
if (poster !== undefined && !given) {
|
|
755
|
+
return fail(400, '"poster" must be the asset id of an uploaded image');
|
|
756
|
+
}
|
|
757
|
+
const pagesBefore = (before ?? []).filter((s) => s.kind === "html");
|
|
758
|
+
let ordinal = 0;
|
|
759
|
+
const out = surfaces.map((s) => {
|
|
760
|
+
if (s.kind !== "html")
|
|
761
|
+
return s;
|
|
762
|
+
const old = (s.id !== undefined && pagesBefore.find((b) => b.id === s.id)) || pagesBefore[ordinal];
|
|
763
|
+
let next = s;
|
|
764
|
+
if (s.poster && old?.kind === "html" && old.html !== s.html && old.poster === s.poster) {
|
|
765
|
+
const { poster: _stale, ...rest } = s;
|
|
766
|
+
next = rest;
|
|
767
|
+
}
|
|
768
|
+
if (ordinal === 0 && given)
|
|
769
|
+
next = { ...next, poster: given };
|
|
770
|
+
ordinal++;
|
|
771
|
+
return next;
|
|
772
|
+
});
|
|
773
|
+
if (given && ordinal === 0)
|
|
774
|
+
return fail(400, '"poster" needs an html surface to be the still of');
|
|
775
|
+
for (const s of out) {
|
|
776
|
+
if (s.kind !== "html" || !s.poster)
|
|
777
|
+
continue;
|
|
778
|
+
const asset = await store.getAsset(s.poster);
|
|
779
|
+
if (!asset)
|
|
780
|
+
return fail(404, `poster "${s.poster}" is not an uploaded asset; upload the image first`);
|
|
781
|
+
if (!asset.contentType.startsWith("image/")) {
|
|
782
|
+
return fail(400, `poster "${s.poster}" is ${asset.contentType}, not an image`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return out;
|
|
786
|
+
}
|
|
678
787
|
function checkSurfaces(surfaces) {
|
|
679
788
|
if (surfaces.length === 0)
|
|
680
789
|
return fail(400, "a variant needs at least one surface");
|
|
@@ -683,57 +792,138 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
683
792
|
}
|
|
684
793
|
return null;
|
|
685
794
|
}
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
async function
|
|
689
|
-
const html = surfaces.find((s) => s.kind === "html");
|
|
690
|
-
if (!html || html.kind !== "html")
|
|
691
|
-
return [];
|
|
692
|
-
const slots = [];
|
|
693
|
-
for (const tag of parseSlotTags(html.html)) {
|
|
694
|
-
let version = tag.version;
|
|
695
|
-
if (version == null)
|
|
696
|
-
version = (await slotTarget(project, tag.slug, tag.variant))?.version ?? null;
|
|
697
|
-
if (version != null)
|
|
698
|
-
slots.push({ slug: tag.slug, variant: tag.variant, version });
|
|
699
|
-
}
|
|
700
|
-
return slots;
|
|
701
|
-
}
|
|
702
|
-
// A slot names a component mock by slug; it renders that mock's first state.
|
|
703
|
-
async function slotTarget(project, slug, variant) {
|
|
795
|
+
// The component post an embed shows: the named variant of its first state,
|
|
796
|
+
// else the accepted one there, else "default", else the first live one.
|
|
797
|
+
async function embedTarget(project, slug, variant) {
|
|
704
798
|
const mock = await store.findMock(project, slug);
|
|
705
799
|
if (!mock)
|
|
706
800
|
return null;
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
801
|
+
const state = mock.states[0] ?? null;
|
|
802
|
+
if (variant)
|
|
803
|
+
return store.findPost(mock.id, state, variant);
|
|
804
|
+
const inState = (await store.listPosts({ mockId: mock.id })).filter((p) => p.state === state);
|
|
805
|
+
return (inState.find((p) => p.status === "accepted") ??
|
|
806
|
+
inState.find((p) => p.variant === DEFAULT_VARIANT && isLive(p.status)) ??
|
|
807
|
+
inState.find((p) => isLive(p.status)) ??
|
|
808
|
+
inState[0] ??
|
|
809
|
+
null);
|
|
810
|
+
}
|
|
811
|
+
// Resolves tags to bodies: the tag's version if it pins one, else a peek
|
|
812
|
+
// (`overrides`, slug → version) the viewer asked for, else the latest. The
|
|
813
|
+
// body shows the component as decided: its slots composed to the fills its
|
|
814
|
+
// settled values chose, its other settled values on the wrapper.
|
|
815
|
+
function embedResolver(project, overrides = {}) {
|
|
816
|
+
return async (tag) => {
|
|
817
|
+
const post = await embedTarget(project, tag.slug, tag.variant);
|
|
818
|
+
if (!post)
|
|
819
|
+
return null;
|
|
820
|
+
const version = tag.version ?? overrides[tag.slug] ?? inUseVersion(post);
|
|
821
|
+
const surfaces = version === post.version
|
|
822
|
+
? post.surfaces
|
|
823
|
+
: post.history.find((h) => h.version === version)?.surfaces;
|
|
824
|
+
const body = surfaces?.find((x) => x.kind === "html");
|
|
825
|
+
if (!surfaces || !body || body.kind !== "html")
|
|
826
|
+
return null;
|
|
827
|
+
const mock = await store.getMock(post.mock);
|
|
828
|
+
const settled = mock
|
|
829
|
+
? settledValues(await replyPayloads(mock.id), mock, await store.listPosts({ mockId: mock.id }))
|
|
830
|
+
: {};
|
|
831
|
+
const slots = slotsInSurfaces(surfaces);
|
|
832
|
+
const fills = new Set(slots.map((sl) => sl.part));
|
|
833
|
+
const rest = Object.fromEntries(Object.entries(settled).filter(([p]) => !fills.has(p)));
|
|
834
|
+
return {
|
|
835
|
+
variant: post.variant,
|
|
836
|
+
version,
|
|
837
|
+
html: slots.length ? composeFills(body.html, chosenFills(slots, settled)) : body.html,
|
|
838
|
+
attrs: knobAttributes(rest).attrs,
|
|
839
|
+
...(Object.keys(settled).length ? { stamp: JSON.stringify(settled) } : {}),
|
|
840
|
+
};
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
// A surface's html with its embeds expanded, and what they resolved to.
|
|
844
|
+
async function composedHtml(project, slug, html, overrides) {
|
|
845
|
+
if (!hasEmbedTags(html))
|
|
846
|
+
return { html, used: [], stamps: [], cycle: null };
|
|
847
|
+
return expandHtml(html, embedResolver(project, overrides), [slug]);
|
|
848
|
+
}
|
|
849
|
+
// The component versions a publish renders with, recorded on the version;
|
|
850
|
+
// an embed chain that loops back to this mock is refused.
|
|
851
|
+
async function publishEmbeds(project, slug, surfaces) {
|
|
852
|
+
const html = surfaces.find((x) => x.kind === "html");
|
|
853
|
+
if (!html || html.kind !== "html")
|
|
854
|
+
return [];
|
|
855
|
+
const { used, cycle } = await composedHtml(project, slug, html.html);
|
|
856
|
+
if (cycle) {
|
|
857
|
+
return fail(400, `embeds loop back on themselves: ${cycle.join(" → ")}`, { cycle });
|
|
858
|
+
}
|
|
859
|
+
return used;
|
|
860
|
+
}
|
|
861
|
+
// Being embedded is what makes a mock a component, so a sketch another mock
|
|
862
|
+
// embeds becomes one; a declared page stays a page.
|
|
863
|
+
async function promoteEmbedded(project, embeds) {
|
|
864
|
+
for (const slug of new Set(embeds.map((e) => e.slug))) {
|
|
865
|
+
const m = await store.findMock(project, slug);
|
|
866
|
+
if (m?.kind !== "sketch")
|
|
720
867
|
continue;
|
|
721
|
-
const
|
|
722
|
-
if (
|
|
723
|
-
|
|
868
|
+
const updated = await store.updateMock(m.id, { kind: "component" });
|
|
869
|
+
if (updated)
|
|
870
|
+
bus.broadcast({ type: "mock-updated", id: updated.id, project });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// What the variants of a mock show through their embeds now: per post, the
|
|
874
|
+
// resolved list (nested included) with each component's versions, and the
|
|
875
|
+
// key that changes when any of them does.
|
|
876
|
+
async function embedViews(mock, posts) {
|
|
877
|
+
const out = new Map();
|
|
878
|
+
for (const post of posts) {
|
|
879
|
+
const html = post.surfaces.find((x) => x.kind === "html");
|
|
880
|
+
if (!html || html.kind !== "html" || !hasEmbedTags(html.html))
|
|
724
881
|
continue;
|
|
882
|
+
const composed = await composedHtml(mock.project, mock.slug, html.html);
|
|
883
|
+
const { used } = composed;
|
|
884
|
+
const list = [];
|
|
885
|
+
for (const e of used) {
|
|
886
|
+
const target = await embedTarget(mock.project, e.slug, e.variant);
|
|
887
|
+
const versions = target
|
|
888
|
+
? [target.version, ...target.history.map((h) => h.version)].sort((a, b) => b - a)
|
|
889
|
+
: [e.version];
|
|
890
|
+
list.push({ ...e, latest: target?.version ?? e.version, versions });
|
|
725
891
|
}
|
|
726
|
-
|
|
727
|
-
? target.surfaces
|
|
728
|
-
: (target.history.find((h) => h.version === version)?.surfaces ?? null);
|
|
729
|
-
const body = surfaces?.find((s) => s.kind === "html");
|
|
730
|
-
bodies.set(cacheKey, body && body.kind === "html" ? body.html : null);
|
|
892
|
+
out.set(post.id, { key: embedsKey(composed), list });
|
|
731
893
|
}
|
|
732
|
-
return
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
// The mocks whose html embeds this slug, directly or through other
|
|
897
|
+
// components: they render differently once it changes.
|
|
898
|
+
async function embeddersOf(project, slug) {
|
|
899
|
+
const mocks = await store.listMocks(project);
|
|
900
|
+
const tagsOf = new Map();
|
|
901
|
+
for (const m of mocks) {
|
|
902
|
+
const slugs = new Set();
|
|
903
|
+
for (const p of await store.listPosts({ mockId: m.id })) {
|
|
904
|
+
for (const x of p.surfaces) {
|
|
905
|
+
if (x.kind === "html")
|
|
906
|
+
for (const t of parseEmbedTags(x.html))
|
|
907
|
+
slugs.add(t.slug);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
tagsOf.set(m.id, slugs);
|
|
911
|
+
}
|
|
912
|
+
const reached = new Set([slug]);
|
|
913
|
+
const out = [];
|
|
914
|
+
for (let grew = true; grew;) {
|
|
915
|
+
grew = false;
|
|
916
|
+
for (const m of mocks) {
|
|
917
|
+
if (reached.has(m.slug))
|
|
918
|
+
continue;
|
|
919
|
+
if ([...(tagsOf.get(m.id) ?? [])].some((s) => reached.has(s))) {
|
|
920
|
+
reached.add(m.slug);
|
|
921
|
+
out.push(m);
|
|
922
|
+
grew = true;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
737
927
|
}
|
|
738
928
|
// Knobs with a handful of discrete choices are usually decisions dressed as
|
|
739
929
|
// controls: the user can't compare options they only see one at a time.
|
|
@@ -752,7 +942,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
752
942
|
// chat instead. The nudge carries a ready ask: mock-wide when every state has
|
|
753
943
|
// the same variant names, else for this state.
|
|
754
944
|
function unboundVariants(mock, posts, state) {
|
|
755
|
-
const live = (s) => posts.filter((p) => p.state === s && p.status
|
|
945
|
+
const live = (s) => posts.filter((p) => p.state === s && isLive(p.status)).map((p) => p.variant);
|
|
756
946
|
const names = live(state);
|
|
757
947
|
if (names.length < 2)
|
|
758
948
|
return null;
|
|
@@ -770,7 +960,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
770
960
|
const states = mock.states.length ? mock.states : [null];
|
|
771
961
|
const wide = states.every((s) => key(live(s)) === key(names));
|
|
772
962
|
// An ask that already picks between some of these variants is the question:
|
|
773
|
-
// a second, generic "Which
|
|
963
|
+
// a second, generic "Which variant?" would make the user choose twice.
|
|
774
964
|
// A mock-wide one only fits while every state has the same variants; else a
|
|
775
965
|
// variant only some states have would leave the others no real choice.
|
|
776
966
|
const existing = mock.asks.find((a) => applies(a) && a.options.some((o) => o.variant) && (a.scope === "state" || wide));
|
|
@@ -794,7 +984,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
794
984
|
],
|
|
795
985
|
};
|
|
796
986
|
return {
|
|
797
|
-
nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing
|
|
987
|
+
nudge: `${where}: bind ${missing.map((m) => `"${m}"`).join(", ")} to your existing question "${existing.id}": call ask with suggestedAsk. Never ask in chat.`,
|
|
798
988
|
ask,
|
|
799
989
|
};
|
|
800
990
|
}
|
|
@@ -802,15 +992,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
802
992
|
// Not the built-in's reserved id: a stored ask under it would collide with the
|
|
803
993
|
// built-in the viewer synthesizes once a new variant goes unbound. Nor any id
|
|
804
994
|
// already taken, which sending this would silently replace.
|
|
805
|
-
const base = wide ? "
|
|
995
|
+
const base = wide ? "choice" : `choice-${slugify(state ?? "")}`;
|
|
806
996
|
let id = base;
|
|
807
997
|
for (let n = 2; mock.asks.some((a) => a.id === id); n++)
|
|
808
998
|
id = `${base}-${n}`;
|
|
809
999
|
const ask = wide
|
|
810
|
-
? { id, text: "Which
|
|
811
|
-
: { id, text: "Which
|
|
1000
|
+
? { id, text: "Which variant?", scope: "mock", options }
|
|
1001
|
+
: { id, text: "Which variant?", scope: "state", state, options };
|
|
812
1002
|
return {
|
|
813
|
-
nudge: `${where} has ${names.length} variants and no
|
|
1003
|
+
nudge: `${where} has ${names.length} variants and no question binds them: call ask with suggestedAsk. Never ask in chat.`,
|
|
814
1004
|
ask,
|
|
815
1005
|
};
|
|
816
1006
|
}
|
|
@@ -821,7 +1011,27 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
821
1011
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
822
1012
|
const detail = mockDetailView(mock, posts);
|
|
823
1013
|
const unbound = unboundVariants(mock, posts, post.state);
|
|
824
|
-
|
|
1014
|
+
// A part with fills that no ask sets is a decision nobody was asked; hand over the ask.
|
|
1015
|
+
const slots = slotsInSurfaces(post.surfaces);
|
|
1016
|
+
const setPaths = new Set(mock.asks.flatMap((a) => a.options.flatMap((o) => Object.keys(o.set ?? {}))));
|
|
1017
|
+
const slotAsks = slots
|
|
1018
|
+
.filter((sl) => !setPaths.has(sl.part))
|
|
1019
|
+
.map((sl) => ({
|
|
1020
|
+
id: sl.part,
|
|
1021
|
+
text: `Which ${sl.label ?? sl.part}?`,
|
|
1022
|
+
scope: "part",
|
|
1023
|
+
part: sl.part,
|
|
1024
|
+
options: sl.fills.map((f) => ({ label: f.label ?? f.name, set: { [sl.part]: f.name } })),
|
|
1025
|
+
}));
|
|
1026
|
+
const nudges = [
|
|
1027
|
+
...(extra.nudges ?? []),
|
|
1028
|
+
...(unbound ? [unbound.nudge] : []),
|
|
1029
|
+
...(slotAsks.length
|
|
1030
|
+
? [
|
|
1031
|
+
`${slotAsks.map((a) => `part "${a.part}"`).join(", ")} ${slotAsks.length === 1 ? "has" : "have"} fills and no question sets ${slotAsks.length === 1 ? "it" : "them"}: send suggestedFillAsks with ask (reword the text in the user's terms)`,
|
|
1032
|
+
]
|
|
1033
|
+
: []),
|
|
1034
|
+
];
|
|
825
1035
|
let partChanges;
|
|
826
1036
|
if (extra.previous) {
|
|
827
1037
|
partChanges = diffParts(partsInSurfaces(extra.previous), partsInSurfaces(post.surfaces));
|
|
@@ -835,6 +1045,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
835
1045
|
slug: mock.slug,
|
|
836
1046
|
title: mock.title,
|
|
837
1047
|
kind: mock.kind,
|
|
1048
|
+
...placeOf(mock),
|
|
838
1049
|
states: mock.states,
|
|
839
1050
|
},
|
|
840
1051
|
post: {
|
|
@@ -842,19 +1053,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
842
1053
|
state: post.state,
|
|
843
1054
|
variant: post.variant,
|
|
844
1055
|
version: post.version,
|
|
845
|
-
status: post.status,
|
|
1056
|
+
status: variantStatus(post.status),
|
|
846
1057
|
surfaces: post.surfaces.map(surfaceRef),
|
|
847
1058
|
},
|
|
848
1059
|
sessionId: post.sessionId,
|
|
849
1060
|
url: mockUrl(ctx.base, mock, post),
|
|
850
1061
|
parts: detail.parts,
|
|
1062
|
+
...(slots.length ? { fills: slots } : {}),
|
|
851
1063
|
...(extra.applied ? { applied: extra.applied } : {}),
|
|
852
1064
|
...(partChanges && (partChanges.vanished.length || partChanges.renamed.length)
|
|
853
1065
|
? { partChanges }
|
|
854
1066
|
: {}),
|
|
855
1067
|
...(nudges.length ? { nudges } : {}),
|
|
856
1068
|
...(unbound ? { suggestedAsk: unbound.ask } : {}),
|
|
1069
|
+
...(slotAsks.length ? { suggestedFillAsks: slotAsks } : {}),
|
|
857
1070
|
...(warnings.length ? { warnings } : {}),
|
|
1071
|
+
...(extra.flow ? { flow: extra.flow } : {}),
|
|
858
1072
|
feedback,
|
|
859
1073
|
}, extra.status ?? 200);
|
|
860
1074
|
}
|
|
@@ -868,26 +1082,46 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
868
1082
|
by,
|
|
869
1083
|
});
|
|
870
1084
|
warmPost(post, mock);
|
|
1085
|
+
void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
|
|
1086
|
+
}
|
|
1087
|
+
// A component's new version changes every mock that shows it: their views
|
|
1088
|
+
// carry a new embeds key, so open viewers refetch and reload those frames.
|
|
1089
|
+
async function announceEmbedders(mock) {
|
|
1090
|
+
for (const m of await embeddersOf(mock.project, mock.slug)) {
|
|
1091
|
+
bus.broadcast({ type: "mock-updated", id: m.id, project: m.project });
|
|
1092
|
+
}
|
|
871
1093
|
}
|
|
872
1094
|
// The one write: publish one variant of one state of a mock. An existing
|
|
873
1095
|
// (mock, state, variant) becomes a new version; anything else is created.
|
|
874
|
-
// Exactly one body: `html`, the full ordered `surfaces` list, or `parts`
|
|
875
|
-
// spliced into the variant's latest (or `from`) version.
|
|
1096
|
+
// Exactly one body: `html`, the full ordered `surfaces` list, or `parts` or
|
|
1097
|
+
// `fills` spliced into the variant's latest (or `from`) version.
|
|
876
1098
|
async function publishFlow(body, ctx) {
|
|
877
1099
|
if (!body || typeof body !== "object")
|
|
878
1100
|
return fail(400, "invalid JSON body");
|
|
879
1101
|
const slugIn = str(body.mock, MAX_TITLE);
|
|
880
1102
|
if (!slugIn)
|
|
881
1103
|
return fail(400, 'provide "mock": the mock slug, e.g. "writer"');
|
|
882
|
-
const given = ["html", "surfaces", "parts"].filter((k) => body[k] !== undefined);
|
|
883
|
-
|
|
884
|
-
|
|
1104
|
+
const given = ["html", "surfaces", "parts", "fills"].filter((k) => body[k] !== undefined);
|
|
1105
|
+
const forkIn = forkArg(body.fork);
|
|
1106
|
+
if (isResult(forkIn))
|
|
1107
|
+
return forkIn;
|
|
1108
|
+
const flowIn = flowArg(body);
|
|
1109
|
+
if (isResult(flowIn))
|
|
1110
|
+
return flowIn;
|
|
1111
|
+
// `flow` alone places an existing screen in a flow without a new version.
|
|
1112
|
+
const flowOnly = given.length === 0 && !forkIn && !!flowIn;
|
|
1113
|
+
if (given.length === 0 && !forkIn && !flowIn) {
|
|
1114
|
+
return fail(400, 'provide "html", "surfaces", "parts" or "fills" (or "fork" alone)');
|
|
1115
|
+
}
|
|
885
1116
|
if (given.length > 1) {
|
|
886
|
-
return fail(400, `pass one of "html", "surfaces" or "
|
|
1117
|
+
return fail(400, `pass one of "html", "surfaces", "parts" or "fills", not ${given.join(" and ")}`);
|
|
887
1118
|
}
|
|
888
|
-
const partEdits =
|
|
1119
|
+
const partEdits = editsArg(body, "parts");
|
|
889
1120
|
if (isResult(partEdits))
|
|
890
1121
|
return partEdits;
|
|
1122
|
+
const fillEdits = editsArg(body, "fills");
|
|
1123
|
+
if (isResult(fillEdits))
|
|
1124
|
+
return fillEdits;
|
|
891
1125
|
const knobs = checkKnobs(body.knobs);
|
|
892
1126
|
if (!knobs.ok)
|
|
893
1127
|
return fail(400, knobs.error);
|
|
@@ -896,12 +1130,22 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
896
1130
|
return fail(400, variantKnobs.error);
|
|
897
1131
|
const stateIn = stateArg(body.state);
|
|
898
1132
|
const variantIn = str(body.variant, MAX_LABEL);
|
|
899
|
-
const kind = body.kind
|
|
1133
|
+
const kind = isMockKind(body.kind) ? body.kind : undefined;
|
|
900
1134
|
const title = str(body.title, MAX_TITLE);
|
|
901
1135
|
const fromN = Number(body.from);
|
|
902
1136
|
const from = Number.isInteger(fromN) && fromN > 0 ? fromN : undefined;
|
|
903
1137
|
const prompt = str(body.prompt, MAX_COMMENT_TEXT);
|
|
904
1138
|
const author = str(body.author, MAX_TITLE);
|
|
1139
|
+
const note = body.note === undefined ? undefined : (str(body.note, MAX_NOTE) ?? "");
|
|
1140
|
+
const widths = widthsArg(body.widths);
|
|
1141
|
+
if (isResult(widths))
|
|
1142
|
+
return widths;
|
|
1143
|
+
const area = labelArg(body, "area", MAX_LABEL);
|
|
1144
|
+
if (isResult(area))
|
|
1145
|
+
return area;
|
|
1146
|
+
const source = labelArg(body, "source", MAX_SOURCE);
|
|
1147
|
+
if (isResult(source))
|
|
1148
|
+
return source;
|
|
905
1149
|
// An existing mock is addressable by id before a session exists.
|
|
906
1150
|
let mock = null;
|
|
907
1151
|
if (typeof body.mock === "string")
|
|
@@ -933,16 +1177,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
933
1177
|
}
|
|
934
1178
|
state = stateIn;
|
|
935
1179
|
}
|
|
1180
|
+
const situationIn = str(body.situation, MAX_LABEL);
|
|
1181
|
+
// A situation is another render of one state ("Couldn't save"); "Normal"
|
|
1182
|
+
// names the state's own render.
|
|
1183
|
+
let situation;
|
|
1184
|
+
if (situationIn && situationIn.toLowerCase() !== NORMAL_SITUATION.toLowerCase()) {
|
|
1185
|
+
if (state === null) {
|
|
1186
|
+
return fail(400, '"situation" needs "state": the state it is another render of');
|
|
1187
|
+
}
|
|
1188
|
+
if (!mock || !mock.states.includes(state)) {
|
|
1189
|
+
return fail(404, `${slug} has no state "${state}" yet: publish its normal render first, then its situations`, mock ? { states: mock.states } : {});
|
|
1190
|
+
}
|
|
1191
|
+
const moment = momentOf(mock.situations, state);
|
|
1192
|
+
// The key feedback names it by, passed back with its own situation.
|
|
1193
|
+
if (moment !== state && moment !== null && situationKey(moment, situationIn) === state) {
|
|
1194
|
+
state = moment;
|
|
1195
|
+
}
|
|
1196
|
+
else if (moment !== state) {
|
|
1197
|
+
return fail(400, `"${state}" is a situation; give "situation" the state it belongs to`);
|
|
1198
|
+
}
|
|
1199
|
+
const key = situationKey(state, situationIn);
|
|
1200
|
+
if (mock.states.includes(key) && !(mock.situations[state] ?? []).includes(situationIn)) {
|
|
1201
|
+
return fail(409, `${slug} already has a state named "${key}"; name the situation otherwise`);
|
|
1202
|
+
}
|
|
1203
|
+
situation = { of: state, name: situationIn };
|
|
1204
|
+
state = key;
|
|
1205
|
+
}
|
|
936
1206
|
const inState = posts.filter((p) => p.state === state);
|
|
937
1207
|
let variant = variantIn;
|
|
938
1208
|
if (!variant) {
|
|
939
|
-
const live = inState.filter((p) => p.status
|
|
1209
|
+
const live = inState.filter((p) => isLive(p.status));
|
|
940
1210
|
if (inState.length === 0)
|
|
941
1211
|
variant = DEFAULT_VARIANT;
|
|
942
1212
|
else if (inState.length === 1)
|
|
943
1213
|
variant = inState[0].variant;
|
|
944
1214
|
else if (live.length === 1)
|
|
945
1215
|
variant = live[0].variant;
|
|
1216
|
+
else if (flowOnly)
|
|
1217
|
+
variant = (live[0] ?? inState[0]).variant;
|
|
946
1218
|
else {
|
|
947
1219
|
return fail(400, `${slug} has several variants; pass variant`, {
|
|
948
1220
|
variants: inState.map(variantLabel),
|
|
@@ -953,9 +1225,54 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
953
1225
|
const missing = () => fail(404, `${slug} has no variant "${variant}"${state === null ? "" : ` in "${state}"`}`, {
|
|
954
1226
|
variants: posts.map(variantLabel),
|
|
955
1227
|
});
|
|
1228
|
+
// A flow steps through states; a situation's step is its state's.
|
|
1229
|
+
const screen = { mock: slug, state: situation ? situation.of : state };
|
|
1230
|
+
if (flowIn) {
|
|
1231
|
+
const full = await flowFull(project, flowIn.name, screen, flowIn.step);
|
|
1232
|
+
if (full)
|
|
1233
|
+
return full;
|
|
1234
|
+
}
|
|
1235
|
+
if (flowOnly) {
|
|
1236
|
+
if (!mock || inState.length === 0) {
|
|
1237
|
+
return fail(404, `${slug} has no ${state === null ? "design" : `state "${state}"`} yet: publish it with "flow" to place it`);
|
|
1238
|
+
}
|
|
1239
|
+
if (!target)
|
|
1240
|
+
return missing();
|
|
1241
|
+
const flow = await placeFlow(project, flowIn.name, screen, flowIn.step);
|
|
1242
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project });
|
|
1243
|
+
return writeResult(mock, target, ctx, { writer: session, flow });
|
|
1244
|
+
}
|
|
1245
|
+
// A fork starts a new variant from a sibling's version: that version's
|
|
1246
|
+
// surfaces are what `parts`, `fills` and kept surface ids refer to.
|
|
1247
|
+
let fork = null;
|
|
1248
|
+
if (forkIn) {
|
|
1249
|
+
if (target) {
|
|
1250
|
+
return fail(409, `${variant} already exists; fork names a new variant to start`);
|
|
1251
|
+
}
|
|
1252
|
+
const source = inState.find((p) => p.variant === forkIn.variant);
|
|
1253
|
+
if (!source) {
|
|
1254
|
+
return fail(404, `no variant "${forkIn.variant}" to fork in this state`, {
|
|
1255
|
+
variants: inState.map(variantLabel),
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
const version = forkIn.version ?? source.version;
|
|
1259
|
+
const surfacesAt = version === source.version
|
|
1260
|
+
? source.surfaces
|
|
1261
|
+
: source.history.find((h) => h.version === version)?.surfaces;
|
|
1262
|
+
if (!surfacesAt)
|
|
1263
|
+
return fail(404, `${source.variant} has no version ${version}`);
|
|
1264
|
+
fork = {
|
|
1265
|
+
ref: { variant: source.variant, version },
|
|
1266
|
+
surfaces: surfacesAt,
|
|
1267
|
+
...(source.knobs ? { knobs: source.knobs } : {}),
|
|
1268
|
+
...(source.note ? { note: source.note } : {}),
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
956
1271
|
// What `parts` and kept surface ids refer to: the variant's current
|
|
957
1272
|
// version, or the one `from` names.
|
|
958
1273
|
const base = () => {
|
|
1274
|
+
if (fork)
|
|
1275
|
+
return fork.surfaces;
|
|
959
1276
|
if (!target)
|
|
960
1277
|
return missing();
|
|
961
1278
|
if (from === undefined || from === target.version)
|
|
@@ -965,11 +1282,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
965
1282
|
};
|
|
966
1283
|
let surfaces;
|
|
967
1284
|
let applied;
|
|
968
|
-
if (
|
|
1285
|
+
if (fork && given.length === 0) {
|
|
1286
|
+
surfaces = fork.surfaces;
|
|
1287
|
+
}
|
|
1288
|
+
else if (partEdits || fillEdits) {
|
|
969
1289
|
const current = base();
|
|
970
1290
|
if (isResult(current))
|
|
971
1291
|
return current;
|
|
972
|
-
const result =
|
|
1292
|
+
const result = partEdits
|
|
1293
|
+
? spliceParts(current, partEdits)
|
|
1294
|
+
: spliceFills(current, fillEdits);
|
|
973
1295
|
if (!result.ok)
|
|
974
1296
|
return fail(400, result.error);
|
|
975
1297
|
surfaces = result.surfaces;
|
|
@@ -981,10 +1303,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
981
1303
|
return parsed;
|
|
982
1304
|
surfaces = parsed;
|
|
983
1305
|
}
|
|
1306
|
+
const before = base();
|
|
1307
|
+
const postered = await withPoster(surfaces, isResult(before) ? null : before, body.poster);
|
|
1308
|
+
if (isResult(postered))
|
|
1309
|
+
return postered;
|
|
1310
|
+
surfaces = postered;
|
|
984
1311
|
const bad = checkSurfaces(surfaces);
|
|
985
1312
|
if (bad)
|
|
986
1313
|
return bad;
|
|
987
|
-
const nudges =
|
|
1314
|
+
const nudges = [
|
|
1315
|
+
...knobNudges({ ...knobs.value, ...variantKnobs.value }),
|
|
1316
|
+
...titleNudges(title),
|
|
1317
|
+
...(target ? [] : posterNudges(surfaces)),
|
|
1318
|
+
];
|
|
1319
|
+
const embeds = await publishEmbeds(project, slug, surfaces);
|
|
1320
|
+
if (isResult(embeds))
|
|
1321
|
+
return embeds;
|
|
1322
|
+
await promoteEmbedded(project, embeds);
|
|
988
1323
|
if (!target) {
|
|
989
1324
|
// A new variant: carried by the session the agent is writing from.
|
|
990
1325
|
if (!session) {
|
|
@@ -1001,14 +1336,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1001
1336
|
project,
|
|
1002
1337
|
slug,
|
|
1003
1338
|
title: title ?? titleFromSlug(slug),
|
|
1004
|
-
kind: kind ?? "
|
|
1339
|
+
kind: kind ?? "sketch",
|
|
1340
|
+
...(area === undefined ? {} : { area }),
|
|
1341
|
+
...(source === undefined ? {} : { source }),
|
|
1005
1342
|
states: state === null ? [] : [state],
|
|
1006
1343
|
knobs: knobs.value,
|
|
1344
|
+
...(widths ? { widths } : {}),
|
|
1007
1345
|
sessionId: session.id,
|
|
1008
1346
|
});
|
|
1009
1347
|
bus.broadcast({ type: "mock-created", id: mock.id, project });
|
|
1010
1348
|
}
|
|
1011
|
-
const slots = mock.kind === "page" || kind === "page" ? await pageSlots(project, surfaces) : [];
|
|
1012
1349
|
const created = await store.createPost({
|
|
1013
1350
|
sessionId: session.id,
|
|
1014
1351
|
mock: mock.id,
|
|
@@ -1016,38 +1353,65 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1016
1353
|
variant,
|
|
1017
1354
|
title: title ?? mock.title,
|
|
1018
1355
|
surfaces,
|
|
1019
|
-
...(Object.keys(variantKnobs.value).length
|
|
1020
|
-
|
|
1356
|
+
...(Object.keys(variantKnobs.value).length
|
|
1357
|
+
? { knobs: variantKnobs.value }
|
|
1358
|
+
: fork?.knobs
|
|
1359
|
+
? { knobs: fork.knobs }
|
|
1360
|
+
: {}),
|
|
1361
|
+
...(note ? { note } : note === undefined && fork?.note ? { note: fork.note } : {}),
|
|
1362
|
+
embeds,
|
|
1363
|
+
...(fork ? { forkedFrom: fork.ref } : {}),
|
|
1021
1364
|
from,
|
|
1022
1365
|
prompt,
|
|
1023
1366
|
author,
|
|
1024
1367
|
});
|
|
1025
1368
|
if (!created)
|
|
1026
1369
|
return fail(404, "session not found");
|
|
1027
|
-
mock = await updateMockAfterWrite(mock, {
|
|
1370
|
+
mock = await updateMockAfterWrite(mock, {
|
|
1371
|
+
state,
|
|
1372
|
+
situation,
|
|
1373
|
+
kind,
|
|
1374
|
+
title,
|
|
1375
|
+
knobs: knobs.value,
|
|
1376
|
+
widths,
|
|
1377
|
+
area,
|
|
1378
|
+
source,
|
|
1379
|
+
session,
|
|
1380
|
+
});
|
|
1381
|
+
const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
|
|
1028
1382
|
announcePost(mock, created, true);
|
|
1029
1383
|
if (prompt && ctx.request)
|
|
1030
1384
|
notifyPublish(mock, created, prompt, ctx);
|
|
1031
|
-
return writeResult(mock, created, ctx, { nudges, status: 201 });
|
|
1385
|
+
return writeResult(mock, created, ctx, { nudges, status: 201, flow });
|
|
1032
1386
|
}
|
|
1033
1387
|
if (!mock)
|
|
1034
1388
|
return missing();
|
|
1035
1389
|
// A new version is written by whoever writes it; without an explicit
|
|
1036
1390
|
// session the variant's own session carries it.
|
|
1037
1391
|
session ??= await store.getSession(target.sessionId);
|
|
1038
|
-
const slots = mock.kind === "page" ? await pageSlots(project, surfaces) : undefined;
|
|
1039
1392
|
const updated = await store.updatePost(target.id, {
|
|
1040
1393
|
surfaces,
|
|
1041
1394
|
title,
|
|
1042
1395
|
...(body.variantKnobs !== undefined ? { knobs: variantKnobs.value } : {}),
|
|
1043
|
-
|
|
1396
|
+
...(note !== undefined ? { note } : {}),
|
|
1397
|
+
embeds,
|
|
1044
1398
|
from,
|
|
1045
1399
|
prompt,
|
|
1046
1400
|
author,
|
|
1047
1401
|
});
|
|
1048
1402
|
if (!updated)
|
|
1049
1403
|
return fail(404, "variant not found");
|
|
1050
|
-
|
|
1404
|
+
const flow = flowIn ? await placeFlow(project, flowIn.name, screen, flowIn.step) : undefined;
|
|
1405
|
+
mock = await updateMockAfterWrite(mock, {
|
|
1406
|
+
state,
|
|
1407
|
+
situation,
|
|
1408
|
+
kind,
|
|
1409
|
+
knobs: knobs.value,
|
|
1410
|
+
widths,
|
|
1411
|
+
area,
|
|
1412
|
+
source,
|
|
1413
|
+
session,
|
|
1414
|
+
});
|
|
1051
1415
|
announcePost(mock, updated, false);
|
|
1052
1416
|
if (prompt && ctx.request)
|
|
1053
1417
|
notifyPublish(mock, updated, prompt, ctx);
|
|
@@ -1056,35 +1420,165 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1056
1420
|
applied,
|
|
1057
1421
|
nudges,
|
|
1058
1422
|
writer: session,
|
|
1423
|
+
flow,
|
|
1059
1424
|
});
|
|
1060
1425
|
}
|
|
1426
|
+
// `flow` names a flow; `step` is the 1-based place of this screen in it
|
|
1427
|
+
// (moving it if it is there already), 0 takes the screen out, and without
|
|
1428
|
+
// `step` a screen not yet in the flow joins at its end.
|
|
1429
|
+
function flowArg(body) {
|
|
1430
|
+
const stepIn = body.step;
|
|
1431
|
+
if (body.flow === undefined || body.flow === null) {
|
|
1432
|
+
return stepIn === undefined || stepIn === null
|
|
1433
|
+
? undefined
|
|
1434
|
+
: fail(400, '"step" needs "flow": the flow\'s name, e.g. "Morning"');
|
|
1435
|
+
}
|
|
1436
|
+
const name = str(body.flow, MAX_LABEL);
|
|
1437
|
+
if (!name)
|
|
1438
|
+
return fail(400, '"flow" must be the flow\'s name, e.g. "Morning"');
|
|
1439
|
+
if (stepIn === undefined || stepIn === null)
|
|
1440
|
+
return { name };
|
|
1441
|
+
const step = Number(stepIn);
|
|
1442
|
+
if (!Number.isInteger(step) || step < 0) {
|
|
1443
|
+
return fail(400, '"step" must be the 1-based place in the flow, or 0 to take it out');
|
|
1444
|
+
}
|
|
1445
|
+
return { name, step };
|
|
1446
|
+
}
|
|
1447
|
+
// Checked before anything is written, so a full flow leaves no new version behind.
|
|
1448
|
+
async function flowFull(project, name, screen, step) {
|
|
1449
|
+
if (step === 0)
|
|
1450
|
+
return null;
|
|
1451
|
+
const flows = await store.listFlows(project);
|
|
1452
|
+
const flow = flows.find((f) => f.name === name);
|
|
1453
|
+
if (!flow && flows.length >= MAX_FLOWS) {
|
|
1454
|
+
return fail(400, `${project} already has ${MAX_FLOWS} flows; reuse one`);
|
|
1455
|
+
}
|
|
1456
|
+
if (flow &&
|
|
1457
|
+
flow.steps.length >= MAX_FLOW_STEPS &&
|
|
1458
|
+
!flow.steps.some((x) => sameScreen(x, screen))) {
|
|
1459
|
+
return fail(400, `flow "${name}" already has ${MAX_FLOW_STEPS} steps`);
|
|
1460
|
+
}
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
async function placeFlow(project, name, screen, step) {
|
|
1464
|
+
let at = step;
|
|
1465
|
+
if (at === undefined) {
|
|
1466
|
+
const flow = (await store.listFlows(project)).find((f) => f.name === name);
|
|
1467
|
+
const i = flow ? flow.steps.findIndex((x) => sameScreen(x, screen)) : -1;
|
|
1468
|
+
at = i >= 0 ? i + 1 : MAX_FLOW_STEPS + 1;
|
|
1469
|
+
}
|
|
1470
|
+
const flow = await store.placeFlowStep(project, name, screen, at);
|
|
1471
|
+
return { name, steps: flow?.steps ?? [] };
|
|
1472
|
+
}
|
|
1473
|
+
// The project's flows as `read` returns them: only steps whose screen exists.
|
|
1474
|
+
async function flowsView(project, mocks) {
|
|
1475
|
+
const bySlug = new Map(mocks.map((m) => [m.slug, m]));
|
|
1476
|
+
const exists = (x) => {
|
|
1477
|
+
const m = bySlug.get(x.mock);
|
|
1478
|
+
return !!m && (x.state === null ? m.states.length === 0 : m.states.includes(x.state));
|
|
1479
|
+
};
|
|
1480
|
+
return (await store.listFlows(project))
|
|
1481
|
+
.map((f) => ({ name: f.name, steps: f.steps.filter(exists) }))
|
|
1482
|
+
.filter((f) => f.steps.length > 0);
|
|
1483
|
+
}
|
|
1484
|
+
// `fork`: "variant" or "variant@N", the sibling version a new variant starts
|
|
1485
|
+
// from.
|
|
1486
|
+
function forkArg(v) {
|
|
1487
|
+
if (v === undefined || v === null || v === "")
|
|
1488
|
+
return undefined;
|
|
1489
|
+
const bad = '"fork" must be "<variant>" or "<variant>@<version>", e.g. "quiet@2"';
|
|
1490
|
+
if (typeof v !== "string")
|
|
1491
|
+
return fail(400, bad);
|
|
1492
|
+
const m = /^(.+?)(?:@(\d+))?$/.exec(v.trim());
|
|
1493
|
+
const variant = m && str(m[1], MAX_LABEL);
|
|
1494
|
+
if (!variant)
|
|
1495
|
+
return fail(400, bad);
|
|
1496
|
+
const version = m[2] === undefined ? undefined : Number(m[2]);
|
|
1497
|
+
if (version !== undefined && version < 1)
|
|
1498
|
+
return fail(400, bad);
|
|
1499
|
+
return version === undefined ? { variant } : { variant, version };
|
|
1500
|
+
}
|
|
1501
|
+
// An optional free-text field of the mock (`area`, `source`): absent keeps
|
|
1502
|
+
// it, "" or null clears it.
|
|
1503
|
+
function labelArg(body, key, max) {
|
|
1504
|
+
const v = body?.[key];
|
|
1505
|
+
if (v === undefined)
|
|
1506
|
+
return undefined;
|
|
1507
|
+
if (v === null)
|
|
1508
|
+
return null;
|
|
1509
|
+
if (typeof v !== "string")
|
|
1510
|
+
return fail(400, `"${key}" must be a string; "" clears it`);
|
|
1511
|
+
return str(v, max) ?? null;
|
|
1512
|
+
}
|
|
1513
|
+
// `widths`: the device widths the mock is judged at, in the agent's order;
|
|
1514
|
+
// [] hands the choice back to the viewer.
|
|
1515
|
+
function widthsArg(v) {
|
|
1516
|
+
if (v === undefined)
|
|
1517
|
+
return undefined;
|
|
1518
|
+
const bad = `"widths" must be up to ${MAX_WIDTHS} integer px widths between ${MIN_WIDTH} and ${MAX_WIDTH}, e.g. [390, 1280]`;
|
|
1519
|
+
if (!Array.isArray(v) || v.length > MAX_WIDTHS)
|
|
1520
|
+
return fail(400, bad);
|
|
1521
|
+
const out = [];
|
|
1522
|
+
for (const w of v) {
|
|
1523
|
+
if (!Number.isInteger(w) || w < MIN_WIDTH || w > MAX_WIDTH)
|
|
1524
|
+
return fail(400, bad);
|
|
1525
|
+
if (!out.includes(w))
|
|
1526
|
+
out.push(w);
|
|
1527
|
+
}
|
|
1528
|
+
return out;
|
|
1529
|
+
}
|
|
1061
1530
|
// `parts`: new outer html per part ("name" or "name#key"), spliced into the
|
|
1062
1531
|
// variant's html so a tweak costs the tweak rather than the whole document.
|
|
1063
|
-
|
|
1064
|
-
|
|
1532
|
+
// `fills`: the same per slot alternative ("part#fill"), replaced or added.
|
|
1533
|
+
function editsArg(body, key) {
|
|
1534
|
+
if (body?.[key] === undefined)
|
|
1065
1535
|
return undefined;
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1536
|
+
const edits = body[key];
|
|
1537
|
+
const shape = key === "parts"
|
|
1538
|
+
? '{ "<part name>": "<html for that part>" }'
|
|
1539
|
+
: '{ "<part>#<fill>": "<html for that fill>" }';
|
|
1540
|
+
if (!edits || typeof edits !== "object" || Array.isArray(edits)) {
|
|
1541
|
+
return fail(400, `"${key}" must be an object: ${shape}`);
|
|
1069
1542
|
}
|
|
1070
|
-
const entries = Object.entries(
|
|
1543
|
+
const entries = Object.entries(edits);
|
|
1071
1544
|
if (entries.length === 0)
|
|
1072
|
-
return fail(400,
|
|
1545
|
+
return fail(400, `"${key}" is empty; name at least one ${key.slice(0, -1)}`);
|
|
1073
1546
|
if (entries.some(([, html]) => typeof html !== "string")) {
|
|
1074
|
-
return fail(400,
|
|
1547
|
+
return fail(400, `each "${key}" value must be the html string for that ${key.slice(0, -1)}`);
|
|
1075
1548
|
}
|
|
1076
|
-
return
|
|
1549
|
+
return edits;
|
|
1077
1550
|
}
|
|
1078
1551
|
// A write may add a state, retitle the mock, declare knobs, and makes its
|
|
1079
1552
|
// session the one a reply is delivered to.
|
|
1080
1553
|
async function updateMockAfterWrite(mock, w) {
|
|
1081
|
-
|
|
1554
|
+
let states;
|
|
1555
|
+
let situations;
|
|
1556
|
+
if (w.state !== null && !mock.states.includes(w.state)) {
|
|
1557
|
+
if (w.situation) {
|
|
1558
|
+
// After its state and that state's other situations, so the order of
|
|
1559
|
+
// states reads moment by moment.
|
|
1560
|
+
const { of, name } = w.situation;
|
|
1561
|
+
const own = mock.situations[of] ?? [];
|
|
1562
|
+
const last = [of, ...own.map((n) => situationKey(of, n))]
|
|
1563
|
+
.map((st) => mock.states.indexOf(st))
|
|
1564
|
+
.reduce((a, b) => Math.max(a, b), -1);
|
|
1565
|
+
states = [...mock.states.slice(0, last + 1), w.state, ...mock.states.slice(last + 1)];
|
|
1566
|
+
situations = { ...mock.situations, [of]: [...own, name] };
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
states = [...mock.states, w.state];
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1082
1572
|
const knobs = Object.keys(w.knobs).length ? { ...mock.knobs, ...w.knobs } : undefined;
|
|
1083
1573
|
const updated = await store.updateMock(mock.id, {
|
|
1084
1574
|
...(states ? { states } : {}),
|
|
1575
|
+
...(situations ? { situations } : {}),
|
|
1085
1576
|
...(w.kind ? { kind: w.kind } : {}),
|
|
1086
1577
|
...(w.title ? { title: w.title } : {}),
|
|
1087
1578
|
...(knobs ? { knobs } : {}),
|
|
1579
|
+
...(w.widths ? { widths: w.widths } : {}),
|
|
1580
|
+
...(w.area === undefined ? {} : { area: w.area }),
|
|
1581
|
+
...(w.source === undefined ? {} : { source: w.source }),
|
|
1088
1582
|
...(w.session ? { sessionId: w.session.id } : {}),
|
|
1089
1583
|
});
|
|
1090
1584
|
if (!updated)
|
|
@@ -1115,17 +1609,62 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1115
1609
|
}
|
|
1116
1610
|
const open = rows.reduce((n, r) => n + r.open, 0);
|
|
1117
1611
|
return ok({
|
|
1118
|
-
...(project ? { project } : {}),
|
|
1612
|
+
...(project ? { project, flows: await flowsView(project, mocks) } : {}),
|
|
1119
1613
|
mocks: rows,
|
|
1120
1614
|
open,
|
|
1121
1615
|
openMocks: rows.filter((r) => r.open > 0).length,
|
|
1122
1616
|
pending,
|
|
1123
1617
|
});
|
|
1124
1618
|
}
|
|
1125
|
-
// The
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1619
|
+
// The project as a tree: every mock with its variants per state (status,
|
|
1620
|
+
// lineage, what its embeds resolve to) and the components it embeds and is
|
|
1621
|
+
// embedded in, and the declared flows. What the viewer's Home and an agent's
|
|
1622
|
+
// `read --tree` lay out.
|
|
1623
|
+
async function treeFlow(query) {
|
|
1624
|
+
const project = str(query.project, MAX_TITLE);
|
|
1625
|
+
if (!project)
|
|
1626
|
+
return fail(400, 'provide "project"');
|
|
1627
|
+
const mocks = await store.listMocks(project);
|
|
1628
|
+
const nodes = [];
|
|
1629
|
+
const pending = [];
|
|
1630
|
+
for (const m of mocks) {
|
|
1631
|
+
const posts = await store.listPosts({ mockId: m.id });
|
|
1632
|
+
const views = await embedViews(m, posts);
|
|
1633
|
+
const embeds = new Set();
|
|
1634
|
+
for (const p of posts) {
|
|
1635
|
+
if (!isLive(p.status))
|
|
1636
|
+
continue;
|
|
1637
|
+
for (const x of p.surfaces) {
|
|
1638
|
+
if (x.kind === "html")
|
|
1639
|
+
for (const t of parseEmbedTags(x.html))
|
|
1640
|
+
embeds.add(t.slug);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
nodes.push(treeNodeView(m, posts, {
|
|
1644
|
+
settled: settledValues(await replyPayloads(m.id), m, posts),
|
|
1645
|
+
embeds: [...embeds],
|
|
1646
|
+
embedViews: views,
|
|
1647
|
+
}));
|
|
1648
|
+
pending.push(pendingOf(m, posts));
|
|
1649
|
+
}
|
|
1650
|
+
const bySlug = new Map(nodes.map((n) => [n.slug, n]));
|
|
1651
|
+
for (const n of nodes) {
|
|
1652
|
+
n.embeds = n.embeds.filter((s) => bySlug.has(s));
|
|
1653
|
+
for (const s of n.embeds)
|
|
1654
|
+
bySlug.get(s).embeddedIn.push(n.slug);
|
|
1655
|
+
}
|
|
1656
|
+
return ok({
|
|
1657
|
+
project,
|
|
1658
|
+
tree: nodes,
|
|
1659
|
+
flows: await flowsView(project, mocks),
|
|
1660
|
+
areaOrder: await areaOrderOf(project),
|
|
1661
|
+
pending,
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
async function replyPayloads(mockId) {
|
|
1665
|
+
return (await store.listComments({ mockId }))
|
|
1666
|
+
.filter((c) => c.kind === "reply" && c.payload && !c.payload.edited)
|
|
1667
|
+
.map((c) => c.payload);
|
|
1129
1668
|
}
|
|
1130
1669
|
async function getMockFlow(ref, query) {
|
|
1131
1670
|
const mock = await resolveMock(ref, query.project, query.session);
|
|
@@ -1136,7 +1675,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1136
1675
|
...mockDetailView(mock, posts, {
|
|
1137
1676
|
body: query.body,
|
|
1138
1677
|
history: query.history,
|
|
1139
|
-
|
|
1678
|
+
settled: settledValues(await replyPayloads(mock.id), mock, posts),
|
|
1679
|
+
embeds: await embedViews(mock, posts),
|
|
1140
1680
|
}),
|
|
1141
1681
|
pending: pendingOf(mock, posts),
|
|
1142
1682
|
});
|
|
@@ -1151,31 +1691,45 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1151
1691
|
const states = (mock.states.length ? mock.states : [null]).filter((s) => wantedState === undefined || s === wantedState);
|
|
1152
1692
|
if (states.length === 0)
|
|
1153
1693
|
return fail(404, `${mock.slug} has no state "${wantedState}"`);
|
|
1694
|
+
const replies = await replyPayloads(mock.id);
|
|
1695
|
+
const lastReply = replies[replies.length - 1] ?? null;
|
|
1696
|
+
const settled = settledValues(replies, mock, posts);
|
|
1154
1697
|
const entries = [];
|
|
1155
1698
|
for (const state of states) {
|
|
1156
1699
|
const inState = posts.filter((p) => p.state === state);
|
|
1157
|
-
const
|
|
1700
|
+
const found = (wantedVariant && inState.find((p) => p.variant === wantedVariant)) ||
|
|
1158
1701
|
inState.find((p) => p.status === "accepted") ||
|
|
1159
1702
|
inState.find((p) => p.status === "open") ||
|
|
1160
1703
|
inState[0];
|
|
1161
|
-
if (!
|
|
1704
|
+
if (!found)
|
|
1162
1705
|
continue;
|
|
1163
|
-
if (wantedVariant &&
|
|
1706
|
+
if (wantedVariant && found.variant !== wantedVariant)
|
|
1164
1707
|
continue;
|
|
1708
|
+
const post = postAtVersion(found, inUseVersion(found)) ?? found;
|
|
1165
1709
|
const html = post.surfaces.find((s) => s.kind === "html");
|
|
1710
|
+
// With fills, the state as decided: only the chosen fills, by every
|
|
1711
|
+
// value sent so far (defaults before any reply). The authored markup with
|
|
1712
|
+
// every fill stays available as `allFills`.
|
|
1713
|
+
const slots = slotsInSurfaces(post.surfaces);
|
|
1714
|
+
const raw = html && html.kind === "html" ? html.html : "";
|
|
1715
|
+
const composed = slots.length ? composeFills(raw, chosenFills(slots, settled)) : raw;
|
|
1166
1716
|
entries.push({
|
|
1167
1717
|
state,
|
|
1168
1718
|
variant: post.variant,
|
|
1169
1719
|
postId: post.id,
|
|
1170
1720
|
version: post.version,
|
|
1171
|
-
|
|
1172
|
-
|
|
1721
|
+
// The agent's newer versions wait until the user puts one in use.
|
|
1722
|
+
...(found.version !== post.version ? { latest: found.version } : {}),
|
|
1723
|
+
status: variantStatus(post.status),
|
|
1724
|
+
html: composed,
|
|
1725
|
+
...(slots.length ? { allFills: raw } : {}),
|
|
1173
1726
|
markdown: postToMarkdown(post, {
|
|
1174
1727
|
postUrl: `${ctx.base}/s/${post.id}`,
|
|
1175
1728
|
assetBase: ctx.base,
|
|
1176
1729
|
}),
|
|
1177
1730
|
history: [
|
|
1178
|
-
...[...
|
|
1731
|
+
...[...found.history]
|
|
1732
|
+
.filter((h) => !(found.hiddenVersions ?? []).includes(h.version))
|
|
1179
1733
|
.sort((a, b) => a.version - b.version)
|
|
1180
1734
|
.map((h) => ({
|
|
1181
1735
|
version: h.version,
|
|
@@ -1184,10 +1738,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1184
1738
|
prompt: h.prompt ?? "",
|
|
1185
1739
|
})),
|
|
1186
1740
|
{
|
|
1187
|
-
version:
|
|
1188
|
-
at:
|
|
1189
|
-
from:
|
|
1190
|
-
prompt:
|
|
1741
|
+
version: found.version,
|
|
1742
|
+
at: found.updatedAt,
|
|
1743
|
+
from: found.from ?? null,
|
|
1744
|
+
prompt: found.prompt ?? "",
|
|
1191
1745
|
},
|
|
1192
1746
|
],
|
|
1193
1747
|
screenshotUrl: screenshots ? `${ctx.base}/s/${post.id}.png?v=${post.version}` : null,
|
|
@@ -1198,7 +1752,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1198
1752
|
variants: posts.map(variantLabel),
|
|
1199
1753
|
});
|
|
1200
1754
|
}
|
|
1201
|
-
const
|
|
1755
|
+
const decisions = lastReply ? decisionsFor(mock, posts, lastReply) : null;
|
|
1202
1756
|
return ok({
|
|
1203
1757
|
project: mock.project,
|
|
1204
1758
|
mock: mock.slug,
|
|
@@ -1206,7 +1760,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1206
1760
|
kind: mock.kind,
|
|
1207
1761
|
states: entries,
|
|
1208
1762
|
knobs: mock.knobs,
|
|
1209
|
-
|
|
1763
|
+
settled,
|
|
1764
|
+
reply: lastReply,
|
|
1765
|
+
...(decisions ? { decisions } : {}),
|
|
1210
1766
|
});
|
|
1211
1767
|
}
|
|
1212
1768
|
// --- asks ---
|
|
@@ -1239,6 +1795,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1239
1795
|
const options = [];
|
|
1240
1796
|
// Seeded with the viewer's write-in, so a label "Other" slugs to "other-2".
|
|
1241
1797
|
const optionIds = new Set([OTHER_ID]);
|
|
1798
|
+
// The agent's own pick, shown as a quiet mark on its option: one per ask.
|
|
1799
|
+
const picks = raw.options.filter((o) => o && typeof o === "object" && o.recommended === true).length;
|
|
1800
|
+
if (picks > 1)
|
|
1801
|
+
return `ask "${text}" recommends ${picks} options; recommend one`;
|
|
1242
1802
|
for (const o of raw.options) {
|
|
1243
1803
|
const opt = typeof o === "string" ? { label: o } : o;
|
|
1244
1804
|
const label = str(opt?.label, 200);
|
|
@@ -1262,12 +1822,32 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1262
1822
|
return checked.error;
|
|
1263
1823
|
set = checked.value;
|
|
1264
1824
|
}
|
|
1265
|
-
|
|
1825
|
+
const pick = opt.recommended === true ? { recommended: true } : {};
|
|
1826
|
+
if (opt.version !== undefined) {
|
|
1827
|
+
const pinned = versionOption(opt.version, label, variant, { scope, state }, mock, posts);
|
|
1828
|
+
if (typeof pinned === "string")
|
|
1829
|
+
return pinned;
|
|
1830
|
+
options.push({ id, label, ...pinned, ...(set ? { set } : {}), ...pick });
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
options.push({
|
|
1834
|
+
id,
|
|
1835
|
+
label,
|
|
1836
|
+
...(variant ? { variant } : {}),
|
|
1837
|
+
...(set ? { set } : {}),
|
|
1838
|
+
...pick,
|
|
1839
|
+
});
|
|
1266
1840
|
}
|
|
1267
1841
|
let id = slugify(str(raw.id, 64) ?? text).slice(0, 40);
|
|
1268
1842
|
if (!str(raw.id, 64))
|
|
1269
1843
|
for (let n = 2; taken.has(id); n++)
|
|
1270
1844
|
id = `${slugify(text).slice(0, 36)}-${n}`;
|
|
1845
|
+
const after = raw.after === undefined ? undefined : str(raw.after, 64);
|
|
1846
|
+
if (raw.after !== undefined) {
|
|
1847
|
+
if (!after || after === id || !taken.has(after)) {
|
|
1848
|
+
return `ask "${text}": after must name an earlier ask's id (asked before it or earlier in this call)`;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1271
1851
|
return {
|
|
1272
1852
|
id,
|
|
1273
1853
|
text,
|
|
@@ -1276,9 +1856,49 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1276
1856
|
...(part ? { part } : {}),
|
|
1277
1857
|
options,
|
|
1278
1858
|
...(raw.multi === true ? { multi: true } : {}),
|
|
1859
|
+
...(after ? { after } : {}),
|
|
1279
1860
|
at: new Date().toISOString(),
|
|
1280
1861
|
};
|
|
1281
1862
|
}
|
|
1863
|
+
// A version option names one visible version of one variant in the ask's
|
|
1864
|
+
// state (`optionState`); the variant may be left out when that state has
|
|
1865
|
+
// only one, or one accepted.
|
|
1866
|
+
function versionOption(raw, label, variant, ask, mock, posts) {
|
|
1867
|
+
const version = Number(raw);
|
|
1868
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
1869
|
+
return `option "${label}": version must be a positive integer`;
|
|
1870
|
+
}
|
|
1871
|
+
if (ask.scope === "part") {
|
|
1872
|
+
return `option "${label}" names a version, which puts it in use: ask with scope "state" or "mock", not "part"`;
|
|
1873
|
+
}
|
|
1874
|
+
if (ask.state === undefined && mock.states.length > 1) {
|
|
1875
|
+
return `option "${label}" names a version: give the ask a state (${mock.slug} has ${mock.states.map((x) => `"${x}"`).join(", ")})`;
|
|
1876
|
+
}
|
|
1877
|
+
const state = optionState(ask, mock);
|
|
1878
|
+
const inState = posts.filter((p) => p.state === state);
|
|
1879
|
+
const accepted = inState.filter((p) => p.status === "accepted");
|
|
1880
|
+
const post = variant
|
|
1881
|
+
? inState.find((p) => p.variant === variant)
|
|
1882
|
+
: inState.length === 1
|
|
1883
|
+
? inState[0]
|
|
1884
|
+
: accepted.length === 1
|
|
1885
|
+
? accepted[0]
|
|
1886
|
+
: undefined;
|
|
1887
|
+
const where = state === null ? mock.slug : `"${state}"`;
|
|
1888
|
+
if (!post) {
|
|
1889
|
+
return variant
|
|
1890
|
+
? `option "${label}" names variant "${variant}", which ${where} does not have`
|
|
1891
|
+
: `option "${label}" names version ${version} but ${where} has several variants (${inState.map((p) => p.variant).join(", ")}): name one`;
|
|
1892
|
+
}
|
|
1893
|
+
const hidden = new Set(post.hiddenVersions ?? []);
|
|
1894
|
+
const visible = [post.version, ...post.history.map((h) => h.version)]
|
|
1895
|
+
.filter((v) => !hidden.has(v))
|
|
1896
|
+
.sort((a, b) => a - b);
|
|
1897
|
+
if (!visible.includes(version)) {
|
|
1898
|
+
return `option "${label}" names ${post.variant} v${version}${hidden.has(version) ? ", which is hidden" : ""}; ${post.variant} has ${visible.map((v) => `v${v}`).join(", ")}`;
|
|
1899
|
+
}
|
|
1900
|
+
return { variant: post.variant, version };
|
|
1901
|
+
}
|
|
1282
1902
|
// Ask the user structured questions. An ask whose id matches an existing one
|
|
1283
1903
|
// replaces it (and reopens it); anything else is appended.
|
|
1284
1904
|
async function askFlow(ref, body, ctx) {
|
|
@@ -1297,11 +1917,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1297
1917
|
const asks = [...mock.asks];
|
|
1298
1918
|
const taken = new Set(asks.map((a) => a.id));
|
|
1299
1919
|
const added = [];
|
|
1920
|
+
const skipped = [];
|
|
1300
1921
|
for (const entry of raw) {
|
|
1301
1922
|
const ask = sanitizeAsk(entry, mock, posts, taken);
|
|
1302
1923
|
if (typeof ask === "string")
|
|
1303
1924
|
return fail(400, ask);
|
|
1304
1925
|
const at = asks.findIndex((a) => a.id === ask.id);
|
|
1926
|
+
// The user closed this one as not worth answering; asking it again is
|
|
1927
|
+
// exactly the noise they closed away. Only their Reopen revives it.
|
|
1928
|
+
if (at >= 0 && asks[at].dismissed) {
|
|
1929
|
+
skipped.push(ask.id);
|
|
1930
|
+
continue;
|
|
1931
|
+
}
|
|
1305
1932
|
if (at >= 0)
|
|
1306
1933
|
asks[at] = ask;
|
|
1307
1934
|
else
|
|
@@ -1309,11 +1936,31 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1309
1936
|
taken.add(ask.id);
|
|
1310
1937
|
added.push(ask);
|
|
1311
1938
|
}
|
|
1939
|
+
const skippedNudges = skipped.map((id) => `ask "${id}": the user dismissed it when they closed ${mock.slug}; not asked again. Don't re-ask it.`);
|
|
1940
|
+
if (!added.length) {
|
|
1941
|
+
return ok({
|
|
1942
|
+
mock: mock.slug,
|
|
1943
|
+
mockId: mock.id,
|
|
1944
|
+
project: mock.project,
|
|
1945
|
+
asks: [],
|
|
1946
|
+
skipped,
|
|
1947
|
+
open: openCount(mock, posts),
|
|
1948
|
+
url: mockUrl(ctx.base, mock),
|
|
1949
|
+
nudges: skippedNudges,
|
|
1950
|
+
feedback: mock.sessionId ? await collectFeedback(mock.sessionId, ctx.base) : [],
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1312
1953
|
let sessionId = mock.sessionId;
|
|
1313
1954
|
if (typeof body.session === "string" && (await store.getSession(body.session))) {
|
|
1314
1955
|
sessionId = body.session;
|
|
1315
1956
|
}
|
|
1316
|
-
|
|
1957
|
+
// A new question on a closed mock brings it back to the open list, or it
|
|
1958
|
+
// would wait where the user no longer looks.
|
|
1959
|
+
const updated = await store.updateMock(mock.id, {
|
|
1960
|
+
asks,
|
|
1961
|
+
sessionId,
|
|
1962
|
+
...(mock.closedAt ? { closedAt: null } : {}),
|
|
1963
|
+
});
|
|
1317
1964
|
if (!updated)
|
|
1318
1965
|
return fail(404, "mock not found");
|
|
1319
1966
|
mock = updated;
|
|
@@ -1341,12 +1988,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1341
1988
|
url: mockUrl(ctx.base, mock),
|
|
1342
1989
|
});
|
|
1343
1990
|
const feedback = session ? await collectFeedback(session.id, ctx.base) : [];
|
|
1344
|
-
const nudges = askNudges(added, mock.asks);
|
|
1991
|
+
const nudges = [...skippedNudges, ...askNudges(added, mock.asks)];
|
|
1345
1992
|
return ok({
|
|
1346
1993
|
mock: mock.slug,
|
|
1347
1994
|
mockId: mock.id,
|
|
1348
1995
|
project: mock.project,
|
|
1349
1996
|
asks: added,
|
|
1997
|
+
...(skipped.length ? { skipped } : {}),
|
|
1350
1998
|
open: openCount(mock, posts),
|
|
1351
1999
|
url: mockUrl(ctx.base, mock),
|
|
1352
2000
|
...(nudges.length ? { nudges } : {}),
|
|
@@ -1367,15 +2015,33 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1367
2015
|
if (reaction !== undefined && !REACTIONS.includes(reaction))
|
|
1368
2016
|
return `reaction must be one of ${REACTIONS.join(", ")}`;
|
|
1369
2017
|
const text = str(c?.text, MAX_COMMENT_TEXT);
|
|
1370
|
-
|
|
2018
|
+
const borrowIn = c?.borrow;
|
|
2019
|
+
if (!text && !reaction && !borrowIn)
|
|
1371
2020
|
return "every comment needs text";
|
|
1372
2021
|
const state = stateArg(c.state) ?? null;
|
|
1373
2022
|
if (state !== null && !mock.states.includes(state))
|
|
1374
2023
|
return `${mock.slug} has no state "${state}"`;
|
|
1375
2024
|
const part = str(c.part, 200) ?? null;
|
|
1376
2025
|
const variant = str(c.variant, 200);
|
|
1377
|
-
|
|
2026
|
+
const post = variant ? posts.find((p) => p.variant === variant && p.state === state) : null;
|
|
2027
|
+
if (variant && !post)
|
|
1378
2028
|
return `${mock.slug} has no variant "${variant}"${state ? ` in "${state}"` : ""}`;
|
|
2029
|
+
const version = c.version;
|
|
2030
|
+
if (version !== undefined &&
|
|
2031
|
+
(!post || !Number.isInteger(version) || version < 1 || version > post.version))
|
|
2032
|
+
return `${mock.slug} has no version ${String(version)}${variant ? ` of "${variant}"` : ""}`;
|
|
2033
|
+
let borrow;
|
|
2034
|
+
if (borrowIn !== undefined) {
|
|
2035
|
+
const from = str(borrowIn?.from, 240);
|
|
2036
|
+
const at = from ? /^(.+)@(\d+)$/.exec(from) : null;
|
|
2037
|
+
const source = at && posts.find((p) => p.variant === at[1] && p.state === state);
|
|
2038
|
+
if (!at || !source || Number(at[2]) < 1 || Number(at[2]) > source.version)
|
|
2039
|
+
return `borrow.from must be variant@version of "${mock.slug}"${state ? ` in "${state}"` : ""}`;
|
|
2040
|
+
if (!post || version === undefined)
|
|
2041
|
+
return "a borrow names the version it goes into";
|
|
2042
|
+
const borrowed = str(borrowIn?.part, 200);
|
|
2043
|
+
borrow = { from: at[0], ...(borrowed ? { part: borrowed } : {}) };
|
|
2044
|
+
}
|
|
1379
2045
|
const a = c.anchor && typeof c.anchor === "object" ? c.anchor : null;
|
|
1380
2046
|
const anchor = a ? sanitizePartAnchor(a) : undefined;
|
|
1381
2047
|
const next = {
|
|
@@ -1385,13 +2051,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1385
2051
|
...(anchor ? { anchor } : {}),
|
|
1386
2052
|
...(reaction ? { reaction: reaction } : {}),
|
|
1387
2053
|
...(variant ? { variant } : {}),
|
|
2054
|
+
...(version !== undefined ? { version: version } : {}),
|
|
2055
|
+
...(borrow ? { borrow } : {}),
|
|
1388
2056
|
};
|
|
1389
2057
|
// One reaction per target: a later tap on the same part replaces the earlier one.
|
|
1390
2058
|
if (reaction) {
|
|
1391
2059
|
const same = out.findIndex((o) => o.reaction &&
|
|
1392
2060
|
o.part === part &&
|
|
1393
2061
|
o.state === state &&
|
|
1394
|
-
(o.variant ?? null) === (variant ?? null)
|
|
2062
|
+
(o.variant ?? null) === (variant ?? null) &&
|
|
2063
|
+
(o.version ?? null) === (version ?? null));
|
|
1395
2064
|
if (same >= 0)
|
|
1396
2065
|
out.splice(same, 1);
|
|
1397
2066
|
}
|
|
@@ -1498,18 +2167,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1498
2167
|
}
|
|
1499
2168
|
return out;
|
|
1500
2169
|
}
|
|
1501
|
-
function
|
|
2170
|
+
function sanitizeBorrowed(raw, posts) {
|
|
1502
2171
|
if (raw === undefined || raw === null)
|
|
1503
2172
|
return {};
|
|
1504
2173
|
if (typeof raw !== "object" || Array.isArray(raw))
|
|
1505
|
-
return '"
|
|
2174
|
+
return '"borrowed" must be an object';
|
|
1506
2175
|
const variants = new Set(posts.map((p) => p.variant));
|
|
1507
2176
|
const out = {};
|
|
1508
2177
|
for (const [part, variant] of Object.entries(raw)) {
|
|
1509
2178
|
if (!part || part.length > 200)
|
|
1510
|
-
return "
|
|
2179
|
+
return "borrowed keys are part names";
|
|
1511
2180
|
if (typeof variant !== "string" || !variants.has(variant)) {
|
|
1512
|
-
return `
|
|
2181
|
+
return `borrowed "${part}" names a variant that does not exist`;
|
|
1513
2182
|
}
|
|
1514
2183
|
out[part] = variant;
|
|
1515
2184
|
}
|
|
@@ -1522,9 +2191,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1522
2191
|
const answers = sanitizeAnswers(pick("answers"), mock, posts);
|
|
1523
2192
|
if (typeof answers === "string")
|
|
1524
2193
|
return answers;
|
|
1525
|
-
const
|
|
1526
|
-
if (typeof
|
|
1527
|
-
return
|
|
2194
|
+
const borrowed = sanitizeBorrowed(pick("borrowed"), posts);
|
|
2195
|
+
if (typeof borrowed === "string")
|
|
2196
|
+
return borrowed;
|
|
1528
2197
|
const tuned = checkKnobValues(pick("tuned"), mock, posts, "tuned");
|
|
1529
2198
|
if (!tuned.ok)
|
|
1530
2199
|
return tuned.error;
|
|
@@ -1559,7 +2228,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1559
2228
|
return {
|
|
1560
2229
|
version,
|
|
1561
2230
|
answers,
|
|
1562
|
-
|
|
2231
|
+
borrowed,
|
|
1563
2232
|
tuned: tuned.value,
|
|
1564
2233
|
comments,
|
|
1565
2234
|
others,
|
|
@@ -1604,33 +2273,44 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1604
2273
|
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
1605
2274
|
return ok({ draft: null });
|
|
1606
2275
|
}
|
|
1607
|
-
function
|
|
2276
|
+
function sanitizeVerdict(raw, mock, posts) {
|
|
1608
2277
|
if (raw === undefined || raw === null)
|
|
1609
2278
|
return undefined;
|
|
1610
2279
|
const kind = raw.kind;
|
|
1611
|
-
if (kind !== "
|
|
1612
|
-
return '
|
|
1613
|
-
}
|
|
2280
|
+
if (kind !== "use" && kind !== "revise")
|
|
2281
|
+
return 'verdict.kind must be "use" or "revise"';
|
|
1614
2282
|
const chosen = chooseVariant(mock, posts, stateArg(raw.state), str(raw.variant, MAX_LABEL));
|
|
1615
2283
|
if (isResult(chosen))
|
|
1616
2284
|
return chosen.body.error;
|
|
1617
2285
|
return { kind, state: chosen.state, variant: chosen.variant };
|
|
1618
2286
|
}
|
|
1619
|
-
// Which variants a reply
|
|
1620
|
-
// the chosen variant(s) in every state the ask covers and archives
|
|
1621
|
-
// siblings there; a part-scoped pick is a
|
|
1622
|
-
// variant
|
|
1623
|
-
|
|
1624
|
-
|
|
2287
|
+
// Which variants a reply puts in use and archives. A variant-bound answer puts
|
|
2288
|
+
// the chosen variant(s) in use in every state the ask covers and archives
|
|
2289
|
+
// their siblings there; a part-scoped pick is a borrow, not a verdict. The
|
|
2290
|
+
// built-in variant questions flip the same way, per state. Each variant is put
|
|
2291
|
+
// in use at the version the reply was made on (`version`, a mock version), not
|
|
2292
|
+
// whatever the agent published since — or, for a version option, at its version.
|
|
2293
|
+
function replyFlips(mock, posts, answers, verdict, version) {
|
|
2294
|
+
const accept = new Map();
|
|
1625
2295
|
const archive = new Set();
|
|
1626
|
-
const settle = (inState, chosen) => {
|
|
2296
|
+
const settle = (inState, chosen, pins) => {
|
|
1627
2297
|
if (!inState.some((p) => chosen.has(p.variant)))
|
|
1628
2298
|
return;
|
|
1629
2299
|
for (const p of inState) {
|
|
1630
|
-
if (chosen.has(p.variant))
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2300
|
+
if (!chosen.has(p.variant)) {
|
|
2301
|
+
if (isLive(p.status))
|
|
2302
|
+
archive.add(p.id);
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
const pin = pins?.get(p.variant);
|
|
2306
|
+
// A version option outranks a looser pick of the same variant (the
|
|
2307
|
+
// built-in Which variant?, or the agent's own variant question) in the
|
|
2308
|
+
// same reply.
|
|
2309
|
+
if (pin === undefined && accept.has(p.id))
|
|
2310
|
+
continue;
|
|
2311
|
+
// A pinned version history retention has since dropped falls back to
|
|
2312
|
+
// what the stage showed, rather than pinning one nothing can render.
|
|
2313
|
+
accept.set(p.id, pin !== undefined && postAtVersion(p, pin) ? pin : versionAt(p, version));
|
|
1634
2314
|
}
|
|
1635
2315
|
};
|
|
1636
2316
|
const builtins = Object.keys(answers)
|
|
@@ -1641,22 +2321,23 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1641
2321
|
if (answer === undefined || ask.scope === "part")
|
|
1642
2322
|
continue;
|
|
1643
2323
|
const ids = Array.isArray(answer) ? answer : [answer];
|
|
1644
|
-
const
|
|
2324
|
+
const picked = ask.options.filter((o) => ids.includes(o.id) && o.variant);
|
|
2325
|
+
const pinned = picked.filter((o) => o.version !== undefined);
|
|
2326
|
+
if (pinned.length) {
|
|
2327
|
+
const state = optionState(ask, mock);
|
|
2328
|
+
settle(posts.filter((p) => p.state === state), new Set(pinned.map((o) => o.variant)), new Map(pinned.map((o) => [o.variant, o.version])));
|
|
2329
|
+
}
|
|
2330
|
+
const chosen = new Set(picked.filter((o) => o.version === undefined).map((o) => o.variant));
|
|
1645
2331
|
if (chosen.size === 0)
|
|
1646
2332
|
continue;
|
|
1647
2333
|
const states = ask.scope === "state" ? [ask.state ?? null] : mock.states.length ? mock.states : [null];
|
|
1648
2334
|
for (const state of states)
|
|
1649
2335
|
settle(posts.filter((p) => p.state === state), chosen);
|
|
1650
2336
|
}
|
|
1651
|
-
if (
|
|
1652
|
-
settle(posts.filter((p) => p.state ===
|
|
1653
|
-
}
|
|
1654
|
-
else if (decision?.kind === "drop") {
|
|
1655
|
-
const post = posts.find((p) => p.state === decision.state && p.variant === decision.variant);
|
|
1656
|
-
if (post)
|
|
1657
|
-
archive.add(post.id);
|
|
2337
|
+
if (verdict?.kind === "use") {
|
|
2338
|
+
settle(posts.filter((p) => p.state === verdict.state), new Set([verdict.variant]));
|
|
1658
2339
|
}
|
|
1659
|
-
for (const id of accept)
|
|
2340
|
+
for (const id of accept.keys())
|
|
1660
2341
|
archive.delete(id);
|
|
1661
2342
|
return { accept, archive };
|
|
1662
2343
|
}
|
|
@@ -1669,6 +2350,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1669
2350
|
if (answer === undefined && other === undefined && note === undefined)
|
|
1670
2351
|
return a;
|
|
1671
2352
|
const next = { ...a };
|
|
2353
|
+
delete next.dismissed;
|
|
1672
2354
|
if (answer !== undefined || other !== undefined) {
|
|
1673
2355
|
delete next.answer;
|
|
1674
2356
|
delete next.other;
|
|
@@ -1704,13 +2386,13 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1704
2386
|
if (typeof draft === "string")
|
|
1705
2387
|
return fail(400, draft);
|
|
1706
2388
|
const text = str(body?.text, MAX_COMMENT_TEXT);
|
|
1707
|
-
const
|
|
1708
|
-
if (typeof
|
|
1709
|
-
return fail(400,
|
|
2389
|
+
const verdict = sanitizeVerdict(body?.verdict, mock, posts);
|
|
2390
|
+
if (typeof verdict === "string")
|
|
2391
|
+
return fail(400, verdict);
|
|
1710
2392
|
const empty = !text &&
|
|
1711
|
-
!
|
|
2393
|
+
!verdict &&
|
|
1712
2394
|
!Object.keys(draft.answers).length &&
|
|
1713
|
-
!Object.keys(draft.
|
|
2395
|
+
!Object.keys(draft.borrowed).length &&
|
|
1714
2396
|
!Object.keys(draft.tuned).length &&
|
|
1715
2397
|
!draft.comments.length &&
|
|
1716
2398
|
!Object.keys(draft.others ?? {}).length &&
|
|
@@ -1725,7 +2407,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1725
2407
|
mockId: mock.id,
|
|
1726
2408
|
version: draft.version,
|
|
1727
2409
|
answers: draft.answers,
|
|
1728
|
-
|
|
2410
|
+
borrowed: draft.borrowed,
|
|
1729
2411
|
tuned: draft.tuned,
|
|
1730
2412
|
comments: draft.comments,
|
|
1731
2413
|
...(Object.keys(draft.others ?? {}).length ? { others: draft.others } : {}),
|
|
@@ -1733,17 +2415,21 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1733
2415
|
...(draft.otherImages ? { otherImages: draft.otherImages } : {}),
|
|
1734
2416
|
...(draft.noteImages ? { noteImages: draft.noteImages } : {}),
|
|
1735
2417
|
...(text ? { text } : {}),
|
|
1736
|
-
...(decision ? { decision } : {}),
|
|
1737
2418
|
};
|
|
1738
2419
|
const asks = mock.asks.map((a) => sentAsk(a, draft));
|
|
1739
|
-
const flips = replyFlips(mock, posts, draft.answers,
|
|
2420
|
+
const flips = replyFlips(mock, posts, draft.answers, verdict, draft.version);
|
|
2421
|
+
if (verdict) {
|
|
2422
|
+
const target = posts.find((p) => p.state === verdict.state && p.variant === verdict.variant);
|
|
2423
|
+
const pinned = verdict.kind === "use" && target ? flips.accept.get(target.id) : undefined;
|
|
2424
|
+
payload.verdict = pinned === undefined ? verdict : { ...verdict, version: pinned };
|
|
2425
|
+
}
|
|
1740
2426
|
const comment = await store.commitReply({
|
|
1741
2427
|
mockId: mock.id,
|
|
1742
2428
|
sessionId,
|
|
1743
2429
|
text: text ?? "",
|
|
1744
2430
|
payload,
|
|
1745
2431
|
asks,
|
|
1746
|
-
accept: [...flips.accept],
|
|
2432
|
+
accept: [...flips.accept].map(([id, version]) => ({ id, version })),
|
|
1747
2433
|
archive: [...flips.archive],
|
|
1748
2434
|
});
|
|
1749
2435
|
if (!comment)
|
|
@@ -1751,6 +2437,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1751
2437
|
announceComment(comment);
|
|
1752
2438
|
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
1753
2439
|
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
2440
|
+
void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
|
|
1754
2441
|
const after = await store.listPosts({ mockId: mock.id });
|
|
1755
2442
|
for (const p of after) {
|
|
1756
2443
|
if (flips.accept.has(p.id) || flips.archive.has(p.id)) {
|
|
@@ -1768,22 +2455,223 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1768
2455
|
event: "decision",
|
|
1769
2456
|
project: mock.project,
|
|
1770
2457
|
slug: mock.slug,
|
|
1771
|
-
variant:
|
|
2458
|
+
variant: verdict?.variant ?? "",
|
|
1772
2459
|
version: draft.version,
|
|
1773
|
-
text: text ||
|
|
2460
|
+
text: text || verdict?.kind || "reply",
|
|
1774
2461
|
url: mockUrl(ctx.base, mock),
|
|
1775
2462
|
});
|
|
1776
2463
|
const [delivered] = await withDelivered([comment]);
|
|
1777
2464
|
return ok({
|
|
1778
2465
|
reply: delivered,
|
|
1779
|
-
|
|
2466
|
+
inUse: after
|
|
1780
2467
|
.filter((p) => flips.accept.has(p.id))
|
|
1781
|
-
.map((p) => ({ state: p.state, variant: p.variant })),
|
|
2468
|
+
.map((p) => ({ state: p.state, variant: p.variant, version: inUseVersion(p) })),
|
|
1782
2469
|
archived: after
|
|
1783
2470
|
.filter((p) => flips.archive.has(p.id))
|
|
1784
2471
|
.map((p) => ({ state: p.state, variant: p.variant })),
|
|
1785
2472
|
}, 201);
|
|
1786
2473
|
}
|
|
2474
|
+
// "Use this": the user puts one exact version of a variant in use. It
|
|
2475
|
+
// archives the variant's live siblings in that state and reaches the agent
|
|
2476
|
+
// once, as a reply carrying the verdict, through the same cursor as a Send;
|
|
2477
|
+
// the user's unsent draft is left for their next Send.
|
|
2478
|
+
async function useFlow(ref, body, ctx) {
|
|
2479
|
+
if (!ctx.viewer)
|
|
2480
|
+
return viewerOnly();
|
|
2481
|
+
const mock = await resolveMock(ref);
|
|
2482
|
+
if (isResult(mock))
|
|
2483
|
+
return mock;
|
|
2484
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2485
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
2486
|
+
if (isResult(chosen))
|
|
2487
|
+
return chosen;
|
|
2488
|
+
const version = Number(body?.version);
|
|
2489
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
2490
|
+
return fail(400, '"version" must be a positive integer');
|
|
2491
|
+
}
|
|
2492
|
+
if (!postAtVersion(chosen, version)) {
|
|
2493
|
+
return fail(404, `${chosen.variant} has no version ${version}`, {
|
|
2494
|
+
versions: [chosen.version, ...chosen.history.map((h) => h.version)],
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
const inUse = { state: chosen.state, variant: chosen.variant, version };
|
|
2498
|
+
if (chosen.status === "accepted" && chosen.acceptedVersion === version) {
|
|
2499
|
+
return ok({ reply: null, inUse, archived: [] });
|
|
2500
|
+
}
|
|
2501
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2502
|
+
const sessionId = mock.sessionId ?? latest?.sessionId;
|
|
2503
|
+
if (!sessionId)
|
|
2504
|
+
return fail(409, `${mock.slug} has no agent session to reply to`);
|
|
2505
|
+
const archive = posts.filter((p) => p.state === chosen.state && p.id !== chosen.id && isLive(p.status));
|
|
2506
|
+
const comment = await store.commitReply({
|
|
2507
|
+
mockId: mock.id,
|
|
2508
|
+
sessionId,
|
|
2509
|
+
text: "",
|
|
2510
|
+
payload: {
|
|
2511
|
+
mockId: mock.id,
|
|
2512
|
+
version,
|
|
2513
|
+
answers: {},
|
|
2514
|
+
borrowed: {},
|
|
2515
|
+
tuned: {},
|
|
2516
|
+
comments: [],
|
|
2517
|
+
verdict: { kind: "use", ...inUse },
|
|
2518
|
+
},
|
|
2519
|
+
asks: mock.asks,
|
|
2520
|
+
accept: [{ id: chosen.id, version }],
|
|
2521
|
+
archive: archive.map((p) => p.id),
|
|
2522
|
+
keepDraft: true,
|
|
2523
|
+
});
|
|
2524
|
+
if (!comment)
|
|
2525
|
+
return fail(409, `${mock.slug} has no agent session to reply to`);
|
|
2526
|
+
announceComment(comment);
|
|
2527
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
2528
|
+
void announceEmbedders(mock).catch((err) => console.warn("[mockpit] embedders", err));
|
|
2529
|
+
for (const p of [chosen, ...archive]) {
|
|
2530
|
+
bus.broadcast({
|
|
2531
|
+
type: "post-updated",
|
|
2532
|
+
id: p.id,
|
|
2533
|
+
mockId: mock.id,
|
|
2534
|
+
sessionId: p.sessionId,
|
|
2535
|
+
version: p.version,
|
|
2536
|
+
by: "user",
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
fireNotify({
|
|
2540
|
+
event: "decision",
|
|
2541
|
+
project: mock.project,
|
|
2542
|
+
slug: mock.slug,
|
|
2543
|
+
variant: chosen.variant,
|
|
2544
|
+
version,
|
|
2545
|
+
text: "use",
|
|
2546
|
+
url: mockUrl(ctx.base, mock),
|
|
2547
|
+
});
|
|
2548
|
+
const [delivered] = await withDelivered([comment]);
|
|
2549
|
+
return ok({
|
|
2550
|
+
reply: delivered,
|
|
2551
|
+
inUse,
|
|
2552
|
+
archived: archive.map((p) => ({ state: p.state, variant: p.variant })),
|
|
2553
|
+
}, 201);
|
|
2554
|
+
}
|
|
2555
|
+
// The user hides one version of a variant from the history, or brings it
|
|
2556
|
+
// back. Hidden, never removed: replies and embeds may point at it, so it still
|
|
2557
|
+
// renders by URL. The version in use can't be hidden, nor a state's last.
|
|
2558
|
+
async function hideFlow(ref, body, ctx) {
|
|
2559
|
+
if (!ctx.viewer)
|
|
2560
|
+
return viewerOnly();
|
|
2561
|
+
const mock = await resolveMock(ref);
|
|
2562
|
+
if (isResult(mock))
|
|
2563
|
+
return mock;
|
|
2564
|
+
if (typeof body?.hidden !== "boolean")
|
|
2565
|
+
return fail(400, '"hidden" must be true or false');
|
|
2566
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2567
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
2568
|
+
if (isResult(chosen))
|
|
2569
|
+
return chosen;
|
|
2570
|
+
const version = Number(body?.version);
|
|
2571
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
2572
|
+
return fail(400, '"version" must be a positive integer');
|
|
2573
|
+
}
|
|
2574
|
+
if (!postAtVersion(chosen, version)) {
|
|
2575
|
+
return fail(404, `${chosen.variant} has no version ${version}`);
|
|
2576
|
+
}
|
|
2577
|
+
if (body.hidden) {
|
|
2578
|
+
if (chosen.status === "accepted" && inUseVersion(chosen) === version) {
|
|
2579
|
+
return fail(409, `v${version} of ${chosen.variant} is in use; use another version first`);
|
|
2580
|
+
}
|
|
2581
|
+
const visible = posts
|
|
2582
|
+
.filter((p) => p.state === chosen.state)
|
|
2583
|
+
.flatMap((p) => [p.version, ...p.history.map((h) => h.version)].map((v) => [p, v]))
|
|
2584
|
+
.filter(([p, v]) => !(p.hiddenVersions ?? []).includes(v));
|
|
2585
|
+
if (visible.length === 1 && visible[0][0].id === chosen.id && visible[0][1] === version) {
|
|
2586
|
+
return fail(409, "the last visible version can't be hidden");
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const updated = await store.setVersionHidden(chosen.id, version, body.hidden);
|
|
2590
|
+
if (!updated)
|
|
2591
|
+
return fail(404, "variant not found");
|
|
2592
|
+
bus.broadcast({
|
|
2593
|
+
type: "post-updated",
|
|
2594
|
+
id: updated.id,
|
|
2595
|
+
mockId: mock.id,
|
|
2596
|
+
sessionId: updated.sessionId,
|
|
2597
|
+
version: updated.version,
|
|
2598
|
+
by: "user",
|
|
2599
|
+
});
|
|
2600
|
+
return ok({
|
|
2601
|
+
state: updated.state,
|
|
2602
|
+
variant: updated.variant,
|
|
2603
|
+
version,
|
|
2604
|
+
hidden: body.hidden,
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
// The user closes a mock from the project list: its open asks are dismissed
|
|
2608
|
+
// unanswered, an unsent draft goes with them, and the agent hears it once,
|
|
2609
|
+
// as a close through the same cursor as a Send.
|
|
2610
|
+
async function closeFlow(ref, ctx) {
|
|
2611
|
+
if (!ctx.viewer)
|
|
2612
|
+
return viewerOnly();
|
|
2613
|
+
const mock = await resolveMock(ref);
|
|
2614
|
+
if (isResult(mock))
|
|
2615
|
+
return mock;
|
|
2616
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2617
|
+
if (mock.closedAt)
|
|
2618
|
+
return ok({ mock: mockSummaryView(mock, posts), dismissed: [] });
|
|
2619
|
+
const dismissed = [...openAsks(mock).map((a) => a.id), ...builtinAskIds(mock, posts, mock.id)];
|
|
2620
|
+
const asks = mock.asks.map((a) => (askAnswered(a) ? a : { ...a, dismissed: true }));
|
|
2621
|
+
const at = new Date().toISOString();
|
|
2622
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2623
|
+
const sessionId = mock.sessionId ?? latest?.sessionId;
|
|
2624
|
+
const session = sessionId ? await store.getSession(sessionId) : null;
|
|
2625
|
+
const comment = session
|
|
2626
|
+
? await store.commitReply({
|
|
2627
|
+
mockId: mock.id,
|
|
2628
|
+
sessionId: session.id,
|
|
2629
|
+
text: "",
|
|
2630
|
+
payload: {
|
|
2631
|
+
mockId: mock.id,
|
|
2632
|
+
version: Math.max(1, ...posts.map((p) => p.version)),
|
|
2633
|
+
answers: {},
|
|
2634
|
+
borrowed: {},
|
|
2635
|
+
tuned: {},
|
|
2636
|
+
comments: [],
|
|
2637
|
+
closed: { dismissed },
|
|
2638
|
+
},
|
|
2639
|
+
asks,
|
|
2640
|
+
accept: [],
|
|
2641
|
+
archive: [],
|
|
2642
|
+
closedAt: at,
|
|
2643
|
+
})
|
|
2644
|
+
: null;
|
|
2645
|
+
if (comment)
|
|
2646
|
+
announceComment(comment);
|
|
2647
|
+
else {
|
|
2648
|
+
// No agent to tell: the mock still leaves the open list.
|
|
2649
|
+
await store.updateMock(mock.id, { asks, closedAt: at });
|
|
2650
|
+
await store.putDraft(mock.id, null);
|
|
2651
|
+
}
|
|
2652
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
2653
|
+
bus.broadcast({ type: "draft-updated", mockId: mock.id });
|
|
2654
|
+
const after = (await store.getMock(mock.id)) ?? mock;
|
|
2655
|
+
return ok({ mock: mockSummaryView(after, posts), dismissed });
|
|
2656
|
+
}
|
|
2657
|
+
// Back from Closed: the asks it dismissed are open again. The agent was
|
|
2658
|
+
// told they were dismissed; answers given now reach it as a normal Send.
|
|
2659
|
+
async function reopenFlow(ref, ctx) {
|
|
2660
|
+
if (!ctx.viewer)
|
|
2661
|
+
return viewerOnly();
|
|
2662
|
+
const mock = await resolveMock(ref);
|
|
2663
|
+
if (isResult(mock))
|
|
2664
|
+
return mock;
|
|
2665
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2666
|
+
if (!mock.closedAt)
|
|
2667
|
+
return ok({ mock: mockSummaryView(mock, posts) });
|
|
2668
|
+
const asks = mock.asks.map(({ dismissed: _, ...a }) => a);
|
|
2669
|
+
const updated = await store.updateMock(mock.id, { asks, closedAt: null });
|
|
2670
|
+
if (!updated)
|
|
2671
|
+
return fail(404, "mock not found");
|
|
2672
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
2673
|
+
return ok({ mock: mockSummaryView(updated, posts) });
|
|
2674
|
+
}
|
|
1787
2675
|
async function restoreFlow(ref, body) {
|
|
1788
2676
|
const mock = await resolveMock(ref, body?.project);
|
|
1789
2677
|
if (isResult(mock))
|
|
@@ -1803,7 +2691,72 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1803
2691
|
version: updated.version,
|
|
1804
2692
|
by: "user",
|
|
1805
2693
|
});
|
|
1806
|
-
return ok({
|
|
2694
|
+
return ok({
|
|
2695
|
+
state: updated.state,
|
|
2696
|
+
variant: updated.variant,
|
|
2697
|
+
status: variantStatus(updated.status),
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
// The user archives a variant they're done with, short of choosing another:
|
|
2701
|
+
// Studio's × on its last version. Restore is the way back. The variant in
|
|
2702
|
+
// use stays, because archiving it would leave the state with nothing chosen
|
|
2703
|
+
// and no reply saying why.
|
|
2704
|
+
async function archiveFlow(ref, body, ctx) {
|
|
2705
|
+
if (!ctx.viewer)
|
|
2706
|
+
return viewerOnly();
|
|
2707
|
+
const mock = await resolveMock(ref);
|
|
2708
|
+
if (isResult(mock))
|
|
2709
|
+
return mock;
|
|
2710
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2711
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
2712
|
+
if (isResult(chosen))
|
|
2713
|
+
return chosen;
|
|
2714
|
+
if (variantStatus(chosen.status) === "inUse")
|
|
2715
|
+
return fail(409, `${chosen.variant} is in use`);
|
|
2716
|
+
const updated = await store.setPostStatus(chosen.id, "archived");
|
|
2717
|
+
if (!updated)
|
|
2718
|
+
return fail(404, "variant not found");
|
|
2719
|
+
bus.broadcast({
|
|
2720
|
+
type: "post-updated",
|
|
2721
|
+
id: updated.id,
|
|
2722
|
+
mockId: mock.id,
|
|
2723
|
+
sessionId: updated.sessionId,
|
|
2724
|
+
version: updated.version,
|
|
2725
|
+
by: "user",
|
|
2726
|
+
});
|
|
2727
|
+
return ok({
|
|
2728
|
+
state: updated.state,
|
|
2729
|
+
variant: updated.variant,
|
|
2730
|
+
status: variantStatus(updated.status),
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2733
|
+
// The user stars (or unstars) a variant: a favourite to come back to, which
|
|
2734
|
+
// decides nothing. Viewer-only; the agent reads it as `starred`.
|
|
2735
|
+
async function starFlow(ref, body, ctx) {
|
|
2736
|
+
if (!ctx.viewer)
|
|
2737
|
+
return viewerOnly();
|
|
2738
|
+
const mock = await resolveMock(ref);
|
|
2739
|
+
if (isResult(mock))
|
|
2740
|
+
return mock;
|
|
2741
|
+
if (typeof body?.starred !== "boolean")
|
|
2742
|
+
return fail(400, '"starred" must be true or false');
|
|
2743
|
+
const posts = await store.listPosts({ mockId: mock.id });
|
|
2744
|
+
const chosen = chooseVariant(mock, posts, stateArg(body?.state), str(body?.variant, MAX_LABEL));
|
|
2745
|
+
if (isResult(chosen))
|
|
2746
|
+
return chosen;
|
|
2747
|
+
const updated = await store.setPostStarred(chosen.id, body.starred);
|
|
2748
|
+
if (!updated)
|
|
2749
|
+
return fail(404, "variant not found");
|
|
2750
|
+
bus.broadcast({
|
|
2751
|
+
type: "post-updated",
|
|
2752
|
+
id: updated.id,
|
|
2753
|
+
mockId: mock.id,
|
|
2754
|
+
sessionId: updated.sessionId,
|
|
2755
|
+
version: updated.version,
|
|
2756
|
+
by: "user",
|
|
2757
|
+
});
|
|
2758
|
+
bus.broadcast({ type: "mock-updated", id: mock.id, project: mock.project });
|
|
2759
|
+
return ok({ state: updated.state, variant: updated.variant, starred: !!updated.starred });
|
|
1807
2760
|
}
|
|
1808
2761
|
// D8 "restore as vN": the user brings an older version back as the newest
|
|
1809
2762
|
// one. Distinct from restoreFlow, which un-archives a variant. Viewer-only
|
|
@@ -1853,6 +2806,216 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
1853
2806
|
bus.broadcast({ type: "mock-deleted", id: mock.id, project: mock.project });
|
|
1854
2807
|
return ok({ ok: true });
|
|
1855
2808
|
}
|
|
2809
|
+
// A write's feedback for the session that made it, when it named one: an
|
|
2810
|
+
// edit needs no session, so without one there is no cursor to read from.
|
|
2811
|
+
const feedbackFor = async (session, base) => typeof session === "string" && session ? collectFeedback(session, base) : [];
|
|
2812
|
+
// `edit`: a mock's own fields, without a new version. A new slug rewrites
|
|
2813
|
+
// every reference to the old one in the project (store.renameMock), so pages
|
|
2814
|
+
// keep showing it and flows keep walking it.
|
|
2815
|
+
async function editMockFlow(ref, body, ctx) {
|
|
2816
|
+
if (!body || typeof body !== "object")
|
|
2817
|
+
return fail(400, "invalid JSON body");
|
|
2818
|
+
const given = ["slug", "title", "kind", "area", "source"].filter((k) => body[k] !== undefined);
|
|
2819
|
+
if (given.length === 0) {
|
|
2820
|
+
return fail(400, 'provide one of "slug", "title", "kind", "area" or "source"');
|
|
2821
|
+
}
|
|
2822
|
+
const mock = await resolveMock(ref, body.project, body.session);
|
|
2823
|
+
if (isResult(mock))
|
|
2824
|
+
return mock;
|
|
2825
|
+
const slugIn = body.slug === undefined ? undefined : str(body.slug, MAX_TITLE);
|
|
2826
|
+
if (body.slug !== undefined && !slugIn)
|
|
2827
|
+
return fail(400, '"slug" must name the mock, e.g. "onni"');
|
|
2828
|
+
const slug = slugIn === undefined ? undefined : slugify(slugIn);
|
|
2829
|
+
const title = str(body.title, MAX_TITLE);
|
|
2830
|
+
if (body.title !== undefined && !title)
|
|
2831
|
+
return fail(400, '"title" must be non-empty text');
|
|
2832
|
+
if (body.kind !== undefined && !isMockKind(body.kind)) {
|
|
2833
|
+
return fail(400, '"kind" must be page, component or sketch');
|
|
2834
|
+
}
|
|
2835
|
+
const kind = body.kind;
|
|
2836
|
+
const area = labelArg(body, "area", MAX_LABEL);
|
|
2837
|
+
if (isResult(area))
|
|
2838
|
+
return area;
|
|
2839
|
+
const source = labelArg(body, "source", MAX_SOURCE);
|
|
2840
|
+
if (isResult(source))
|
|
2841
|
+
return source;
|
|
2842
|
+
const renaming = slug !== undefined && slug !== mock.slug;
|
|
2843
|
+
if (renaming && (await store.findMock(mock.project, slug))) {
|
|
2844
|
+
return fail(409, `${mock.project} already has a mock "${slug}"; pick another slug`);
|
|
2845
|
+
}
|
|
2846
|
+
// Being embedded is what makes a mock a component: a sketch that is still
|
|
2847
|
+
// embedded would be promoted back on its embedder's next publish.
|
|
2848
|
+
if (kind === "sketch" && mock.kind !== "sketch") {
|
|
2849
|
+
const embedders = await embeddersOf(mock.project, mock.slug);
|
|
2850
|
+
if (embedders.length) {
|
|
2851
|
+
return fail(409, `${mock.slug} is embedded in ${embedders.map((m) => m.slug).join(", ")}; being embedded makes it a component. Take the embeds out first`, { embeddedIn: embedders.map((m) => m.slug) });
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
let next = mock;
|
|
2855
|
+
// The rename goes first: it is the part that can still lose a race for the slug.
|
|
2856
|
+
if (renaming) {
|
|
2857
|
+
const renamed = await store.renameMock(mock.id, slug);
|
|
2858
|
+
if (!renamed)
|
|
2859
|
+
return fail(409, `${mock.project} already has a mock "${slug}"`);
|
|
2860
|
+
next = renamed;
|
|
2861
|
+
clearRenderCache();
|
|
2862
|
+
}
|
|
2863
|
+
if (title !== undefined || kind !== undefined || area !== undefined || source !== undefined) {
|
|
2864
|
+
next =
|
|
2865
|
+
(await store.updateMock(mock.id, {
|
|
2866
|
+
...(title === undefined ? {} : { title }),
|
|
2867
|
+
...(kind === undefined ? {} : { kind }),
|
|
2868
|
+
...(area === undefined ? {} : { area }),
|
|
2869
|
+
...(source === undefined ? {} : { source }),
|
|
2870
|
+
})) ?? next;
|
|
2871
|
+
}
|
|
2872
|
+
bus.broadcast({ type: "mock-updated", id: next.id, project: next.project });
|
|
2873
|
+
if (renaming)
|
|
2874
|
+
await announceEmbedders(next);
|
|
2875
|
+
if (ctx.viewer)
|
|
2876
|
+
await tellAgentEdited(mock, next);
|
|
2877
|
+
// Flows are declared apart from kind, so a page that stops being one stays
|
|
2878
|
+
// in them until the agent takes it out.
|
|
2879
|
+
const nudges = [...titleNudges(title)];
|
|
2880
|
+
if (kind !== undefined && kind !== "page" && mock.kind === "page") {
|
|
2881
|
+
for (const f of await store.listFlows(next.project)) {
|
|
2882
|
+
if (!f.steps.some((x) => x.mock === next.slug))
|
|
2883
|
+
continue;
|
|
2884
|
+
nudges.push(`${next.slug} is still a step of flow "${f.name}"; publish it with flow "${f.name}" and step 0 to take it out`);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
return ok({
|
|
2888
|
+
mock: {
|
|
2889
|
+
id: next.id,
|
|
2890
|
+
project: next.project,
|
|
2891
|
+
slug: next.slug,
|
|
2892
|
+
title: next.title,
|
|
2893
|
+
kind: next.kind,
|
|
2894
|
+
...placeOf(next),
|
|
2895
|
+
states: next.states,
|
|
2896
|
+
},
|
|
2897
|
+
...(renaming ? { renamed: { from: mock.slug, to: next.slug } } : {}),
|
|
2898
|
+
url: mockUrl(ctx.base, next),
|
|
2899
|
+
...(nudges.length ? { nudges } : {}),
|
|
2900
|
+
feedback: await feedbackFor(body.session, ctx.base),
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
// The user moved a mock from Home (another kind or area): the agent hears it
|
|
2904
|
+
// once, through the same cursor as a Send, on the session driving the mock.
|
|
2905
|
+
// Nothing to tell when no agent has written it.
|
|
2906
|
+
async function tellAgentEdited(before, after) {
|
|
2907
|
+
const edited = {
|
|
2908
|
+
...(after.kind !== before.kind ? { kind: after.kind } : {}),
|
|
2909
|
+
...(after.area !== before.area ? { area: after.area } : {}),
|
|
2910
|
+
};
|
|
2911
|
+
if (Object.keys(edited).length === 0)
|
|
2912
|
+
return;
|
|
2913
|
+
const posts = await store.listPosts({ mockId: after.id });
|
|
2914
|
+
const latest = [...posts].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
2915
|
+
const sessionId = after.sessionId ?? latest?.sessionId;
|
|
2916
|
+
if (!sessionId)
|
|
2917
|
+
return;
|
|
2918
|
+
const comment = await store.commitReply({
|
|
2919
|
+
mockId: after.id,
|
|
2920
|
+
sessionId,
|
|
2921
|
+
text: "",
|
|
2922
|
+
payload: {
|
|
2923
|
+
mockId: after.id,
|
|
2924
|
+
version: Math.max(1, ...posts.map((p) => p.version)),
|
|
2925
|
+
answers: {},
|
|
2926
|
+
borrowed: {},
|
|
2927
|
+
tuned: {},
|
|
2928
|
+
comments: [],
|
|
2929
|
+
edited,
|
|
2930
|
+
},
|
|
2931
|
+
asks: after.asks,
|
|
2932
|
+
accept: [],
|
|
2933
|
+
archive: [],
|
|
2934
|
+
keepDraft: true,
|
|
2935
|
+
});
|
|
2936
|
+
if (comment)
|
|
2937
|
+
announceComment(comment);
|
|
2938
|
+
}
|
|
2939
|
+
const areaOrderOf = async (project) => {
|
|
2940
|
+
const raw = await store.getSetting(`${AREA_ORDER_SETTING}:${project}`);
|
|
2941
|
+
if (!raw)
|
|
2942
|
+
return [];
|
|
2943
|
+
try {
|
|
2944
|
+
const v = JSON.parse(raw);
|
|
2945
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
2946
|
+
}
|
|
2947
|
+
catch {
|
|
2948
|
+
return [];
|
|
2949
|
+
}
|
|
2950
|
+
};
|
|
2951
|
+
// `areaOrder`: the areas in the order Home lists them; [] (or "") hands the
|
|
2952
|
+
// order back to the alphabet.
|
|
2953
|
+
function areaOrderArg(v) {
|
|
2954
|
+
const list = typeof v === "string" ? v.split(",") : v;
|
|
2955
|
+
if (!Array.isArray(list) || list.some((x) => typeof x !== "string")) {
|
|
2956
|
+
return fail(400, '"areaOrder" must be a list of area names, e.g. ["Shell", "Food"]');
|
|
2957
|
+
}
|
|
2958
|
+
const names = [...new Set(list.map((x) => stripNul(x).trim()).filter(Boolean))];
|
|
2959
|
+
if (names.length > MAX_AREAS)
|
|
2960
|
+
return fail(400, `"areaOrder" takes at most ${MAX_AREAS} areas`);
|
|
2961
|
+
if (names.some((x) => x.length > MAX_LABEL)) {
|
|
2962
|
+
return fail(400, `an area name is at most ${MAX_LABEL} characters`);
|
|
2963
|
+
}
|
|
2964
|
+
return names;
|
|
2965
|
+
}
|
|
2966
|
+
// `edit` on a project: a new name for it, carrying its mocks, sessions,
|
|
2967
|
+
// flows and settings (refused onto a project that already has mocks), and
|
|
2968
|
+
// the order Home lists its areas in.
|
|
2969
|
+
async function editProjectFlow(name, body, ctx) {
|
|
2970
|
+
if (!body || typeof body !== "object")
|
|
2971
|
+
return fail(400, "invalid JSON body");
|
|
2972
|
+
const from = str(name, MAX_TITLE);
|
|
2973
|
+
if (!from)
|
|
2974
|
+
return fail(400, "provide the project to edit");
|
|
2975
|
+
if (body.name === undefined && body.areaOrder === undefined) {
|
|
2976
|
+
return fail(400, 'provide "name" (the project\'s new name) or "areaOrder"');
|
|
2977
|
+
}
|
|
2978
|
+
const to = body.name === undefined ? undefined : str(body.name, MAX_TITLE);
|
|
2979
|
+
if (body.name !== undefined && !to)
|
|
2980
|
+
return fail(400, '"name" must be the project\'s new name');
|
|
2981
|
+
if (to === from)
|
|
2982
|
+
return fail(400, `${from} already has that name`);
|
|
2983
|
+
const order = body.areaOrder === undefined ? undefined : areaOrderArg(body.areaOrder);
|
|
2984
|
+
if (isResult(order))
|
|
2985
|
+
return order;
|
|
2986
|
+
const mocks = await store.listMocks(from);
|
|
2987
|
+
if (mocks.length === 0)
|
|
2988
|
+
return fail(404, `no project "${from}" with mocks`);
|
|
2989
|
+
if (to !== undefined) {
|
|
2990
|
+
if (!(await store.renameProject(from, to))) {
|
|
2991
|
+
return fail(409, `${to} already has mocks; pick another name`);
|
|
2992
|
+
}
|
|
2993
|
+
clearRenderCache();
|
|
2994
|
+
}
|
|
2995
|
+
const project = to ?? from;
|
|
2996
|
+
if (order !== undefined) {
|
|
2997
|
+
await store.setSetting(`${AREA_ORDER_SETTING}:${project}`, JSON.stringify(order));
|
|
2998
|
+
}
|
|
2999
|
+
for (const m of mocks)
|
|
3000
|
+
bus.broadcast({ type: "mock-updated", id: m.id, project });
|
|
3001
|
+
const named = new Set(mocks.flatMap((m) => (m.area ? [m.area] : [])));
|
|
3002
|
+
const unnamed = (order ?? []).filter((a) => !named.has(a));
|
|
3003
|
+
return ok({
|
|
3004
|
+
project,
|
|
3005
|
+
...(to === undefined ? {} : { renamed: { from, to } }),
|
|
3006
|
+
...(order === undefined ? {} : { areaOrder: order }),
|
|
3007
|
+
mocks: mocks.length,
|
|
3008
|
+
url: `${ctx.base}/project/${encodeURIComponent(project)}`,
|
|
3009
|
+
...(unnamed.length
|
|
3010
|
+
? {
|
|
3011
|
+
nudges: [
|
|
3012
|
+
`no mock in ${project} has area ${unnamed.map((a) => `"${a}"`).join(", ")} yet; Home lists it once one does`,
|
|
3013
|
+
],
|
|
3014
|
+
}
|
|
3015
|
+
: {}),
|
|
3016
|
+
feedback: await feedbackFor(body.session, ctx.base),
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
1856
3019
|
// --- comments ---
|
|
1857
3020
|
function numberInRange(value, min, max) {
|
|
1858
3021
|
const n = Number(value);
|
|
@@ -2178,6 +3341,8 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2178
3341
|
// tells the agent something ("2 of 3 answered", or nobody is looking).
|
|
2179
3342
|
const pendingOf = (mock, posts) => ({
|
|
2180
3343
|
mock: mock.slug,
|
|
3344
|
+
// Closed by the user: nothing there is waiting on them, or on you.
|
|
3345
|
+
...(mock.closedAt ? { closed: true } : {}),
|
|
2181
3346
|
viewerOpen: viewing.has(mock.id),
|
|
2182
3347
|
draft: mock.draft
|
|
2183
3348
|
? {
|
|
@@ -2336,7 +3501,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2336
3501
|
maxSize: MAX_ASSET_BYTES,
|
|
2337
3502
|
onError: (c) => c.json({ error: `asset exceeds ${MAX_ASSET_BYTES} bytes` }, 413),
|
|
2338
3503
|
});
|
|
2339
|
-
const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN,
|
|
3504
|
+
const withOrigin = (text, c) => text.replaceAll(LOCAL_ORIGIN, requestOrigin(c.req.raw));
|
|
2340
3505
|
const injectHead = (text, head) => {
|
|
2341
3506
|
const headClose = text.lastIndexOf("</head>");
|
|
2342
3507
|
return headClose >= 0
|
|
@@ -2365,6 +3530,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2365
3530
|
? `window.__MOCKPIT_PUBLIC_READ__=${JSON.stringify(publicRead)};`
|
|
2366
3531
|
: "",
|
|
2367
3532
|
screenshots ? "window.__MOCKPIT_SCREENSHOTS__=true;" : "",
|
|
3533
|
+
renderRev ? `window.__MOCKPIT_RENDER_REV__=${JSON.stringify(renderRev)};` : "",
|
|
2368
3534
|
].join("");
|
|
2369
3535
|
return injectHead(text, `<script>${config}</script>`);
|
|
2370
3536
|
};
|
|
@@ -2388,7 +3554,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2388
3554
|
// with every pixel-affecting input pinned in the image URL so the Worker can
|
|
2389
3555
|
// cache it at the edge.
|
|
2390
3556
|
const mockPreviewHead = (mock, post, request, mode, rendererGeneration) => {
|
|
2391
|
-
const origin =
|
|
3557
|
+
const origin = requestOrigin(request);
|
|
2392
3558
|
const publicBasePath = requestBasePath(request);
|
|
2393
3559
|
const canonical = `${origin}${publicBasePath}/project/${encodeURIComponent(mock.project)}/${encodeURIComponent(mock.slug)}`;
|
|
2394
3560
|
const imageUrl = new URL(`${origin}${publicBasePath}/s/${post.id}.png`);
|
|
@@ -2423,7 +3589,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2423
3589
|
// sandboxed surface documents at /s/:id are *meant* to be framed and carry
|
|
2424
3590
|
// their own `sandbox` CSP header instead, so they never pass through here.
|
|
2425
3591
|
c.header("Content-Security-Policy", "frame-ancestors 'self'");
|
|
2426
|
-
const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml,
|
|
3592
|
+
const html = withAppHead(c.req.raw, await workspaceMode())(withDocumentTitle(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw, !!publicRead && !isAuthenticated(c), opts.title), opts.title));
|
|
2427
3593
|
if (!opts.preview)
|
|
2428
3594
|
return html;
|
|
2429
3595
|
const { mock, post } = opts.preview;
|
|
@@ -2467,7 +3633,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2467
3633
|
if (!mock)
|
|
2468
3634
|
return c.html(await configuredViewerHtml(c, { title: c.req.param("name") }));
|
|
2469
3635
|
const posts = await store.listPosts({ mockId: mock.id });
|
|
2470
|
-
const post = posts.find((p) => p.status
|
|
3636
|
+
const post = posts.find((p) => isLive(p.status)) ?? posts[0];
|
|
2471
3637
|
return c.html(await configuredViewerHtml(c, {
|
|
2472
3638
|
title: mock.title,
|
|
2473
3639
|
...(post ? { preview: { mock, post } } : {}),
|
|
@@ -2475,10 +3641,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2475
3641
|
});
|
|
2476
3642
|
// --- REST plumbing for the shared flows ---
|
|
2477
3643
|
const flowCtx = (c, strict = true) => ({
|
|
2478
|
-
base: `${
|
|
2479
|
-
//
|
|
2480
|
-
//
|
|
2481
|
-
viewer: c.req.
|
|
3644
|
+
base: `${requestOrigin(c.req.raw)}${requestBasePath(c.req.raw)}`,
|
|
3645
|
+
// Only the trusted viewer may act as the user (drafts, replies,
|
|
3646
|
+
// user-authored comments); server/viewerRequest.ts says how it is known.
|
|
3647
|
+
viewer: isViewerRequest(c.req.raw),
|
|
2482
3648
|
strict,
|
|
2483
3649
|
request: c.req.raw,
|
|
2484
3650
|
signal: c.req.raw.signal,
|
|
@@ -2488,6 +3654,17 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2488
3654
|
const flag = (c, key) => c.req.query(key) === "1";
|
|
2489
3655
|
// --- sessions ---
|
|
2490
3656
|
app.get("/api/sessions", async (c) => {
|
|
3657
|
+
// A watcher started with every conversation finds that conversation's
|
|
3658
|
+
// session without creating one, and never mistakes a sibling's in the same
|
|
3659
|
+
// repo for its own.
|
|
3660
|
+
const key = c.req.query("key");
|
|
3661
|
+
if (key !== undefined) {
|
|
3662
|
+
const session = key ? await store.getSessionByKey(key) : null;
|
|
3663
|
+
if (!session)
|
|
3664
|
+
return c.json([]);
|
|
3665
|
+
const posts = await store.listPosts({ sessionId: session.id });
|
|
3666
|
+
return c.json([sessionRowView(session, posts.length)]);
|
|
3667
|
+
}
|
|
2491
3668
|
const [sessions, counts] = await Promise.all([
|
|
2492
3669
|
store.listSessions(),
|
|
2493
3670
|
store.countPostsBySession(),
|
|
@@ -2573,12 +3750,27 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2573
3750
|
variant: c.req.query("variant"),
|
|
2574
3751
|
}, flowCtx(c)));
|
|
2575
3752
|
});
|
|
3753
|
+
app.patch("/api/mocks/:id", async (c) => {
|
|
3754
|
+
const body = await jsonBody(c);
|
|
3755
|
+
// A slug names its project in the body or, as on the reads, in the query.
|
|
3756
|
+
const project = c.req.query("project");
|
|
3757
|
+
const withProject = body && typeof body === "object" && body.project === undefined && project
|
|
3758
|
+
? { ...body, project }
|
|
3759
|
+
: body;
|
|
3760
|
+
return send(c, await editMockFlow(c.req.param("id"), withProject, flowCtx(c)));
|
|
3761
|
+
});
|
|
2576
3762
|
app.delete("/api/mocks/:id", async (c) => send(c, await removeMockFlow(c.req.param("id"), { project: c.req.query("project") })));
|
|
2577
3763
|
app.post("/api/mocks/:id/asks", async (c) => send(c, await askFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2578
3764
|
app.get("/api/mocks/:id/draft", async (c) => send(c, await getDraftFlow(c.req.param("id"), flowCtx(c))));
|
|
2579
3765
|
app.put("/api/mocks/:id/draft", async (c) => send(c, await putDraftFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2580
3766
|
app.delete("/api/mocks/:id/draft", async (c) => send(c, await deleteDraftFlow(c.req.param("id"), flowCtx(c))));
|
|
2581
3767
|
app.post("/api/mocks/:id/reply", async (c) => send(c, await replyFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3768
|
+
app.post("/api/mocks/:id/use", async (c) => send(c, await useFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3769
|
+
app.post("/api/mocks/:id/hide", async (c) => send(c, await hideFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3770
|
+
app.post("/api/mocks/:id/close", async (c) => send(c, await closeFlow(c.req.param("id"), flowCtx(c))));
|
|
3771
|
+
app.post("/api/mocks/:id/reopen", async (c) => send(c, await reopenFlow(c.req.param("id"), flowCtx(c))));
|
|
3772
|
+
app.post("/api/mocks/:id/star", async (c) => send(c, await starFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
3773
|
+
app.post("/api/mocks/:id/archive", async (c) => send(c, await archiveFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
2582
3774
|
app.post("/api/mocks/:id/restore", async (c) => send(c, await restoreFlow(c.req.param("id"), await jsonBody(c))));
|
|
2583
3775
|
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))));
|
|
2584
3776
|
app.post("/api/mocks/:id/say", async (c) => send(c, await sayFlow(c.req.param("id"), await jsonBody(c), flowCtx(c))));
|
|
@@ -2708,7 +3900,15 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2708
3900
|
});
|
|
2709
3901
|
// The project's design system, imported by `mockpit init` and injected into
|
|
2710
3902
|
// every html surface of the project (see renderHtmlPage).
|
|
3903
|
+
app.get("/api/projects/:name/tree", async (c) => send(c, await treeFlow({ project: c.req.param("name") })));
|
|
3904
|
+
app.patch("/api/projects/:name", async (c) => send(c, await editProjectFlow(c.req.param("name"), await jsonBody(c), flowCtx(c))));
|
|
2711
3905
|
app.get("/api/projects/:name/design", async (c) => c.json(await designFor(c.req.param("name"))));
|
|
3906
|
+
// A project's rules file (.mockpit/rules.md, sent by `mockpit rules` and
|
|
3907
|
+
// `mockpit init`) and the user's own (`mockpit rules --mine`).
|
|
3908
|
+
app.get("/api/projects/:name/rules", async (c) => c.json({ text: (await store.getSetting(`${RULES_SETTING}:${c.req.param("name")}`)) ?? "" }));
|
|
3909
|
+
app.put("/api/projects/:name/rules", async (c) => send(c, await saveRules(`${RULES_SETTING}:${c.req.param("name")}`, await jsonBody(c))));
|
|
3910
|
+
app.get("/api/rules", async (c) => c.json({ text: (await userRulesText()) ?? "" }));
|
|
3911
|
+
app.put("/api/rules", async (c) => send(c, await saveRules(USER_RULES_SETTING, await jsonBody(c))));
|
|
2712
3912
|
app.put("/api/projects/:name/design", async (c) => {
|
|
2713
3913
|
const body = await c.req.json().catch(() => null);
|
|
2714
3914
|
if (!body || typeof body !== "object")
|
|
@@ -2986,7 +4186,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2986
4186
|
// agent publishing over one origin must never be served to a viewer on another.
|
|
2987
4187
|
// `knobs` is the canonical (validated, path-sorted) JSON of `?k=`, so two
|
|
2988
4188
|
// spellings of the same values share an entry.
|
|
2989
|
-
const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}`;
|
|
4189
|
+
const renderKey = (o) => `${o.postId}:${o.idx}:${o.version}:${o.mode ?? "os"}:${o.origin}:${o.knobs ?? ""}:${o.embeds ?? ""}`;
|
|
2990
4190
|
// The document itself. Shared by the GET below and the publish-time pre-warm,
|
|
2991
4191
|
// so both produce byte-identical output for one key.
|
|
2992
4192
|
async function buildSurfaceDoc(args) {
|
|
@@ -2994,12 +4194,10 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
2994
4194
|
const theme = themeById(DEFAULT_THEME_ID);
|
|
2995
4195
|
const themeId = theme.id;
|
|
2996
4196
|
if (surface.kind === "html") {
|
|
2997
|
-
//
|
|
2998
|
-
//
|
|
2999
|
-
//
|
|
3000
|
-
const html = args.
|
|
3001
|
-
? await expandPageHtml(args.mock.project, args.post, surface.html)
|
|
3002
|
-
: surface.html;
|
|
4197
|
+
// Embedded components are expanded server-side (the caller passes the
|
|
4198
|
+
// expansion), so the whole page is still ONE sandboxed document rather
|
|
4199
|
+
// than nested frames.
|
|
4200
|
+
const html = args.composed ?? surface.html;
|
|
3003
4201
|
return renderHtmlPage({
|
|
3004
4202
|
title: args.title,
|
|
3005
4203
|
html,
|
|
@@ -3026,9 +4224,9 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3026
4224
|
// The runtime's module cache makes every later call cheap, so there's no memo
|
|
3027
4225
|
// here to keep in sync. On the Worker the module is already inside the
|
|
3028
4226
|
// deployed bundle — the import defers evaluating it, not fetching it — so this
|
|
3029
|
-
// needs no network at runtime.
|
|
3030
|
-
//
|
|
3031
|
-
//
|
|
4227
|
+
// needs no network at runtime. Check it on real workerd (`npm run dev:cloud`):
|
|
4228
|
+
// a dynamic import resolving differently there is exactly the way this
|
|
4229
|
+
// optimization could break in production and nowhere else.
|
|
3032
4230
|
const { renderCode, renderDiff, renderMarkdown, renderTerminal } = await import("./richRender.js");
|
|
3033
4231
|
const rendered = surface.kind === "markdown"
|
|
3034
4232
|
? await renderMarkdown(surface, { theme: themeId, mode })
|
|
@@ -3062,8 +4260,18 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3062
4260
|
if (!isSandboxedSurfaceKind(surface.kind))
|
|
3063
4261
|
continue;
|
|
3064
4262
|
warmed++;
|
|
4263
|
+
const composed = surface.kind === "html" && hasEmbedTags(surface.html)
|
|
4264
|
+
? await composedHtml(mock.project, mock.slug, surface.html)
|
|
4265
|
+
: null;
|
|
3065
4266
|
for (const mode of ["light", "dark"]) {
|
|
3066
|
-
const key = renderKey({
|
|
4267
|
+
const key = renderKey({
|
|
4268
|
+
postId: post.id,
|
|
4269
|
+
idx,
|
|
4270
|
+
version: post.version,
|
|
4271
|
+
mode,
|
|
4272
|
+
origin,
|
|
4273
|
+
embeds: composed ? embedsKey(composed) : undefined,
|
|
4274
|
+
});
|
|
3067
4275
|
if (renderCache.has(key))
|
|
3068
4276
|
continue;
|
|
3069
4277
|
await cachedRender(key, () => buildSurfaceDoc({
|
|
@@ -3075,6 +4283,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3075
4283
|
mode,
|
|
3076
4284
|
origin,
|
|
3077
4285
|
design,
|
|
4286
|
+
composed: composed?.html,
|
|
3078
4287
|
}));
|
|
3079
4288
|
}
|
|
3080
4289
|
}
|
|
@@ -3088,7 +4297,11 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3088
4297
|
const modeParam = c.req.query("mode");
|
|
3089
4298
|
const mode = modeParam === "light" || modeParam === "dark" ? modeParam : undefined;
|
|
3090
4299
|
const kParam = c.req.query("k");
|
|
3091
|
-
|
|
4300
|
+
// `e`: the embeds key the caller saw (cache identity); `embed`: a peek at
|
|
4301
|
+
// older component versions, JSON {slug: version}.
|
|
4302
|
+
const eParam = c.req.query("e");
|
|
4303
|
+
const embedParam = c.req.query("embed");
|
|
4304
|
+
const origin = requestOrigin(c.req.raw);
|
|
3092
4305
|
// Cache-first. A hit needs nothing from the post row, and the row is the
|
|
3093
4306
|
// expensive part of this route (surfaces + every retained version). Only a
|
|
3094
4307
|
// version-pinned request can take this path: without `ver` the current
|
|
@@ -3096,13 +4309,16 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3096
4309
|
// request never does: its values are checked against the mock's knobs,
|
|
3097
4310
|
// which can change under a pinned version.
|
|
3098
4311
|
const pinned = Number(ver);
|
|
3099
|
-
|
|
4312
|
+
// A document with embeds is stored only under the key its resolved embeds
|
|
4313
|
+
// give, so a hit here was rendered with exactly the `e` this URL names.
|
|
4314
|
+
if (Number.isInteger(pinned) && pinned > 0 && kParam === undefined && !embedParam) {
|
|
3100
4315
|
const hit = renderCacheHit(renderKey({
|
|
3101
4316
|
postId: c.req.param("id"),
|
|
3102
4317
|
idx: Number(surfaceParam),
|
|
3103
4318
|
version: pinned,
|
|
3104
4319
|
mode,
|
|
3105
4320
|
origin,
|
|
4321
|
+
embeds: eParam,
|
|
3106
4322
|
}));
|
|
3107
4323
|
if (hit !== undefined) {
|
|
3108
4324
|
surfaceDocHeaders(c, true);
|
|
@@ -3158,16 +4374,59 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3158
4374
|
knobsKey = JSON.stringify(knobs);
|
|
3159
4375
|
}
|
|
3160
4376
|
}
|
|
4377
|
+
let composed = null;
|
|
4378
|
+
if (surface.kind === "html" && hasEmbedTags(surface.html) && mock) {
|
|
4379
|
+
let overrides;
|
|
4380
|
+
if (embedParam !== undefined) {
|
|
4381
|
+
let raw;
|
|
4382
|
+
try {
|
|
4383
|
+
raw = JSON.parse(embedParam);
|
|
4384
|
+
}
|
|
4385
|
+
catch {
|
|
4386
|
+
raw = null;
|
|
4387
|
+
}
|
|
4388
|
+
const ok = raw !== null &&
|
|
4389
|
+
typeof raw === "object" &&
|
|
4390
|
+
!Array.isArray(raw) &&
|
|
4391
|
+
Object.values(raw).every((v) => Number.isInteger(v) && v > 0);
|
|
4392
|
+
if (!ok || embedParam.length > MAX_KNOB_QUERY) {
|
|
4393
|
+
surfaceDocHeaders(c, false);
|
|
4394
|
+
return c.text("embed must be URL-encoded JSON of {slug: version}", 400);
|
|
4395
|
+
}
|
|
4396
|
+
overrides = raw;
|
|
4397
|
+
}
|
|
4398
|
+
composed = await composedHtml(mock.project, mock.slug, surface.html, overrides);
|
|
4399
|
+
}
|
|
4400
|
+
const eKey = composed ? embedsKey(composed) : undefined;
|
|
3161
4401
|
// Every input is in the URL (the palette is fixed, the mode is a query
|
|
3162
|
-
// param), so a version-pinned document never changes
|
|
3163
|
-
|
|
4402
|
+
// param), so a version-pinned document never changes, unless it follows a
|
|
4403
|
+
// component's latest version and the URL does not name that state (`e`).
|
|
4404
|
+
surfaceDocHeaders(c, ver != null && (eKey === undefined || eKey === eParam));
|
|
3164
4405
|
// Cache the finished document under the same key the pre-warm uses; the
|
|
3165
|
-
// resolved `version`
|
|
3166
|
-
//
|
|
3167
|
-
|
|
3168
|
-
|
|
4406
|
+
// resolved `version` and embeds key make it immutable, so a hit is always
|
|
4407
|
+
// correct.
|
|
4408
|
+
const cacheKey = renderKey({
|
|
4409
|
+
postId: post.id,
|
|
4410
|
+
idx,
|
|
4411
|
+
version,
|
|
4412
|
+
mode,
|
|
4413
|
+
origin,
|
|
4414
|
+
knobs: knobsKey,
|
|
4415
|
+
embeds: eKey,
|
|
4416
|
+
});
|
|
3169
4417
|
const design = mock ? await designFor(mock.project) : null;
|
|
3170
|
-
const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({
|
|
4418
|
+
const doc = await cachedRender(cacheKey, async () => buildSurfaceDoc({
|
|
4419
|
+
post,
|
|
4420
|
+
mock,
|
|
4421
|
+
surface,
|
|
4422
|
+
title,
|
|
4423
|
+
version,
|
|
4424
|
+
mode,
|
|
4425
|
+
origin,
|
|
4426
|
+
design,
|
|
4427
|
+
knobs,
|
|
4428
|
+
composed: composed?.html,
|
|
4429
|
+
}));
|
|
3171
4430
|
return c.html(doc);
|
|
3172
4431
|
};
|
|
3173
4432
|
app.get("/s/:id", renderPostPage);
|
|
@@ -3228,7 +4487,7 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3228
4487
|
const result = await uploadAsset(body);
|
|
3229
4488
|
if ("error" in result)
|
|
3230
4489
|
return c.json({ error: result.error }, result.status);
|
|
3231
|
-
const origin =
|
|
4490
|
+
const origin = requestOrigin(c.req.raw);
|
|
3232
4491
|
const publicBasePath = requestBasePath(c.req.raw);
|
|
3233
4492
|
const assetPath = encodeURIComponent(result.asset.id);
|
|
3234
4493
|
return c.json({ ...result.asset, url: `${origin}${publicBasePath}/a/${assetPath}` }, 201);
|
|
@@ -3382,11 +4641,14 @@ export function createApp({ store, viewerHtml, setupText, topics = {}, authentic
|
|
|
3382
4641
|
const mcpFlows = {
|
|
3383
4642
|
publish: (body, ctx) => publishFlow(body, ctx),
|
|
3384
4643
|
list: (query) => listMocksFlow(query),
|
|
4644
|
+
tree: (query) => treeFlow(query),
|
|
3385
4645
|
get: (ref, query) => getMockFlow(ref, query),
|
|
3386
4646
|
ask: (ref, body, ctx) => askFlow(ref, body, ctx),
|
|
3387
4647
|
exportMock: (ref, query, ctx) => exportFlow(ref, query, ctx),
|
|
3388
4648
|
say: (ref, body, ctx) => sayFlow(ref, body, ctx),
|
|
3389
4649
|
feedback: (query, ctx) => feedbackFlow(query, ctx),
|
|
4650
|
+
edit: (ref, body, ctx) => editMockFlow(ref, body, ctx),
|
|
4651
|
+
editProject: (name, body, ctx) => editProjectFlow(name, body, ctx),
|
|
3390
4652
|
};
|
|
3391
4653
|
const guide = (project, topic) => topic ? (topics[topic] ?? "") : briefFor(project);
|
|
3392
4654
|
// --- run: an agent script over the same flows (server/run.ts) ---
|