@animastor/orchestration 0.1.3 → 0.1.5
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/package.json +1 -1
- package/src/orchestration/scene-callbacks.js +57 -5
- package/src/orchestration/scene-orchestrator.js +70 -7
- package/src/runtime/dispatch-engine.js +310 -5
- package/src/runtime/dispatch-outcomes.js +124 -0
- package/src/runtime/hub-cancel-port.js +6 -0
- package/src/runtime/persistence-port.js +6 -0
- package/src/runtime/reconciliation-engine.js +63 -9
- package/src/runtime/runtime-scheduler.js +18 -1
- package/src/runtime/scene-window.js +28 -0
- package/test/artifact-root-binding.test.js +174 -0
- package/test/capacity-deferral.test.js +265 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// DISPATCH OUTCOMES — the shared taxonomy of "nothing was enqueued"
|
|
3
|
+
//
|
|
4
|
+
// Stage 118 (audit 117 · D3/M4/M5): the classification of an executor
|
|
5
|
+
// outcome lives in ONE module so the dispatch engine (runtime tier) and the
|
|
6
|
+
// stage executors (orchestration tier) cannot drift apart.
|
|
7
|
+
//
|
|
8
|
+
// Buckets
|
|
9
|
+
// ───────
|
|
10
|
+
// • capacity — the GPU capacity is not there right now:
|
|
11
|
+
// 'no_worker_available' | 'duplicate_pending' | 'ambiguous_post'
|
|
12
|
+
// ⇒ a BOUNDED retry (deferral window) is the right response.
|
|
13
|
+
//
|
|
14
|
+
// • idempotent — the logical task is ALREADY COMPLETE (the completion marker
|
|
15
|
+
// exists): 'already_completed'.
|
|
16
|
+
// This is NOT a capacity problem and MUST NOT be classified as
|
|
17
|
+
// one (audit 117 · M4): mixing it into the capacity bucket
|
|
18
|
+
// turned "this task will never run" into an endless retry
|
|
19
|
+
// loop. It gets its own, much longer backoff window and its
|
|
20
|
+
// own reporting class.
|
|
21
|
+
//
|
|
22
|
+
// The host side (@animastor/generation → backend/src/helpers/queue-idempotency)
|
|
23
|
+
// mirrors these sets for the transport-level reasons it produces; the two
|
|
24
|
+
// lists must stay in sync (see queue-idempotency.cjs · CAPACITY_REASONS).
|
|
25
|
+
// ======================================================
|
|
26
|
+
|
|
27
|
+
const CAPACITY_OUTCOMES = new Set([
|
|
28
|
+
'no_worker_available', // no live, type-compatible worker
|
|
29
|
+
'duplicate_pending', // the job is already pending in the lane
|
|
30
|
+
'ambiguous_post', // the POST outcome was ambiguous (the hub may have taken the task)
|
|
31
|
+
// Stage 129: the intent-fence step could not run (Redis refused the atomic
|
|
32
|
+
// path / the client cannot run it). Nothing was enqueued and the send IS
|
|
33
|
+
// retryable, so it belongs to the bounded-retry bucket.
|
|
34
|
+
'intent_receipt_unavailable',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const IDEMPOTENT_OUTCOMES = new Set([
|
|
38
|
+
'already_completed', // the logical task is already complete (artifact missing)
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fence rejections (Stage 129, audit 128 · F5): the backend refused this
|
|
43
|
+
* dispatch because it belongs to a generation the intent record already
|
|
44
|
+
* superseded, or because the dispatch/intent pair is inconsistent.
|
|
45
|
+
*
|
|
46
|
+
* Retrying the SAME generation can never succeed, so they are NOT retryable —
|
|
47
|
+
* but they must also not fall into the historic "immediate retry on the next
|
|
48
|
+
* tick" bucket, which would turn a stale send into a 5-second dispatch →
|
|
49
|
+
* cancel → rollback loop. They get the long (idempotent) deferral window and
|
|
50
|
+
* stay visible in the logs/metrics.
|
|
51
|
+
*/
|
|
52
|
+
const FENCE_REJECTED_OUTCOMES = new Set([
|
|
53
|
+
'stale_intent',
|
|
54
|
+
'receipt_conflict',
|
|
55
|
+
'intent_not_active',
|
|
56
|
+
'intent_corrupt',
|
|
57
|
+
'intent_missing',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Is this outcome a CAPACITY gap (bounded retry with a short window)? */
|
|
61
|
+
function isCapacityOutcome(reason) {
|
|
62
|
+
return CAPACITY_OUTCOMES.has(String(reason || ''));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is this outcome an idempotent suppression (the task is already done)? */
|
|
66
|
+
function isIdempotentOutcome(reason) {
|
|
67
|
+
return IDEMPOTENT_OUTCOMES.has(String(reason || ''));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Is this outcome a fence rejection (terminal for this generation)? */
|
|
71
|
+
function isFenceRejectedOutcome(reason) {
|
|
72
|
+
return FENCE_REJECTED_OUTCOMES.has(String(reason || ''));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Is this outcome deferrable at all (capacity, idempotent OR fence-rejected)? */
|
|
76
|
+
function isDeferrableOutcome(reason) {
|
|
77
|
+
return isCapacityOutcome(reason) || isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Classify the reasons why NOTHING was enqueued for a stage.
|
|
82
|
+
*
|
|
83
|
+
* Stage 118 (audit 117 · M3): the Stage 116 implementation required EXACTLY
|
|
84
|
+
* ONE distinct reason (`notSentReasons.size === 1`). A scene whose units
|
|
85
|
+
* failed for two DIFFERENT deferrable reasons (e.g. one unit gated by
|
|
86
|
+
* "no live worker" and another suppressed as "duplicate pending") was
|
|
87
|
+
* therefore NOT deferred and kept the 5-second dispatch/cancel churn.
|
|
88
|
+
*
|
|
89
|
+
* The rule now: EVERY reason must be deferrable; the most frequent one is
|
|
90
|
+
* reported. A single non-deferrable reason (a real failure) keeps the
|
|
91
|
+
* historic semantics (an immediate retry on the next tick) — the
|
|
92
|
+
* audit-6929ba5 contract depends on it.
|
|
93
|
+
*
|
|
94
|
+
* @param {Map<string, number>|Object<string, number>} reasons
|
|
95
|
+
* @returns {string|null} the dominant deferrable reason, or null.
|
|
96
|
+
*/
|
|
97
|
+
function classifyNotSentReasons(reasons) {
|
|
98
|
+
const entries = reasons instanceof Map
|
|
99
|
+
? [...reasons.entries()]
|
|
100
|
+
: Object.entries(reasons || {});
|
|
101
|
+
if (entries.length === 0) return null;
|
|
102
|
+
if (!entries.every(([reason]) => isDeferrableOutcome(reason))) return null;
|
|
103
|
+
let dominant = null;
|
|
104
|
+
let dominantCount = -1;
|
|
105
|
+
for (const [reason, count] of entries) {
|
|
106
|
+
const n = Number(count) || 0;
|
|
107
|
+
if (n > dominantCount) {
|
|
108
|
+
dominant = reason;
|
|
109
|
+
dominantCount = n;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return dominant;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
CAPACITY_OUTCOMES,
|
|
117
|
+
IDEMPOTENT_OUTCOMES,
|
|
118
|
+
FENCE_REJECTED_OUTCOMES,
|
|
119
|
+
isCapacityOutcome,
|
|
120
|
+
isIdempotentOutcome,
|
|
121
|
+
isFenceRejectedOutcome,
|
|
122
|
+
isDeferrableOutcome,
|
|
123
|
+
classifyNotSentReasons,
|
|
124
|
+
};
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
// a cancelled dispatch still owns on the GPU Hub
|
|
31
31
|
// (orchestrator resetScenes cancel leg,
|
|
32
32
|
// reconciliation RELEASE_STALE_LEASE recovery leg).
|
|
33
|
+
// Accepts EITHER an array of dispatch ids (one
|
|
34
|
+
// `?dispatch_id=` DELETE per id) OR an empty list with
|
|
35
|
+
// `options.book_id` (Stage 122 · N1 — a single
|
|
36
|
+
// book-scoped `?book_id=` sweep, used by the
|
|
37
|
+
// regenerate post-release lane sweep; audit 121 found
|
|
38
|
+
// the empty-list + book_id form was a silent no-op).
|
|
33
39
|
// Returns { requested, cleared, failed }; hub
|
|
34
40
|
// unavailability is non-fatal per-id (warn + count),
|
|
35
41
|
// never throws for a single failed id.
|
|
@@ -82,6 +82,12 @@ const OPS = [
|
|
|
82
82
|
'registry.registerSceneImageRedis',
|
|
83
83
|
'registry.registerSceneVideoRedis',
|
|
84
84
|
|
|
85
|
+
// Stage 122 (N2 · audit 121): the video send path frees an ORPHANED
|
|
86
|
+
// completion marker through this op (Redis-domain, `redis`-first like the
|
|
87
|
+
// registry.*Redis ops) — the orchestration tier cannot require the backend
|
|
88
|
+
// queue-idempotency helper directly.
|
|
89
|
+
'idem.releaseTaskDoneIfOrphan',
|
|
90
|
+
|
|
85
91
|
'filesystem.getSceneAudioPath',
|
|
86
92
|
|
|
87
93
|
'listBooks',
|
|
@@ -15,9 +15,12 @@ const fs = require('fs').promises;
|
|
|
15
15
|
const syncFs = require('fs');
|
|
16
16
|
const syncPath = require('path');
|
|
17
17
|
|
|
18
|
-
const { lazyHostBinding } = require('../host/host-bindings');
|
|
18
|
+
const { lazyHostBinding, hostBinding } = require('../host/host-bindings');
|
|
19
19
|
// §32.30: former host requires resolve lazily through the host-bindings seam.
|
|
20
20
|
const state = lazyHostBinding('state');
|
|
21
|
+
// §32.30: the artifact root resolves through the composition-root seam at
|
|
22
|
+
// call time (Stage 96: former hardcoded artifact root closed here).
|
|
23
|
+
const artifactRoot = () => hostBinding('artifactRoot');
|
|
21
24
|
// S-4: filename grammar composed from the canonical owner (bytes unchanged)
|
|
22
25
|
const artifactNaming = require('@animastor/generation').artifactNaming;
|
|
23
26
|
// S-2: registered stage list resolved from media registry
|
|
@@ -121,7 +124,7 @@ async function checkOrphanVideoState(redis, bookId, chapterId, sceneId) {
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
const buildId = resolveBookBuildId(bookId);
|
|
124
|
-
const videoPath =
|
|
127
|
+
const videoPath = `${artifactRoot()}/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
|
|
125
128
|
|
|
126
129
|
try {
|
|
127
130
|
await fs.access(videoPath);
|
|
@@ -154,14 +157,39 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
|
|
|
154
157
|
// Orphan-check uses the canonical book build identity (audit d9d67a3).
|
|
155
158
|
const buildId = resolveBookBuildId(bookId);
|
|
156
159
|
const imageModule = lazyHostBinding('media.image');
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
let imageInfo = imageModule.resolveCanonicalSceneImage(
|
|
161
|
+
artifactRoot(),
|
|
159
162
|
buildId,
|
|
160
163
|
bookId,
|
|
161
164
|
chapterId,
|
|
162
165
|
sceneId,
|
|
163
166
|
);
|
|
164
167
|
|
|
168
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────────────
|
|
169
|
+
// Legacy scan first (byte-identical legacy behavior). A versioned-only
|
|
170
|
+
// build never writes the canonical file, so with the scan empty we ask
|
|
171
|
+
// the image facade for the published pointer of this scene build — the
|
|
172
|
+
// facade is flag-gated, flag OFF answers null with ZERO PG calls.
|
|
173
|
+
//
|
|
174
|
+
// An UNREADABLE pointer store ABORTS this reconcile pass (logged,
|
|
175
|
+
// `pass_incomplete`) instead of being read as "no publication": that
|
|
176
|
+
// reading would recommend regenerating a scene that is already
|
|
177
|
+
// published. No orphan issue is emitted on the aborted pass, so no
|
|
178
|
+
// regeneration is launched.
|
|
179
|
+
if (!imageInfo) {
|
|
180
|
+
try {
|
|
181
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
182
|
+
imageInfo = await imageModule.resolveVersionedSceneProof(
|
|
183
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId);
|
|
184
|
+
}
|
|
185
|
+
} catch (pgErr) {
|
|
186
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no regeneration launched`);
|
|
187
|
+
const err = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
188
|
+
err.code = 'pass_incomplete';
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
165
193
|
if (!imageInfo) {
|
|
166
194
|
return {
|
|
167
195
|
type: 'orphan_image_state',
|
|
@@ -248,11 +276,37 @@ async function checkOrphanAssets(redis, bookId, chapterId, sceneId) {
|
|
|
248
276
|
try {
|
|
249
277
|
await fs.access(assets.image.path);
|
|
250
278
|
} catch (err) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
279
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────
|
|
280
|
+
// With the flag ON a published pointer is the authority: a LIVE
|
|
281
|
+
// versioned file for this scene build explains the missing
|
|
282
|
+
// canonical file (versioned-only build) → no issue is emitted.
|
|
283
|
+
// Flag OFF ⇒ the facade answers null with ZERO PG calls and the
|
|
284
|
+
// legacy issue is reported exactly as before.
|
|
285
|
+
// An UNREADABLE pointer store aborts the pass (logged) instead of
|
|
286
|
+
// emitting a regeneration recommendation on a guess.
|
|
287
|
+
let coveredByPointer = false;
|
|
288
|
+
const imageModule = lazyHostBinding('media.image');
|
|
289
|
+
try {
|
|
290
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
291
|
+
const buildId = resolveBookBuildId(bookId);
|
|
292
|
+
coveredByPointer = !!(await imageModule.resolveVersionedSceneProof(
|
|
293
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId));
|
|
294
|
+
}
|
|
295
|
+
} catch (pgErr) {
|
|
296
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no recommendation emitted`);
|
|
297
|
+
const e = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
298
|
+
e.code = 'pass_incomplete';
|
|
299
|
+
throw e;
|
|
300
|
+
}
|
|
301
|
+
if (coveredByPointer) {
|
|
302
|
+
log(`missing_image_file suppressed: ${bookId}/${chapterId}/${sceneId} is covered by a published versioned pointer`);
|
|
303
|
+
} else {
|
|
304
|
+
orphanAssets.push({
|
|
305
|
+
type: 'missing_image_file',
|
|
306
|
+
scene: { bookId, chapterId, sceneId },
|
|
307
|
+
path: assets.image.path
|
|
308
|
+
});
|
|
309
|
+
}
|
|
256
310
|
}
|
|
257
311
|
}
|
|
258
312
|
|
|
@@ -388,6 +388,7 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
388
388
|
throttled: 0,
|
|
389
389
|
skipped: 0,
|
|
390
390
|
completed: 0,
|
|
391
|
+
deferred: 0,
|
|
391
392
|
errors: []
|
|
392
393
|
};
|
|
393
394
|
|
|
@@ -466,6 +467,10 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
466
467
|
summary.skipped++;
|
|
467
468
|
} else if (result.throttled > 0) {
|
|
468
469
|
summary.throttled += result.throttled;
|
|
470
|
+
} else if (result.deferred > 0) {
|
|
471
|
+
// Stage 118 (M9): a bounded deferral is a normal, quiet
|
|
472
|
+
// state — it must not pollute the tick error list.
|
|
473
|
+
summary.deferred += result.deferred;
|
|
469
474
|
} else if (result.reason === 'backpressure' || result.reason === 'throttled') {
|
|
470
475
|
summary.throttled++;
|
|
471
476
|
} else if (result.success) {
|
|
@@ -480,7 +485,7 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
480
485
|
}
|
|
481
486
|
|
|
482
487
|
log(`=== TICK COMPLETE ===`);
|
|
483
|
-
log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}`);
|
|
488
|
+
log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}, Deferred: ${summary.deferred}`);
|
|
484
489
|
|
|
485
490
|
if (summary.errors.length > 0) {
|
|
486
491
|
log(`Errors: ${summary.errors.length}`);
|
|
@@ -572,6 +577,9 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
572
577
|
let dispatched = 0;
|
|
573
578
|
let throttled = 0;
|
|
574
579
|
let anyCompleted = false;
|
|
580
|
+
// Stage 118 (M9): bounded deferrals are counted separately — they are
|
|
581
|
+
// neither a dispatch nor an error.
|
|
582
|
+
let deferred = 0;
|
|
575
583
|
const failures = [];
|
|
576
584
|
|
|
577
585
|
// Dispatch ALL eligible stages in this tick.
|
|
@@ -595,6 +603,12 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
595
603
|
// Stage finished inside the executor (already_done / cache_hit /
|
|
596
604
|
// no_segments) — not a failure, nothing to dispatch.
|
|
597
605
|
anyCompleted = true;
|
|
606
|
+
} else if (result.deferred) {
|
|
607
|
+
// Stage 118 (M9): a bounded deferral is NOT an error — nothing
|
|
608
|
+
// happened, the stage is waiting for capacity (or is already
|
|
609
|
+
// idempotently complete). Reporting it as a failure filled the
|
|
610
|
+
// tick summary with "errors" 12 times per minute per scene.
|
|
611
|
+
deferred++;
|
|
598
612
|
} else {
|
|
599
613
|
// Stage 50: раньше per-stage отказ (circuit_open, retry budget,
|
|
600
614
|
// lease, generating_transition_failed, …) молча проглатывался и
|
|
@@ -607,6 +621,9 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
607
621
|
if (dispatched > 0) {
|
|
608
622
|
return { dispatched, throttled, stages: stages.length };
|
|
609
623
|
}
|
|
624
|
+
if (deferred > 0 && failures.length === 0) {
|
|
625
|
+
return { dispatched: 0, throttled, deferred, stages: stages.length, success: true, reason: 'deferred' };
|
|
626
|
+
}
|
|
610
627
|
if (failures.length > 0) {
|
|
611
628
|
return { dispatched: 0, throttled, stages: stages.length, success: false, reason: failures.join(', ') };
|
|
612
629
|
}
|
|
@@ -158,6 +158,34 @@ async function getSceneFilesStatus(buildDir, bookId, chapterId, sceneId) {
|
|
|
158
158
|
result.image.exists = files.some(f => f.startsWith(imagePrefix) && f.endsWith('.png'));
|
|
159
159
|
} catch (_) {}
|
|
160
160
|
|
|
161
|
+
// ── Stage 143a (phase 2a · B1) ───────────────────────────────────
|
|
162
|
+
// A versioned-only build keeps its IU images under {build}/.versions/**,
|
|
163
|
+
// which this scan can never see. Only when the LEGACY scan came up empty
|
|
164
|
+
// do we ask the image facade for a published pointer; the facade is
|
|
165
|
+
// flag-gated, so flag OFF answers null with ZERO PostgreSQL calls and
|
|
166
|
+
// this function behaves exactly as before (condition 2).
|
|
167
|
+
//
|
|
168
|
+
// A pointer-store ERROR is logged and treated as "no proof" — image
|
|
169
|
+
// stays NOT existing. It is never read as "the file is there" (that
|
|
170
|
+
// would write a false READY) and never throws: this is an advisory
|
|
171
|
+
// checker whose five callers (checkSceneContentCache,
|
|
172
|
+
// restoreChunkStatusForScene, reconcileWindowStatuses, scene-restoration,
|
|
173
|
+
// rebuildWorkList) must keep running on a PG outage.
|
|
174
|
+
if (!result.image.exists) {
|
|
175
|
+
try {
|
|
176
|
+
const imageModule = lazyHostBinding('media.image');
|
|
177
|
+
if (imageModule && typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
178
|
+
const buildId = path.basename(path.resolve(buildDir));
|
|
179
|
+
const proof = await imageModule.resolveVersionedSceneProof(
|
|
180
|
+
OUTPUT_DIR, buildId, bookId, chapterId, sceneId);
|
|
181
|
+
if (proof) result.image.exists = true;
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
warn(`image pointer proof unavailable for ${bookId}/${chapterId}/${sceneId} (${err.message}) — image treated as absent`);
|
|
185
|
+
result.image.exists = false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
161
189
|
// Check video file
|
|
162
190
|
const videoPath = path.join(buildDir, artifactNaming.sceneVideoName(bookId, chapterId, sceneId));
|
|
163
191
|
result.video.exists = fs.existsSync(videoPath);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// STAGE 96 — ARTIFACT ROOT BINDING (поведение закрытых литералов)
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Четыре production-литерала '/data/output' в orchestration-пакете
|
|
5
|
+
// (scene-callbacks.js:281,393 · reconciliation-engine.js:124,158)
|
|
6
|
+
// переведены на существующий шов artifactRoot (host-bindings, §32.30).
|
|
7
|
+
//
|
|
8
|
+
// Тесты проверяют ПОВЕДЕНИЕ на свежих исходниках пакета (src/, а не
|
|
9
|
+
// npm-копия в backend/node_modules):
|
|
10
|
+
// — путь строится из привязанного корня, а не из литерала;
|
|
11
|
+
// — при корне '/data/output' (контракт сохранённых данных PG/Redis)
|
|
12
|
+
// результирующая строка байт-в-байт равна прежней.
|
|
13
|
+
// ======================================================
|
|
14
|
+
|
|
15
|
+
const { expect } = require('chai');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { bindHostModules } = require('../src/host/host-bindings');
|
|
20
|
+
const { createMockRedis } = require('./mocks/redis-mock');
|
|
21
|
+
|
|
22
|
+
const engine = require('../src/runtime/reconciliation-engine');
|
|
23
|
+
const callbacks = require('../src/orchestration/scene-callbacks');
|
|
24
|
+
|
|
25
|
+
const BOOK = 'b1';
|
|
26
|
+
const CHAPTER = 'ch1';
|
|
27
|
+
const SCENE = 'sc1';
|
|
28
|
+
|
|
29
|
+
let tmpRoot;
|
|
30
|
+
let redis;
|
|
31
|
+
let currentStates;
|
|
32
|
+
|
|
33
|
+
const imageStub = {
|
|
34
|
+
resolveCanonicalSceneImage: () => null,
|
|
35
|
+
getImageMetadata: async () => null,
|
|
36
|
+
};
|
|
37
|
+
const videoStub = {
|
|
38
|
+
validateVideoFile: () => ({ valid: false, duration: null, metadata: null }),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const stateStub = {
|
|
42
|
+
AssetState: {
|
|
43
|
+
NEW: 'new', DIRTY: 'dirty', PENDING: 'pending',
|
|
44
|
+
GENERATING: 'generating', READY: 'ready',
|
|
45
|
+
FAILED: 'failed', PLACEHOLDER: 'placeholder',
|
|
46
|
+
},
|
|
47
|
+
getAssetStates: async () => ({ audio: 'new', ...currentStates }),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const failFast = (name) => () => {
|
|
51
|
+
throw new Error(`[ARTIFACT-ROOT-TEST] ${name} not wired`);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function wire(artifactRootValue) {
|
|
55
|
+
bindHostModules({
|
|
56
|
+
state: () => stateStub,
|
|
57
|
+
media: () => ({ audio: {}, image: imageStub, video: videoStub }),
|
|
58
|
+
journal: () => ({ appendSceneEvent: async () => {} }),
|
|
59
|
+
artifactRoot: () => artifactRootValue,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('Stage 96 — artifactRoot-шов вместо литералов', () => {
|
|
64
|
+
before(() => {
|
|
65
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'stage96-artifact-root-'));
|
|
66
|
+
fs.mkdirSync(path.join(tmpRoot, 'default'), { recursive: true });
|
|
67
|
+
redis = createMockRedis();
|
|
68
|
+
wire(tmpRoot);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
after(() => {
|
|
72
|
+
// Вернуть дефолтный fail-fast, как в test/test-bindings.js
|
|
73
|
+
bindHostModules({
|
|
74
|
+
state: failFast('state'),
|
|
75
|
+
media: failFast('media'),
|
|
76
|
+
journal: failFast('journal'),
|
|
77
|
+
artifactRoot: failFast('artifactRoot'),
|
|
78
|
+
});
|
|
79
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('reconciliation-engine — checkOrphanVideoState', () => {
|
|
83
|
+
it('missingFile строится из привязанного корня, а не из литерала', async () => {
|
|
84
|
+
currentStates = { video: 'ready' };
|
|
85
|
+
const res = await engine.checkOrphanVideoState(redis, BOOK, CHAPTER, SCENE);
|
|
86
|
+
expect(res).to.be.an('object');
|
|
87
|
+
expect(res.missingFile).to.equal(`${tmpRoot}/default/${BOOK}_${CHAPTER}_${SCENE}.mp4`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('null, когда файл существует ПОД ПРИВЯЗАННЫМ корнем', async () => {
|
|
91
|
+
const file = path.join(tmpRoot, 'default', `${BOOK}_${CHAPTER}_${SCENE}.mp4`);
|
|
92
|
+
fs.writeFileSync(file, 'x');
|
|
93
|
+
try {
|
|
94
|
+
currentStates = { video: 'ready' };
|
|
95
|
+
expect(await engine.checkOrphanVideoState(redis, BOOK, CHAPTER, SCENE)).to.be.null;
|
|
96
|
+
} finally {
|
|
97
|
+
fs.rmSync(file, { force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('reconciliation-engine — checkOrphanImageState', () => {
|
|
103
|
+
it('resolveCanonicalSceneImage получает привязанный корень', async () => {
|
|
104
|
+
let seenRoot = null;
|
|
105
|
+
imageStub.resolveCanonicalSceneImage = (root) => { seenRoot = root; return null; };
|
|
106
|
+
try {
|
|
107
|
+
currentStates = { image: 'ready' };
|
|
108
|
+
const res = await engine.checkOrphanImageState(redis, BOOK, CHAPTER, SCENE);
|
|
109
|
+
expect(seenRoot).to.equal(tmpRoot);
|
|
110
|
+
expect(res).to.be.an('object');
|
|
111
|
+
expect(res.type).to.equal('orphan_image_state');
|
|
112
|
+
} finally {
|
|
113
|
+
imageStub.resolveCanonicalSceneImage = () => null;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('scene-callbacks — handleVideoCompleted', () => {
|
|
119
|
+
it('validateVideoFile получает путь под привязанным корнем', async () => {
|
|
120
|
+
let seenPath = null;
|
|
121
|
+
videoStub.validateVideoFile = (p) => {
|
|
122
|
+
seenPath = p;
|
|
123
|
+
return { valid: false, duration: null, metadata: null };
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
currentStates = { video: 'generating' };
|
|
127
|
+
const res = await callbacks.handleVideoCompleted(redis, BOOK, CHAPTER, SCENE, 'build1');
|
|
128
|
+
expect(seenPath).to.equal(`${tmpRoot}/build1/${BOOK}_${CHAPTER}_${SCENE}.mp4`);
|
|
129
|
+
expect(res).to.deep.include({ ok: false, retryable: true, reason: 'video_invalid' });
|
|
130
|
+
} finally {
|
|
131
|
+
videoStub.validateVideoFile = () => ({ valid: false, duration: null, metadata: null });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('scene-callbacks — handleImageCompleted', () => {
|
|
137
|
+
it('resolveCanonicalSceneImage получает привязанный корень', async () => {
|
|
138
|
+
let seenRoot = null;
|
|
139
|
+
imageStub.resolveCanonicalSceneImage = (root) => { seenRoot = root; return null; };
|
|
140
|
+
try {
|
|
141
|
+
currentStates = { image: 'generating' };
|
|
142
|
+
const res = await callbacks.handleImageCompleted(redis, BOOK, CHAPTER, SCENE, 'build1');
|
|
143
|
+
expect(seenRoot).to.equal(tmpRoot);
|
|
144
|
+
expect(res).to.deep.include({ ok: false, retryable: true, reason: 'image_not_found' });
|
|
145
|
+
} finally {
|
|
146
|
+
imageStub.resolveCanonicalSceneImage = () => null;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('обратная совместимость (контракт контейнерных путей)', () => {
|
|
152
|
+
it('при корне /data/output строка байт-в-байт равна прежнему литералу', async () => {
|
|
153
|
+
wire('/data/output');
|
|
154
|
+
try {
|
|
155
|
+
let seenPath = null;
|
|
156
|
+
videoStub.validateVideoFile = (p) => {
|
|
157
|
+
seenPath = p;
|
|
158
|
+
return { valid: false, duration: null, metadata: null };
|
|
159
|
+
};
|
|
160
|
+
currentStates = { video: 'generating' };
|
|
161
|
+
await callbacks.handleVideoCompleted(redis, BOOK, CHAPTER, SCENE, 'build1');
|
|
162
|
+
// ровно то, что до Stage 96 формировал шаблон
|
|
163
|
+
expect(seenPath).to.equal(`/data/output/build1/${BOOK}_${CHAPTER}_${SCENE}.mp4`);
|
|
164
|
+
|
|
165
|
+
currentStates = { video: 'ready' };
|
|
166
|
+
const orphan = await engine.checkOrphanVideoState(redis, BOOK, CHAPTER, SCENE);
|
|
167
|
+
expect(orphan.missingFile).to.equal(`/data/output/default/${BOOK}_${CHAPTER}_${SCENE}.mp4`);
|
|
168
|
+
} finally {
|
|
169
|
+
videoStub.validateVideoFile = () => ({ valid: false, duration: null, metadata: null });
|
|
170
|
+
wire(tmpRoot);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|