@animastor/orchestration 0.1.3 → 0.1.4

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.3",
3
+ "version": "0.1.4",
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",
@@ -278,7 +278,7 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
278
278
  log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
279
279
 
280
280
  const sceneImage = image.resolveCanonicalSceneImage(
281
- '/data/output', buildId, bookId, chapterId, sceneId
281
+ artifactRoot(), buildId, bookId, chapterId, sceneId
282
282
  );
283
283
 
284
284
  // Per-asset check
@@ -390,7 +390,7 @@ async function handleVideoCompleted(redis, bookId, chapterId, sceneId, buildId)
390
390
  // T1: возвращает { ok, retryable, reason, artifact }
391
391
  log(`VIDEO_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
392
392
 
393
- const videoPath = `/data/output/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
393
+ const videoPath = `${artifactRoot()}/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
394
394
  const { valid, duration, metadata } = video.validateVideoFile(videoPath);
395
395
 
396
396
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
@@ -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 = `/data/output/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
127
+ const videoPath = `${artifactRoot()}/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
125
128
 
126
129
  try {
127
130
  await fs.access(videoPath);
@@ -155,7 +158,7 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
155
158
  const buildId = resolveBookBuildId(bookId);
156
159
  const imageModule = lazyHostBinding('media.image');
157
160
  const imageInfo = imageModule.resolveCanonicalSceneImage(
158
- '/data/output',
161
+ artifactRoot(),
159
162
  buildId,
160
163
  bookId,
161
164
  chapterId,
@@ -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
+ });