"use strict";
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.
Object.defineProperty(exports, "__esModule", { value: true });
exports.SceneStoryService = void 0;
/**
 * sceneStoryService — the entry point for the Scene Story pipeline.
 *
 * Drives the four stages of /scene-reconstruct end-to-end without ever
 * touching runAgentDrivenAnalysis or session.orchestrator.analyze:
 *
 *   Stage 1  scene_reconstruction skill (no LLM)
 *   Stage 2  per-interval Agent deep-dive via SceneAnalysisJobRunner
 *   Stage 3  Haiku cross-scene narrative summary
 *   Stage 4  SceneReport persistence (currently kept on the in-memory session)
 *
 * SSE event flow uses the new scene_story_* event names. The legacy
 * track_data event is also emitted once after Stage 1 so the existing
 * frontend keeps painting timelines until story_controller migrates to
 * the new event names.
 *
 * Cancel semantics:
 *  - cancel() flips a runner-level flag immediately
 *  - queued jobs transition to 'cancelled'
 *  - running jobs keep executing (SkillExecutor has no abort) but their
 *    results land as 'dropped' rather than 'completed'
 *  - waitForAllDone() resolves once nothing is in flight
 *  - the service then finalises with a partial SceneReport and emits a
 *    terminal event so the SSE stream can close cleanly
 */
const uuid_1 = require("../../utils/uuid");
const skillExecutor_1 = require("../../services/skillEngine/skillExecutor");
const config_1 = require("../../config");
const resourceOwnership_1 = require("../../services/resourceOwnership");
const sqlSemaphore_1 = require("../../services/traceProcessor/sqlSemaphore");
const sceneCostEstimator_1 = require("./sceneCostEstimator");
const sceneIntervalBuilder_1 = require("./sceneIntervalBuilder");
const sceneAnalysisJobRunner_1 = require("./sceneAnalysisJobRunner");
const sceneStage1Runner_1 = require("./sceneStage1Runner");
const sceneStage1Verifier_1 = require("./sceneStage1Verifier");
const sceneStage3Summarizer_1 = require("./sceneStage3Summarizer");
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
class SceneStoryService {
    constructor(deps) {
        this.deps = deps;
        this.runners = new Map();
        this.inProgress = new Set();
        this.memoryReportById = new Map();
        /**
         * Concurrent-request dedupe: while a pipeline for an owner-scoped
         * `traceHash` is running, peer requests for the same owner + hash await the
         * in-flight promise instead of starting a duplicate pipeline. RPC traces
         * have no hash key, so duplicate concurrent requests there fall through and
         * run their own pipeline (rare and harmless).
         */
        this.pendingByHash = new Map();
        this.toEnvelopes = deps.toEnvelopes ?? ((result, traceId) => skillExecutor_1.SkillExecutor.toDataEnvelopes(result, undefined, {
            traceId,
            traceSide: 'current',
        }));
    }
    /**
     * Run the full Scene Story pipeline for a session. Resolves when the
     * pipeline reaches a terminal state (completed / failed / cancelled).
     *
     * Errors thrown inside the pipeline are caught and surfaced via SSE so
     * the caller does not need its own try/catch (the route handler still
     * wraps it for safety).
     */
    async start(args) {
        const { sessionId, traceId, skillExecutor, options } = args;
        const forceRefresh = options?.forceRefresh ?? false;
        const routeProfile = options?.routeProfile ?? 'legacy';
        const previewOnly = options?.previewOnly === true;
        const selection = options?.selection ?? { scope: 'all' };
        const cacheableByHash = !previewOnly && selection.scope === 'all';
        const session = this.deps.getSession(sessionId);
        if (!session) {
            throw new Error(`SceneStoryService.start: session ${sessionId} not found`);
        }
        if (this.inProgress.has(sessionId)) {
            throw new Error(`SceneStoryService.start: session ${sessionId} is already running`);
        }
        this.inProgress.add(sessionId);
        session.status = 'running';
        session.lastActivityAt = Date.now();
        let scenes = [];
        let intervals = [];
        let sceneVerification;
        let runner;
        let traceDurationSec = 0;
        let pipelineError;
        let traceHash = null;
        let pendingKeyToDelete = null;
        let resolvePending;
        let rejectPending;
        try {
            // ── Cache check ─────────────────────────────────────────────────────
            // Done before any expensive work so a returning user gets a sub-second
            // response on the same trace. Hashing reads the file (~5-10s for 1GB);
            // RPC traces skip the hash and check the memory cache by traceId.
            traceHash = await this.deps.computeHash(traceId);
            options?.cancelToken?.throwIfAborted();
            if (cacheableByHash && !forceRefresh) {
                // Disk (by hash) or memory (by traceId) cache lookup.
                const cached = await this.lookupCachedReport(traceHash, traceId, routeProfile, args.owner);
                if (cached) {
                    this.emitCachedReport(sessionId, session, cached);
                    return cached;
                }
            }
            // 3) In-flight pipeline dedupe — only file-backed traces have a hash
            // key, so concurrent RPC requests fall through and run independently.
            const pendingKey = traceHash
                ? buildOwnerScopedCacheKey(traceHash, routeProfile, args.owner)
                : null;
            if (cacheableByHash && pendingKey && !forceRefresh) {
                const inFlight = this.pendingByHash.get(pendingKey);
                if (inFlight) {
                    const shared = await inFlight;
                    this.emitCachedReport(sessionId, session, shared);
                    return shared;
                }
                // Register a deferred promise so peer requests can wait on us.
                const pending = new Promise((res, rej) => {
                    resolvePending = res;
                    rejectPending = rej;
                });
                this.pendingByHash.set(pendingKey, pending);
                pendingKeyToDelete = pendingKey;
                // Swallow unhandled-rejection — peer awaiters that come and go later
                // will see the rejection through their own await.
                pending.catch(() => undefined);
            }
            this.deps.broadcast(sessionId, {
                type: 'progress',
                content: { phase: 'detecting', message: '场景检测中' },
                timestamp: Date.now(),
            });
            // ── Stage 1: scene_reconstruction skill ──────────────────────────────
            // We also capture each envelope into a local array so the finalised
            // SceneReport can persist them for cache-hit replay; without this,
            // re-opening a cached trace would lose the lane-overlay state.
            options?.cancelToken?.throwIfAborted();
            const stage1Envelopes = [];
            const stage1 = await new sceneStage1Runner_1.SceneStage1Runner({
                execute: (skillId, tid, params) => skillExecutor.execute(skillId, tid, params),
                toEnvelopes: this.toEnvelopes,
            }).run(traceId, (env) => {
                stage1Envelopes.push(env);
                // Forward each envelope as a `data` SSE event so the existing
                // track_overlay frontend code keeps populating state lanes.
                this.deps.broadcast(sessionId, {
                    type: 'data',
                    content: env,
                    timestamp: Date.now(),
                });
            });
            scenes = stage1.scenes;
            traceDurationSec = stage1.traceDurationSec;
            sceneVerification = await (0, sceneStage1Verifier_1.runSceneStage1Verifier)({
                scenes,
                traceDurationSec,
                enableLlm: routeProfile === 'smart' && options?.verifyWithLlm === true,
            });
            options?.cancelToken?.throwIfAborted();
            const cap = args.options?.analysisCap ??
                (0, sceneCostEstimator_1.estimateSceneStoryCost)({ traceDurationSec }).expectedScenes;
            const candidateIntervals = (0, sceneIntervalBuilder_1.buildAnalysisIntervals)(scenes, { cap, routeProfile });
            const selectedScenes = previewOnly
                ? []
                : (0, sceneIntervalBuilder_1.selectAnalysisEligibleScenes)(scenes, selection);
            intervals = previewOnly
                ? []
                : (0, sceneIntervalBuilder_1.buildAnalysisIntervals)(selectedScenes, { cap, routeProfile });
            if (routeProfile === 'smart') {
                this.deps.broadcast(sessionId, {
                    type: 'scene_story_smart_eta_refined',
                    content: {
                        etaSec: estimateSmartEtaSec(previewOnly ? candidateIntervals.length : intervals.length),
                        etaConfidence: 'medium',
                        expectedDeepDives: previewOnly ? candidateIntervals.length : intervals.length,
                        selectionMode: previewOnly ? 'selection_required' : selection.scope,
                        selectedSceneCount: selectedScenes.length,
                    },
                    timestamp: Date.now(),
                });
            }
            // Mark which scenes were selected for analysis.
            const selectedSceneIds = new Set(intervals.map((i) => i.displayedSceneId));
            for (const scene of scenes) {
                if (selectedSceneIds.has(scene.id)) {
                    scene.analysisState = 'queued';
                }
            }
            // Sync to legacy session.scenes / session.trackEvents so the legacy
            // frontend that listens to `track_data` keeps working until C5 lands.
            session.scenes = scenes.map(toLegacySceneShape);
            session.trackEvents = scenes.map(toLegacyTrackEventShape);
            this.deps.broadcast(sessionId, {
                type: 'scene_story_detected',
                content: {
                    scenes,
                    analysisIntervals: intervals.length,
                    candidateIntervals: candidateIntervals.length,
                    sceneVerification,
                    previewOnly,
                },
                timestamp: Date.now(),
            });
            // Legacy `track_data` event for the rollout period.
            this.deps.broadcast(sessionId, {
                type: 'track_data',
                content: { tracks: session.trackEvents, scenes: session.scenes },
                timestamp: Date.now(),
            });
            if (previewOnly) {
                const previewReport = this.finalizeSelectionPreview({
                    sessionId,
                    traceId,
                    session,
                    scenes,
                    traceDurationSec,
                    traceHash,
                    stage1Envelopes,
                    routeProfile,
                    candidateIntervalCount: candidateIntervals.length,
                    sceneVerification,
                });
                return previewReport;
            }
            // Skip Stage 2 entirely if nothing matched a route.
            if (intervals.length === 0) {
                options?.cancelToken?.throwIfAborted();
                const emptyReport = await this.finalize({
                    sessionId,
                    traceId,
                    session,
                    scenes,
                    jobs: [],
                    summary: null,
                    cancelled: false,
                    traceDurationSec,
                    traceHash,
                    stage1Envelopes,
                    routeProfile,
                    cacheableByHash,
                    sceneVerification,
                });
                resolvePending?.(emptyReport);
                return emptyReport;
            }
            // ── Stage 2: per-interval Agent deep-dive ────────────────────────────
            options?.cancelToken?.throwIfAborted();
            runner = new sceneAnalysisJobRunner_1.SceneAnalysisJobRunner({
                concurrency: config_1.sceneStoryConfig.analysisConcurrency,
                maxRetries: config_1.sceneStoryConfig.jobMaxRetries,
                traceId,
                analysisId: sessionId,
                skillExecutor,
                runExecution: routeProfile === 'smart'
                    ? (fn) => (0, sqlSemaphore_1.runWithSmartTraceSqlSemaphore)(traceId, fn)
                    : undefined,
                toDataEnvelopes: routeProfile === 'smart'
                    ? (result, tid) => this.toEnvelopes(result, tid)
                    : undefined,
                onEvent: (event) => this.handleJobEvent(sessionId, scenes, event),
            });
            this.runners.set(sessionId, runner);
            runner.enqueue(intervals);
            await runner.waitForAllDone();
            const jobs = runner.getJobs();
            const cancelled = runner.isCancelled();
            options?.cancelToken?.throwIfAborted();
            // ── Stage 3: cross-scene narrative summary ──────────────────────────
            let summary = null;
            if (!cancelled) {
                this.deps.broadcast(sessionId, {
                    type: 'progress',
                    content: { phase: 'summarizing', message: '生成整体叙述' },
                    timestamp: Date.now(),
                });
                options?.cancelToken?.throwIfAborted();
                summary = await (0, sceneStage3Summarizer_1.runStage3Summary)({ scenes, jobs });
            }
            // ── Stage 4: finalise + persist ──────────────────────────────────────
            options?.cancelToken?.throwIfAborted();
            const finalReport = await this.finalize({
                sessionId,
                traceId,
                session,
                scenes,
                jobs,
                summary,
                cancelled,
                traceDurationSec,
                traceHash,
                stage1Envelopes,
                routeProfile,
                cacheableByHash,
                sceneVerification,
            });
            resolvePending?.(finalReport);
            return finalReport;
        }
        catch (err) {
            pipelineError = err;
            session.status = 'failed';
            session.error = pipelineError.message;
            this.deps.broadcast(sessionId, {
                type: 'error',
                content: { message: pipelineError.message },
                timestamp: Date.now(),
            });
            // Wake any peer requests that were awaiting this hash so they propagate
            // the same failure on their own SSE channels (instead of hanging).
            rejectPending?.(pipelineError);
            return null;
        }
        finally {
            if (pendingKeyToDelete)
                this.pendingByHash.delete(pendingKeyToDelete);
            this.runners.delete(sessionId);
            this.inProgress.delete(sessionId);
        }
    }
    /**
     * Request cancellation of an in-flight scene story run. The pipeline keeps
     * running until its in-flight jobs settle, then transitions to 'cancelled'
     * and emits the terminal events itself — callers do not need to wait.
     *
     * Returns true when a runner was found and cancelled; false otherwise
     * (already settled or never started).
     */
    cancel(sessionId) {
        const runner = this.runners.get(sessionId);
        if (!runner)
            return false;
        runner.cancel();
        // Session-scope cancel — distinct from per-job cancel which uses
        // scope: 'job'. Frontend dispatchers must inspect content.scope.
        this.deps.broadcast(sessionId, {
            type: 'scene_story_cancelled',
            content: { scope: 'session', reason: 'user_requested', sessionId },
            timestamp: Date.now(),
        });
        return true;
    }
    // -------------------------------------------------------------------------
    // Internal
    // -------------------------------------------------------------------------
    handleJobEvent(sessionId, scenes, event) {
        if (event.type !== 'all_done' && 'job' in event && event.job) {
            const job = event.job;
            const scene = scenes.find((s) => s.id === job.interval.displayedSceneId);
            if (scene) {
                scene.analysisState = jobStateToAnalysisState(event.type) ?? scene.analysisState;
                scene.analysisJobId = job.jobId;
            }
        }
        const sseType = mapJobEventToSseType(event.type);
        if (!sseType)
            return;
        const content = {};
        // Mark every job-derived event explicitly so the frontend can tell
        // them apart from the session-level scene_story_cancelled emitted by
        // SceneStoryService.cancel().
        if (sseType === 'scene_story_cancelled')
            content.scope = 'job';
        if ('job' in event && event.job) {
            content.jobId = event.job.jobId;
            content.displayedSceneId = event.job.interval.displayedSceneId;
            content.skillId = event.job.interval.skillId;
            content.attempt = event.job.attempt;
            content.state = event.job.state;
        }
        if (event.type === 'job_completed' && event.result) {
            content.result = {
                durationMs: event.result.durationMs,
                displayResultCount: event.result.displayResults.length,
            };
        }
        if (event.type === 'job_failed' || event.type === 'job_retrying') {
            content.error = event.error;
        }
        this.deps.broadcast(sessionId, {
            type: sseType,
            content,
            timestamp: Date.now(),
        });
    }
    async finalize(args) {
        const jobs = args.routeProfile === 'smart'
            ? await attachSmartJobArtifactRefs({
                traceId: args.traceId,
                jobs: args.jobs,
                artifactStore: this.deps.jobArtifactStore,
            })
            : args.jobs;
        const report = buildSceneReport({
            analysisId: args.sessionId,
            traceId: args.traceId,
            tenantId: args.session.tenantId,
            workspaceId: args.session.workspaceId,
            userId: args.session.userId,
            createdAt: args.session.createdAt,
            scenes: args.scenes,
            jobs,
            summary: args.summary,
            cancelled: args.cancelled,
            traceDurationSec: args.traceDurationSec,
            traceHash: args.traceHash,
            stage1Envelopes: args.stage1Envelopes,
            routeProfile: args.routeProfile,
            sceneVerification: args.sceneVerification,
        });
        args.session.sceneStoryReport = report;
        args.session.status = args.cancelled ? 'cancelled' : 'completed';
        args.session.lastActivityAt = Date.now();
        // Persist BEFORE broadcasting scene_story_report_ready so any client
        // that immediately calls GET /scene-reconstruct/report/:id is guaranteed
        // to find the report rather than racing the disk write.
        await this.persistReport(report, args.traceId, args.routeProfile, {
            indexByHash: args.cacheableByHash,
        });
        this.deps.broadcast(args.sessionId, {
            type: 'scene_story_report_ready',
            content: {
                reportId: report.reportId,
                partial: report.partialReport,
                summary: report.summary,
                sceneCount: report.displayedScenes.length,
                jobCount: report.jobs.length,
            },
            timestamp: Date.now(),
        });
        // Final progress event so the legacy frontend has a clean terminal signal.
        this.deps.broadcast(args.sessionId, {
            type: 'progress',
            content: {
                phase: args.cancelled ? 'cancelled' : 'completed',
                message: args.cancelled ? '场景还原已取消' : '场景还原完成',
            },
            timestamp: Date.now(),
        });
        return report;
    }
    finalizeSelectionPreview(args) {
        const report = buildSceneReport({
            analysisId: args.sessionId,
            traceId: args.traceId,
            tenantId: args.session.tenantId,
            workspaceId: args.session.workspaceId,
            userId: args.session.userId,
            createdAt: args.session.createdAt,
            scenes: args.scenes,
            jobs: [],
            summary: '场景盘点已完成，等待用户选择智能分析深钻范围。',
            cancelled: false,
            traceDurationSec: args.traceDurationSec,
            traceHash: args.traceHash,
            stage1Envelopes: args.stage1Envelopes,
            routeProfile: args.routeProfile,
            sceneVerification: args.sceneVerification,
        });
        args.session.sceneStoryReport = report;
        args.session.status = 'completed';
        args.session.lastActivityAt = Date.now();
        this.memoryReportById.set(report.reportId, report);
        this.deps.broadcast(args.sessionId, {
            type: 'scene_story_selection_ready',
            content: {
                sceneCount: report.displayedScenes.length,
                candidateIntervalCount: args.candidateIntervalCount,
                sceneTypeCounts: countSceneTypes(report.displayedScenes),
                reportId: report.reportId,
                sceneVerification: report.sceneVerification,
            },
            timestamp: Date.now(),
        });
        this.deps.broadcast(args.sessionId, {
            type: 'progress',
            content: {
                phase: 'selection_ready',
                message: '场景盘点完成,请选择智能分析范围',
            },
            timestamp: Date.now(),
        });
        return report;
    }
    /**
     * Persist a finalised SceneReport to whichever cache layer matches the
     * trace's origin. File-backed traces go into the disk store with the
     * configured TTL; external RPC traces fall into the in-memory LRU keyed
     * by traceId.
     *
     * Errors propagate. The contract that `scene_story_report_ready` only
     * fires after a successful persist depends on this — silently swallowing
     * a save failure would let peer requests get a `reportId` they can't
     * subsequently load via `GET /scene-reconstruct/report/:id`.
     */
    async persistReport(report, traceId, routeProfile, options = {}) {
        if (report.traceOrigin === 'file' && report.traceHash) {
            await this.deps.reportStore.save(report, routeProfile, {
                indexByHash: options.indexByHash !== false,
            });
            this.memoryReportById.set(report.reportId, report);
        }
        else {
            if (options.indexByHash !== false) {
                this.deps.memoryCache.set(traceId, report, routeProfile);
            }
            this.memoryReportById.set(report.reportId, report);
        }
    }
    /**
     * Replay a cached SceneReport on a new session's SSE channel. Used by both
     * the disk-cache and memory-cache hit paths and by peer-request dedupe
     * after awaiting a sibling pipeline.
     *
     * The emitted event sequence collapses Stage 1/2/3 into a single
     * scene_story_report_ready terminal:
     *   progress {phase:'cached'}
     *   → scene_story_detected
     *   → track_data (legacy)
     *   → scene_story_report_ready
     *   → progress {phase:'completed'}
     *
     * Frontend story_controller already treats scene_story_report_ready as
     * terminal, so this fast path renders correctly without any extra
     * frontend changes.
     */
    emitCachedReport(sessionId, session, report) {
        const now = Date.now();
        session.sceneStoryReport = report;
        session.scenes = report.displayedScenes.map(toLegacySceneShape);
        session.trackEvents = report.displayedScenes.map(toLegacyTrackEventShape);
        session.status = 'completed';
        session.lastActivityAt = now;
        this.deps.broadcast(sessionId, {
            type: 'progress',
            content: { phase: 'cached', message: '已命中缓存,加载历史报告' },
            timestamp: now,
        });
        // Replay Stage1 DataEnvelopes so lane overlays / state-timeline tracks
        // render the same way they would on a cold run. Without this, cache hits
        // would show the scene list but no lane overlays.
        for (const env of report.cachedDataEnvelopes) {
            this.deps.broadcast(sessionId, {
                type: 'data',
                content: env,
                timestamp: now,
            });
        }
        this.deps.broadcast(sessionId, {
            type: 'scene_story_detected',
            content: {
                scenes: report.displayedScenes,
                analysisIntervals: report.jobs.length,
                sceneVerification: report.sceneVerification,
                previewOnly: report.jobs.length === 0 && report.summary === '场景盘点已完成，等待用户选择智能分析深钻范围。',
            },
            timestamp: now,
        });
        // Legacy track_data so the existing track_overlay code keeps painting
        // lanes for cache hits as well.
        this.deps.broadcast(sessionId, {
            type: 'track_data',
            content: { tracks: session.trackEvents, scenes: session.scenes },
            timestamp: now,
        });
        this.deps.broadcast(sessionId, {
            type: 'scene_story_report_ready',
            content: {
                reportId: report.reportId,
                partial: report.partialReport,
                summary: report.summary,
                sceneCount: report.displayedScenes.length,
                jobCount: report.jobs.length,
                cached: true,
            },
            timestamp: now,
        });
        this.deps.broadcast(sessionId, {
            type: 'progress',
            content: { phase: 'completed', message: '场景还原完成 (缓存)' },
            timestamp: now,
        });
    }
    // -------------------------------------------------------------------------
    // Public preview / report endpoints
    // -------------------------------------------------------------------------
    /**
     * Cheap preview for the /scene-reconstruct/preview endpoint. Computes the
     * trace's content hash, checks both cache layers, and falls through to a
     * formula-based ETA + USD estimate. Never starts the heavy pipeline.
     *
     * Latency profile:
     *   - cached + file-backed: hash + index lookup (~10s for 1GB; <100ms for small)
     *   - cached + RPC: O(1) Map lookup
     *   - cold:        hash + trace_bounds SQL probe (~50ms)
     */
    async previewOnly(args) {
        const { traceId, owner } = args;
        const routeProfile = args.routeProfile ?? 'legacy';
        // Hash and probe are independent — run in parallel. Hash dominates for
        // large files (5-10s for 1GB), probe is ~50ms. Parallelising cuts cold
        // preview latency from `hash + probe` to `max(hash, probe)`.
        const [hash, probedDurationSec] = await Promise.all([
            this.deps.computeHash(traceId),
            this.deps.probeDuration(traceId),
        ]);
        const cached = await this.lookupCachedReport(hash, traceId, routeProfile, owner);
        if (cached) {
            const dur = cached.traceMeta.durationSec;
            return {
                estimate: (0, sceneCostEstimator_1.estimateSceneStoryCost)({ traceDurationSec: dur }),
                cached,
                traceDurationSec: dur,
            };
        }
        return {
            estimate: (0, sceneCostEstimator_1.estimateSceneStoryCost)({ traceDurationSec: probedDurationSec }),
            cached: null,
            traceDurationSec: probedDurationSec,
        };
    }
    /**
     * GET /scene-reconstruct/report/:reportId — direct lookup by reportId.
     * Returns null if the report has been evicted (TTL expired) or never
     * existed; the route handler maps null to a 404.
     */
    async getReport(reportId) {
        const persisted = await this.deps.reportStore.loadById(reportId);
        if (persisted)
            return persisted;
        const memory = this.memoryReportById.get(reportId) ?? null;
        if (memory && memory.expiresAt !== null && memory.expiresAt < Date.now()) {
            this.memoryReportById.delete(reportId);
            return null;
        }
        return memory;
    }
    /**
     * Unified cache lookup: file-backed traces go via the disk store by hash;
     * external RPC traces go via the in-memory LRU by traceId. Used by both
     * start() (cache check) and previewOnly() so the lookup strategy is
     * defined in exactly one place.
     */
    async lookupCachedReport(hash, traceId, routeProfile, owner) {
        const report = hash
            ? await this.deps.reportStore.loadByHash(hash, routeProfile)
            : this.deps.memoryCache.get(traceId, routeProfile) ?? null;
        if (!report)
            return null;
        return owner ? ((0, resourceOwnership_1.ownersMatch)(report, owner) ? report : null) : report;
    }
}
exports.SceneStoryService = SceneStoryService;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildOwnerScopedCacheKey(hash, routeProfile, owner) {
    if (!owner)
        return `${hash}:${routeProfile}:legacy-owner`;
    return `${hash}:${routeProfile}:${owner.tenantId || ''}:${owner.workspaceId || ''}:${owner.userId || owner.ownerUserId || ''}`;
}
function countSceneTypes(scenes) {
    const counts = {};
    for (const scene of scenes) {
        counts[scene.sceneType] = (counts[scene.sceneType] || 0) + 1;
    }
    return counts;
}
function jobStateToAnalysisState(jobEventType) {
    switch (jobEventType) {
        case 'job_queued': return 'queued';
        case 'job_started': return 'running';
        case 'job_completed': return 'completed';
        case 'job_failed': return 'failed';
        case 'job_cancelled': return 'cancelled';
        case 'job_dropped': return 'dropped';
        default: return null;
    }
}
function mapJobEventToSseType(type) {
    switch (type) {
        case 'job_queued': return 'scene_story_queued';
        case 'job_started': return 'scene_story_started';
        case 'job_retrying': return 'scene_story_retrying';
        case 'job_completed': return 'scene_story_completed';
        case 'job_failed': return 'scene_story_failed';
        case 'job_cancelled': return 'scene_story_cancelled';
        case 'job_dropped': return 'scene_story_dropped';
        default: return null;
    }
}
async function attachSmartJobArtifactRefs(args) {
    if (!args.artifactStore)
        return args.jobs;
    const out = [];
    for (const job of args.jobs) {
        const result = job.result;
        const projection = result?.projection;
        if (!result ||
            !projection ||
            projection.omittedRowCount <= 0 ||
            projection.artifactRef) {
            out.push(job);
            continue;
        }
        const artifactRef = await args.artifactStore.save({
            traceId: args.traceId,
            jobId: job.jobId,
            displayedSceneId: result.displayedSceneId,
            skillId: result.skillId,
            displayResults: result.displayResults,
            dataEnvelopes: result.dataEnvelopes,
            createdAt: Date.now(),
        });
        out.push({
            ...job,
            result: {
                ...result,
                projection: {
                    ...projection,
                    artifactRef,
                },
            },
        });
    }
    return out;
}
function buildSceneReport(args) {
    const failedCount = args.jobs.filter((j) => j.state === 'failed').length;
    const partial = args.cancelled || failedCount > 0;
    const totalDurationMs = Date.now() - args.createdAt;
    const insights = [];
    if (args.summary && args.scenes.length > 0) {
        insights.push({
            title: '整体叙述',
            body: args.summary,
            relatedDisplayedSceneIds: args.scenes.map((s) => s.id),
        });
    }
    // Hash presence is the source of truth for the trace's origin: a file
    // we can read deterministically (and hence cache by content) vs an
    // ephemeral external RPC connection that resets when the backend
    // restarts.
    const isFileBacked = args.traceHash !== null;
    const traceOrigin = isFileBacked ? 'file' : 'external_rpc';
    const cachePolicy = isFileBacked
        ? 'disk_7d'
        : 'memory_session';
    const expiresAt = isFileBacked
        ? Date.now() + config_1.sceneStoryConfig.reportTtlMs
        : null;
    return {
        reportId: (0, uuid_1.uuidv4)(),
        tenantId: args.tenantId,
        workspaceId: args.workspaceId,
        userId: args.userId,
        traceHash: args.traceHash,
        traceId: args.traceId,
        traceOrigin,
        cachePolicy,
        expiresAt,
        createdAt: args.createdAt,
        traceMeta: { durationSec: args.traceDurationSec },
        displayedScenes: args.scenes,
        sceneVerification: args.sceneVerification,
        cachedDataEnvelopes: args.stage1Envelopes,
        jobs: args.routeProfile === 'smart'
            ? args.jobs.map(sanitizeSmartReportJob)
            : args.jobs,
        summary: args.summary,
        insights,
        partialReport: partial,
        totalDurationMs,
        generatedBy: {
            runtime: 'claude-sdk',
            pipelineVersion: 'v2',
        },
    };
}
function sanitizeSmartReportJob(job) {
    if (!job.result)
        return job;
    return {
        ...job,
        result: {
            ...job.result,
            displayResults: [],
            dataEnvelopes: [],
        },
    };
}
function estimateSmartEtaSec(intervalCount) {
    if (intervalCount <= 0)
        return 3;
    return Math.max(10, Math.ceil(intervalCount * 12));
}
// ---------------------------------------------------------------------------
// Legacy session shape conversion
// ---------------------------------------------------------------------------
/**
 * Convert a DisplayedScene to the loose shape that legacy frontend code
 * expects on session.scenes (uses `type` and `appPackage` field names).
 * The frontend's session.scenes is `any[]`, so a structural shim is enough.
 */
function toLegacySceneShape(scene) {
    return {
        id: scene.id,
        type: scene.sceneType,
        sceneType: scene.sceneType,
        sourceStepId: scene.sourceStepId,
        startTs: scene.startTs,
        endTs: scene.endTs,
        durationMs: scene.durationMs,
        appPackage: scene.processName,
        metadata: scene.metadata,
        severity: scene.severity,
    };
}
function toLegacyTrackEventShape(scene) {
    return {
        id: scene.id,
        type: scene.sceneType,
        label: scene.label,
        startTs: scene.startTs,
        endTs: scene.endTs,
        durationMs: scene.durationMs,
        processName: scene.processName,
    };
}
//# sourceMappingURL=sceneStoryService.js.map