wicked-interactive 0.4.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/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +26 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/ensure-siblings.mjs +94 -0
- package/bin/wi-watch.mjs +111 -0
- package/bin/wicked-interactive.js +96 -0
- package/frontend/dist/assets/index-Df5rc-Mm.js +41 -0
- package/frontend/dist/assets/index-Dq_AQHYX.css +1 -0
- package/frontend/dist/index.html +13 -0
- package/package.json +40 -0
- package/src/core/feedback-schema.js +124 -0
- package/src/core/instrument.js +116 -0
- package/src/core/regenerate.js +140 -0
- package/src/core/theme.js +79 -0
- package/src/core/versions.js +109 -0
- package/src/index.js +7 -0
- package/src/service/bus.js +30 -0
- package/src/service/demo.js +411 -0
- package/src/service/export.js +124 -0
- package/src/service/fsstore.js +26 -0
- package/src/service/generation.js +105 -0
- package/src/service/preflight.js +84 -0
- package/src/service/server.js +580 -0
- package/src/service/structural.js +103 -0
- package/src/service/theme-source.js +63 -0
- package/src/service/workspace.js +141 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// server.js — the long-running local service (ADR-0005): serve versions, accept feedback
|
|
2
|
+
// as the single writer, watch for _v{n}.md, regenerate, and push updates over SSE (ADR-0006).
|
|
3
|
+
// wicked-bus is the event spine (ADR-0004); SSE is the user-facing "ready" signal.
|
|
4
|
+
|
|
5
|
+
import express from "express";
|
|
6
|
+
import chokidar from "chokidar";
|
|
7
|
+
import { basename, dirname, resolve, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { busEmit, EVENTS } from "./bus.js";
|
|
12
|
+
import { initWorkspace, writeFeedback, processFeedbackFile, forkVersion, loadManifest, readVersionHtml } from "./workspace.js";
|
|
13
|
+
import { applyStructuralResponse, REQUESTS_DIR } from "./structural.js";
|
|
14
|
+
import { writeGenerationRequest, applyGeneratedDraft, generationPlaceholder, GEN_RESPONSE } from "./generation.js";
|
|
15
|
+
import { demoPlaceholder, writeDemoRequest, recordDemo, exportGif, RECORDINGS_DIR } from "./demo.js";
|
|
16
|
+
import { exportHtml, exportPdf } from "./export.js";
|
|
17
|
+
import { preflight } from "./preflight.js";
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {string} opts.dir document workspace directory (already initialised)
|
|
24
|
+
* @param {string} [opts.documentId]
|
|
25
|
+
* @param {Function} [opts.llm] structural-change LLM (increment 4)
|
|
26
|
+
* @param {boolean} [opts.watch] enable chokidar processing (default true)
|
|
27
|
+
*/
|
|
28
|
+
export function createServer({ dir, documentId = "doc", watch = true, frontendDir, tap } = {}) {
|
|
29
|
+
const app = express();
|
|
30
|
+
app.use(express.json({ limit: "5mb" }));
|
|
31
|
+
const sseClients = new Set();
|
|
32
|
+
|
|
33
|
+
// `tap` is the cross-server hook used by createMultiServer to fan all per-doc events
|
|
34
|
+
// into a single top-level /api/events/all stream. It's a fire-and-forget callback —
|
|
35
|
+
// exceptions are swallowed so a dead tap can't break the doc's own SSE clients.
|
|
36
|
+
function broadcast(event, data) {
|
|
37
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
38
|
+
for (const res of sseClients) res.write(frame);
|
|
39
|
+
if (tap) { try { tap(event, data); } catch { /* never let the tap break broadcasts */ } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Pipeline emitter: fan out to the bus and (for html updates) to connected browsers.
|
|
43
|
+
function emit(key, payload) {
|
|
44
|
+
busEmit(EVENTS[key], payload);
|
|
45
|
+
if (key === "HTML_UPDATED") broadcast("html-updated", payload);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// FIFO serialization (ADR-0007): process watcher events one at a time so concurrent
|
|
49
|
+
// regenerations never race on the manifest. Exposed on the returned object so callers
|
|
50
|
+
// (and tests) can drive processing through the same queue deterministically.
|
|
51
|
+
let queue = Promise.resolve();
|
|
52
|
+
const enqueue = (task) => { queue = queue.then(task).catch(() => {}); return queue; };
|
|
53
|
+
|
|
54
|
+
// Plugin install-gate (ADR-0016): report which sibling plugins are present so the
|
|
55
|
+
// editor can block on missing ones. Cheap (existsSync only); safe to call on every load.
|
|
56
|
+
app.get("/api/preflight", (_req, res) => res.json(preflight()));
|
|
57
|
+
|
|
58
|
+
app.get("/api/versions", (_req, res) => {
|
|
59
|
+
try { res.json(loadManifest(dir)); } catch (e) { res.status(404).json({ error: e.message }); }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function sendVersion(res, v) {
|
|
63
|
+
try { res.type("html").send(readVersionHtml(dir, v)); }
|
|
64
|
+
catch { res.status(404).send(`version ${v} not found`); }
|
|
65
|
+
}
|
|
66
|
+
app.get("/doc", (_req, res) => {
|
|
67
|
+
try { sendVersion(res, loadManifest(dir).head); } catch (e) { res.status(404).send(e.message); }
|
|
68
|
+
});
|
|
69
|
+
app.get("/doc/:version", (req, res) => sendVersion(res, Number(req.params.version)));
|
|
70
|
+
|
|
71
|
+
// Single-writer feedback intake (ADR-0002). chokidar processes the written file.
|
|
72
|
+
app.post("/api/feedback", (req, res) => {
|
|
73
|
+
const { items, author } = req.body || {};
|
|
74
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
75
|
+
return res.status(400).json({ error: "items[] required" });
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const { version, file } = writeFeedback(dir, { items, author });
|
|
79
|
+
busEmit(EVENTS.FEEDBACK_RECEIVED, {
|
|
80
|
+
document_id: documentId, version_target: version, feedback_file: file,
|
|
81
|
+
item_count: items.length, ts: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
res.json({ version, file });
|
|
84
|
+
} catch (e) {
|
|
85
|
+
res.status(400).json({ error: e.message });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Fork / "start again from here" (ADR-0008, AC-21): non-destructive.
|
|
90
|
+
app.post("/api/fork", (req, res) => {
|
|
91
|
+
const from = Number(req.body?.from);
|
|
92
|
+
if (!Number.isInteger(from)) return res.status(400).json({ error: "from (version number) required" });
|
|
93
|
+
try {
|
|
94
|
+
const { version, parent } = forkVersion(dir, from);
|
|
95
|
+
emit("HTML_UPDATED", {
|
|
96
|
+
document_id: documentId, version, html_file: `_v${version}.html`, prev_version: parent, ts: new Date().toISOString(),
|
|
97
|
+
});
|
|
98
|
+
res.json({ version, parent });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
res.status(400).json({ error: e.message });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Export to self-contained HTML or PDF (ADR-0009), triggered from the browser.
|
|
105
|
+
// POST creates the file under <workspace>/exports/; response includes a `download`
|
|
106
|
+
// URL the frontend can hit to actually pull the bytes (the server-side `path` alone
|
|
107
|
+
// never reached the user's machine — see the 2026-05-28 "PDF/HTML might generate,
|
|
108
|
+
// but not downloading" report).
|
|
109
|
+
app.post("/api/export", (req, res) => {
|
|
110
|
+
const version = Number(req.body?.version);
|
|
111
|
+
const format = String(req.body?.format || "html").toLowerCase();
|
|
112
|
+
if (!Number.isInteger(version)) return res.status(400).json({ error: "version (number) required" });
|
|
113
|
+
if (format !== "html" && format !== "pdf") return res.status(400).json({ error: "format must be html or pdf" });
|
|
114
|
+
try {
|
|
115
|
+
const result = format === "pdf" ? exportPdf(dir, version) : exportHtml(dir, version);
|
|
116
|
+
const file = basename(result.path);
|
|
117
|
+
const download = `${req.baseUrl || ""}/api/export/file/${encodeURIComponent(file)}`;
|
|
118
|
+
busEmit(EVENTS.EXPORT_REQUESTED, { document_id: documentId, version, format, ts: new Date().toISOString() });
|
|
119
|
+
res.json({ format, ...result, file, download });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
res.status(400).json({ error: e.message });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Download the actual exported file. Content-Disposition: attachment forces a save
|
|
126
|
+
// dialog regardless of the file's MIME type. Filenames are restricted to the slug
|
|
127
|
+
// characters export.js uses, so this can't path-traverse.
|
|
128
|
+
app.get("/api/export/file/:name", (req, res) => {
|
|
129
|
+
const name = req.params.name;
|
|
130
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).send("invalid name");
|
|
131
|
+
const filePath = join(dir, "exports", name);
|
|
132
|
+
if (!existsSync(filePath)) return res.status(404).send("not found");
|
|
133
|
+
const isPdf = name.toLowerCase().endsWith(".pdf");
|
|
134
|
+
res.setHeader("Content-Type", isPdf ? "application/pdf" : "text/html; charset=utf-8");
|
|
135
|
+
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
|
136
|
+
res.sendFile(filePath);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --- Demo (ADR-0018): the agent authors demo.spec.mjs (the click-path); this model-free
|
|
140
|
+
// service EXECUTES + RECORDS it with Playwright and lands the storyboard as a version.
|
|
141
|
+
// Deterministic replay — the same spec yields the same recording. Enqueued on the FIFO
|
|
142
|
+
// so a record never races a feedback regeneration on the manifest.
|
|
143
|
+
app.post("/api/demo/record", (req, res) => {
|
|
144
|
+
const headless = req.body?.headless !== false;
|
|
145
|
+
enqueue(async () => {
|
|
146
|
+
broadcast("status", { state: "working", message: "Recording the demo with Playwright…", ts: new Date().toISOString() });
|
|
147
|
+
try {
|
|
148
|
+
const result = await recordDemo(dir, {
|
|
149
|
+
emit, documentId, headless,
|
|
150
|
+
onStep: ({ index, total, label }) => broadcast("status", {
|
|
151
|
+
state: "working", message: `Step ${index}${total ? `/${total}` : ""}: ${label}`, ts: new Date().toISOString(),
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
broadcast("status", { state: "complete", message: `Recorded v${result.version} (${result.steps.length} steps).`, version: result.version, ts: new Date().toISOString() });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
broadcast("error", { file: "demo.spec.mjs", error: e.message });
|
|
157
|
+
broadcast("status", { state: "error", message: `Demo recording failed: ${e.message}`, ts: new Date().toISOString() });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
res.json({ ok: true, recording: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Convert a recorded version's webm -> animated GIF (embeddable where video isn't, e.g. a
|
|
164
|
+
// GitHub README). Lazy + cached; ffmpeg is an optional system dep, so a missing binary comes
|
|
165
|
+
// back as a 400 with an install hint rather than a crash. Synchronous like /api/export.
|
|
166
|
+
app.post("/api/demo/gif", (req, res) => {
|
|
167
|
+
const version = Number(req.body?.version);
|
|
168
|
+
if (!Number.isInteger(version)) return res.status(400).json({ error: "version (number) required" });
|
|
169
|
+
try {
|
|
170
|
+
const { path, bytes, cached } = exportGif(dir, version);
|
|
171
|
+
const name = basename(path);
|
|
172
|
+
const download = `${req.baseUrl || ""}/api/demo/recording/${encodeURIComponent(name)}`;
|
|
173
|
+
res.json({ ok: true, file: name, bytes, cached, download });
|
|
174
|
+
} catch (e) {
|
|
175
|
+
res.status(400).json({ error: e.message });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Stream a recorded demo video / thumbnail / GIF. Path-locked to the slug charset (same
|
|
180
|
+
// guard as the export download) so it can't path-traverse out of recordings/.
|
|
181
|
+
app.get("/api/demo/recording/:name", (req, res) => {
|
|
182
|
+
const name = req.params.name;
|
|
183
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).send("invalid name");
|
|
184
|
+
const filePath = join(dir, RECORDINGS_DIR, name);
|
|
185
|
+
if (!existsSync(filePath)) return res.status(404).send("not found");
|
|
186
|
+
const lower = name.toLowerCase();
|
|
187
|
+
const type = lower.endsWith(".webm") ? "video/webm"
|
|
188
|
+
: lower.endsWith(".gif") ? "image/gif"
|
|
189
|
+
: lower.endsWith(".png") ? "image/png"
|
|
190
|
+
: lower.endsWith(".jpg") || lower.endsWith(".jpeg") ? "image/jpeg"
|
|
191
|
+
: "application/octet-stream";
|
|
192
|
+
res.setHeader("Content-Type", type);
|
|
193
|
+
res.sendFile(filePath);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Conversation log (ADR-0014): append-only transcript persisted across reloads.
|
|
197
|
+
const convoFile = () => resolve(dir, "conversation.jsonl");
|
|
198
|
+
function logConvo(entry) {
|
|
199
|
+
try { appendFileSync(convoFile(), JSON.stringify({ ...entry, ts: entry.ts || new Date().toISOString() }) + "\n"); }
|
|
200
|
+
catch { /* best-effort */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Agent status channel (ADR-0012): the supervising agent posts progress / questions.
|
|
204
|
+
app.post("/api/status", (req, res) => {
|
|
205
|
+
const { state, message, version, requestId, question, options } = req.body || {};
|
|
206
|
+
broadcast("status", { state, message, version, requestId, question, options, ts: new Date().toISOString() });
|
|
207
|
+
if (message || question) logConvo({ role: "agent", text: question || message, state });
|
|
208
|
+
res.json({ ok: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// User -> agent message (ADR-0014), and agent -> user when the supervising agent
|
|
212
|
+
// replies through this lane. Default to "user"; honor an explicit role so an agent
|
|
213
|
+
// post lands as "agent" instead of masquerading as the user. Whitelisted to keep
|
|
214
|
+
// the value safe as a CSS class suffix (wi-msg--{role}) on the frontend.
|
|
215
|
+
const MSG_ROLES = new Set(["user", "agent", "assistant"]);
|
|
216
|
+
app.post("/api/message", (req, res) => {
|
|
217
|
+
const text = (req.body?.text || "").toString().trim();
|
|
218
|
+
if (!text) return res.status(400).json({ error: "text required" });
|
|
219
|
+
const reqRole = (req.body?.role || "").toString();
|
|
220
|
+
const role = MSG_ROLES.has(reqRole) ? reqRole : "user";
|
|
221
|
+
const entry = { role, text, ts: new Date().toISOString() };
|
|
222
|
+
logConvo(entry);
|
|
223
|
+
broadcast("message", entry);
|
|
224
|
+
res.json({ ok: true });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.get("/api/conversation", (_req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const lines = existsSync(convoFile()) ? readFileSync(convoFile(), "utf-8").trim() : "";
|
|
230
|
+
res.json(lines ? lines.split("\n").map((l) => JSON.parse(l)) : []);
|
|
231
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// User's answer to an agent question -> written as a file the agent reads, + broadcast.
|
|
235
|
+
app.post("/api/answer", (req, res) => {
|
|
236
|
+
const { requestId, answer } = req.body || {};
|
|
237
|
+
if (!requestId) return res.status(400).json({ error: "requestId required" });
|
|
238
|
+
mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true });
|
|
239
|
+
const file = `${requestId}.answer.json`;
|
|
240
|
+
writeFileSync(resolve(dir, REQUESTS_DIR, file), JSON.stringify({ requestId, answer, ts: new Date().toISOString() }, null, 2));
|
|
241
|
+
broadcast("answer", { requestId, answer });
|
|
242
|
+
res.json({ ok: true, file });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// --- Sources (ADR-0017): reference material the supervising agent indexes into the
|
|
246
|
+
// brain and draws on when generating/updating. No uploads — the service is local, so
|
|
247
|
+
// the agent reads these real absolute paths directly. The browser only picks paths.
|
|
248
|
+
const sourcesFile = () => resolve(dir, REQUESTS_DIR, "sources.json");
|
|
249
|
+
function readSources() {
|
|
250
|
+
try {
|
|
251
|
+
const f = sourcesFile();
|
|
252
|
+
if (!existsSync(f)) return [];
|
|
253
|
+
const parsed = JSON.parse(readFileSync(f, "utf-8"));
|
|
254
|
+
return Array.isArray(parsed?.sources) ? parsed.sources : [];
|
|
255
|
+
} catch { return []; }
|
|
256
|
+
}
|
|
257
|
+
function writeSources(sources) {
|
|
258
|
+
mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true });
|
|
259
|
+
writeFileSync(sourcesFile(), JSON.stringify({ sources }, null, 2));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
app.get("/api/sources", (_req, res) => res.json({ sources: readSources() }));
|
|
263
|
+
|
|
264
|
+
// User attaches one or more local paths. Dedupe by absolute path; new paths start
|
|
265
|
+
// status "pending" until the agent indexes them. Broadcast so the agent's tail sees it.
|
|
266
|
+
app.post("/api/sources", (req, res) => {
|
|
267
|
+
const incoming = Array.isArray(req.body?.paths) ? req.body.paths : [];
|
|
268
|
+
const note = (req.body?.note || "").toString().trim();
|
|
269
|
+
const paths = incoming.map((p) => (p || "").toString().trim()).filter(Boolean).map((p) => resolve(p));
|
|
270
|
+
if (paths.length === 0) return res.status(400).json({ error: "paths required" });
|
|
271
|
+
const sources = readSources();
|
|
272
|
+
const known = new Set(sources.map((s) => s.path));
|
|
273
|
+
const added = [];
|
|
274
|
+
for (const p of paths) {
|
|
275
|
+
if (known.has(p)) continue;
|
|
276
|
+
known.add(p);
|
|
277
|
+
const entry = { path: p, note, status: "pending", added_at: new Date().toISOString(), indexed_at: null };
|
|
278
|
+
sources.push(entry);
|
|
279
|
+
added.push(entry);
|
|
280
|
+
}
|
|
281
|
+
writeSources(sources);
|
|
282
|
+
if (added.length) {
|
|
283
|
+
broadcast("sources", { sources, added });
|
|
284
|
+
logConvo({ role: "event", text: `Sources attached: ${added.map((s) => basename(s.path)).join(", ")}${note ? ` — ${note}` : ""}` });
|
|
285
|
+
}
|
|
286
|
+
res.json({ ok: true, sources, added });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Agent marks a source's index status (pending -> indexing -> indexed | error).
|
|
290
|
+
const SOURCE_STATES = new Set(["pending", "indexing", "indexed", "error"]);
|
|
291
|
+
app.post("/api/sources/status", (req, res) => {
|
|
292
|
+
const path = (req.body?.path || "").toString().trim();
|
|
293
|
+
const status = (req.body?.status || "").toString();
|
|
294
|
+
if (!path) return res.status(400).json({ error: "path required" });
|
|
295
|
+
if (!SOURCE_STATES.has(status)) return res.status(400).json({ error: "invalid status" });
|
|
296
|
+
const target = resolve(path);
|
|
297
|
+
const sources = readSources();
|
|
298
|
+
const entry = sources.find((s) => s.path === target);
|
|
299
|
+
if (!entry) return res.status(404).json({ error: "unknown source" });
|
|
300
|
+
entry.status = status;
|
|
301
|
+
if (status === "indexed") entry.indexed_at = new Date().toISOString();
|
|
302
|
+
writeSources(sources);
|
|
303
|
+
broadcast("sources", { sources });
|
|
304
|
+
res.json({ ok: true, sources });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Local filesystem browser for the path picker. The page can't read real disk paths
|
|
308
|
+
// from <input type=file>, so the user navigates the local tree here and we hand back
|
|
309
|
+
// absolute paths. Localhost-only; dotfiles hidden; never exposes file contents.
|
|
310
|
+
function isLocalRequest(req) {
|
|
311
|
+
const ip = (req.ip || req.socket?.remoteAddress || "").replace(/^::ffff:/, "");
|
|
312
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "localhost" || ip === "";
|
|
313
|
+
}
|
|
314
|
+
app.get("/api/fs", (req, res) => {
|
|
315
|
+
if (!isLocalRequest(req)) return res.status(403).json({ error: "local only" });
|
|
316
|
+
const home = homedir();
|
|
317
|
+
const target = resolve((req.query?.path || "").toString().trim() || home);
|
|
318
|
+
try {
|
|
319
|
+
const st = statSync(target);
|
|
320
|
+
if (!st.isDirectory()) return res.status(400).json({ error: "not a directory" });
|
|
321
|
+
const entries = readdirSync(target, { withFileTypes: true })
|
|
322
|
+
.filter((d) => !d.name.startsWith("."))
|
|
323
|
+
.map((d) => {
|
|
324
|
+
let dir = d.isDirectory();
|
|
325
|
+
if (d.isSymbolicLink()) { try { dir = statSync(join(target, d.name)).isDirectory(); } catch { dir = false; } }
|
|
326
|
+
return { name: d.name, path: join(target, d.name), dir };
|
|
327
|
+
})
|
|
328
|
+
.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
|
|
329
|
+
const parent = dirname(target);
|
|
330
|
+
res.json({ path: target, parent: parent === target ? null : parent, home, entries });
|
|
331
|
+
} catch (e) {
|
|
332
|
+
const code = e.code === "EACCES" ? 403 : e.code === "ENOENT" ? 404 : 500;
|
|
333
|
+
res.status(code).json({ error: e.message });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.get("/events", (req, res) => {
|
|
338
|
+
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
339
|
+
res.flushHeaders?.();
|
|
340
|
+
res.write("event: ready\ndata: {}\n\n");
|
|
341
|
+
sseClients.add(res);
|
|
342
|
+
req.on("close", () => sseClients.delete(res));
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Serve the built React app at / (production). In dev, Vite serves it and proxies the
|
|
346
|
+
// API/doc/events back here. Mounted after the API routes so they take precedence.
|
|
347
|
+
const staticDir = frontendDir || resolve(HERE, "../../frontend/dist");
|
|
348
|
+
if (existsSync(staticDir)) app.use(express.static(staticDir));
|
|
349
|
+
|
|
350
|
+
let watcher;
|
|
351
|
+
const root = resolve(dir);
|
|
352
|
+
const FEEDBACK_FILE = /^_v\d+\.md$/; // feedback batch, in the workspace root
|
|
353
|
+
const RESPONSE_FILE = /^_v\d+\.response\.json$/; // agent's structural reply, in requests/
|
|
354
|
+
const GEN_RESPONSE_FILE = new RegExp(`^${GEN_RESPONSE.replace(/\./g, "\\.")}$`); // first-draft reply
|
|
355
|
+
function startWatching() {
|
|
356
|
+
mkdirSync(resolve(dir, REQUESTS_DIR), { recursive: true }); // so depth:1 sees responses
|
|
357
|
+
// chokidar v4 dropped globs — watch the tree (depth 1) and route by path here.
|
|
358
|
+
watcher = chokidar.watch(dir, { ignoreInitial: true, depth: 1 });
|
|
359
|
+
watcher.on("add", (p) => enqueue(async () => {
|
|
360
|
+
const name = basename(p);
|
|
361
|
+
try {
|
|
362
|
+
if (FEEDBACK_FILE.test(name) && resolve(dirname(p)) === root) {
|
|
363
|
+
const result = await processFeedbackFile(dir, name, { emit, documentId });
|
|
364
|
+
if (!result.idempotent) {
|
|
365
|
+
broadcast("processed", {
|
|
366
|
+
version: result.version, applied: result.applied,
|
|
367
|
+
rejected: result.rejected, stale: result.stale,
|
|
368
|
+
awaiting_structural: result.awaiting_structural,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
} else if (RESPONSE_FILE.test(name)) {
|
|
372
|
+
const result = await applyStructuralResponse(dir, name, { emit, documentId });
|
|
373
|
+
broadcast("processed", {
|
|
374
|
+
version: result.version, parent: result.parent,
|
|
375
|
+
applied: result.applied, rejected: result.rejected, structural: true,
|
|
376
|
+
});
|
|
377
|
+
} else if (GEN_RESPONSE_FILE.test(name)) {
|
|
378
|
+
const result = await applyGeneratedDraft(dir, name, { emit, documentId });
|
|
379
|
+
broadcast("processed", {
|
|
380
|
+
version: result.version, parent: result.parent, generated: true,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
} catch (e) {
|
|
384
|
+
broadcast("error", { file: name, error: e.message });
|
|
385
|
+
}
|
|
386
|
+
}));
|
|
387
|
+
return new Promise((res) => watcher.on("ready", res));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let server;
|
|
391
|
+
async function start(port = 0) {
|
|
392
|
+
if (watch) await startWatching();
|
|
393
|
+
return new Promise((resolve) => {
|
|
394
|
+
server = app.listen(port, () => resolve(server.address().port));
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function stop() {
|
|
398
|
+
if (watcher) await watcher.close();
|
|
399
|
+
if (server) await new Promise((r) => server.close(r));
|
|
400
|
+
for (const res of sseClients) res.end();
|
|
401
|
+
sseClients.clear();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return { app, start, stop, startWatching, enqueue, emit, broadcast, get clients() { return sseClients.size; } };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Multi-document mode (ADR-0015): one express server hosting many workspaces
|
|
409
|
+
// under a docs root. Each doc gets its own createServer sub-app mounted at
|
|
410
|
+
// /d/:doc/. Top-level adds GET /api/docs + POST /api/docs.
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
const DOC_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/; // slug-safe, no path separators
|
|
414
|
+
|
|
415
|
+
function slugify(name) {
|
|
416
|
+
return String(name || "").toLowerCase().trim().replace(/[^a-z0-9-]+/g, "-")
|
|
417
|
+
.replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 64);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Create a multi-doc server. `root` is the parent dir holding one subdir per doc. */
|
|
421
|
+
export function createMultiServer({ root, frontendDir, llm } = {}) {
|
|
422
|
+
if (!root) throw new Error("createMultiServer: root is required");
|
|
423
|
+
mkdirSync(root, { recursive: true });
|
|
424
|
+
const top = express();
|
|
425
|
+
top.use(express.json({ limit: "5mb" }));
|
|
426
|
+
|
|
427
|
+
const docs = new Map(); // name -> { svc, dir }
|
|
428
|
+
let topServer;
|
|
429
|
+
|
|
430
|
+
// Cross-doc event stream (operator-facing). Per-doc servers call `tap()` on every
|
|
431
|
+
// broadcast; we fan them out to anyone subscribed to /api/events/all, prepending the
|
|
432
|
+
// doc name so the receiver can route. Cheap (in-memory; no extra HTTP hops).
|
|
433
|
+
const topClients = new Set();
|
|
434
|
+
function topBroadcast(doc, event, data) {
|
|
435
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify({ doc, ...data })}\n\n`;
|
|
436
|
+
for (const res of topClients) res.write(frame);
|
|
437
|
+
}
|
|
438
|
+
// SSE heartbeat — a comment frame every 15s so half-open sockets surface as errors
|
|
439
|
+
// promptly and downstream watchdogs (bin/wi-watch.mjs has STALL_MS=180s) stay green
|
|
440
|
+
// even if several pings are lost. SSE comments start with `:` and are ignored by EventSource.
|
|
441
|
+
//
|
|
442
|
+
// We also `setNoDelay(true)` on the socket so the kernel doesn't buffer the (small)
|
|
443
|
+
// comment payloads via Nagle — pre-fix, the 24-byte heartbeats sat in the socket
|
|
444
|
+
// buffer and the watcher's 60s watchdog tripped every 7-16 minutes.
|
|
445
|
+
top.get("/api/events/all", (req, res) => {
|
|
446
|
+
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
447
|
+
res.flushHeaders?.();
|
|
448
|
+
res.socket?.setNoDelay(true);
|
|
449
|
+
res.write("event: ready\ndata: {\"watching\":\"*\"}\n\n");
|
|
450
|
+
topClients.add(res);
|
|
451
|
+
const heartbeat = setInterval(() => { try { res.write(`: ping ${Date.now()}\n\n`); } catch { /* socket dead — close handler will clear */ } }, 15_000);
|
|
452
|
+
const cleanup = () => { clearInterval(heartbeat); topClients.delete(res); };
|
|
453
|
+
req.on("close", cleanup);
|
|
454
|
+
res.on("close", cleanup);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
function docDir(name) { return resolve(root, name); }
|
|
458
|
+
function isExistingDoc(name) {
|
|
459
|
+
return DOC_NAME.test(name) && existsSync(join(docDir(name), "versions.json"));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function mountDoc(name) {
|
|
463
|
+
if (docs.has(name)) return docs.get(name);
|
|
464
|
+
if (!isExistingDoc(name)) throw new Error(`unknown or invalid doc: ${name}`);
|
|
465
|
+
const dir = docDir(name);
|
|
466
|
+
const svc = createServer({
|
|
467
|
+
dir, documentId: name, llm, watch: false, frontendDir: null,
|
|
468
|
+
tap: (event, data) => topBroadcast(name, event, data), // fan into /api/events/all
|
|
469
|
+
});
|
|
470
|
+
await svc.startWatching();
|
|
471
|
+
top.use(`/d/${name}`, svc.app);
|
|
472
|
+
docs.set(name, { svc, dir });
|
|
473
|
+
return docs.get(name);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function listDocs() {
|
|
477
|
+
const out = [];
|
|
478
|
+
for (const entry of (existsSync(root) ? readdirSync(root, { withFileTypes: true }) : [])) {
|
|
479
|
+
if (!entry.isDirectory()) continue;
|
|
480
|
+
const name = entry.name;
|
|
481
|
+
if (!DOC_NAME.test(name)) continue;
|
|
482
|
+
const v = join(docDir(name), "versions.json");
|
|
483
|
+
if (!existsSync(v)) continue;
|
|
484
|
+
try {
|
|
485
|
+
const m = JSON.parse(readFileSync(v, "utf-8"));
|
|
486
|
+
const last = m.versions[m.versions.length - 1] || {};
|
|
487
|
+
out.push({ name, kind: m.kind || "doc", head: m.head, versions: m.versions.length, updated_at: last.created_at || null });
|
|
488
|
+
} catch { /* skip malformed */ }
|
|
489
|
+
}
|
|
490
|
+
return out.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Top-level (cross-doc) endpoints
|
|
494
|
+
top.get("/api/preflight", (_req, res) => res.json(preflight()));
|
|
495
|
+
top.get("/api/docs", (_req, res) => res.json(listDocs()));
|
|
496
|
+
|
|
497
|
+
top.post("/api/docs", async (req, res) => {
|
|
498
|
+
const raw = req.body?.name;
|
|
499
|
+
const kind = String(req.body?.kind || "").toLowerCase();
|
|
500
|
+
const fromSource = kind === "source";
|
|
501
|
+
const isDemo = kind === "demo";
|
|
502
|
+
const sourcePaths = (Array.isArray(req.body?.source_paths) ? req.body.source_paths : [])
|
|
503
|
+
.map((s) => String(s).trim()).filter(Boolean);
|
|
504
|
+
const brief = String(req.body?.brief ?? "").trim();
|
|
505
|
+
const demoUrl = String(req.body?.url ?? "").trim();
|
|
506
|
+
const name = DOC_NAME.test(raw) ? raw : slugify(raw);
|
|
507
|
+
if (!name || !DOC_NAME.test(name)) return res.status(400).json({ error: "valid name required (lowercase letters, digits, hyphens; up to 64 chars)" });
|
|
508
|
+
if (isExistingDoc(name)) return res.status(409).json({ error: "doc already exists", name });
|
|
509
|
+
|
|
510
|
+
// Demo (ADR-0018): point at a live URL; the service seeds a placeholder storyboard v0 and
|
|
511
|
+
// hands a demo request to the supervising agent, which explores the app, authors
|
|
512
|
+
// demo.spec.mjs, then calls POST /d/<doc>/api/demo/record to execute + record it.
|
|
513
|
+
if (isDemo) {
|
|
514
|
+
let u;
|
|
515
|
+
try { u = new URL(demoUrl); } catch { return res.status(400).json({ error: "a valid http(s) url is required for a demo" }); }
|
|
516
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return res.status(400).json({ error: "demo url must be http or https" });
|
|
517
|
+
try {
|
|
518
|
+
const dir = docDir(name);
|
|
519
|
+
initWorkspace(dir, demoPlaceholder(name, demoUrl, brief), { kind: "demo" });
|
|
520
|
+
const { svc } = await mountDoc(name);
|
|
521
|
+
writeDemoRequest(dir, { url: demoUrl, brief, documentId: name });
|
|
522
|
+
svc.broadcast("demo", { document_id: name, url: demoUrl, brief, base_html: "_v0.html", ts: new Date().toISOString() });
|
|
523
|
+
return res.json({ name, head: 0, kind: "demo", learning: true });
|
|
524
|
+
} catch (e) {
|
|
525
|
+
return res.status(400).json({ error: e.message });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// "From my content" (ADR-0010): the service is model-free, so it can't read files or
|
|
530
|
+
// generate. It seeds a placeholder v0, then hands a generation request to the supervising
|
|
531
|
+
// agent, which indexes the source(s) and lands the real first draft as a follow-on version.
|
|
532
|
+
// kind:source needs *something* to build from — either source files or a written brief.
|
|
533
|
+
// (Brief-only is a first-class path: the agent generates from the brief alone, no files.)
|
|
534
|
+
if (fromSource && sourcePaths.length === 0 && !brief) return res.status(400).json({ error: "add at least one source path or a brief" });
|
|
535
|
+
const html = fromSource
|
|
536
|
+
? (String(req.body?.html ?? "") || generationPlaceholder(name, sourcePaths, brief))
|
|
537
|
+
: String(req.body?.html ?? "");
|
|
538
|
+
if (!fromSource && !html.trim()) return res.status(400).json({ error: "html required" });
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const dir = docDir(name);
|
|
542
|
+
initWorkspace(dir, html); // seeds _v0.html + versions.json
|
|
543
|
+
const { svc } = await mountDoc(name);
|
|
544
|
+
if (fromSource) {
|
|
545
|
+
writeGenerationRequest(dir, { sourcePaths, brief, documentId: name });
|
|
546
|
+
// Fan a generation event onto /api/events/all so the assist loop picks it up.
|
|
547
|
+
svc.broadcast("generation", { document_id: name, source_paths: sourcePaths, brief, base_html: "_v0.html", ts: new Date().toISOString() });
|
|
548
|
+
}
|
|
549
|
+
res.json({ name, head: 0, ...(fromSource ? { generating: true } : {}) });
|
|
550
|
+
} catch (e) {
|
|
551
|
+
// initWorkspace / mountDoc can throw on malformed HTML or a filesystem error.
|
|
552
|
+
// Return a 400 instead of leaking a 500 + stack to the browser.
|
|
553
|
+
res.status(400).json({ error: e.message });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// Mount any docs already on disk so their routes are live from the first request.
|
|
558
|
+
// (Synchronous-enough — chokidar 'ready' resolves quickly per dir.)
|
|
559
|
+
async function bootstrap() {
|
|
560
|
+
for (const entry of (existsSync(root) ? readdirSync(root, { withFileTypes: true }) : [])) {
|
|
561
|
+
if (entry.isDirectory() && isExistingDoc(entry.name)) await mountDoc(entry.name);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Static frontend (SPA) at /, mounted LAST so /api/* and /d/* take precedence.
|
|
566
|
+
const staticDir = frontendDir || resolve(HERE, "../../frontend/dist");
|
|
567
|
+
if (existsSync(staticDir)) top.use(express.static(staticDir));
|
|
568
|
+
|
|
569
|
+
async function start(port = 0) {
|
|
570
|
+
await bootstrap();
|
|
571
|
+
return new Promise((resolve) => { topServer = top.listen(port, () => resolve(topServer.address().port)); });
|
|
572
|
+
}
|
|
573
|
+
async function stop() {
|
|
574
|
+
for (const { svc } of docs.values()) { try { await svc.stop(); } catch {} }
|
|
575
|
+
if (topServer) await new Promise((r) => topServer.close(r));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return { app: top, start, stop, mountDoc, listDocs, get docCount() { return docs.size; } };
|
|
579
|
+
}
|
|
580
|
+
|