@mulmoclaude/mulmoscript-plugin 4.4.0 → 4.5.1
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/dist/contract-BHqxMUWm.cjs +82 -0
- package/dist/contract-BHqxMUWm.cjs.map +1 -0
- package/dist/contract-BnIl6w_f.js +53 -0
- package/dist/contract-BnIl6w_f.js.map +1 -0
- package/dist/core/contract.d.ts +80 -6
- package/dist/core/contract.d.ts.map +1 -1
- package/dist/core/definition.d.ts.map +1 -1
- package/dist/core/paths.d.ts +41 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/{plugin-BOq5YkTl.cjs → plugin-C5JC2lNR.cjs} +51 -1
- package/dist/plugin-C5JC2lNR.cjs.map +1 -0
- package/dist/{plugin-CAhxJ5WM.js → plugin-Jo8iGv4K.js} +40 -2
- package/dist/plugin-Jo8iGv4K.js.map +1 -0
- package/dist/server/dispatch.d.ts +0 -6
- package/dist/server/dispatch.d.ts.map +1 -1
- package/dist/server/ops.d.ts +29 -18
- package/dist/server/ops.d.ts.map +1 -1
- package/dist/server/types.d.ts +75 -2
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server.cjs +468 -110
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +468 -110
- package/dist/server.js.map +1 -1
- package/dist/style.css +30 -18
- package/dist/vue/View.vue.d.ts.map +1 -1
- package/dist/vue/composables/useDeckEditor.d.ts.map +1 -1
- package/dist/vue/index.d.ts +1 -1
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/subscription.d.ts +16 -0
- package/dist/vue/subscription.d.ts.map +1 -0
- package/dist/vue/transport.d.ts +16 -5
- package/dist/vue/transport.d.ts.map +1 -1
- package/dist/vue.cjs +110 -21
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +110 -21
- package/dist/vue.js.map +1 -1
- package/package.json +11 -11
- package/dist/contract-BhN3yKJC.cjs +0 -36
- package/dist/contract-BhN3yKJC.cjs.map +0 -1
- package/dist/contract-CIt1ZAtt.js +0 -19
- package/dist/contract-CIt1ZAtt.js.map +0 -1
- package/dist/plugin-BOq5YkTl.cjs.map +0 -1
- package/dist/plugin-CAhxJ5WM.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as normalizeStoryPath,
|
|
2
|
-
import { n as SCRIPT_CHANGED_EVENT, t as GENERATION_EVENT } from "./contract-
|
|
1
|
+
import { c as normalizeStoryPath, f as storyRefWithin, i as executeUpdateScript, n as executeMulmoScriptSave, p as errorMessage, r as executeUpdateBeat, u as storiesRelativePath } from "./plugin-Jo8iGv4K.js";
|
|
2
|
+
import { n as SCRIPT_CHANGED_EVENT, r as normalizeRoot, t as GENERATION_EVENT } from "./contract-BnIl6w_f.js";
|
|
3
3
|
import { existsSync, mkdirSync, realpathSync, statSync, unlinkSync } from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { addSessionProgressCallback, audio, generateBeatAudio, generateBeatImage, generateReferenceImage, getBeatAnimatedVideoPath, getBeatAudioPathOrUrl, getBeatMoviePaths, getBeatPngImagePath, getFileObject, getReferenceImagePath, images, initializeContextFromFiles, movie, movieFilePath, pdf, pdfFilePath, removeSessionProgressCallback, setGraphAILogger } from "mulmocast";
|
|
@@ -18,6 +18,29 @@ async function fileToDataUri(filePath, mimeType) {
|
|
|
18
18
|
return `data:${mimeType};base64,${(await readFile(filePath)).toString("base64")}`;
|
|
19
19
|
}
|
|
20
20
|
//#endregion
|
|
21
|
+
//#region src/server/types.ts
|
|
22
|
+
/**
|
|
23
|
+
* Host capabilities the server ops run against. Only genuinely
|
|
24
|
+
* host-specific transport lives here — the mulmocast orchestration, path
|
|
25
|
+
* containment, and generation-state tracking are all in-package.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Which named-root capabilities a host has NOT wired.
|
|
29
|
+
*
|
|
30
|
+
* Pure and exported so the rule can be tested without building a server: the
|
|
31
|
+
* condition used to key on the root COUNT alone, so a host that HAD passed
|
|
32
|
+
* `artifactsFor` was told at every boot that its writes land in the default
|
|
33
|
+
* root — the opposite of what the code then did (#3022).
|
|
34
|
+
*
|
|
35
|
+
* Reads and uploads need nothing from the host, so they never appear here.
|
|
36
|
+
*/
|
|
37
|
+
function missingRootCapabilities(backend) {
|
|
38
|
+
const missing = [];
|
|
39
|
+
if (backend.rootScopedGenerationState !== true) missing.push("GENERATION is refused until this host declares `rootScopedGenerationState` (its pending-generation state must carry the root, or it must keep none)");
|
|
40
|
+
if (backend.artifactsFor === void 0) missing.push("save/update land in the DEFAULT root until this host passes `artifactsFor`");
|
|
41
|
+
return missing;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
21
44
|
//#region src/server/mulmoErrorCapture.ts
|
|
22
45
|
var capturedErrors = new AsyncLocalStorage();
|
|
23
46
|
var loggerInstalled = false;
|
|
@@ -166,11 +189,17 @@ async function withBeatProgress(beats, onBeat, body) {
|
|
|
166
189
|
}
|
|
167
190
|
/** Map identity for the in-flight tracker. JSON array keeps the three
|
|
168
191
|
* fields unambiguous (a human-visible delimiter could collide). */
|
|
169
|
-
function generationMapKey(kind, filePath, key) {
|
|
170
|
-
|
|
192
|
+
function generationMapKey(kind, filePath, key, root) {
|
|
193
|
+
const normalized = normalizeRoot(root);
|
|
194
|
+
return normalized === "" ? JSON.stringify([
|
|
171
195
|
kind,
|
|
172
196
|
filePath,
|
|
173
197
|
key
|
|
198
|
+
]) : JSON.stringify([
|
|
199
|
+
kind,
|
|
200
|
+
filePath,
|
|
201
|
+
key,
|
|
202
|
+
normalized
|
|
174
203
|
]);
|
|
175
204
|
}
|
|
176
205
|
/**
|
|
@@ -181,19 +210,60 @@ function generationMapKey(kind, filePath, key) {
|
|
|
181
210
|
function createMulmoScriptServerOps(backend) {
|
|
182
211
|
const log = backend.log ?? NOOP_LOG;
|
|
183
212
|
setMulmoErrorCaptureLogger(log);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
213
|
+
const rootDirs = /* @__PURE__ */ new Map([["", path.resolve(backend.storiesDir)]]);
|
|
214
|
+
for (const [id, dir] of Object.entries(backend.extraRoots ?? {})) {
|
|
215
|
+
const trimmed = id.trim();
|
|
216
|
+
if (trimmed === "") throw new Error("mulmoScript: extraRoots key must not be empty — the empty id is reserved for the default stories root");
|
|
217
|
+
if (rootDirs.has(trimmed)) throw new Error(`mulmoScript: extraRoots keys must be distinct after trimming — "${trimmed}" is registered twice`);
|
|
218
|
+
rootDirs.set(trimmed, path.resolve(dir));
|
|
219
|
+
}
|
|
220
|
+
warnAboutUnwiredRoots();
|
|
221
|
+
/**
|
|
222
|
+
* Tell a host which named-root capabilities it has not wired — and nothing
|
|
223
|
+
* when it has wired them all.
|
|
224
|
+
*
|
|
225
|
+
* The condition used to be the root COUNT alone, so a host that had passed
|
|
226
|
+
* `artifactsFor` was still told, at every boot, that its writes land in the
|
|
227
|
+
* default root. That is not a stale wording: it is the opposite of what the
|
|
228
|
+
* code then does, read by the hosts that got the wiring RIGHT (#3022, from
|
|
229
|
+
* the consuming host).
|
|
230
|
+
*
|
|
231
|
+
* Silence when nothing is missing, because a warning that always fires is
|
|
232
|
+
* one people learn to skip — and then the host that really did forget
|
|
233
|
+
* `artifactsFor` cannot tell either. Each clause is emitted only when that
|
|
234
|
+
* capability is actually absent, so the message says what is true for THIS
|
|
235
|
+
* host rather than what was true when it was written.
|
|
236
|
+
*/
|
|
237
|
+
function warnAboutUnwiredRoots() {
|
|
238
|
+
if (rootDirs.size <= 1) return;
|
|
239
|
+
const missing = missingRootCapabilities(backend);
|
|
240
|
+
if (missing.length === 0) return;
|
|
241
|
+
log.warn(`extra stories roots registered — reads and uploads work, but ${missing.join(", and ")} (#3019)`, { roots: [...rootDirs.keys()].filter((id) => id !== "") });
|
|
242
|
+
}
|
|
243
|
+
/** The registered directory for a wire `root`, or null when the host never
|
|
244
|
+
* registered it. Null is a REJECTION, not a fallback to the default: an
|
|
245
|
+
* unknown root must not quietly read the workspace's file of the same
|
|
246
|
+
* name. */
|
|
247
|
+
function rootDir(root) {
|
|
248
|
+
return rootDirs.get(normalizeRoot(root)) ?? null;
|
|
249
|
+
}
|
|
250
|
+
function toStoryRef(absolutePath, root) {
|
|
251
|
+
const dir = rootDir(root);
|
|
252
|
+
if (dir === null) return null;
|
|
253
|
+
const base = ensureStoriesReal(root) ?? dir;
|
|
254
|
+
return storyRefWithin(base, absolutePath, path);
|
|
255
|
+
}
|
|
256
|
+
const storiesRealCache = /* @__PURE__ */ new Map();
|
|
257
|
+
function ensureStoriesReal(root) {
|
|
258
|
+
const dir = rootDir(root);
|
|
259
|
+
if (dir === null) return null;
|
|
260
|
+
const cached = storiesRealCache.get(dir);
|
|
261
|
+
if (cached) return cached;
|
|
193
262
|
try {
|
|
194
|
-
mkdirSync(
|
|
195
|
-
|
|
196
|
-
|
|
263
|
+
if (normalizeRoot(root) === "") mkdirSync(dir, { recursive: true });
|
|
264
|
+
const real = realpathSync(dir);
|
|
265
|
+
storiesRealCache.set(dir, real);
|
|
266
|
+
return real;
|
|
197
267
|
} catch {
|
|
198
268
|
return null;
|
|
199
269
|
}
|
|
@@ -209,8 +279,9 @@ function createMulmoScriptServerOps(backend) {
|
|
|
209
279
|
* stories/ is a regular directory or a legitimate symlink to another
|
|
210
280
|
* location. ENOENT and traversal are distinguished (404 vs 400).
|
|
211
281
|
*/
|
|
212
|
-
function resolveStory(filePath) {
|
|
213
|
-
|
|
282
|
+
function resolveStory(filePath, root) {
|
|
283
|
+
if (rootDir(root) === null) return opBadRequest("Unknown stories root");
|
|
284
|
+
const storiesReal = ensureStoriesReal(root);
|
|
214
285
|
if (!storiesReal) return opServerError("stories directory not available");
|
|
215
286
|
if (path.isAbsolute(filePath)) return opBadRequest("Invalid filePath");
|
|
216
287
|
const ARTIFACTS_STORIES = "artifacts/stories";
|
|
@@ -230,6 +301,19 @@ function createMulmoScriptServerOps(backend) {
|
|
|
230
301
|
};
|
|
231
302
|
}
|
|
232
303
|
/**
|
|
304
|
+
* Whether this root is one the host registered.
|
|
305
|
+
*
|
|
306
|
+
* Every other root-aware op learns this from `resolveStory`, which needs a
|
|
307
|
+
* file. `pendingGenerations` needs none — it only filters an in-memory map —
|
|
308
|
+
* so an unregistered root produced `{ ok: true, pending: [] }`, and a host
|
|
309
|
+
* typo or a stale card read back as "no work is running" (Codex P2 on
|
|
310
|
+
* #3015). An unknown root is a question this package cannot answer, and the
|
|
311
|
+
* answer it must not give is a confident empty one.
|
|
312
|
+
*/
|
|
313
|
+
function guardStoryRootRegistered(root) {
|
|
314
|
+
return rootDir(root) === null ? opBadRequest(`unknown stories root "${normalizeRoot(root)}"`) : null;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
233
317
|
* Realpath containment pre-guard for wire paths handed to the phase-1
|
|
234
318
|
* core's save/reopen/update executes. The core's own path guard is
|
|
235
319
|
* lexical (it runs against the generic FileOps, whose read/write follows
|
|
@@ -241,11 +325,121 @@ function createMulmoScriptServerOps(backend) {
|
|
|
241
325
|
* validation (including the script-vs-filePath mode check) belongs to
|
|
242
326
|
* the core.
|
|
243
327
|
*/
|
|
244
|
-
function guardStoryWirePath(filePath) {
|
|
328
|
+
function guardStoryWirePath(filePath, root) {
|
|
245
329
|
if (typeof filePath !== "string" || filePath === "") return null;
|
|
246
|
-
const resolved = resolveStory(filePath);
|
|
330
|
+
const resolved = resolveStory(filePath, root);
|
|
247
331
|
return resolved.ok ? null : resolved;
|
|
248
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Whether a GENERATION may run in this root.
|
|
335
|
+
*
|
|
336
|
+
* The pair identity is complete inside this package but stops at the host
|
|
337
|
+
* boundary: a host's per-session store keys pending work by
|
|
338
|
+
* `(kind, filePath, key)` — `generationKey` in `@mulmobridge/protocol`,
|
|
339
|
+
* which bridges also consume. Two roots running the same generation in one
|
|
340
|
+
* session would collapse to one entry, and either completion would clear the
|
|
341
|
+
* other root's indicator (Codex P1 on #3015).
|
|
342
|
+
*
|
|
343
|
+
* Whose hazard it is decides who answers: a host declares
|
|
344
|
+
* `rootScopedGenerationState` when its own pending state carries the root —
|
|
345
|
+
* or when it keeps none, which is MulmoTerminal's case. It was refused for a
|
|
346
|
+
* collision it cannot have (#3019).
|
|
347
|
+
*
|
|
348
|
+
* Absent still refuses, so a host that has not thought about this keeps the
|
|
349
|
+
* shipped behaviour rather than being quietly opened up.
|
|
350
|
+
*/
|
|
351
|
+
function guardStoryGenerationRoot(root) {
|
|
352
|
+
if (normalizeRoot(root) === "") return null;
|
|
353
|
+
const registered = guardStoryRootRegistered(root);
|
|
354
|
+
if (registered) return registered;
|
|
355
|
+
if (backend.rootScopedGenerationState === true) return null;
|
|
356
|
+
return opBadRequest("generating in a non-default stories root is not supported yet");
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The write half of the same fail-closed rule.
|
|
360
|
+
*
|
|
361
|
+
* It lives in the ops that write rather than at their dispatch sites,
|
|
362
|
+
* because a per-site guard is a list someone has to remember to extend:
|
|
363
|
+
* `save` / `updateBeat` / `updateScript` were guarded and the two upload
|
|
364
|
+
* kinds were not, so an image could still land in a named root
|
|
365
|
+
* (CodeRabbit on #3015). `save` / `update* ` cannot follow suit — they run
|
|
366
|
+
* through the package executors, not through these ops — so
|
|
367
|
+
* `test_server_roots.ts` walks the whole ops surface and fails on any op
|
|
368
|
+
* that is neither in the read-only allowlist nor refusing.
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* Whether a WRITE may target this root.
|
|
372
|
+
*
|
|
373
|
+
* Reads are root-aware; writes are not. `executeMulmoScriptSave` and the
|
|
374
|
+
* update executors run against one `FileOps`, bound by the host to the
|
|
375
|
+
* default root, so a write naming another root would rewrite the DEFAULT
|
|
376
|
+
* root's identically-named file and then announce the other one as changed
|
|
377
|
+
* (#3015 review G1). Closing it was fail-closed: "readable but not yet
|
|
378
|
+
* writable" beats "wrote somewhere else and said so".
|
|
379
|
+
*
|
|
380
|
+
* It opens per root, not globally: the host answers `artifactsFor` for the
|
|
381
|
+
* roots it can serve, and a root it cannot is still refused. A host that
|
|
382
|
+
* wires nothing keeps the shipped refusal (#3019).
|
|
383
|
+
*/
|
|
384
|
+
function guardStoryWriteRoot(root) {
|
|
385
|
+
if (normalizeRoot(root) === "") return null;
|
|
386
|
+
const registered = guardStoryRootRegistered(root);
|
|
387
|
+
if (registered) return registered;
|
|
388
|
+
return artifactsForRoot(root) === null ? opBadRequest("writing to a non-default stories root is not supported yet") : null;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The FileOps a write to this root must go through.
|
|
392
|
+
*
|
|
393
|
+
* The default root keeps the single `artifacts` it always had, byte for
|
|
394
|
+
* byte. A named root is served only when the host both REGISTERED it
|
|
395
|
+
* (`extraRoots` — the containment boundary, so a host cannot widen the
|
|
396
|
+
* addressable set through this back door) and can supply a FileOps for it.
|
|
397
|
+
*/
|
|
398
|
+
function artifactsForRoot(root) {
|
|
399
|
+
const normalized = normalizeRoot(root);
|
|
400
|
+
if (normalized === "") return backend.artifacts;
|
|
401
|
+
if (rootDir(normalized) === null) return null;
|
|
402
|
+
const hostOps = backend.artifactsFor?.(normalized);
|
|
403
|
+
return hostOps === void 0 || hostOps === null ? null : storiesScoped(hostOps);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Address a named root's FileOps the way the READ side addresses it.
|
|
407
|
+
*
|
|
408
|
+
* The two sides disagreed about one path segment, and the disagreement was
|
|
409
|
+
* silent. A read strips `stories/` and resolves under the registered
|
|
410
|
+
* directory (`<root>/<rel>`); a write handed the executors' wire path
|
|
411
|
+
* straight to the host's FileOps, which is rooted at that same registered
|
|
412
|
+
* directory, so the bytes landed in `<root>/stories/<rel>`. `save` then
|
|
413
|
+
* REPORTED SUCCESS and returned a wire path that could not be read back —
|
|
414
|
+
* a card pointing at nothing (#3020 review H1, from the consuming host).
|
|
415
|
+
*
|
|
416
|
+
* Stripping here makes "the directory you registered is the directory your
|
|
417
|
+
* FileOps is rooted at" true, which is what a host writes without being
|
|
418
|
+
* told. The default root is untouched: its FileOps is rooted one level up,
|
|
419
|
+
* at `<workspace>/artifacts`, and is shared with other plugins.
|
|
420
|
+
*
|
|
421
|
+
* A path that is not a stories wire path throws rather than passing
|
|
422
|
+
* through. Everything reaching here has been through `normalizeStoryPath`
|
|
423
|
+
* or `storyFilePath`, so one that has not is a bug — and letting it through
|
|
424
|
+
* would write it somewhere nobody can read, which is the failure this whole
|
|
425
|
+
* wrapper exists to end.
|
|
426
|
+
*/
|
|
427
|
+
function storiesScoped(inner) {
|
|
428
|
+
const within = (wirePath) => {
|
|
429
|
+
const relative = storiesRelativePath(wirePath);
|
|
430
|
+
if (relative === null) throw new Error(`mulmoScript: "${wirePath}" is not a stories path — a named root's FileOps takes stories paths only`);
|
|
431
|
+
return relative;
|
|
432
|
+
};
|
|
433
|
+
return {
|
|
434
|
+
read: async (wirePath) => inner.read(within(wirePath)),
|
|
435
|
+
readBytes: async (wirePath) => inner.readBytes(within(wirePath)),
|
|
436
|
+
write: async (wirePath, data) => inner.write(within(wirePath), data),
|
|
437
|
+
readDir: async (wirePath) => inner.readDir(within(wirePath)),
|
|
438
|
+
stat: async (wirePath) => inner.stat(within(wirePath)),
|
|
439
|
+
exists: async (wirePath) => inner.exists(within(wirePath)),
|
|
440
|
+
unlink: async (wirePath) => inner.unlink(within(wirePath))
|
|
441
|
+
};
|
|
442
|
+
}
|
|
249
443
|
function ffmpegGuard() {
|
|
250
444
|
if (backend.isFfmpegAvailable?.() === false) return {
|
|
251
445
|
ok: false,
|
|
@@ -255,6 +449,13 @@ function createMulmoScriptServerOps(backend) {
|
|
|
255
449
|
return null;
|
|
256
450
|
}
|
|
257
451
|
const inFlightGenerations = /* @__PURE__ */ new Map();
|
|
452
|
+
/** Emit `root` only when it names a non-default one: an event carrying
|
|
453
|
+
* `root: ""` and one carrying nothing must stay indistinguishable to every
|
|
454
|
+
* pre-#3014 consumer. */
|
|
455
|
+
function rootField(root) {
|
|
456
|
+
const normalized = normalizeRoot(root);
|
|
457
|
+
return normalized === "" ? {} : { root: normalized };
|
|
458
|
+
}
|
|
258
459
|
/** Tracker state and events key on the canonical `stories/<rel>` wire
|
|
259
460
|
* form: subscribers (the View's pubsub filter, `pendingGenerations`
|
|
260
461
|
* callers) match by exact string, so the accepted alias spellings
|
|
@@ -264,9 +465,23 @@ function createMulmoScriptServerOps(backend) {
|
|
|
264
465
|
function canonicalWirePath(filePath) {
|
|
265
466
|
return normalizeStoryPath(filePath) ?? filePath;
|
|
266
467
|
}
|
|
267
|
-
|
|
468
|
+
/**
|
|
469
|
+
* `error` and `root` travel in an options object, NOT as two trailing
|
|
470
|
+
* optional positionals.
|
|
471
|
+
*
|
|
472
|
+
* They were positional once, and appending `root` to the sixteen call sites
|
|
473
|
+
* put it in `error`'s slot at the nine that pass no error: every start event
|
|
474
|
+
* carried `error: "<root>"`, the root was dropped from the key, the tracker
|
|
475
|
+
* entry was filed under the default root — and because the matching finish
|
|
476
|
+
* DID pass both, its key differed and the entry was never deleted, leaking
|
|
477
|
+
* one row per generation for the life of the process. Two adjacent optional
|
|
478
|
+
* strings cannot be told apart by the type checker, so the shape is the only
|
|
479
|
+
* thing that can prevent it (#3015 review).
|
|
480
|
+
*/
|
|
481
|
+
function publishGeneration(chatSessionId, kind, filePath, key, finished, opts = {}) {
|
|
482
|
+
const { error, root } = opts;
|
|
268
483
|
const wirePath = canonicalWirePath(filePath);
|
|
269
|
-
const mapKey = generationMapKey(kind, wirePath, key);
|
|
484
|
+
const mapKey = generationMapKey(kind, wirePath, key, root);
|
|
270
485
|
const existing = inFlightGenerations.get(mapKey);
|
|
271
486
|
if (finished) {
|
|
272
487
|
if (existing && existing.count > 1) {
|
|
@@ -283,6 +498,7 @@ function createMulmoScriptServerOps(backend) {
|
|
|
283
498
|
kind,
|
|
284
499
|
filePath: wirePath,
|
|
285
500
|
key,
|
|
501
|
+
root: normalizeRoot(root),
|
|
286
502
|
count: 1
|
|
287
503
|
});
|
|
288
504
|
}
|
|
@@ -291,7 +507,8 @@ function createMulmoScriptServerOps(backend) {
|
|
|
291
507
|
filePath: wirePath,
|
|
292
508
|
key,
|
|
293
509
|
done: finished,
|
|
294
|
-
...error ? { error } : {}
|
|
510
|
+
...error ? { error } : {},
|
|
511
|
+
...rootField(root)
|
|
295
512
|
};
|
|
296
513
|
backend.onGenerationEvent?.(chatSessionId, event);
|
|
297
514
|
}
|
|
@@ -302,21 +519,31 @@ function createMulmoScriptServerOps(backend) {
|
|
|
302
519
|
* save — reloading there would rebuild the element the caret is in, mid-keystroke. A write
|
|
303
520
|
* from the agent carries no origin, so everyone reloads.
|
|
304
521
|
*/
|
|
305
|
-
function publishScriptChanged(filePath, origin) {
|
|
522
|
+
function publishScriptChanged(filePath, origin, root) {
|
|
306
523
|
backend.onScriptChanged?.({
|
|
307
524
|
filePath: canonicalWirePath(filePath),
|
|
308
|
-
...origin === void 0 ? {} : { origin }
|
|
525
|
+
...origin === void 0 ? {} : { origin },
|
|
526
|
+
...rootField(root)
|
|
309
527
|
});
|
|
310
528
|
}
|
|
311
|
-
/**
|
|
312
|
-
*
|
|
313
|
-
|
|
529
|
+
/**
|
|
530
|
+
* Snapshot of generations currently in flight for one script — the View's
|
|
531
|
+
* mount-time catch-up.
|
|
532
|
+
*
|
|
533
|
+
* Filtered on the PAIR. Filtering on `filePath` alone hands a View watching
|
|
534
|
+
* `repoB/stories/deck.json` the run started for `repoA`'s identically-named
|
|
535
|
+
* deck, and the caller cannot discard it because the returned event would
|
|
536
|
+
* carry no root either (Codex P1 / CodeRabbit on #3015).
|
|
537
|
+
*/
|
|
538
|
+
function pendingGenerations(filePath, root) {
|
|
314
539
|
const wirePath = canonicalWirePath(filePath);
|
|
315
|
-
|
|
540
|
+
const wanted = normalizeRoot(root);
|
|
541
|
+
return [...inFlightGenerations.values()].filter((entry) => entry.filePath === wirePath && entry.root === wanted).map(({ kind, key }) => ({
|
|
316
542
|
kind,
|
|
317
543
|
filePath: wirePath,
|
|
318
544
|
key,
|
|
319
|
-
done: false
|
|
545
|
+
done: false,
|
|
546
|
+
...rootField(root)
|
|
320
547
|
}));
|
|
321
548
|
}
|
|
322
549
|
/**
|
|
@@ -328,7 +555,7 @@ function createMulmoScriptServerOps(backend) {
|
|
|
328
555
|
async function runStoryOp(filePath, options, handler, deps = {}) {
|
|
329
556
|
const resolver = deps.resolveStory ?? resolveStory;
|
|
330
557
|
const build = deps.buildContext ?? buildContext;
|
|
331
|
-
const resolved = resolver(filePath);
|
|
558
|
+
const resolved = resolver(filePath, options.root);
|
|
332
559
|
if (!resolved.ok) return resolved;
|
|
333
560
|
try {
|
|
334
561
|
const context = await build(resolved.absolutePath, options.force ?? false);
|
|
@@ -349,8 +576,11 @@ function createMulmoScriptServerOps(backend) {
|
|
|
349
576
|
return opServerError(errorMessage(err));
|
|
350
577
|
}
|
|
351
578
|
}
|
|
352
|
-
async function beatImageOp(filePath, beatIndex) {
|
|
353
|
-
return runStoryOp(filePath, {
|
|
579
|
+
async function beatImageOp(filePath, beatIndex, root) {
|
|
580
|
+
return runStoryOp(filePath, {
|
|
581
|
+
operation: "beat-image",
|
|
582
|
+
root
|
|
583
|
+
}, async ({ context }) => {
|
|
354
584
|
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
355
585
|
if (!existsSync(imagePath)) return {
|
|
356
586
|
ok: true,
|
|
@@ -362,9 +592,10 @@ function createMulmoScriptServerOps(backend) {
|
|
|
362
592
|
};
|
|
363
593
|
});
|
|
364
594
|
}
|
|
365
|
-
async function beatAudioOp(filePath, beatIndex) {
|
|
595
|
+
async function beatAudioOp(filePath, beatIndex, root) {
|
|
366
596
|
return runStoryOp(filePath, {
|
|
367
597
|
operation: "beat-audio",
|
|
598
|
+
root,
|
|
368
599
|
onContextMissing: () => ({
|
|
369
600
|
ok: true,
|
|
370
601
|
audio: null
|
|
@@ -386,8 +617,11 @@ function createMulmoScriptServerOps(backend) {
|
|
|
386
617
|
};
|
|
387
618
|
});
|
|
388
619
|
}
|
|
389
|
-
async function beatMovieOp(filePath, beatIndex) {
|
|
390
|
-
return runStoryOp(filePath, {
|
|
620
|
+
async function beatMovieOp(filePath, beatIndex, root) {
|
|
621
|
+
return runStoryOp(filePath, {
|
|
622
|
+
operation: "beat-movie",
|
|
623
|
+
root
|
|
624
|
+
}, async ({ context }) => {
|
|
391
625
|
const { movieFile, soundEffectFile, lipSyncFile } = getBeatMoviePaths(context, beatIndex);
|
|
392
626
|
const existing = [
|
|
393
627
|
lipSyncFile,
|
|
@@ -397,12 +631,15 @@ function createMulmoScriptServerOps(backend) {
|
|
|
397
631
|
].find((candidate) => existsSync(candidate));
|
|
398
632
|
return {
|
|
399
633
|
ok: true,
|
|
400
|
-
moviePath: existing ? toStoryRef(existing) : null
|
|
634
|
+
moviePath: existing ? toStoryRef(existing, root) : null
|
|
401
635
|
};
|
|
402
636
|
});
|
|
403
637
|
}
|
|
404
|
-
async function characterImageOp(filePath, key) {
|
|
405
|
-
return runStoryOp(filePath, {
|
|
638
|
+
async function characterImageOp(filePath, key, root) {
|
|
639
|
+
return runStoryOp(filePath, {
|
|
640
|
+
operation: "character-image",
|
|
641
|
+
root
|
|
642
|
+
}, async ({ context }) => {
|
|
406
643
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
407
644
|
if (!existsSync(imagePath)) return {
|
|
408
645
|
ok: true,
|
|
@@ -417,46 +654,51 @@ function createMulmoScriptServerOps(backend) {
|
|
|
417
654
|
/** Shared "output exists and is newer than the source script" gate for
|
|
418
655
|
* movie / PDF status. A stale artifact (script edited after it was
|
|
419
656
|
* generated) reports null so the UI re-offers the Generate button. */
|
|
420
|
-
function freshOutputRef(outputPath, absoluteFilePath) {
|
|
657
|
+
function freshOutputRef(outputPath, absoluteFilePath, root) {
|
|
421
658
|
if (!existsSync(outputPath)) return null;
|
|
422
659
|
if (statSync(outputPath).mtimeMs < statSync(absoluteFilePath).mtimeMs) return null;
|
|
423
|
-
return toStoryRef(outputPath);
|
|
660
|
+
return toStoryRef(outputPath, root);
|
|
424
661
|
}
|
|
425
|
-
async function movieStatusOp(filePath) {
|
|
662
|
+
async function movieStatusOp(filePath, root) {
|
|
426
663
|
return runStoryOp(filePath, {
|
|
427
664
|
operation: "movie-status",
|
|
665
|
+
root,
|
|
428
666
|
onContextMissing: () => ({
|
|
429
667
|
ok: true,
|
|
430
668
|
moviePath: null
|
|
431
669
|
})
|
|
432
670
|
}, async ({ absoluteFilePath, context }) => ({
|
|
433
671
|
ok: true,
|
|
434
|
-
moviePath: freshOutputRef(movieFilePath(context), absoluteFilePath)
|
|
672
|
+
moviePath: freshOutputRef(movieFilePath(context), absoluteFilePath, root)
|
|
435
673
|
}));
|
|
436
674
|
}
|
|
437
|
-
async function pdfStatusOp(filePath) {
|
|
675
|
+
async function pdfStatusOp(filePath, root) {
|
|
438
676
|
return runStoryOp(filePath, {
|
|
439
677
|
operation: "pdf-status",
|
|
678
|
+
root,
|
|
440
679
|
onContextMissing: () => ({
|
|
441
680
|
ok: true,
|
|
442
681
|
pdfPath: null
|
|
443
682
|
})
|
|
444
683
|
}, async ({ absoluteFilePath, context }) => ({
|
|
445
684
|
ok: true,
|
|
446
|
-
pdfPath: freshOutputRef(pdfFilePath(context, PDF_MODE), absoluteFilePath)
|
|
685
|
+
pdfPath: freshOutputRef(pdfFilePath(context, PDF_MODE), absoluteFilePath, root)
|
|
447
686
|
}));
|
|
448
687
|
}
|
|
449
688
|
async function renderBeatOp(args) {
|
|
450
|
-
const { filePath, beatIndex, force, chatSessionId } = args;
|
|
689
|
+
const { filePath, beatIndex, force, chatSessionId, root } = args;
|
|
690
|
+
const rootGuard = guardStoryGenerationRoot(root);
|
|
691
|
+
if (rootGuard) return rootGuard;
|
|
451
692
|
const ffmpeg = ffmpegGuard();
|
|
452
693
|
if (ffmpeg) return ffmpeg;
|
|
453
694
|
const mapKey = String(beatIndex);
|
|
454
|
-
publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false);
|
|
695
|
+
publishGeneration(chatSessionId, "beatImage", filePath, mapKey, false, { root });
|
|
455
696
|
let genError;
|
|
456
697
|
try {
|
|
457
698
|
const result = await runStoryOp(filePath, {
|
|
458
699
|
force,
|
|
459
|
-
operation: "render-beat"
|
|
700
|
+
operation: "render-beat",
|
|
701
|
+
root
|
|
460
702
|
}, async ({ context }) => {
|
|
461
703
|
await generateBeatImage({
|
|
462
704
|
index: beatIndex,
|
|
@@ -473,18 +715,24 @@ function createMulmoScriptServerOps(backend) {
|
|
|
473
715
|
if (!result.ok) genError = result.error;
|
|
474
716
|
return result;
|
|
475
717
|
} finally {
|
|
476
|
-
publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true,
|
|
718
|
+
publishGeneration(chatSessionId, "beatImage", filePath, mapKey, true, {
|
|
719
|
+
error: genError,
|
|
720
|
+
root
|
|
721
|
+
});
|
|
477
722
|
}
|
|
478
723
|
}
|
|
479
724
|
async function generateBeatAudioOp(args) {
|
|
480
|
-
const { filePath, beatIndex, force, chatSessionId } = args;
|
|
725
|
+
const { filePath, beatIndex, force, chatSessionId, root } = args;
|
|
726
|
+
const rootGuard = guardStoryGenerationRoot(root);
|
|
727
|
+
if (rootGuard) return rootGuard;
|
|
481
728
|
const mapKey = String(beatIndex);
|
|
482
|
-
publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false);
|
|
729
|
+
publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, false, { root });
|
|
483
730
|
let genError;
|
|
484
731
|
try {
|
|
485
732
|
const result = await runStoryOp(filePath, {
|
|
486
733
|
force,
|
|
487
|
-
operation: "generate-beat-audio"
|
|
734
|
+
operation: "generate-beat-audio",
|
|
735
|
+
root
|
|
488
736
|
}, async ({ context }) => {
|
|
489
737
|
await generateBeatAudio(beatIndex, context, { settings: process.env });
|
|
490
738
|
const beat = context.studio.script.beats[beatIndex];
|
|
@@ -507,17 +755,23 @@ function createMulmoScriptServerOps(backend) {
|
|
|
507
755
|
if (!result.ok) genError = result.error;
|
|
508
756
|
return result;
|
|
509
757
|
} finally {
|
|
510
|
-
publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true,
|
|
758
|
+
publishGeneration(chatSessionId, "beatAudio", filePath, mapKey, true, {
|
|
759
|
+
error: genError,
|
|
760
|
+
root
|
|
761
|
+
});
|
|
511
762
|
}
|
|
512
763
|
}
|
|
513
764
|
async function renderCharacterOp(args) {
|
|
514
|
-
const { filePath, key, force, chatSessionId } = args;
|
|
515
|
-
|
|
765
|
+
const { filePath, key, force, chatSessionId, root } = args;
|
|
766
|
+
const rootGuard = guardStoryGenerationRoot(root);
|
|
767
|
+
if (rootGuard) return rootGuard;
|
|
768
|
+
publishGeneration(chatSessionId, "characterImage", filePath, key, false, { root });
|
|
516
769
|
let genError;
|
|
517
770
|
try {
|
|
518
771
|
const result = await runStoryOp(filePath, {
|
|
519
772
|
force,
|
|
520
|
-
operation: "render-character"
|
|
773
|
+
operation: "render-character",
|
|
774
|
+
root
|
|
521
775
|
}, async ({ context }) => {
|
|
522
776
|
const imageEntries = context.studio.script.imageParams?.images ?? {};
|
|
523
777
|
const imageEntry = imageEntries[key];
|
|
@@ -541,11 +795,17 @@ function createMulmoScriptServerOps(backend) {
|
|
|
541
795
|
if (!result.ok) genError = result.error;
|
|
542
796
|
return result;
|
|
543
797
|
} finally {
|
|
544
|
-
publishGeneration(chatSessionId, "characterImage", filePath, key, true,
|
|
798
|
+
publishGeneration(chatSessionId, "characterImage", filePath, key, true, {
|
|
799
|
+
error: genError,
|
|
800
|
+
root
|
|
801
|
+
});
|
|
545
802
|
}
|
|
546
803
|
}
|
|
547
|
-
async function uploadBeatImageOp(filePath, beatIndex, imageData) {
|
|
548
|
-
return runStoryOp(filePath, {
|
|
804
|
+
async function uploadBeatImageOp(filePath, beatIndex, imageData, root) {
|
|
805
|
+
return runStoryOp(filePath, {
|
|
806
|
+
operation: "upload-beat-image",
|
|
807
|
+
root
|
|
808
|
+
}, async ({ context }) => {
|
|
549
809
|
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
550
810
|
const base64 = stripDataUri(imageData);
|
|
551
811
|
await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
|
|
@@ -555,8 +815,11 @@ function createMulmoScriptServerOps(backend) {
|
|
|
555
815
|
};
|
|
556
816
|
});
|
|
557
817
|
}
|
|
558
|
-
async function uploadCharacterImageOp(filePath, key, imageData) {
|
|
559
|
-
return runStoryOp(filePath, {
|
|
818
|
+
async function uploadCharacterImageOp(filePath, key, imageData, root) {
|
|
819
|
+
return runStoryOp(filePath, {
|
|
820
|
+
operation: "upload-character-image",
|
|
821
|
+
root
|
|
822
|
+
}, async ({ context }) => {
|
|
560
823
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
561
824
|
const base64 = stripDataUri(imageData);
|
|
562
825
|
await backend.writeFileAtomic(imagePath, Buffer.from(base64, "base64"));
|
|
@@ -605,54 +868,68 @@ function createMulmoScriptServerOps(backend) {
|
|
|
605
868
|
* initiating View (and any other mounted View) reloads assets off disk
|
|
606
869
|
* as they land — the successor of the SSE per-beat events.
|
|
607
870
|
*/
|
|
608
|
-
async function generateMovieOp(filePath, chatSessionId) {
|
|
871
|
+
async function generateMovieOp(filePath, chatSessionId, root) {
|
|
872
|
+
const rootGuard = guardStoryGenerationRoot(root);
|
|
873
|
+
if (rootGuard) return rootGuard;
|
|
609
874
|
const ffmpeg = ffmpegGuard();
|
|
610
875
|
if (ffmpeg) return ffmpeg;
|
|
611
|
-
const resolved = resolveStory(filePath);
|
|
876
|
+
const resolved = resolveStory(filePath, root);
|
|
612
877
|
if (!resolved.ok) return resolved;
|
|
613
878
|
const absoluteFilePath = resolved.absolutePath;
|
|
614
879
|
if (inFlightMovies.has(absoluteFilePath)) return opBadRequest("Movie generation is already in progress for this script");
|
|
615
880
|
inFlightMovies.add(absoluteFilePath);
|
|
616
|
-
publishGeneration(chatSessionId, "movie", filePath, "", false);
|
|
881
|
+
publishGeneration(chatSessionId, "movie", filePath, "", false, { root });
|
|
617
882
|
let genError;
|
|
618
883
|
try {
|
|
619
884
|
const result = await runMovieGeneration(absoluteFilePath, (event) => {
|
|
620
|
-
publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true);
|
|
885
|
+
publishGeneration(chatSessionId, event.kind === "image" ? "beatImage" : "beatAudio", filePath, String(event.beatIndex), true, { root });
|
|
621
886
|
});
|
|
622
887
|
if (!result.ok) {
|
|
623
888
|
genError = result.error;
|
|
624
889
|
return opServerError(result.error);
|
|
625
890
|
}
|
|
891
|
+
const movieRef = toStoryRef(result.outputPath, root);
|
|
892
|
+
if (movieRef === null) return opServerError("generated movie is outside the registered stories root");
|
|
626
893
|
return {
|
|
627
894
|
ok: true,
|
|
628
|
-
moviePath:
|
|
895
|
+
moviePath: movieRef
|
|
629
896
|
};
|
|
630
897
|
} catch (err) {
|
|
631
898
|
genError = errorMessage(err);
|
|
632
899
|
return opServerError(genError);
|
|
633
900
|
} finally {
|
|
634
901
|
inFlightMovies.delete(absoluteFilePath);
|
|
635
|
-
publishGeneration(chatSessionId, "movie", filePath, "", true,
|
|
902
|
+
publishGeneration(chatSessionId, "movie", filePath, "", true, {
|
|
903
|
+
error: genError,
|
|
904
|
+
root
|
|
905
|
+
});
|
|
636
906
|
}
|
|
637
907
|
}
|
|
638
|
-
function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId) {
|
|
908
|
+
function triggerAutoBackgroundMovie(absoluteFilePath, wireFilePath, chatSessionId, root) {
|
|
909
|
+
if (guardStoryGenerationRoot(root)) {
|
|
910
|
+
log.warn("refused an auto background movie in a non-default stories root", {
|
|
911
|
+
filePath: wireFilePath,
|
|
912
|
+
root
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
639
916
|
if (inFlightMovies.has(absoluteFilePath)) return;
|
|
640
917
|
inFlightMovies.add(absoluteFilePath);
|
|
641
|
-
runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId);
|
|
918
|
+
runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root);
|
|
642
919
|
}
|
|
643
|
-
async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId) {
|
|
920
|
+
async function runBackgroundMovieGeneration(absoluteFilePath, wireFilePath, chatSessionId, root) {
|
|
644
921
|
const errorSidecarPath = `${absoluteFilePath}.error.txt`;
|
|
645
922
|
try {
|
|
646
923
|
unlinkSync(errorSidecarPath);
|
|
647
924
|
} catch {}
|
|
648
|
-
publishGeneration(chatSessionId, "movie", wireFilePath, "", false);
|
|
925
|
+
publishGeneration(chatSessionId, "movie", wireFilePath, "", false, { root });
|
|
649
926
|
let genError;
|
|
650
927
|
try {
|
|
651
928
|
const result = await runMovieGeneration(absoluteFilePath, (event) => {
|
|
652
929
|
const eventKind = event.kind === "image" ? "beatImage" : "beatAudio";
|
|
653
930
|
const key = String(event.beatIndex);
|
|
654
|
-
publishGeneration(chatSessionId, eventKind, wireFilePath, key, false);
|
|
655
|
-
setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true));
|
|
931
|
+
publishGeneration(chatSessionId, eventKind, wireFilePath, key, false, { root });
|
|
932
|
+
setImmediate(() => publishGeneration(chatSessionId, eventKind, wireFilePath, key, true, { root }));
|
|
656
933
|
});
|
|
657
934
|
if (!result.ok) {
|
|
658
935
|
genError = result.error;
|
|
@@ -676,7 +953,10 @@ function createMulmoScriptServerOps(backend) {
|
|
|
676
953
|
});
|
|
677
954
|
} finally {
|
|
678
955
|
inFlightMovies.delete(absoluteFilePath);
|
|
679
|
-
publishGeneration(chatSessionId, "movie", wireFilePath, "", true,
|
|
956
|
+
publishGeneration(chatSessionId, "movie", wireFilePath, "", true, {
|
|
957
|
+
error: genError,
|
|
958
|
+
root
|
|
959
|
+
});
|
|
680
960
|
}
|
|
681
961
|
}
|
|
682
962
|
async function writeErrorSidecar(errorSidecarPath, message) {
|
|
@@ -712,15 +992,17 @@ function createMulmoScriptServerOps(backend) {
|
|
|
712
992
|
}
|
|
713
993
|
/** Long-held foreground PDF generation (the package View's `generatePdf`
|
|
714
994
|
* dispatch) — the PDF sibling of `generateMovieOp`. */
|
|
715
|
-
async function generatePdfOp(filePath, chatSessionId) {
|
|
995
|
+
async function generatePdfOp(filePath, chatSessionId, root) {
|
|
996
|
+
const rootGuard = guardStoryGenerationRoot(root);
|
|
997
|
+
if (rootGuard) return rootGuard;
|
|
716
998
|
const ffmpeg = ffmpegGuard();
|
|
717
999
|
if (ffmpeg) return ffmpeg;
|
|
718
|
-
const resolved = resolveStory(filePath);
|
|
1000
|
+
const resolved = resolveStory(filePath, root);
|
|
719
1001
|
if (!resolved.ok) return resolved;
|
|
720
1002
|
const absoluteFilePath = resolved.absolutePath;
|
|
721
1003
|
if (inFlightPdfs.has(absoluteFilePath)) return opBadRequest("PDF generation is already in progress for this script");
|
|
722
1004
|
inFlightPdfs.add(absoluteFilePath);
|
|
723
|
-
publishGeneration(chatSessionId, "pdf", filePath, "", false);
|
|
1005
|
+
publishGeneration(chatSessionId, "pdf", filePath, "", false, { root });
|
|
724
1006
|
let genError;
|
|
725
1007
|
try {
|
|
726
1008
|
const context = await buildContext(absoluteFilePath);
|
|
@@ -729,22 +1011,27 @@ function createMulmoScriptServerOps(backend) {
|
|
|
729
1011
|
return opServerError(genError);
|
|
730
1012
|
}
|
|
731
1013
|
const result = await runPdfGeneration(context, (beatIndex) => {
|
|
732
|
-
publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true);
|
|
1014
|
+
publishGeneration(chatSessionId, "beatImage", filePath, String(beatIndex), true, { root });
|
|
733
1015
|
});
|
|
734
1016
|
if (!result.ok) {
|
|
735
1017
|
genError = result.error;
|
|
736
1018
|
return opServerError(result.error);
|
|
737
1019
|
}
|
|
1020
|
+
const pdfRef = toStoryRef(result.outputPath, root);
|
|
1021
|
+
if (pdfRef === null) return opServerError("generated PDF is outside the registered stories root");
|
|
738
1022
|
return {
|
|
739
1023
|
ok: true,
|
|
740
|
-
pdfPath:
|
|
1024
|
+
pdfPath: pdfRef
|
|
741
1025
|
};
|
|
742
1026
|
} catch (err) {
|
|
743
1027
|
genError = errorMessage(err);
|
|
744
1028
|
return opServerError(genError);
|
|
745
1029
|
} finally {
|
|
746
1030
|
inFlightPdfs.delete(absoluteFilePath);
|
|
747
|
-
publishGeneration(chatSessionId, "pdf", filePath, "", true,
|
|
1031
|
+
publishGeneration(chatSessionId, "pdf", filePath, "", true, {
|
|
1032
|
+
error: genError,
|
|
1033
|
+
root
|
|
1034
|
+
});
|
|
748
1035
|
}
|
|
749
1036
|
}
|
|
750
1037
|
return {
|
|
@@ -752,6 +1039,10 @@ function createMulmoScriptServerOps(backend) {
|
|
|
752
1039
|
toStoryRef,
|
|
753
1040
|
resolveStory,
|
|
754
1041
|
guardStoryWirePath,
|
|
1042
|
+
guardStoryRootRegistered,
|
|
1043
|
+
guardStoryWriteRoot,
|
|
1044
|
+
artifactsForRoot,
|
|
1045
|
+
guardStoryGenerationRoot,
|
|
755
1046
|
ffmpegGuard,
|
|
756
1047
|
runStoryOp,
|
|
757
1048
|
publishGeneration,
|
|
@@ -850,12 +1141,56 @@ var UPLOAD_KINDS = /* @__PURE__ */ new Set(["uploadBeatImage", "uploadCharacterI
|
|
|
850
1141
|
* FileOps, guarded by the instance's realpath containment
|
|
851
1142
|
* (`guardStoryWirePath`) — the core's own guard is lexical.
|
|
852
1143
|
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Stamp the root a successful result acted in.
|
|
1146
|
+
*
|
|
1147
|
+
* Only on success: a failure carries `code` and `error`, and adding a root to
|
|
1148
|
+
* it would invite a reader to treat the pair as addressable when the call did
|
|
1149
|
+
* not happen. Only when NON-default, so a result for a call that named no root
|
|
1150
|
+
* stays byte-identical to what this package returned before roots existed —
|
|
1151
|
+
* which is what keeps every existing card working untouched.
|
|
1152
|
+
*/
|
|
1153
|
+
function withRoot(result, root) {
|
|
1154
|
+
const normalized = normalizeRoot(root);
|
|
1155
|
+
if (normalized === "") return result;
|
|
1156
|
+
if (!isRecord(result) || result.ok !== true) return result;
|
|
1157
|
+
return {
|
|
1158
|
+
...result,
|
|
1159
|
+
root: normalized
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
/** `undefined` when `root` is absent or a string; a failure envelope otherwise. */
|
|
1163
|
+
function guardSuppliedRoot(root) {
|
|
1164
|
+
if (root === void 0 || typeof root === "string") return void 0;
|
|
1165
|
+
return {
|
|
1166
|
+
ok: false,
|
|
1167
|
+
code: "bad_request",
|
|
1168
|
+
error: `mulmoScript root must be a string, got ${typeof root}`
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
853
1171
|
function createMulmoScriptDispatchHandler(ops) {
|
|
854
|
-
|
|
1172
|
+
/**
|
|
1173
|
+
* The executor context for a write, bound to the root it names.
|
|
1174
|
+
*
|
|
1175
|
+
* One `FileOps` was held for the whole handler, so the executors — which
|
|
1176
|
+
* take a WIRE path (`stories/…`) and resolve it against whatever FileOps
|
|
1177
|
+
* they are given — wrote a named root's script into the DEFAULT root's
|
|
1178
|
+
* identically-named file. Choosing here keeps `MulmoScriptExecuteContext`
|
|
1179
|
+
* and every executor unchanged: the root never reaches them, only the right
|
|
1180
|
+
* FileOps does (#3019).
|
|
1181
|
+
*/
|
|
1182
|
+
function executeContextFor(root) {
|
|
1183
|
+
const artifacts = ops.artifactsForRoot(root);
|
|
1184
|
+
return artifacts === null ? null : { files: { artifacts } };
|
|
1185
|
+
}
|
|
855
1186
|
async function saveKind(args) {
|
|
856
|
-
const
|
|
1187
|
+
const rootGuard = ops.guardStoryWriteRoot(str(args.root));
|
|
1188
|
+
if (rootGuard) return fromOpFailure(rootGuard);
|
|
1189
|
+
const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
|
|
857
1190
|
if (guard) return fromOpFailure(guard);
|
|
858
|
-
const
|
|
1191
|
+
const context = executeContextFor(str(args.root));
|
|
1192
|
+
if (context === null) return invalidArgs("save");
|
|
1193
|
+
const outcome = await executeMulmoScriptSave(context, {
|
|
859
1194
|
script: args.script,
|
|
860
1195
|
filename: str(args.filename),
|
|
861
1196
|
filePath: str(args.filePath)
|
|
@@ -869,11 +1204,15 @@ function createMulmoScriptDispatchHandler(ops) {
|
|
|
869
1204
|
};
|
|
870
1205
|
}
|
|
871
1206
|
async function updateKind(kind, args) {
|
|
872
|
-
const
|
|
1207
|
+
const rootGuard = ops.guardStoryWriteRoot(str(args.root));
|
|
1208
|
+
if (rootGuard) return fromOpFailure(rootGuard);
|
|
1209
|
+
const guard = ops.guardStoryWirePath(args.filePath, str(args.root));
|
|
873
1210
|
if (guard) return fromOpFailure(guard);
|
|
874
|
-
const
|
|
1211
|
+
const context = executeContextFor(str(args.root));
|
|
1212
|
+
if (context === null) return invalidArgs(kind);
|
|
1213
|
+
const outcome = kind === "updateBeat" ? await executeUpdateBeat(context, args) : await executeUpdateScript(context, args);
|
|
875
1214
|
if (!outcome.ok) return fromPackageFailure(outcome);
|
|
876
|
-
ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin));
|
|
1215
|
+
ops.publishScriptChanged(str(args.filePath) ?? "", str(args.origin), str(args.root));
|
|
877
1216
|
return { ok: true };
|
|
878
1217
|
}
|
|
879
1218
|
const STATUS_OPS = {
|
|
@@ -889,30 +1228,37 @@ function createMulmoScriptDispatchHandler(ops) {
|
|
|
889
1228
|
const statusOp = STATUS_OPS[kind];
|
|
890
1229
|
if (statusOp) {
|
|
891
1230
|
const filePath = str(args.filePath);
|
|
892
|
-
return filePath ? envelope(await statusOp(filePath)) : invalidArgs(kind);
|
|
1231
|
+
return filePath ? envelope(await statusOp(filePath, str(args.root))) : invalidArgs(kind);
|
|
893
1232
|
}
|
|
894
1233
|
if (kind === "characterImage") {
|
|
895
1234
|
const parsed = keyArgs(args);
|
|
896
|
-
return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key)) : invalidArgs(kind);
|
|
1235
|
+
return parsed ? envelope(await ops.characterImageOp(parsed.filePath, parsed.key, str(args.root))) : invalidArgs(kind);
|
|
897
1236
|
}
|
|
898
1237
|
const parsed = beatArgs(args);
|
|
899
1238
|
if (!parsed) return invalidArgs(kind);
|
|
900
|
-
return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex));
|
|
1239
|
+
return envelope(await BEAT_PROBE_OPS[kind](parsed.filePath, parsed.beatIndex, str(args.root)));
|
|
1240
|
+
}
|
|
1241
|
+
/** Movie and PDF take the whole script; the other generate kinds take a beat
|
|
1242
|
+
* or a character within it. */
|
|
1243
|
+
async function wholeScriptGenerationKind(kind, args) {
|
|
1244
|
+
const filePath = str(args.filePath);
|
|
1245
|
+
if (!filePath) return invalidArgs(kind);
|
|
1246
|
+
const chatSessionId = str(args.chatSessionId);
|
|
1247
|
+
const root = str(args.root);
|
|
1248
|
+
return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId, root) : await ops.generatePdfOp(filePath, chatSessionId, root));
|
|
901
1249
|
}
|
|
902
1250
|
async function generateKind(kind, args) {
|
|
1251
|
+
if (kind === "generateMovie" || kind === "generatePdf") return wholeScriptGenerationKind(kind, args);
|
|
903
1252
|
const chatSessionId = str(args.chatSessionId);
|
|
1253
|
+
const root = str(args.root);
|
|
904
1254
|
const force = args.force === true;
|
|
905
|
-
if (kind === "generateMovie" || kind === "generatePdf") {
|
|
906
|
-
const filePath = str(args.filePath);
|
|
907
|
-
if (!filePath) return invalidArgs(kind);
|
|
908
|
-
return envelope(kind === "generateMovie" ? await ops.generateMovieOp(filePath, chatSessionId) : await ops.generatePdfOp(filePath, chatSessionId));
|
|
909
|
-
}
|
|
910
1255
|
if (kind === "renderCharacter") {
|
|
911
1256
|
const parsed = keyArgs(args);
|
|
912
1257
|
return parsed ? envelope(await ops.renderCharacterOp({
|
|
913
1258
|
...parsed,
|
|
914
1259
|
force,
|
|
915
|
-
chatSessionId
|
|
1260
|
+
chatSessionId,
|
|
1261
|
+
root
|
|
916
1262
|
})) : invalidArgs(kind);
|
|
917
1263
|
}
|
|
918
1264
|
const parsed = beatArgs(args);
|
|
@@ -920,11 +1266,13 @@ function createMulmoScriptDispatchHandler(ops) {
|
|
|
920
1266
|
return envelope(kind === "renderBeat" ? await ops.renderBeatOp({
|
|
921
1267
|
...parsed,
|
|
922
1268
|
force,
|
|
923
|
-
chatSessionId
|
|
1269
|
+
chatSessionId,
|
|
1270
|
+
root
|
|
924
1271
|
}) : await ops.generateBeatAudioOp({
|
|
925
1272
|
...parsed,
|
|
926
1273
|
force,
|
|
927
|
-
chatSessionId
|
|
1274
|
+
chatSessionId,
|
|
1275
|
+
root
|
|
928
1276
|
}));
|
|
929
1277
|
}
|
|
930
1278
|
async function uploadKind(kind, args) {
|
|
@@ -932,33 +1280,43 @@ function createMulmoScriptDispatchHandler(ops) {
|
|
|
932
1280
|
if (!imageData) return invalidArgs(kind);
|
|
933
1281
|
if (kind === "uploadCharacterImage") {
|
|
934
1282
|
const parsed = keyArgs(args);
|
|
935
|
-
return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData)) : invalidArgs(kind);
|
|
1283
|
+
return parsed ? envelope(await ops.uploadCharacterImageOp(parsed.filePath, parsed.key, imageData, str(args.root))) : invalidArgs(kind);
|
|
936
1284
|
}
|
|
937
1285
|
const parsed = beatArgs(args);
|
|
938
1286
|
if (!parsed) return invalidArgs(kind);
|
|
939
|
-
return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData));
|
|
1287
|
+
return envelope(await ops.uploadBeatImageOp(parsed.filePath, parsed.beatIndex, imageData, str(args.root)));
|
|
940
1288
|
}
|
|
941
|
-
|
|
942
|
-
const
|
|
943
|
-
if (!
|
|
1289
|
+
async function pendingKind(kind, args) {
|
|
1290
|
+
const filePath = str(args.filePath);
|
|
1291
|
+
if (!filePath) return invalidArgs(kind);
|
|
1292
|
+
const root = str(args.root);
|
|
1293
|
+
const rootGuard = ops.guardStoryRootRegistered(root);
|
|
1294
|
+
if (rootGuard) return fromOpFailure(rootGuard);
|
|
1295
|
+
return {
|
|
1296
|
+
ok: true,
|
|
1297
|
+
pending: ops.pendingGenerations(filePath, root)
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
/** Which handler serves this kind. Routing only — the caller tags the answer. */
|
|
1301
|
+
async function route(kind, args) {
|
|
944
1302
|
if (kind === "save") return saveKind(args);
|
|
945
1303
|
if (kind === "updateBeat" || kind === "updateScript") return updateKind(kind, args);
|
|
946
1304
|
if (PROBE_KINDS.has(kind)) return probeKind(kind, args);
|
|
947
1305
|
if (GENERATE_KINDS.has(kind)) return generateKind(kind, args);
|
|
948
1306
|
if (UPLOAD_KINDS.has(kind)) return uploadKind(kind, args);
|
|
949
|
-
if (kind === "pendingGenerations")
|
|
950
|
-
const filePath = str(args.filePath);
|
|
951
|
-
if (!filePath) return invalidArgs(kind);
|
|
952
|
-
return {
|
|
953
|
-
ok: true,
|
|
954
|
-
pending: ops.pendingGenerations(filePath)
|
|
955
|
-
};
|
|
956
|
-
}
|
|
1307
|
+
if (kind === "pendingGenerations") return pendingKind(kind, args);
|
|
957
1308
|
return {
|
|
958
1309
|
ok: false,
|
|
959
1310
|
code: "bad_request",
|
|
960
1311
|
error: `unknown mulmoScript dispatch kind "${kind}"`
|
|
961
1312
|
};
|
|
1313
|
+
}
|
|
1314
|
+
return async (args) => {
|
|
1315
|
+
const kind = str(args.kind);
|
|
1316
|
+
if (!kind) return invalidArgs("<missing>");
|
|
1317
|
+
const malformedRoot = guardSuppliedRoot(args.root);
|
|
1318
|
+
if (malformedRoot) return malformedRoot;
|
|
1319
|
+
return withRoot(await route(kind, args), str(args.root));
|
|
962
1320
|
};
|
|
963
1321
|
}
|
|
964
1322
|
//#endregion
|