@animastor/orchestration 0.1.5 → 0.2.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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-27)
|
|
4
|
+
|
|
5
|
+
Version-line split for `stage143a-foundation-phase1`: the branch line moves
|
|
6
|
+
`0.1.6 → 0.2.0` so it can never collide with, or be shadowed by, the hotfix
|
|
7
|
+
line. **No functional change** — the content is the `0.1.6` code below (D1 +
|
|
8
|
+
A1 already in this branch).
|
|
9
|
+
|
|
10
|
+
**The hotfix line is separate:** `0.1.7` (branch `hotfix/d1-d2-a1`) is cut
|
|
11
|
+
from `0.1.4`, not from this line, and carries only D1 + the A1 version-gate
|
|
12
|
+
fallback (D2 and the host side of A1 live in `backend`). `^0.1.x` therefore
|
|
13
|
+
resolves within the `0.1.x` line, while this branch's backend depends on
|
|
14
|
+
`^0.2.0`.
|
|
15
|
+
|
|
16
|
+
## 0.1.7 (2026-10-27) — hotfix line (содержимое входит в 0.2.0)
|
|
17
|
+
|
|
18
|
+
Hotfix line (branch `hotfix/d1-d2-a1`, base `78e8f1ae` = `0.1.4`): only the
|
|
19
|
+
D1 and A1-gate fixes are carried in this package. It is a **separate line from
|
|
20
|
+
the `0.1.6` entry of the `stage143a-foundation-phase1` branch** — this package
|
|
21
|
+
is `0.1.4` + the two fixes, not a descendant of `0.1.5`/`0.1.6`.
|
|
22
|
+
|
|
23
|
+
**In this package:** D1, A1-gate. **In `backend` (not here):** D2 and the host
|
|
24
|
+
side of A1 (`scene-assets-repo.getAssetNullBuild` + runtime adapter + port op).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `orchestration/orchestrator.js` (D1, stage 146) — the
|
|
29
|
+
`require('@animastor/generation').artifactNaming` binding lived INSIDE
|
|
30
|
+
`beginStage()`, but `resetScenes()` also reads it in the stale-PNG/preview
|
|
31
|
+
pre-delete branch (step 6). Any regeneration that passed `cleanPngUnitIds`
|
|
32
|
+
therefore threw `ReferenceError: artifactNaming is not defined`, and
|
|
33
|
+
`/regenerate` returned HTTP 500. The binding now sits at module scope, next
|
|
34
|
+
to the other module-level requires; `beginStage()` keeps its own
|
|
35
|
+
`dispatchEngine` lazy require. Behavior is otherwise unchanged.
|
|
36
|
+
- `orchestration/orchestrator.js` (`completeStage` version gate, A1 /
|
|
37
|
+
stage 146 §5.4) — when `sceneAssets.getAsset(..., buildId)` found no row for
|
|
38
|
+
THIS build, the gate went fail-closed (DIRTY) even though the key's only row
|
|
39
|
+
was a legacy `build_id IS NULL` row: `getAsset` filters `build_id = $5`, and
|
|
40
|
+
`markReady` — the only writer able to stamp `build_id`
|
|
41
|
+
(`buildIdFromArtifactPath`) — runs BEHIND the gate. The scene then
|
|
42
|
+
re-dispatched every 5 s tick (`cache_hit` → callback → `stale` → DIRTY)
|
|
43
|
+
forever. The gate now performs exactly ONE additional read of the SAME key
|
|
44
|
+
with `build_id IS NULL` (never another build's row, never "latest any row")
|
|
45
|
+
through the PersistencePort op `sceneAssets.getAssetNullBuild`, applies the
|
|
46
|
+
SAME version rules (`scene_content_version`, plus
|
|
47
|
+
`scene_audio_config_version` for audio), and lets `markReady` stamp
|
|
48
|
+
`build_id` as before. A NULL row whose version is below the scene version
|
|
49
|
+
still fails the gate (DIRTY), so the loop converges after the next real
|
|
50
|
+
dispatch instead of spinning. PG errors stay fail-closed.
|
|
51
|
+
|
|
52
|
+
### Shipped by backend, not by this package
|
|
53
|
+
|
|
54
|
+
- D2 — `/api/v1/debug/runtime/*` middleware now calls `next()`
|
|
55
|
+
(`backend/src/routes/debug-routes.cjs`).
|
|
56
|
+
- A1 host side — `sceneAssets.getAssetNullBuild` implementation and delegation
|
|
57
|
+
(`backend/src/storage/postgres/repositories/scene-assets-repo.js`,
|
|
58
|
+
`backend/src/storage/runtime-persistence-adapter.js`) plus the op
|
|
59
|
+
declaration in `runtime/persistence-port.js`.
|
|
60
|
+
|
|
61
|
+
## 0.1.6 (2026-10-26)
|
|
62
|
+
|
|
63
|
+
Stage 146 — D1 fix: `artifactNaming` hoisted to module scope.
|
|
64
|
+
Stage 147 — A1 fix: version-gate sees the legacy `build_id IS NULL` row.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- `orchestration/orchestrator.js` — the `require('@animastor/generation')
|
|
69
|
+
.artifactNaming` binding lived INSIDE `beginStage()`, but
|
|
70
|
+
`resetScenes()` also reads it in the stale-PNG/preview pre-delete branch
|
|
71
|
+
(step 6). Any regeneration that passed `cleanPngUnitIds` therefore threw
|
|
72
|
+
`ReferenceError: artifactNaming is not defined`, and `/regenerate`
|
|
73
|
+
returned HTTP 500. The binding now sits at module scope, next to the
|
|
74
|
+
other module-level requires; `beginStage()` keeps its own
|
|
75
|
+
`dispatchEngine` lazy require. Behavior is otherwise unchanged.
|
|
76
|
+
- `orchestration/orchestrator.js` (`completeStage` version gate, A1 /
|
|
77
|
+
stage146 §5.4) — when `sceneAssets.getAsset(..., buildId)` found no row
|
|
78
|
+
for THIS build, the gate went fail-closed (DIRTY) even though the key's
|
|
79
|
+
only row was a legacy `build_id IS NULL` row: `getAsset` filters
|
|
80
|
+
`build_id = $5`, and `markReady` — the only writer able to stamp
|
|
81
|
+
`build_id` (`buildIdFromArtifactPath`) — runs BEHIND the gate. The scene
|
|
82
|
+
then re-dispatched every 5 s tick (`cache_hit` → callback → `stale` →
|
|
83
|
+
DIRTY) forever. The gate now performs exactly ONE additional read of the
|
|
84
|
+
SAME key with `build_id IS NULL` (never another build's row, never
|
|
85
|
+
"latest any row") through the same PersistencePort op
|
|
86
|
+
`sceneAssets.getAssetNullBuild`, applies the SAME version rules
|
|
87
|
+
(`scene_content_version`, plus `scene_audio_config_version` for audio),
|
|
88
|
+
and lets `markReady` stamp `build_id` as before. A NULL row whose
|
|
89
|
+
version is below the scene version still fails the gate (DIRTY) — the
|
|
90
|
+
correct version arrives from `stampSceneVersionsForDispatch` (dispatch
|
|
91
|
+
step 5.5 stamps ALL rows of the key, incl. `build_id IS NULL`, before
|
|
92
|
+
every generation/cache_hit), so the loop converges after the next real
|
|
93
|
+
dispatch instead of spinning. PG errors stay fail-closed.
|
|
94
|
+
|
|
95
|
+
### Changed (host adapter, A1)
|
|
96
|
+
|
|
97
|
+
- `backend/src/storage/postgres/repositories/scene-assets-repo.js` — new
|
|
98
|
+
`getAssetNullBuild` (same key, `build_id IS NULL`, latest NULL row).
|
|
99
|
+
- `backend/src/storage/runtime-persistence-adapter.js` — delegates the new
|
|
100
|
+
op; `runtime/persistence-port.js` — declares `sceneAssets.getAssetNullBuild`
|
|
101
|
+
in `OPS` (no SQL crosses the port).
|
|
102
|
+
|
|
103
|
+
## 0.1.5 (2026-10-26)
|
|
104
|
+
|
|
105
|
+
Stage 143a — versioned-pointer readers + atomic publication. See the
|
|
106
|
+
player 0.1.2/0.1.3 entries (same stage) for the flag-gated IU-media
|
|
107
|
+
contour this package complements.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animastor/orchestration",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Animastor Orchestration — the generation orchestration/runtime contour of the Animastor backend, physically extracted from the host (§32.30): the unified generation lifecycle owner (orchestrator facade, stage executor, scene callbacks/restoration), the dispatch engine and its lease/quota/retry managers, the reconciliation engine and counter reconciliation, the scene window/scheduler/worker-health, the runtime-result emitter/consumer, and the eight O-2..O-10 port contracts (Persistence, SceneData, PlaceholderAudio, ProgressEvents, AudioFsm, VideoFsm, HubCancel, LayerConfig). The package reaches the host (runtime config slices, Redis-domain state layer, media facades, the S-5 seam registry, the artifact root, the PW-2 workspace resolver) ONLY through the composition-root host-bindings seam — no backend/src require, no storage/book/routes/services, no Redis, no PG, no Express inside.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animastor",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"src/",
|
|
19
19
|
"test/",
|
|
20
20
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"CHANGELOG.md"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"test": "mocha --exit --require test/test-bindings.js test/*.test.js"
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
|
|
21
21
|
// S-2: media-type knowledge (stage lists, fail-event map) resolved via registry
|
|
22
22
|
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
23
|
+
// S-4: filename grammar composed from the canonical owner (bytes unchanged).
|
|
24
|
+
// D1 (stage146): must live at module scope — resetScenes() uses it outside the
|
|
25
|
+
// beginStage() body, so a per-function declaration threw ReferenceError there.
|
|
26
|
+
const artifactNaming = require('@animastor/generation').artifactNaming;
|
|
23
27
|
// S-5: pure FSM-writer ownership (markDirtyScene/setScene*) — re-exported from
|
|
24
28
|
// the state layer; bodies moved verbatim (see state/scene-state-ops.js header).
|
|
25
29
|
const { lazyHostBinding } = require('../host/host-bindings');
|
|
@@ -56,8 +60,6 @@ async function planScene(redis, bookId, chapterId, sceneId) {
|
|
|
56
60
|
// для исполнения генерации).
|
|
57
61
|
async function beginStage(redis, scene, loadedBook, buildId, stage) {
|
|
58
62
|
const dispatchEngine = require('../runtime/dispatch-engine');
|
|
59
|
-
// S-4: filename grammar composed from the canonical owner (bytes unchanged)
|
|
60
|
-
const artifactNaming = require('@animastor/generation').artifactNaming;
|
|
61
63
|
const bookId = scene.book_id;
|
|
62
64
|
const chapterId = scene.chapter_id;
|
|
63
65
|
const sceneId = scene.scene_id;
|
|
@@ -157,20 +159,37 @@ async function completeStage(redis, bookId, chapterId, sceneId, stage, buildId,
|
|
|
157
159
|
|
|
158
160
|
if (sceneRows.length === 1) {
|
|
159
161
|
const sv = sceneRows[0];
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
if (asset) {
|
|
162
|
+
const versionsConfirm = (row) => {
|
|
163
163
|
const contentVersionConfirmed =
|
|
164
|
-
|
|
164
|
+
row.scene_content_version != null &&
|
|
165
165
|
sv.content_version != null &&
|
|
166
|
-
|
|
166
|
+
row.scene_content_version >= sv.content_version;
|
|
167
167
|
const audioVersionConfirmed =
|
|
168
168
|
stage !== 'audio' || (
|
|
169
|
-
|
|
169
|
+
row.scene_audio_config_version != null &&
|
|
170
170
|
sv.audio_config_version != null &&
|
|
171
|
-
|
|
171
|
+
row.scene_audio_config_version >= sv.audio_config_version
|
|
172
172
|
);
|
|
173
|
-
|
|
173
|
+
return contentVersionConfirmed && audioVersionConfirmed;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let asset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, stage, buildId);
|
|
177
|
+
|
|
178
|
+
if (!asset) {
|
|
179
|
+
// A1 (stage147): единственная строка ключа может быть
|
|
180
|
+
// легаси-строкой с build_id = NULL (Stage 73/76 семейство):
|
|
181
|
+
// getAsset(buildId) её не видит, а markReady — единственный
|
|
182
|
+
// писатель build_id — вызывается ПОСЛЕ gate (замкнутый цикл
|
|
183
|
+
// DIRTY каждые 5 с, stage146 §5.4). РОВНО одно дополнительное
|
|
184
|
+
// чтение строки того же ключа с build_id IS NULL — не строки
|
|
185
|
+
// другого билда и не «последней любой». Версии проверяются
|
|
186
|
+
// теми же правилами; после пропуска markReady проставит
|
|
187
|
+
// build_id по существующей логике (buildIdFromArtifactPath).
|
|
188
|
+
asset = await persist('sceneAssets.getAssetNullBuild')(bookId, chapterId, sceneId, stage);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (asset) {
|
|
192
|
+
shouldWriteReady = versionsConfirm(asset);
|
|
174
193
|
}
|
|
175
194
|
} else if (sceneRows.length === 0) {
|
|
176
195
|
// Legacy-книга без version-схемы — READY разрешён (сцена
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
// is a routes-side concern and stays there
|
|
25
25
|
// sceneAssets — per-asset status truth + version gates + dirty
|
|
26
26
|
// units (orchestrator completeStage version gate,
|
|
27
|
-
//
|
|
27
|
+
// incl. its A1 build_id IS NULL fallback read
|
|
28
|
+
// getAssetNullBuild, scene-orchestrator dirty reads, scene-window
|
|
28
29
|
// staleness, scene-callbacks markReady/dirty clears,
|
|
29
30
|
// scene-restoration, reconciliation rebuildWorkList);
|
|
30
31
|
// markStale is consumed by state/scene-state-ops
|
|
@@ -58,6 +59,9 @@ const OPS = [
|
|
|
58
59
|
'tasks.hasActiveTaskForScene',
|
|
59
60
|
|
|
60
61
|
'sceneAssets.getAsset',
|
|
62
|
+
// A1 (stage147): version-gate fallback read — same key with
|
|
63
|
+
// build_id IS NULL ONLY (never another build, never "latest any row").
|
|
64
|
+
'sceneAssets.getAssetNullBuild',
|
|
61
65
|
'sceneAssets.markReady',
|
|
62
66
|
'sceneAssets.stampSceneVersionsForDispatch',
|
|
63
67
|
'sceneAssets.getDirtyUnitIds',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// STAGE 146 — D1 REGRESSION: artifactNaming must be visible to resetScenes
|
|
3
|
+
// ======================================================
|
|
4
|
+
// D1: `const artifactNaming = require('@animastor/generation').artifactNaming;`
|
|
5
|
+
// used to live INSIDE beginStage(). resetScenes() also reads it (step 6 — the
|
|
6
|
+
// stale-PNG/preview pre-delete branch, formerly lines 534/537/555), so on the
|
|
7
|
+
// pre-fix code that branch threw `ReferenceError: artifactNaming is not
|
|
8
|
+
// defined` and /regenerate returned HTTP 500.
|
|
9
|
+
//
|
|
10
|
+
// This test exercises EXACTLY that branch on the fresh package sources:
|
|
11
|
+
// • resetScenes() with a dirty unit and cleanPngUnitIds reaches step 6;
|
|
12
|
+
// • it must NOT throw;
|
|
13
|
+
// • it must delete the old PNG AND the old preview for that unit.
|
|
14
|
+
//
|
|
15
|
+
// The test FAILS on the pre-fix code (the ReferenceError escapes resetScenes —
|
|
16
|
+
// step 6 is not wrapped in try/catch) and PASSES once artifactNaming is hoisted
|
|
17
|
+
// to module scope.
|
|
18
|
+
// ======================================================
|
|
19
|
+
|
|
20
|
+
const { expect } = require('chai');
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { bindHostModules } = require('../src/host/host-bindings');
|
|
25
|
+
const { setHubCancelPort, _resetHubCancelPort } = require('../src/runtime/hub-cancel-port');
|
|
26
|
+
const { createMockRedis } = require('./mocks/redis-mock');
|
|
27
|
+
const artifactNaming = require('@animastor/generation').artifactNaming;
|
|
28
|
+
|
|
29
|
+
const orchestrator = require('../src/orchestration/orchestrator');
|
|
30
|
+
|
|
31
|
+
const BOOK = 'b1';
|
|
32
|
+
const CHAPTER = 'ch1';
|
|
33
|
+
const SCENE = 'sc1';
|
|
34
|
+
const BUILD = 'build1';
|
|
35
|
+
const UID = 'iu_001';
|
|
36
|
+
|
|
37
|
+
describe('Stage 146 — D1: resetScenes reads artifactNaming at module scope', function () {
|
|
38
|
+
this.timeout(10000);
|
|
39
|
+
|
|
40
|
+
let tmpRoot;
|
|
41
|
+
let redis;
|
|
42
|
+
|
|
43
|
+
before(() => {
|
|
44
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'stage146-d1-'));
|
|
45
|
+
redis = createMockRedis();
|
|
46
|
+
// resetScenes reaches the PNG-delete branch through these two seams.
|
|
47
|
+
bindHostModules({
|
|
48
|
+
config: () => ({
|
|
49
|
+
OUTPUT_DIR: tmpRoot,
|
|
50
|
+
TIMEOUTS: { FORCE_DISPATCH_TTL_S: 3600 },
|
|
51
|
+
}),
|
|
52
|
+
journal: () => ({
|
|
53
|
+
EventType: { SCENE_RESET: 'SCENE_RESET', SCENE_RESET_COMPLETED: 'SCENE_RESET_COMPLETED' },
|
|
54
|
+
appendSceneEvent: async () => {},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
setHubCancelPort({ clearHubDispatches: async () => ({ requested: 0, cleared: 0, failed: 0 }) });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
after(() => {
|
|
61
|
+
_resetHubCancelPort();
|
|
62
|
+
// Restore the fail-fast default from test/test-bindings.js.
|
|
63
|
+
bindHostModules({
|
|
64
|
+
config: () => { throw new Error('[D1-TEST] config unwired'); },
|
|
65
|
+
journal: () => { throw new Error('[D1-TEST] journal unwired'); },
|
|
66
|
+
});
|
|
67
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('removes the stale PNG and preview of a dirty unit without throwing', async () => {
|
|
71
|
+
const buildDir = path.join(tmpRoot, BUILD);
|
|
72
|
+
fs.mkdirSync(buildDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const pngPath = path.join(buildDir, artifactNaming.sceneImageName(BOOK, CHAPTER, SCENE, UID));
|
|
75
|
+
const previewPath = path.join(buildDir, artifactNaming.sceneImagePreviewName(BOOK, CHAPTER, SCENE, UID));
|
|
76
|
+
fs.writeFileSync(pngPath, 'png-bytes');
|
|
77
|
+
fs.writeFileSync(previewPath, 'preview-bytes');
|
|
78
|
+
expect(fs.existsSync(pngPath), 'fixture PNG must exist').to.equal(true);
|
|
79
|
+
expect(fs.existsSync(previewPath), 'fixture preview must exist').to.equal(true);
|
|
80
|
+
|
|
81
|
+
const scenes = [{ chapter_id: CHAPTER, scene_id: SCENE, dirty_layers: ['image'] }];
|
|
82
|
+
const layerCfg = { image_enabled: true };
|
|
83
|
+
const bookDiff = { markDirtyScenes: async () => ({ marked: 1 }) };
|
|
84
|
+
|
|
85
|
+
const res = await orchestrator.resetScenes(
|
|
86
|
+
redis, BOOK, BUILD, scenes, layerCfg,
|
|
87
|
+
{
|
|
88
|
+
scope: 'SCENE',
|
|
89
|
+
chapterId: CHAPTER,
|
|
90
|
+
sceneId: SCENE,
|
|
91
|
+
bookDiff,
|
|
92
|
+
cleanPngUnitIds: { [`${CHAPTER}_${SCENE}`]: [UID] },
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(res.reset_scenes).to.equal(1);
|
|
97
|
+
expect(res.marked).to.equal(1);
|
|
98
|
+
// The pre-fix bug threw before this point, so these two assertions are
|
|
99
|
+
// what the regression pins down.
|
|
100
|
+
expect(fs.existsSync(pngPath), 'old PNG must be deleted').to.equal(false);
|
|
101
|
+
expect(fs.existsSync(previewPath), 'old preview must be deleted').to.equal(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// STAGE 147 — A1 REGRESSION: version-gate must see the build_id IS NULL row
|
|
3
|
+
// ======================================================
|
|
4
|
+
// A1 (stage146 §5.4): the key's only scene_assets row had build_id = NULL.
|
|
5
|
+
// completeStage read the asset via sceneAssets.getAsset(..., buildId) —
|
|
6
|
+
// which filters `build_id = $5` — so the row was invisible; markReady, the
|
|
7
|
+
// ONLY writer able to stamp build_id (buildIdFromArtifactPath), runs BEHIND
|
|
8
|
+
// the gate. Result: endless DIRTY + re-dispatch every 5 s scheduler tick
|
|
9
|
+
// (cache_hit → callback → "stale" → DIRTY).
|
|
10
|
+
//
|
|
11
|
+
// The fix (orchestrator.completeStage): when the build-scoped read misses,
|
|
12
|
+
// perform exactly ONE additional read of the SAME key with build_id IS NULL
|
|
13
|
+
// (port op sceneAssets.getAssetNullBuild — never another build's row, never
|
|
14
|
+
// "latest any row"), apply the SAME version rules, then let markReady stamp
|
|
15
|
+
// build_id as before.
|
|
16
|
+
//
|
|
17
|
+
// On the PRE-FIX code tests 1 and 2 FAIL (gate stays DIRTY / fallback op
|
|
18
|
+
// never consulted); they PASS with the fix. Fail-closed on PG errors is
|
|
19
|
+
// preserved for both reads.
|
|
20
|
+
// ======================================================
|
|
21
|
+
|
|
22
|
+
const { expect } = require('chai');
|
|
23
|
+
const { bindHostModules } = require('../src/host/host-bindings');
|
|
24
|
+
const {
|
|
25
|
+
OPS, setPersistencePort, _resetPersistencePort,
|
|
26
|
+
} = require('../src/runtime/persistence-port');
|
|
27
|
+
const { createMockRedis } = require('./mocks/redis-mock');
|
|
28
|
+
|
|
29
|
+
const ORCH = require.resolve('../src/orchestration/orchestrator');
|
|
30
|
+
const CALLBACKS = require.resolve('../src/orchestration/scene-callbacks');
|
|
31
|
+
const DISPATCH = require.resolve('../src/runtime/dispatch-engine');
|
|
32
|
+
|
|
33
|
+
const BOOK = 'book-a1';
|
|
34
|
+
const CHAPTER = 'ch-a1';
|
|
35
|
+
const SCENE = 'sc-a1';
|
|
36
|
+
const BUILD = 'build_a1';
|
|
37
|
+
const ARTIFACT = `/data/output/${BUILD}/scene_a1.png`;
|
|
38
|
+
const SCENE_VERSIONS = [{ content_version: 5, audio_config_version: 5 }];
|
|
39
|
+
|
|
40
|
+
// Port adapter built from the declared OPS (contract completeness is
|
|
41
|
+
// asserted implicitly: setPersistencePort fails fast on any missing op).
|
|
42
|
+
function makeAdapter(overrides = {}) {
|
|
43
|
+
const adapter = {};
|
|
44
|
+
for (const op of OPS) {
|
|
45
|
+
const noop = async () => null;
|
|
46
|
+
const [ns, fn] = op.includes('.') ? op.split('.') : [null, op];
|
|
47
|
+
if (ns === null) adapter[fn] = overrides[op] || noop;
|
|
48
|
+
else {
|
|
49
|
+
adapter[ns] = adapter[ns] || {};
|
|
50
|
+
adapter[ns][fn] = (overrides[op]) || noop;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return adapter;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('Stage 147 — A1: version-gate fallback на строку build_id IS NULL (пакет)', function () {
|
|
57
|
+
this.timeout(10000);
|
|
58
|
+
|
|
59
|
+
let redis;
|
|
60
|
+
let calls;
|
|
61
|
+
let savedCache;
|
|
62
|
+
|
|
63
|
+
function stubModule(resolvedPath, exports) {
|
|
64
|
+
savedCache.set(resolvedPath, require.cache[resolvedPath]);
|
|
65
|
+
require.cache[resolvedPath] = { exports, loaded: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
redis = createMockRedis();
|
|
70
|
+
calls = { markReady: [], nullBuildReads: [] };
|
|
71
|
+
savedCache = new Map();
|
|
72
|
+
for (const p of [CALLBACKS, DISPATCH]) {
|
|
73
|
+
savedCache.set(p, require.cache[p]);
|
|
74
|
+
delete require.cache[p];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Host seams the gate path touches (state restore after READY).
|
|
78
|
+
bindHostModules({
|
|
79
|
+
config: () => ({ OUTPUT_DIR: '/data/output' }),
|
|
80
|
+
state: () => ({
|
|
81
|
+
AssetState: {
|
|
82
|
+
NEW: 'new', DIRTY: 'dirty', PENDING: 'pending',
|
|
83
|
+
GENERATING: 'generating', READY: 'ready', FAILED: 'failed',
|
|
84
|
+
},
|
|
85
|
+
unsafeRestoreAssetState: async (r, b, c, s, asset, status) => {
|
|
86
|
+
calls.stateWrite = { asset, status };
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
journal: () => ({ EventType: {}, appendSceneEvent: async () => {} }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
stubModule(CALLBACKS, {
|
|
93
|
+
STAGE_HANDLERS: {
|
|
94
|
+
image: async () => ({ ok: true, artifact: { buildId: BUILD, path: ARTIFACT } }),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
stubModule(DISPATCH, {
|
|
98
|
+
verifyDispatchIdentity: async () => ({ valid: true, metadata: {} }),
|
|
99
|
+
finalizeDispatch: async (r, b, c, s, stage, opts) => {
|
|
100
|
+
calls.finalize = { stage, opts };
|
|
101
|
+
return { finalized: true };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
setPersistencePort(makeAdapter({
|
|
106
|
+
'sceneVersions.getSceneVersions': async () => SCENE_VERSIONS,
|
|
107
|
+
'sceneAssets.getAsset': async () => (calls.buildRow === undefined ? null : calls.buildRow),
|
|
108
|
+
'sceneAssets.getAssetNullBuild': async (...args) => {
|
|
109
|
+
calls.nullBuildReads.push(args);
|
|
110
|
+
if (calls.nullRowError) throw calls.nullRowError;
|
|
111
|
+
return calls.nullRow || null;
|
|
112
|
+
},
|
|
113
|
+
'sceneAssets.markReady': async (...args) => {
|
|
114
|
+
calls.markReady.push(args);
|
|
115
|
+
return { status: 'ready' };
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
for (const [p, entry] of savedCache) {
|
|
122
|
+
if (entry) require.cache[p] = entry;
|
|
123
|
+
else delete require.cache[p];
|
|
124
|
+
}
|
|
125
|
+
_resetPersistencePort();
|
|
126
|
+
calls = {};
|
|
127
|
+
// Возврат fail-fast дефолтов из test/test-bindings.js
|
|
128
|
+
bindHostModules({
|
|
129
|
+
journal: () => {
|
|
130
|
+
throw new Error('[ORCH-TEST-BINDINGS] host journal not wired — wire it in the test that asserts journal behavior');
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function runGate(stage = 'image') {
|
|
136
|
+
const orchestrator = require(ORCH);
|
|
137
|
+
return orchestrator.completeStage(redis, BOOK, CHAPTER, SCENE, stage, BUILD, 'dispatch-a1');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it('1. NULL-строка с подтверждённой версией → READY с одного вызова, markReady вызван', async () => {
|
|
141
|
+
calls.buildRow = null; // getAsset(buildId) промахнулся
|
|
142
|
+
calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
|
|
143
|
+
|
|
144
|
+
const result = await runGate();
|
|
145
|
+
|
|
146
|
+
expect(result.completed, 'fallback-чтение должно пропустить READY').to.be.true;
|
|
147
|
+
expect(calls.markReady).to.have.length(1);
|
|
148
|
+
expect(calls.markReady[0].slice(0, 5)).to.deep.equal([BOOK, CHAPTER, SCENE, 'image', ARTIFACT]);
|
|
149
|
+
expect(calls.nullBuildReads, 'дополнительное чтение — ровно одно').to.have.length(1);
|
|
150
|
+
expect(calls.nullBuildReads[0]).to.deep.equal([BOOK, CHAPTER, SCENE, 'image']);
|
|
151
|
+
expect(calls.stateWrite).to.deep.equal({ asset: 'image', status: 'ready' });
|
|
152
|
+
expect(calls.finalize.opts.outcome).to.equal('success');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('2. NULL-строка с устаревшей версией (2 < 5) → не READY', async () => {
|
|
156
|
+
calls.buildRow = null;
|
|
157
|
+
calls.nullRow = { build_id: null, scene_content_version: 2, scene_audio_config_version: 2 };
|
|
158
|
+
|
|
159
|
+
const result = await runGate();
|
|
160
|
+
|
|
161
|
+
expect(result.completed).to.be.false;
|
|
162
|
+
expect(result.reason).to.equal('stale');
|
|
163
|
+
expect(calls.markReady).to.have.length(0);
|
|
164
|
+
expect(calls.stateWrite).to.deep.equal({ asset: 'image', status: 'dirty' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('3. строки этого билда нет, NULL-строки нет (только чужой билд) → не READY', async () => {
|
|
168
|
+
calls.buildRow = null;
|
|
169
|
+
calls.nullRow = null;
|
|
170
|
+
|
|
171
|
+
const result = await runGate();
|
|
172
|
+
|
|
173
|
+
expect(result.completed).to.be.false;
|
|
174
|
+
expect(result.reason).to.equal('stale');
|
|
175
|
+
expect(calls.markReady).to.have.length(0);
|
|
176
|
+
expect(calls.nullBuildReads).to.have.length(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('4. строка этого билда найдена → fallback не выполняется', async () => {
|
|
180
|
+
calls.buildRow = { build_id: BUILD, scene_content_version: 5, scene_audio_config_version: 5 };
|
|
181
|
+
calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
|
|
182
|
+
|
|
183
|
+
const result = await runGate();
|
|
184
|
+
|
|
185
|
+
expect(result.completed).to.be.true;
|
|
186
|
+
expect(calls.nullBuildReads).to.have.length(0);
|
|
187
|
+
expect(calls.markReady).to.have.length(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('5. ошибка PG при основном чтении → fail-closed', async () => {
|
|
191
|
+
const adapter = require('../src/runtime/persistence-port').persistence();
|
|
192
|
+
adapter.sceneAssets.getAsset = async () => { throw new Error('pg down'); };
|
|
193
|
+
calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
|
|
194
|
+
|
|
195
|
+
const result = await runGate();
|
|
196
|
+
|
|
197
|
+
expect(result.completed).to.be.false;
|
|
198
|
+
expect(calls.markReady).to.have.length(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('6. ошибка PG при fallback-чтении → fail-closed', async () => {
|
|
202
|
+
calls.buildRow = null;
|
|
203
|
+
calls.nullRowError = new Error('pg down (null-build read)');
|
|
204
|
+
|
|
205
|
+
const result = await runGate();
|
|
206
|
+
|
|
207
|
+
expect(result.completed).to.be.false;
|
|
208
|
+
expect(result.reason).to.equal('stale');
|
|
209
|
+
expect(calls.markReady).to.have.length(0);
|
|
210
|
+
});
|
|
211
|
+
});
|