@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. 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
+ };