@animastor/orchestration 0.1.1
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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/host/host-bindings.js +138 -0
- package/src/index.js +99 -0
- package/src/orchestration/index.js +40 -0
- package/src/orchestration/orchestrator.js +630 -0
- package/src/orchestration/runtime-result-consumer.js +54 -0
- package/src/orchestration/scene-callbacks.js +542 -0
- package/src/orchestration/scene-orchestrator.js +601 -0
- package/src/orchestration/scene-restoration.js +129 -0
- package/src/orchestration/scene-utils.js +36 -0
- package/src/runtime/active-scenes-index.js +164 -0
- package/src/runtime/audio-fsm-port.js +187 -0
- package/src/runtime/circuit-breaker.js +588 -0
- package/src/runtime/counter-reconciliation.js +308 -0
- package/src/runtime/dispatch-engine.js +1768 -0
- package/src/runtime/failure-taxonomy.js +125 -0
- package/src/runtime/hub-cancel-port.js +122 -0
- package/src/runtime/layer-config-port.js +136 -0
- package/src/runtime/lease-manager.js +607 -0
- package/src/runtime/persistence-port.js +162 -0
- package/src/runtime/placeholder-audio-port.js +117 -0
- package/src/runtime/progress-events-port.js +121 -0
- package/src/runtime/reconciliation-engine.js +2405 -0
- package/src/runtime/retry-budget-manager.js +229 -0
- package/src/runtime/runtime-metrics.js +427 -0
- package/src/runtime/runtime-result-emitter.js +123 -0
- package/src/runtime/runtime-scheduler.js +629 -0
- package/src/runtime/scene-data-port.js +114 -0
- package/src/runtime/scene-window.js +827 -0
- package/src/runtime/video-fsm-port.js +189 -0
- package/src/runtime/worker-health.js +270 -0
- package/test/counter-reconciliation.test.js +164 -0
- package/test/mocks/redis-mock.js +221 -0
- package/test/test-bindings.js +60 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { lazyHostBinding, hostBinding } = require('../host/host-bindings');
|
|
4
|
+
// §32.30: former host requires resolve lazily through the host-bindings seam.
|
|
5
|
+
const state = lazyHostBinding('state');
|
|
6
|
+
// S-4: identifier grammar composed from the canonical owner (bytes unchanged)
|
|
7
|
+
const artifactNaming = require('@animastor/generation').artifactNaming;
|
|
8
|
+
const runtimeScheduler = require('../runtime/runtime-scheduler');
|
|
9
|
+
// O-2: persistence arrives ONLY through the PersistencePort (host adapter:
|
|
10
|
+
// storage/runtime-persistence-adapter) — the scene-assets repository and the
|
|
11
|
+
// database handle left this file. SQL lives host-side.
|
|
12
|
+
const persist = require('../runtime/persistence-port').persist;
|
|
13
|
+
// O-4: placeholder audio arrives ONLY through the PlaceholderAudioPort
|
|
14
|
+
// (host adapter: storage/placeholder-audio-adapter) — the ffmpeg/fs host
|
|
15
|
+
// service (../services/placeholder-audio) left this file.
|
|
16
|
+
const placeholderAudioOp = require('../runtime/placeholder-audio-port').placeholderAudioOp;
|
|
17
|
+
const { log, warn } = require('./scene-utils');
|
|
18
|
+
|
|
19
|
+
// §32.29 step 4a: the former process.env.OUTPUT_DIR read is closed — the
|
|
20
|
+
// artifact root arrives through the composition-root host-bindings seam
|
|
21
|
+
// (the host passes config.OUTPUT_DIR; identical value, identical fallback
|
|
22
|
+
// semantics are host-owned now).
|
|
23
|
+
const OUTPUT_DIR = () => hostBinding('artifactRoot');
|
|
24
|
+
|
|
25
|
+
async function restoreSceneChunkStatus(redis, buildId, bookId, chapterId, sceneId, hasDirtyUnits, unitIds) {
|
|
26
|
+
const sceneWindow = require('../runtime/scene-window');
|
|
27
|
+
const buildDir = path.join(OUTPUT_DIR(), buildId);
|
|
28
|
+
const fileStatus = await sceneWindow.getSceneFilesStatus(buildDir, bookId, chapterId, sceneId);
|
|
29
|
+
|
|
30
|
+
if (!hasDirtyUnits) {
|
|
31
|
+
// Нет информации о конкретных dirty-юнитах (например, rebuild_all=true).
|
|
32
|
+
// Проверяем, есть ли какой-либо контент на диске.
|
|
33
|
+
// 🔧 Placeholder audio file counts as EXISTS but NOT as real content.
|
|
34
|
+
// If only placeholder audio exists, the scene still needs regeneration.
|
|
35
|
+
let audioIsReal = false;
|
|
36
|
+
if (fileStatus.audio.exists) {
|
|
37
|
+
try {
|
|
38
|
+
audioIsReal = await placeholderAudioOp('hasRealAudio')(bookId, chapterId, sceneId, buildId);
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
const hasAnyContent = audioIsReal || fileStatus.image.exists || fileStatus.video.exists;
|
|
42
|
+
if (!hasAnyContent) {
|
|
43
|
+
log(`[RESTORE-SKIP] ${bookId}/${chapterId}/${sceneId}: no real content on disk — leaving as-is for regeneration`);
|
|
44
|
+
return { restored: false, reason: 'no_content' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Контент существует на диске (от предыдущей генерации).
|
|
48
|
+
// Восстанавливаем чанки, чтобы /assets-state показывал корректный прогресс
|
|
49
|
+
// (файлы, которые уже есть на диске).
|
|
50
|
+
await sceneWindow.restoreChunkStatusForScene(redis, buildId, bookId, chapterId, sceneId);
|
|
51
|
+
|
|
52
|
+
// T8: syncLinearState удалён — per-asset state единственный source of truth
|
|
53
|
+
log(`[RESTORE-PARTIAL] ${bookId}/${chapterId}/${sceneId}: chunks restored from disk (audio=${audioIsReal ? 'real' : 'placeholder'}, image=${fileStatus.image.exists}), keeping pending for regeneration`);
|
|
54
|
+
return { restored: true, reason: 'partial_restore' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Есть конкретные dirty-юниты (изменён visual prompt и т.п.).
|
|
58
|
+
// Удаляем stale PNG для этих юнитов, чтобы GPU обязательно перегенерировал их.
|
|
59
|
+
if (unitIds && Array.isArray(unitIds) && unitIds.length > 0) {
|
|
60
|
+
for (const unitId of unitIds) {
|
|
61
|
+
const imageIUId = artifactNaming.iuAssetId(bookId, chapterId, sceneId, unitId);
|
|
62
|
+
const pngPath = path.join(buildDir, `${imageIUId}.png`);
|
|
63
|
+
try {
|
|
64
|
+
if (fs.existsSync(pngPath)) {
|
|
65
|
+
fs.unlinkSync(pngPath);
|
|
66
|
+
log(`[RESTORE-PRE-DELETE] Deleted stale PNG: ${imageIUId}.png`);
|
|
67
|
+
}
|
|
68
|
+
} catch (delErr) {
|
|
69
|
+
console.warn(`[RESTORE-PRE-DELETE] Failed to delete ${imageIUId}.png: ${delErr.message}`);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await redis.del(`animastor:job:${imageIUId}:image`);
|
|
73
|
+
log(`[RESTORE-PRE-DELETE] Cleared GPU dedup for ${imageIUId}`);
|
|
74
|
+
} catch (dedupErr) {
|
|
75
|
+
console.warn(`[RESTORE-PRE-DELETE] Failed to clear dedup for ${imageIUId}: ${dedupErr.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Аудио не было затронуто изменением visual prompt — восстанавливаем.
|
|
81
|
+
await sceneWindow.restoreChunkStatusForScene(redis, buildId, bookId, chapterId, sceneId);
|
|
82
|
+
|
|
83
|
+
// Audio: не затронуто, оставляем READY (если файл существует И версия актуальна).
|
|
84
|
+
// Д.3 (M3): Don't mark audio as READY solely based on file existence —
|
|
85
|
+
// check PG version first. If content_version was bumped (force-regen),
|
|
86
|
+
// stale audio files don't count as ready.
|
|
87
|
+
if (fileStatus.audio.exists) {
|
|
88
|
+
let audioVersionStale = false;
|
|
89
|
+
try {
|
|
90
|
+
// O-2: version staleness via the PersistencePort (same scenes-table
|
|
91
|
+
// row shape as pre-O-2, SQL lives in the host adapter).
|
|
92
|
+
const sceneRows = await persist('sceneVersions.getSceneVersions')(bookId, chapterId, sceneId);
|
|
93
|
+
if (sceneRows.length > 0) {
|
|
94
|
+
const sv = sceneRows[0];
|
|
95
|
+
const aAsset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, 'audio', buildId)
|
|
96
|
+
|| await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, 'audio');
|
|
97
|
+
if (aAsset && aAsset.scene_content_version != null && sv.content_version != null &&
|
|
98
|
+
aAsset.scene_content_version < sv.content_version) {
|
|
99
|
+
audioVersionStale = true;
|
|
100
|
+
}
|
|
101
|
+
if (aAsset && aAsset.scene_audio_config_version != null && sv.audio_config_version != null &&
|
|
102
|
+
aAsset.scene_audio_config_version < sv.audio_config_version) {
|
|
103
|
+
audioVersionStale = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (_) {}
|
|
107
|
+
if (!audioVersionStale) {
|
|
108
|
+
await state.unsafeRestoreAssetState(redis, bookId, chapterId, sceneId, 'audio', state.AssetState.READY);
|
|
109
|
+
} else {
|
|
110
|
+
log(`[RESTORE-AUDIO-STALE] ${bookId}/${chapterId}/${sceneId}: audio file exists but version is stale — leaving pending`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Image: всегда DIRTY — при наличии dirty-юнитов визуальный контент
|
|
114
|
+
// нужно перегенерировать. Если сюда попали с hasDirtyUnits=true,
|
|
115
|
+
// значит есть изменения, влияющие на image.
|
|
116
|
+
// M5: Route through markDirtyScene — only image needs DIRTY
|
|
117
|
+
// (audio was just set to READY above, video is not affected)
|
|
118
|
+
// M5 Шаг 3: syncLinearState уже внутри markDirtyScene
|
|
119
|
+
// S-5: canonical owner is the state layer (facade re-exports it) —
|
|
120
|
+
// calling the facade here would keep a scene-restoration→orchestrator
|
|
121
|
+
// cycle edge inside orchestration.
|
|
122
|
+
const stateOps = lazyHostBinding('stateOps');
|
|
123
|
+
await stateOps.markDirtyScene(redis, bookId, chapterId, sceneId, ['image']);
|
|
124
|
+
|
|
125
|
+
log(`[RESTORE-PER-UNIT] ${bookId}/${chapterId}/${sceneId}: ${unitIds?.length || 0} dirty unit(s) — audio=${fileStatus.audio.exists}, image=${unitIds?.length > 0 ? 'dirty' : fileStatus.image.exists}, PNG pre-deleted`);
|
|
126
|
+
return { restored: true, reason: 'per_unit_restore' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { restoreSceneChunkStatus };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const { lazyHostBinding } = require('../host/host-bindings');
|
|
2
|
+
// §32.30: former host require ('../state/event-journal') resolves lazily
|
|
3
|
+
// through the host-bindings seam.
|
|
4
|
+
const journal = lazyHostBinding('journal');
|
|
5
|
+
|
|
6
|
+
const logPrefix = '[ORCH]';
|
|
7
|
+
|
|
8
|
+
function log(msg) {
|
|
9
|
+
console.log(`${logPrefix} ${msg}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function warn(msg) {
|
|
13
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function error(msg) {
|
|
17
|
+
console.error(`${logPrefix} ❌ ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function logEvent(redis, scene, type, stateName, details = {}) {
|
|
21
|
+
const bookId = scene.book_id;
|
|
22
|
+
const chapterId = scene.chapter_id;
|
|
23
|
+
const sceneId = scene.scene_id;
|
|
24
|
+
|
|
25
|
+
await journal.appendSceneEvent(
|
|
26
|
+
redis,
|
|
27
|
+
bookId,
|
|
28
|
+
chapterId,
|
|
29
|
+
sceneId,
|
|
30
|
+
type,
|
|
31
|
+
stateName,
|
|
32
|
+
details
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { log, warn, error, logEvent };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// ACTIVE SCENES INDEX
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Redis Set tracking active scenes (_audio_pending through video_generating).
|
|
5
|
+
// Enables efficient iteration without SCAN operations.
|
|
6
|
+
|
|
7
|
+
const { lazyHostBinding } = require('../host/host-bindings');
|
|
8
|
+
// §32.30: former host requires resolve lazily through the host-bindings seam.
|
|
9
|
+
const state = lazyHostBinding('state');
|
|
10
|
+
|
|
11
|
+
const ACTIVE_SCENES_KEY = 'animastor:active-scenes';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a scene is in the active index.
|
|
15
|
+
*/
|
|
16
|
+
async function isActive(redis, bookId, chapterId, sceneId) {
|
|
17
|
+
const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
|
|
18
|
+
return await redis.sismember(ACTIVE_SCENES_KEY, sceneKey);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get all active scene keys.
|
|
23
|
+
*/
|
|
24
|
+
async function getAllActiveSceneKeys(redis) {
|
|
25
|
+
return await redis.smembers(ACTIVE_SCENES_KEY);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get count of active scenes.
|
|
30
|
+
*/
|
|
31
|
+
async function getActiveCount(redis) {
|
|
32
|
+
return await redis.scard(ACTIVE_SCENES_KEY);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add scene to active index when it enters generating/pending state.
|
|
37
|
+
*/
|
|
38
|
+
async function addActiveScene(redis, bookId, chapterId, sceneId) {
|
|
39
|
+
const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
|
|
40
|
+
const added = await redis.sadd(ACTIVE_SCENES_KEY, sceneKey);
|
|
41
|
+
return { added: added > 0, sceneKey };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove scene from active index when it completes or fails.
|
|
46
|
+
*/
|
|
47
|
+
async function removeActiveScene(redis, bookId, chapterId, sceneId) {
|
|
48
|
+
const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
|
|
49
|
+
const removed = await redis.srem(ACTIVE_SCENES_KEY, sceneKey);
|
|
50
|
+
return { removed: removed > 0, sceneKey };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse scene key back to components.
|
|
55
|
+
*/
|
|
56
|
+
function parseSceneKey(sceneKey) {
|
|
57
|
+
const parts = sceneKey.split(':');
|
|
58
|
+
if (parts.length !== 3) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
bookId: parts[0],
|
|
63
|
+
chapterId: parts[1],
|
|
64
|
+
sceneId: parts[2]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get detailed info about all active scenes.
|
|
70
|
+
*/
|
|
71
|
+
async function getActiveScenesDetail(redis) {
|
|
72
|
+
const activeKeys = await getAllActiveSceneKeys(redis);
|
|
73
|
+
const scenes = [];
|
|
74
|
+
|
|
75
|
+
for (const key of activeKeys) {
|
|
76
|
+
const parsed = parseSceneKey(key);
|
|
77
|
+
if (!parsed) continue;
|
|
78
|
+
|
|
79
|
+
const assetStates = await state.getAssetStates(redis, parsed.bookId, parsed.chapterId, parsed.sceneId);
|
|
80
|
+
|
|
81
|
+
scenes.push({
|
|
82
|
+
...parsed,
|
|
83
|
+
currentState: 'active',
|
|
84
|
+
updatedAt: null,
|
|
85
|
+
buildId: null,
|
|
86
|
+
assetStates
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return scenes;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Find scenes in a specific state.
|
|
95
|
+
*/
|
|
96
|
+
async function findScenesByState(redis, targetState) {
|
|
97
|
+
const allActive = await getActiveScenesDetail(redis);
|
|
98
|
+
return allActive.filter(s => s.currentState === targetState);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* T8: Check if scene is currently generating (audio, image, or video).
|
|
103
|
+
* Uses per-asset states as source of truth.
|
|
104
|
+
*/
|
|
105
|
+
async function isGenerating(redis, bookId, chapterId, sceneId) {
|
|
106
|
+
const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
107
|
+
if (!assetStates) return false;
|
|
108
|
+
return assetStates.audio === state.AssetState.GENERATING ||
|
|
109
|
+
assetStates.image === state.AssetState.GENERATING ||
|
|
110
|
+
assetStates.video === state.AssetState.GENERATING;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* T8: Check if scene is in pending state (waiting to start).
|
|
115
|
+
* Uses per-asset states as source of truth.
|
|
116
|
+
*/
|
|
117
|
+
async function isPending(redis, bookId, chapterId, sceneId) {
|
|
118
|
+
const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
119
|
+
if (!assetStates) return false;
|
|
120
|
+
return assetStates.audio === state.AssetState.PENDING ||
|
|
121
|
+
assetStates.image === state.AssetState.PENDING ||
|
|
122
|
+
assetStates.video === state.AssetState.PENDING;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* T8: Check if scene is in terminal state (complete or failed).
|
|
127
|
+
* Uses per-asset states as source of truth.
|
|
128
|
+
*/
|
|
129
|
+
async function isTerminal(redis, bookId, chapterId, sceneId) {
|
|
130
|
+
const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
131
|
+
if (!assetStates) return false;
|
|
132
|
+
// Terminal: all enabled assets are READY, PLACEHOLDER, or FAILED
|
|
133
|
+
const allAssetsFinished = (
|
|
134
|
+
(assetStates.audio === state.AssetState.READY ||
|
|
135
|
+
assetStates.audio === state.AssetState.PLACEHOLDER ||
|
|
136
|
+
assetStates.audio === state.AssetState.FAILED) &&
|
|
137
|
+
(assetStates.image === state.AssetState.READY ||
|
|
138
|
+
assetStates.image === state.AssetState.PLACEHOLDER ||
|
|
139
|
+
assetStates.image === state.AssetState.FAILED) &&
|
|
140
|
+
(assetStates.video === state.AssetState.READY ||
|
|
141
|
+
assetStates.video === state.AssetState.PLACEHOLDER ||
|
|
142
|
+
assetStates.video === state.AssetState.FAILED)
|
|
143
|
+
);
|
|
144
|
+
return allAssetsFinished;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ======================================================
|
|
148
|
+
// EXPORTS
|
|
149
|
+
// ======================================================
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
ACTIVE_SCENES_KEY,
|
|
153
|
+
isActive,
|
|
154
|
+
getAllActiveSceneKeys,
|
|
155
|
+
getActiveCount,
|
|
156
|
+
addActiveScene,
|
|
157
|
+
removeActiveScene,
|
|
158
|
+
parseSceneKey,
|
|
159
|
+
getActiveScenesDetail,
|
|
160
|
+
findScenesByState,
|
|
161
|
+
isGenerating,
|
|
162
|
+
isPending,
|
|
163
|
+
isTerminal
|
|
164
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// O-7 PORT: AudioFsmPort — runtime/orchestration
|
|
3
|
+
// audio-FSM contract
|
|
4
|
+
// ======================================================
|
|
5
|
+
// The runtime and orchestration tiers must not know the audio-orchestrator
|
|
6
|
+
// host service. The Redis key grammar (`animastor:audio-orch:*`), the JSON
|
|
7
|
+
// state envelope, the valid-transition map and every chunk-completeness /
|
|
8
|
+
// merge / hub-dedup detail stay HOST-side (backend/src/services/
|
|
9
|
+
// audio-orchestrator.js); the tiers drive the audio scene FSM ONLY through
|
|
10
|
+
// this port, wired by the composition root (backend.cjs) at startup.
|
|
11
|
+
//
|
|
12
|
+
// runtime/** + orchestration/** (tier consumers)
|
|
13
|
+
// ↓ consume audio-FSM ops through
|
|
14
|
+
// runtime/audio-fsm-port.js ← THIS PORT (tier-owned contract)
|
|
15
|
+
// ↑ wired by the host
|
|
16
|
+
// storage/audio-fsm-adapter (host FSM adapter)
|
|
17
|
+
//
|
|
18
|
+
// This is the O-2/O-3/O-4/O-5 port convention (runtime/persistence-port.js,
|
|
19
|
+
// runtime/scene-data-port.js, runtime/placeholder-audio-port.js,
|
|
20
|
+
// runtime/progress-events-port.js): a zero-require contract module, a
|
|
21
|
+
// fail-fast call-time resolver, one set/wired/reset surface. No Redis key
|
|
22
|
+
// strings, no JSON state encoding, no transition-map internals and no
|
|
23
|
+
// "future ops" cross the boundary — the contract below is EXACTLY the set
|
|
24
|
+
// of operations the two tiers use today (measured at O-7 reconnaissance,
|
|
25
|
+
// §32.24; the O-6 audit had measured 13 ops for the combined MediaFsmPort
|
|
26
|
+
// audio half — the tiers' actual usage is 12 ops + the phase constants):
|
|
27
|
+
//
|
|
28
|
+
// getState — read the audio-FSM phase record (dispatch DONE
|
|
29
|
+
// guard, invariant checks)
|
|
30
|
+
// setState — persist a repaired record (recovery leg that
|
|
31
|
+
// re-fills expected_count before re-driving merge)
|
|
32
|
+
// deleteState — drop a stale record before re-initialization
|
|
33
|
+
// (scene-orchestrator stale-phase reset, recovery
|
|
34
|
+
// unknown-phase sweep)
|
|
35
|
+
// initPlaceholderReady — create the initial PLACEHOLDER_READY record
|
|
36
|
+
// (scene-window startScene, scene-orchestrator
|
|
37
|
+
// no_state/stale-phase recovery init)
|
|
38
|
+
// setGenerating — PLACEHOLDER_READY → GENERATING before TTS dispatch
|
|
39
|
+
// setWaitingChunks — GENERATING → WAITING_CHUNKS before jobs are sent
|
|
40
|
+
// setMerging — → MERGING fast-track legs
|
|
41
|
+
// setDone — → DONE fast-track/recovery legs
|
|
42
|
+
// setFailed — → FAILED (orchestrator failStage facade sync,
|
|
43
|
+
// recovery legs)
|
|
44
|
+
// completeChunk — drive chunk-completeness + merge from a chunk
|
|
45
|
+
// callback / recovery (host-owned merge logic)
|
|
46
|
+
// failWaitingScene — WAITING_CHUNKS → FAILED with hub-dedup cleanup
|
|
47
|
+
// (stall watchdog, recovery)
|
|
48
|
+
// scanAllStates — enumerate every non-terminal record (stall
|
|
49
|
+
// watchdog, startup recovery)
|
|
50
|
+
// PHASES — the phase-name constants the tiers compare
|
|
51
|
+
// orchState.phase against (DONE, FAILED, NEW,
|
|
52
|
+
// PLACEHOLDER_READY, GENERATING, WAITING_CHUNKS,
|
|
53
|
+
// MERGING). Phase NAMES are the FSM's public
|
|
54
|
+
// vocabulary; everything about their STORAGE
|
|
55
|
+
// (key grammar, envelope, transitions) stays
|
|
56
|
+
// host-side in the adapter.
|
|
57
|
+
//
|
|
58
|
+
// Optional-load semantics are the ADAPTER's concern, not the port's: the
|
|
59
|
+
// reconciliation optional-load legs (try/catch → return 0 when the host
|
|
60
|
+
// service is absent) keep their exact pre-O-7 behavior by wrapping the
|
|
61
|
+
// ADAPTER's lazy require, not by relaxing this fail-fast contract.
|
|
62
|
+
//
|
|
63
|
+
// Deliberately NOT in the port: the video FSM (services/video-orchestrator
|
|
64
|
+
// — its own future seam step, never merged here), key()/PREFIX/createState/
|
|
65
|
+
// transitionState internals (Redis/storage knowledge), any combined
|
|
66
|
+
// MediaFsmPort surface (rejected at O-6 as a 27-member god-interface) and
|
|
67
|
+
// any CRUD "for later".
|
|
68
|
+
//
|
|
69
|
+
// Docs: docs/architecture/generation-module-extraction-reconnaissance.md §32.24 (O-7 DONE)
|
|
70
|
+
// ======================================================
|
|
71
|
+
|
|
72
|
+
const OPS = [
|
|
73
|
+
'getState',
|
|
74
|
+
'setState',
|
|
75
|
+
'deleteState',
|
|
76
|
+
'initPlaceholderReady',
|
|
77
|
+
'setGenerating',
|
|
78
|
+
'setWaitingChunks',
|
|
79
|
+
'setMerging',
|
|
80
|
+
'setDone',
|
|
81
|
+
'setFailed',
|
|
82
|
+
'completeChunk',
|
|
83
|
+
'failWaitingScene',
|
|
84
|
+
'scanAllStates',
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// Phase names the tier consumers compare against — the FSM's public
|
|
88
|
+
// vocabulary (a frozen set: adding one is a contract change, not a key
|
|
89
|
+
// grammar change — the adapter owns the storage behind these names).
|
|
90
|
+
const PHASE_CONSTANTS = [
|
|
91
|
+
'NEW',
|
|
92
|
+
'PLACEHOLDER_READY',
|
|
93
|
+
'GENERATING',
|
|
94
|
+
'WAITING_CHUNKS',
|
|
95
|
+
'MERGING',
|
|
96
|
+
'DONE',
|
|
97
|
+
'FAILED',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
let impl = null;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Wire the host audio-FSM adapter. Called once by the composition root
|
|
104
|
+
* (backend.cjs) BEFORE any runtime/orchestration audio-FSM consumer runs.
|
|
105
|
+
* The adapter must expose every op in OPS as a function; the PHASES
|
|
106
|
+
* constants resolve lazily at call time (audioFsmPhases()) so wiring never
|
|
107
|
+
* forces a host-service load — the O-2..O-5 lazy-resolution discipline.
|
|
108
|
+
* @param {object} adapter — flat map with every op in OPS as a function
|
|
109
|
+
* plus a PHASES constant object (validated on first phase read, not here).
|
|
110
|
+
*/
|
|
111
|
+
function setAudioFsmPort(adapter) {
|
|
112
|
+
if (!adapter) {
|
|
113
|
+
throw new Error('audio-fsm-port: adapter is required');
|
|
114
|
+
}
|
|
115
|
+
for (const op of OPS) {
|
|
116
|
+
if (typeof adapter[op] !== 'function') {
|
|
117
|
+
throw new Error(`audio-fsm-port: adapter must implement ${op}()`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!('PHASES' in adapter)) {
|
|
121
|
+
throw new Error('audio-fsm-port: adapter must carry the PHASES constants');
|
|
122
|
+
}
|
|
123
|
+
impl = adapter;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve the port at CALL time — fail-fast when unwired. */
|
|
127
|
+
function audioFsm() {
|
|
128
|
+
if (!impl) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'audio-fsm-port: not wired — the composition root must call ' +
|
|
131
|
+
'setAudioFsmPort(adapter) (storage/audio-fsm-adapter) ' +
|
|
132
|
+
'before runtime/orchestration audio-FSM consumers run (O-7)'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return impl;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Shorthand op resolver: `audioFsmOp('getState')(...)`.
|
|
140
|
+
* Fails fast when the port or the single op is missing — a missing phase
|
|
141
|
+
* writer silently degrading into a no-op would corrupt the audio FSM
|
|
142
|
+
* (ghost GENERATING scenes, lost recovery legs).
|
|
143
|
+
* @param {string} op — op name from OPS
|
|
144
|
+
*/
|
|
145
|
+
function audioFsmOp(op) {
|
|
146
|
+
const fn = impl?.[op];
|
|
147
|
+
if (typeof fn !== 'function') {
|
|
148
|
+
throw new Error(`audio-fsm-port: op '${op}' is not wired (O-7)`);
|
|
149
|
+
}
|
|
150
|
+
return fn;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Phase constants through the port (fail-fast at CALL time — never loaded
|
|
154
|
+
* during composition-root wiring, preserving the lazy-resolution parity
|
|
155
|
+
* with test stubs). */
|
|
156
|
+
function audioFsmPhases() {
|
|
157
|
+
const phases = audioFsm().PHASES;
|
|
158
|
+
if (!phases || typeof phases !== 'object') {
|
|
159
|
+
throw new Error('audio-fsm-port: PHASES is not wired (O-7)');
|
|
160
|
+
}
|
|
161
|
+
for (const phase of PHASE_CONSTANTS) {
|
|
162
|
+
if (!(phase in phases)) {
|
|
163
|
+
throw new Error(`audio-fsm-port: PHASES is missing the ${phase} constant (O-7)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return phases;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isAudioFsmPortWired() {
|
|
170
|
+
return impl !== null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Test hygiene: drop the wiring (mocha hooks). */
|
|
174
|
+
function _resetAudioFsmPort() {
|
|
175
|
+
impl = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = {
|
|
179
|
+
OPS,
|
|
180
|
+
PHASE_CONSTANTS,
|
|
181
|
+
setAudioFsmPort,
|
|
182
|
+
audioFsm,
|
|
183
|
+
audioFsmOp,
|
|
184
|
+
audioFsmPhases,
|
|
185
|
+
isAudioFsmPortWired,
|
|
186
|
+
_resetAudioFsmPort,
|
|
187
|
+
};
|