@animastor/orchestration 0.1.4 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animastor/orchestration",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
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",
@@ -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
+ });