// tools/safe-handler.ts — Generic error-boundary wrapper for MCP tool handlers.
// Catches any unhandled error and returns a graceful MCP error response
// instead of crashing the server. Includes execution timeout via Promise.race.
import { withLicenseGate, withUsageTracking } from './license-gate.js';
import { isTrialActive } from '../engine/trial-engine.js';
import { sanitizePath } from './sanitize-path.js';
import { ensureHooksStarted } from './start-hooks.js';
import { ensureWorkersStarted } from '../engine/workers/index.js';
import { recordError } from '../storage/error-telemetry-store.js';
import { hashProjectPath } from '../storage/base-store.js';
import { reportToolError, reportToolValidationError, isErrorReportingEnabled, } from '../engine/telemetry/error-reporter.js';
import { sendTelemetryEvent } from '../engine/telemetry/telemetry-client.js';
import { PLANU_VERSION } from '../config/version.js';
import { recordToolTokens, extractOutputText } from './token-recording.js';
import { DriftCacheStore } from '../storage/drift-cache-store.js';
import { consumeUpdateBanner } from '../engine/update-notifier.js';
import { getOrCreateInstallationId } from '../engine/telemetry/telemetry-store.js';
import { compressToolOutput } from './output-compressor.js';
import { recordToolTokens as recordLlmTokens } from '../engine/llm-runtime/index.js';
import { guardToolResultOutput } from './output-integrity-guard.js';
// SPEC-902: Structured error contract
import { wrapError, isStructuredError } from '../engine/errors/error-wrapper.js';
import { getErrorRecoveryRegistry } from '../engine/errors/registry-loader.js';
import { structuredErrorToResult } from '../engine/errors/error-result.js';
import { checkBudgetEnforcement } from '../engine/cost-guardrails.js';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { maybeWriteCheckpoint } from '../engine/session/checkpoint-writer.js';
// SPEC-922: Compact response auto-on
import { parseContextHeader } from '../engine/compact/header-parser.js';
import { evaluateCompactMode } from '../engine/compact/threshold-evaluator.js';
import { loadCompactThresholds } from '../engine/compact/registry-loader.js';
import { applyCompactMode } from '../engine/compact/compact-middleware.js';
import { globalCompactStateStore } from '../engine/compact/state-store.js';
import { recordCompactDecision, recordVerboseDecision, recordNoHeader, } from '../engine/compact/metrics.js';
// ---------------------------------------------------------------------------
// Project initialization state — read-only check, zero file writes
// ---------------------------------------------------------------------------
/**
 * Tracks project paths where planu.json was NOT found on first tool call.
 * Populated lazily and read-only — no mutations to the filesystem.
 */
const uninitializedProjects = new Set();
/**
 * Checks if planu.json exists at the given projectPath.
 * If not found, records the path in uninitializedProjects.
 * Completely synchronous and side-effect-free (no file writes).
 */
function ensureProjectKnown(projectPath) {
    if (!existsSync(join(projectPath, 'planu.json'))) {
        uninitializedProjects.add(projectPath);
    }
}
/**
 * Returns true if planu.json was found at the given projectPath on the
 * most recent tool call. Returns false if the path was never seen or if
 * planu.json was absent.
 */
export function isProjectInitialized(projectPath) {
    return !uninitializedProjects.has(projectPath);
}
/**
 * Removes a project path from the uninitialized set.
 * Called by init-project after a successful initialization so subsequent
 * tool calls reflect the new state.
 */
export function markProjectInitialized(projectPath) {
    uninitializedProjects.delete(projectPath);
}
// ---------------------------------------------------------------------------
// Drift banner — non-blocking informational notice
// ---------------------------------------------------------------------------
/**
 * Default drift threshold: 0.3 (30%). Override via PLANU_DRIFT_THRESHOLD env var.
 */
const DEFAULT_DRIFT_BANNER_THRESHOLD = 0.3;
function getDriftBannerThreshold() {
    const env = process.env.PLANU_DRIFT_THRESHOLD;
    if (env === undefined || env === '') {
        return DEFAULT_DRIFT_BANNER_THRESHOLD;
    }
    const parsed = parseFloat(env);
    return Number.isFinite(parsed) && parsed > 0 && parsed <= 1
        ? parsed
        : DEFAULT_DRIFT_BANNER_THRESHOLD;
}
/**
 * Checks for pending drift cache entries for a project.
 * Returns a banner string if any pending entry has driftScore >= threshold,
 * or null if nothing to show.
 * Marks all pending entries as shown (fire-and-forget).
 */
async function checkPendingDriftBanner(projectPath) {
    if (process.env.PLANU_AUTO_DRIFT === 'false') {
        return null;
    }
    try {
        const projectId = hashProjectPath(projectPath);
        const store = new DriftCacheStore(projectId);
        const threshold = getDriftBannerThreshold();
        const pending = await store.getPendingBanners(threshold);
        if (pending.length === 0) {
            return null;
        }
        // Mark as shown — fire-and-forget
        /* v8 ignore start */
        store.markAllShown().catch((err) => {
            console.error('[Planu] Failed to mark drift banners as shown:', err);
        });
        /* v8 ignore stop */
        const specIds = pending.map((e) => e.specId).join(', ');
        const maxScore = Math.max(...pending.map((e) => e.driftScore));
        return (`\n⚠️  Auto-drift alert: specs [${specIds}] may have drifted ` +
            `(score: ${maxScore.toFixed(2)}). Run detect_drift to review.`);
    }
    catch {
        // Never block a tool call due to drift cache errors
        return null;
    }
}
/**
 * Injects a drift banner into a ToolResult if the banner is non-null.
 * The banner is appended as a new text content block so it does not
 * interfere with structured output consumed by callers.
 */
function injectDriftBanner(result, banner) {
    if (banner === null || result.isError === true) {
        return result;
    }
    return {
        ...result,
        content: [...result.content, { type: 'text', text: banner }],
    };
}
/**
 * Sanitizes path-like fields in tool args before passing to handler.
 * Checks `projectPath` and `path` fields for directory traversal attacks.
 * Also triggers lazy auto-start for hooks and workers when a projectPath is present.
 */
function sanitizeArgs(args) {
    if (args === null || typeof args !== 'object') {
        return args;
    }
    const obj = { ...args };
    if (typeof obj.projectPath === 'string') {
        obj.projectPath = sanitizePath(obj.projectPath);
        // Auto-start hooks + workers on first tool call with projectPath (fire-and-forget)
        ensureHooksStarted(obj.projectPath);
        ensureWorkersStarted(obj.projectPath);
        // Read-only check: track whether planu.json exists (no file writes)
        ensureProjectKnown(obj.projectPath);
    }
    if (typeof obj.path === 'string') {
        obj.path = sanitizePath(obj.path);
    }
    if (typeof obj.htmlPath === 'string') {
        obj.htmlPath = sanitizePath(obj.htmlPath);
    }
    if (typeof obj.outputPath === 'string') {
        obj.outputPath = sanitizePath(obj.outputPath);
    }
    return obj;
}
/** Default timeout: 60 seconds. Override via PLANU_TOOL_TIMEOUT_MS env var. */
const DEFAULT_TIMEOUT_MS = 60_000;
/**
 * Session ID generated once per process startup.
 * Used to group all tool calls from a single MCP session in the token ledger.
 */
const SESSION_ID = `SES-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
function getTimeoutMs() {
    const envVal = process.env.PLANU_TOOL_TIMEOUT_MS;
    if (envVal === undefined || envVal === '') {
        return DEFAULT_TIMEOUT_MS;
    }
    const parsed = Number(envVal);
    return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
}
/**
 * Races a handler promise against a timeout. Returns a timeout error ToolResult
 * if the handler exceeds the configured duration.
 */
async function withTimeout(fn, ms) {
    let timer;
    const timeoutPromise = new Promise((_resolve, reject) => {
        timer = setTimeout(() => {
            reject(new Error(`Tool execution timed out after ${ms}ms`));
        }, ms);
    });
    try {
        return await Promise.race([fn(), timeoutPromise]);
    }
    finally {
        clearTimeout(timer);
    }
}
/**
 * Races a promise against a hard deadline, resolving with `fallback` on timeout.
 * Unlike withTimeout (which rejects), this always resolves — safe for non-critical gates
 * where a timeout should degrade gracefully rather than throw.
 *
 * @param p       - The promise to race
 * @param ms      - Timeout in milliseconds
 * @param fallback - Value returned when the timeout fires
 */
export function withToolTimeout(p, ms, fallback) {
    let t;
    const timeout = new Promise((res) => {
        t = setTimeout(() => {
            res(fallback);
        }, ms);
    });
    return Promise.race([
        p.finally(() => {
            clearTimeout(t);
        }),
        timeout,
    ]);
}
/**
 * Wraps a tool handler so that any unhandled error is caught and returned as a
 * graceful MCP error response, preventing server crashes. Also enforces a
 * configurable execution timeout (default 60s, env PLANU_TOOL_TIMEOUT_MS).
 *
 * Usage — only for alwaysAllowed tools (activate_license, license_status, etc.):
 *   server.registerTool('my_tool', schema, safe(async (args) => { ... }));
 */
export function safe(handler) {
    return safeWithTelemetry(undefined, handler);
}
/**
 * Internal wrapper: error boundary + optional telemetry recording.
 * When `toolName` is provided, failures are recorded via error-telemetry-store (fire-and-forget).
 */
function safeWithTelemetry(toolName, handler) {
    return async (args, extra) => {
        const safeArgs = sanitizeArgs(args);
        const projectPath = safeArgs !== null && typeof safeArgs === 'object'
            ? safeArgs.projectPath
            : undefined;
        const startTime = Date.now();
        // SPEC-922: Parse context header and evaluate compact mode
        const contextUsed = parseContextHeader(extra && typeof extra === 'object' ? extra : undefined);
        const clientId = extra && typeof extra === 'object'
            ? (extra.sessionId ?? 'default')
            : 'default';
        const prevMode = globalCompactStateStore.get(clientId);
        const thresholds = loadCompactThresholds();
        const decision = evaluateCompactMode(contextUsed, prevMode, thresholds);
        globalCompactStateStore.set(clientId, decision.mode);
        // Track metrics
        if (decision.reason === 'no_header') {
            recordNoHeader();
        }
        else if (decision.mode === 'compact') {
            recordCompactDecision();
        }
        else {
            recordVerboseDecision();
        }
        try {
            const rawResult = (await withTimeout(() => handler(safeArgs, extra), getTimeoutMs()));
            // Defensive guard: belt-and-suspenders alongside withUsageTracking guard and noImplicitReturns.
            const result = rawResult ?? {
                content: [{ type: 'text', text: 'Internal error: tool handler returned no result' }],
                isError: true,
            };
            // SPEC-455: Compress verbose JSON outputs to save LLM tokens
            const compressed = compressToolOutput(result);
            // SPEC-922: Apply compact mode middleware
            const compacted = applyCompactMode(compressed, decision);
            // Inject pending drift banner (non-blocking, informational only)
            const driftBanner = projectPath !== undefined ? await checkPendingDriftBanner(projectPath) : null;
            const withDrift = injectDriftBanner(compacted, driftBanner);
            // Inject update banner (once per version check, consumed and cleared)
            const updateBanner = consumeUpdateBanner();
            const finalResult = updateBanner !== null && withDrift.isError !== true
                ? {
                    ...withDrift,
                    content: [...withDrift.content, { type: 'text', text: updateBanner }],
                }
                : withDrift;
            const guardedResult = guardToolResultOutput(toolName, finalResult);
            // Report validation errors (isError:true returned by handler logic, not exceptions)
            if (toolName !== undefined && guardedResult.isError === true) {
                const firstContent = guardedResult.content[0];
                const errText = firstContent?.type === 'text' ? firstContent.text : 'unknown error';
                reportToolValidationError(toolName, errText);
            }
            // Record token usage for successful calls (fire-and-forget, only when projectPath available)
            if (toolName !== undefined && projectPath !== undefined && guardedResult.isError !== true) {
                const outputText = extractOutputText(guardedResult);
                recordToolTokens(toolName, projectPath, args, outputText, SESSION_ID);
            }
            // SPEC-460: Track real token estimates in LLM runtime (session-scoped, measured)
            if (toolName !== undefined) {
                const inputText = JSON.stringify(args);
                const outputText = extractOutputText(guardedResult);
                recordLlmTokens(toolName, inputText, outputText);
            }
            // Emit tool_used telemetry for successful calls (fire-and-forget)
            /* v8 ignore start */
            if (toolName !== undefined && guardedResult.isError !== true && isErrorReportingEnabled()) {
                getOrCreateInstallationId()
                    .then((installationId) => {
                    sendTelemetryEvent({
                        event: 'tool_used',
                        properties: {
                            tool: toolName,
                            planVersion: PLANU_VERSION,
                            nodeVersion: process.version,
                            platform: process.platform,
                            durationMs: Date.now() - startTime,
                        },
                        installationId,
                    });
                })
                    .catch(() => {
                    /* best-effort — never block tool response */
                });
            }
            /* v8 ignore stop */
            // SPEC-387: Audit trail — fire-and-forget, never throws
            /* v8 ignore start */
            if (toolName !== undefined) {
                try {
                    const [{ appendEntry, getLastHash }, { buildAuditEntry }, { hashEntry }] = await Promise.all([
                        import('../storage/audit-trail-store.js'),
                        import('../engine/audit-trail/formatter.js'),
                        import('../engine/audit-trail/hasher.js'),
                    ]);
                    const prevHash = getLastHash();
                    const partial = buildAuditEntry({
                        toolName,
                        args,
                        outputType: guardedResult.isError === true ? 'error' : 'success',
                        durationMs: Date.now() - startTime,
                        prevHash,
                    });
                    appendEntry({ ...partial, hash: hashEntry(partial) });
                }
                catch {
                    // Silent — audit must never affect tool execution
                }
            }
            /* v8 ignore stop */
            // SPEC-563: Auto session checkpoint — fire-and-forget, non-blocking
            if (toolName !== undefined) {
                maybeWriteCheckpoint(toolName, guardedResult.isError !== true, projectPath);
            }
            return guardedResult;
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            if (toolName !== undefined && projectPath !== undefined) {
                const projectId = hashProjectPath(projectPath);
                // Fire-and-forget: do not block the error response
                recordError(projectId, {
                    timestamp: new Date().toISOString(),
                    module: `tools/${toolName}`,
                    severity: 'error',
                    message,
                    /* v8 ignore start */
                }).catch((err) => {
                    console.error('[Planu] Telemetry recording failed:', err);
                });
                /* v8 ignore stop */
            }
            // Remote error reporting — fire-and-forget, respects PLANU_TELEMETRY=off
            if (toolName !== undefined) {
                reportToolError(toolName, error);
            }
            // SPEC-902: Structured error contract with recovery hints
            const registry = getErrorRecoveryRegistry();
            const structured = isStructuredError(error)
                ? error
                : wrapError(error, { toolName: toolName ?? 'unknown', registry });
            const errorResult = structuredErrorToResult(structured);
            // Log unmatched errors for registry improvement (fire-and-forget)
            /* v8 ignore start */
            const matchedRule = registry.match(error);
            if (!matchedRule && toolName !== undefined) {
                void Promise.resolve().then(async () => {
                    try {
                        const { recordError: recordErrorTelemetry } = await import('../storage/error-telemetry-store.js');
                        const projectId = projectPath ? hashProjectPath(projectPath) : 'global';
                        recordErrorTelemetry(projectId, {
                            timestamp: new Date().toISOString(),
                            module: `tools/${toolName}`,
                            severity: 'warning',
                            message: `Unmatched error: ${message}`,
                        }).catch(() => {
                            // best-effort logging, ignore failure
                        });
                    }
                    catch {
                        // best-effort
                    }
                });
            }
            /* v8 ignore stop */
            // SPEC-387: Audit trail for error path — fire-and-forget, never throws
            /* v8 ignore start */
            if (toolName !== undefined) {
                try {
                    const [{ appendEntry, getLastHash }, { buildAuditEntry }, { hashEntry }] = await Promise.all([
                        import('../storage/audit-trail-store.js'),
                        import('../engine/audit-trail/formatter.js'),
                        import('../engine/audit-trail/hasher.js'),
                    ]);
                    const prevHash = getLastHash();
                    const partial = buildAuditEntry({
                        toolName,
                        args,
                        outputType: 'error',
                        durationMs: Date.now() - startTime,
                        prevHash,
                    });
                    appendEntry({ ...partial, hash: hashEntry(partial) });
                }
                catch {
                    // Silent — audit must never affect tool execution
                }
            }
            /* v8 ignore stop */
            return errorResult;
        }
    };
}
/**
 * Composable wrapper: usage tracking + error boundary.
 * Use for free-tier tools that need rate limiting and usage recording.
 *
 * Usage:
 *   server.registerTool('free_tool', schema, safeTracked('free_tool', async (args) => { ... }));
 */
export function safeTracked(toolName, handler) {
    return safeWithTelemetry(toolName, withUsageTracking(toolName, handler));
}
/**
 * Composable wrapper: usage tracking + license gate + error boundary.
 * Use for tools that require a paid plan.
 *
 * Trial bypass: if the 30-day free trial is active, skips the license gate
 * entirely and routes to safeTracked (usage tracking without restriction).
 *
 * Usage:
 *   server.registerTool('pro_tool', schema, safeLicensed('pro_tool', async (args) => { ... }));
 */
export function safeLicensed(toolName, handler) {
    return safeWithTelemetry(toolName, async (args, extra) => {
        // Enforce spec budget limit when specId is present in args (opt-in enforcement)
        if (args !== null && typeof args === 'object') {
            const obj = args;
            const specId = typeof obj.specId === 'string' ? obj.specId : undefined;
            const projectPath = typeof obj.projectPath === 'string' ? obj.projectPath : undefined;
            if (specId !== undefined && projectPath !== undefined) {
                await checkBudgetEnforcement(specId, projectPath);
            }
        }
        // Check server-backed 30-day trial (fast local read only)
        if (process.env.PLANU_SKIP_TRIAL !== 'true') {
            const trialActive = isTrialActive(); // always false — trial tools removed
            if (trialActive) {
                return withUsageTracking(toolName, handler)(args, extra);
            }
        }
        return withUsageTracking(toolName, withLicenseGate(toolName, handler))(args, extra);
    });
}
//# sourceMappingURL=safe-handler.js.map