@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.1.5",
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 asset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, stage, buildId);
161
-
162
- if (asset) {
162
+ const versionsConfirm = (row) => {
163
163
  const contentVersionConfirmed =
164
- asset.scene_content_version != null &&
164
+ row.scene_content_version != null &&
165
165
  sv.content_version != null &&
166
- asset.scene_content_version >= sv.content_version;
166
+ row.scene_content_version >= sv.content_version;
167
167
  const audioVersionConfirmed =
168
168
  stage !== 'audio' || (
169
- asset.scene_audio_config_version != null &&
169
+ row.scene_audio_config_version != null &&
170
170
  sv.audio_config_version != null &&
171
- asset.scene_audio_config_version >= sv.audio_config_version
171
+ row.scene_audio_config_version >= sv.audio_config_version
172
172
  );
173
- shouldWriteReady = contentVersionConfirmed && audioVersionConfirmed;
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
- // scene-orchestrator dirty reads, scene-window
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
+ });