// src/turn-health/classify.ts
var ARTIFACT_PART_KINDS = /* @__PURE__ */ new Set(["file", "image", "work-product", "plan", "interaction"]);
var SAMPLE_CHARS = 120;
function asRecord(value) {
  return typeof value === "object" && value !== null ? value : null;
}
function nonEmptyString(value) {
  return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function isUnparseableJson(value) {
  const trimmed = value.trim();
  if (trimmed.length === 0) return false;
  try {
    JSON.parse(trimmed);
    return false;
  } catch {
    return true;
  }
}
var SETTLED_TOOL_STATUSES = /* @__PURE__ */ new Set(["completed", "complete", "success", "done"]);
function classifyTurnOutcome(input) {
  const reasons = [];
  if (input.failed) {
    reasons.push({
      kind: "turn_failed",
      reason: nonEmptyString(input.failureReason) ?? "unspecified"
    });
  }
  const parts = Array.isArray(input.parts) ? input.parts : [];
  let hasVisibleText = nonEmptyString(input.finalText) !== null;
  let artifactCount = 0;
  for (const raw of parts) {
    const part = asRecord(raw);
    if (!part) continue;
    const type = typeof part.type === "string" ? part.type : "";
    if (type === "text" && nonEmptyString(part.text) !== null) {
      hasVisibleText = true;
      continue;
    }
    if (ARTIFACT_PART_KINDS.has(type)) {
      artifactCount += 1;
      continue;
    }
    if (type !== "tool") continue;
    const tool = nonEmptyString(part.tool) ?? "unknown";
    const state = asRecord(part.state);
    const status = typeof state?.status === "string" ? state.status : "unknown";
    const toolInput = state?.input;
    if (typeof toolInput === "string" && isUnparseableJson(toolInput)) {
      reasons.push({
        kind: "malformed_tool_call",
        tool,
        inputLength: toolInput.length,
        sample: toolInput.slice(0, SAMPLE_CHARS)
      });
      continue;
    }
    if (!SETTLED_TOOL_STATUSES.has(status)) {
      reasons.push({ kind: "tool_call_no_effect", tool, status });
    }
  }
  if (!input.failed && !hasVisibleText && artifactCount === 0) {
    reasons.push({
      kind: "empty_completion",
      outputTokens: input.outputTokens ?? null,
      partCount: parts.length,
      ...input.durationMs !== void 0 ? { durationMs: input.durationMs } : {}
    });
  }
  return {
    healthy: reasons.length === 0,
    severity: severityOf(reasons),
    reasons
  };
}
function severityOf(reasons) {
  if (reasons.length === 0) return null;
  const critical = reasons.some((r) => r.kind === "empty_completion" || r.kind === "turn_failed");
  return critical ? "critical" : "warning";
}
function describeReason(reason) {
  switch (reason.kind) {
    case "empty_completion":
      return `completed with NO output (${reason.partCount} parts, outputTokens=${reason.outputTokens ?? "unknown"})`;
    case "malformed_tool_call":
      return `tool \`${reason.tool}\` arguments did not parse (${reason.inputLength} chars): ${reason.sample}`;
    case "tool_call_no_effect":
      return `tool \`${reason.tool}\` left no effect (status=${reason.status})`;
    case "turn_failed":
      return `turn failed: ${reason.reason}`;
  }
}

// src/turn-health/sink.ts
function turnAlert(input) {
  const kinds = [...new Set(input.reasons.map((r) => r.kind))].sort();
  return {
    product: input.product,
    severity: input.severity,
    // Keyed by product + reason kinds ONLY. A blank-completion storm across
    // 200 turns is one incident, not 200 pages.
    key: `turn:${input.product}:${kinds.join("+")}`,
    title: `${input.product}: turn completed but delivered nothing (${kinds.join(", ")})`,
    details: input.reasons.map(describeReason),
    data: {
      kinds,
      ...input.threadId ? { threadId: input.threadId } : {},
      ...input.turnId ? { turnId: input.turnId } : {},
      ...input.model ? { model: input.model } : {}
    },
    at: input.at ?? Date.now()
  };
}
function createWebhookAlertSink(options) {
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
  return {
    async deliver(alert) {
      const icon = alert.severity === "critical" ? ":rotating_light:" : ":warning:";
      const lines = [
        `${icon} *${alert.title}*`,
        ...alert.details.map((d) => `\u2022 ${d}`),
        `_${new Date(alert.at).toISOString()}_`
      ];
      const response = await fetchImpl(options.webhookUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: lines.join("\n") })
      });
      if (!response.ok) {
        throw new Error(`alert webhook responded ${response.status}`);
      }
    }
  };
}
function createConsoleAlertSink(log = console.error) {
  return {
    async deliver(alert) {
      log(
        `[turn-health] ${alert.severity.toUpperCase()} ${alert.title} :: ${alert.details.join(" | ")}`
      );
    }
  };
}
function createMultiAlertSink(sinks) {
  return {
    async deliver(alert) {
      const settled = await Promise.allSettled(sinks.map((s) => s.deliver(alert)));
      const failures = settled.filter((r) => r.status === "rejected");
      if (failures.length === sinks.length && sinks.length > 0) {
        throw new Error("every alert sink failed");
      }
    }
  };
}
function createMemoryThrottleStore() {
  const seen = /* @__PURE__ */ new Map();
  return {
    async lastSentAt(key) {
      return seen.get(key) ?? null;
    },
    async markSent(key, at) {
      seen.set(key, at);
    }
  };
}
function createThrottledAlertSink(inner, options) {
  const store = options.store ?? createMemoryThrottleStore();
  return {
    async deliver(alert) {
      const last = await store.lastSentAt(alert.key);
      if (last !== null && alert.at - last < options.windowMs) return;
      await inner.deliver(alert);
      await store.markSent(alert.key, alert.at);
    }
  };
}
function createGuardedAlertSink(inner, onError = (e) => console.error("[turn-health] alert delivery failed", e)) {
  return {
    async deliver(alert) {
      try {
        await inner.deliver(alert);
      } catch (error) {
        onError(error);
      }
    }
  };
}

// src/turn-health/lifecycle.ts
function errorText(error) {
  if (error instanceof Error) return error.message;
  if (typeof error === "string") return error;
  return String(error);
}
function createTurnHealthLifecycle(options) {
  const sink = createGuardedAlertSink(options.sink);
  return {
    async onTurnComplete(info) {
      const verdict = classifyTurnOutcome({
        finalText: info.finalText,
        outputTokens: info.usage?.outputTokens ?? null,
        durationMs: info.durationMs
      });
      options.onVerdict?.({
        product: options.product,
        healthy: verdict.healthy,
        kinds: verdict.reasons.map((r) => r.kind),
        durationMs: info.durationMs
      });
      if (verdict.healthy || verdict.severity === null) return;
      await sink.deliver(
        turnAlert({
          product: options.product,
          severity: verdict.severity,
          reasons: verdict.reasons,
          ...info.threadId ? { threadId: info.threadId } : {},
          ...info.executionId ? { turnId: info.executionId } : {}
        })
      );
    },
    async onTurnError(info) {
      const verdict = classifyTurnOutcome({
        failed: true,
        failureReason: errorText(info.error),
        durationMs: info.durationMs
      });
      options.onVerdict?.({
        product: options.product,
        healthy: false,
        kinds: verdict.reasons.map((r) => r.kind),
        durationMs: info.durationMs
      });
      await sink.deliver(
        turnAlert({
          product: options.product,
          severity: "critical",
          reasons: verdict.reasons,
          ...info.threadId ? { threadId: info.threadId } : {},
          ...info.executionId ? { turnId: info.executionId } : {}
        })
      );
    }
  };
}

// src/turn-health/sweep.ts
function parseParts(raw) {
  if (Array.isArray(raw)) return raw;
  if (typeof raw !== "string" || raw.trim().length === 0) return [];
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}
var HOUR_MS = 36e5;
var SHELL_ERROR_REPLY_PREFIXES = [
  "The sandbox model stream stopped before a clean completion.",
  "The sandbox agent returned an error before producing a visible answer."
];
async function sweepSilentFailures(options) {
  const now = options.now ?? Date.now();
  const minAgeMs = options.minAgeMs ?? 15 * 6e4;
  const maxAgeMs = options.maxAgeMs ?? 7 * 24 * HOUR_MS;
  const lookbackMs = options.lookbackMs ?? 24 * HOUR_MS;
  const limit = options.limit ?? 500;
  const emptyRateThreshold = options.emptyRateThreshold ?? 0.05;
  const minTurnsForRate = options.minTurnsForRate ?? 10;
  const [unanswered, turns] = await Promise.all([
    options.source.findUnansweredThreads({ minAgeMs, maxAgeMs, now }),
    options.source.listRecentAssistantTurns({ sinceMs: now - lookbackMs, now, limit })
  ]);
  const alerts = [];
  const pendingUserMessages = unanswered.reduce((sum, t) => sum + t.pendingMessages, 0);
  const oldestUnansweredMs = unanswered.reduce((max, t) => Math.max(max, t.oldestAgeMs), 0);
  if (unanswered.length > 0) {
    const hours = (oldestUnansweredMs / HOUR_MS).toFixed(1);
    alerts.push({
      product: options.product,
      // A day of total silence is not a warning.
      severity: oldestUnansweredMs >= 24 * HOUR_MS ? "critical" : "warning",
      key: `sweep:${options.product}:unanswered_threads`,
      title: `${options.product}: ${pendingUserMessages} user message(s) unanswered across ${unanswered.length} thread(s)`,
      details: [
        `oldest unanswered message: ${hours}h`,
        ...unanswered.slice(0, 5).map(
          (t) => `thread ${t.threadId}: ${t.pendingMessages} pending, oldest ${(t.oldestAgeMs / HOUR_MS).toFixed(1)}h`
        )
      ],
      data: {
        unansweredThreads: unanswered.length,
        pendingUserMessages,
        oldestUnansweredMs
      },
      at: now
    });
  }
  let emptyCompletions = 0;
  let malformedToolCalls = 0;
  let toolCallsWithoutEffect = 0;
  let unhealthyTurns = 0;
  const malformedSamples = [];
  for (const row of turns) {
    const verdict = classifyTurnOutcome({
      finalText: row.content,
      parts: parseParts(row.parts),
      outputTokens: row.outputTokens ?? null
    });
    if (verdict.healthy) continue;
    unhealthyTurns += 1;
    for (const reason of verdict.reasons) {
      if (reason.kind === "empty_completion") emptyCompletions += 1;
      if (reason.kind === "malformed_tool_call") {
        malformedToolCalls += 1;
        if (malformedSamples.length < 3) malformedSamples.push(reason);
      }
      if (reason.kind === "tool_call_no_effect") toolCallsWithoutEffect += 1;
    }
  }
  if (malformedToolCalls > 0) {
    alerts.push({
      product: options.product,
      severity: "critical",
      key: `sweep:${options.product}:malformed_tool_call`,
      title: `${options.product}: ${malformedToolCalls} tool call(s) had unparseable arguments \u2014 deliverables silently dropped`,
      details: malformedSamples.map(describeReason),
      data: { malformedToolCalls, turnsJudged: turns.length },
      at: now
    });
  }
  if (turns.length >= minTurnsForRate) {
    const rate = emptyCompletions / turns.length;
    if (rate > emptyRateThreshold) {
      alerts.push({
        product: options.product,
        severity: "critical",
        key: `sweep:${options.product}:empty_completion_rate`,
        title: `${options.product}: ${(rate * 100).toFixed(1)}% of turns completed with no output`,
        details: [
          `${emptyCompletions} of ${turns.length} settled turns delivered nothing`,
          `threshold ${(emptyRateThreshold * 100).toFixed(1)}%`
        ],
        data: { emptyCompletions, turnsJudged: turns.length, rate },
        at: now
      });
    }
  }
  for (const alert of alerts) await options.sink.deliver(alert);
  return {
    product: options.product,
    unansweredThreads: unanswered.length,
    pendingUserMessages,
    oldestUnansweredMs,
    turnsJudged: turns.length,
    unhealthyTurns,
    emptyCompletions,
    malformedToolCalls,
    toolCallsWithoutEffect,
    alerts
  };
}
function createD1TurnHealthSource(db, options = {}) {
  const message = safeIdentifier(options.messageTable ?? "message");
  const thread = safeIdentifier(options.threadTable ?? "thread");
  const errorPrefixes = [...options.errorReplyPrefixes ?? SHELL_ERROR_REPLY_PREFIXES];
  return {
    async findUnansweredThreads({ minAgeMs, maxAgeMs, now }) {
      const cutoffSeconds = Math.floor((now - minAgeMs) / 1e3);
      const floorSeconds = Math.floor((now - maxAgeMs) / 1e3);
      const errorClause = errorPrefixes.map((_, i) => ` AND a.content NOT LIKE ?${i + 3} || '%'`).join("");
      const { results } = await db.prepare(
        `SELECT m.thread_id AS threadId,
                  COUNT(*) AS pendingMessages,
                  MIN(m.created_at) AS oldestCreatedAt
             FROM ${message} m
            WHERE m.role = 'user'
              AND m.created_at <= ?1
              AND m.created_at >= ?2
              AND m.created_at > COALESCE(
                    (SELECT MAX(a.created_at)
                       FROM ${message} a
                      WHERE a.thread_id = m.thread_id
                        AND a.role = 'assistant'
                        AND length(trim(a.content)) > 0${errorClause}), 0)
            GROUP BY m.thread_id
            ORDER BY oldestCreatedAt ASC`
      ).bind(cutoffSeconds, floorSeconds, ...errorPrefixes).all();
      return results.map((row) => ({
        threadId: row.threadId,
        pendingMessages: Number(row.pendingMessages),
        oldestAgeMs: now - Number(row.oldestCreatedAt) * 1e3
      }));
    },
    async listRecentAssistantTurns({ sinceMs, limit }) {
      const sinceSeconds = Math.floor(sinceMs / 1e3);
      const { results } = await db.prepare(
        `SELECT id, thread_id AS threadId, content, parts,
                  output_tokens AS outputTokens, model, created_at AS createdAt
             FROM ${message}
            WHERE role = 'assistant' AND created_at >= ?1
            ORDER BY created_at DESC
            LIMIT ?2`
      ).bind(sinceSeconds, limit).all();
      return results.map((row) => ({
        id: String(row.id),
        threadId: String(row.threadId),
        content: typeof row.content === "string" ? row.content : "",
        parts: row.parts,
        outputTokens: row.outputTokens === null ? null : Number(row.outputTokens),
        model: row.model ?? null,
        createdAt: Number(row.createdAt) * 1e3
      }));
    }
  };
  function safeIdentifier(name) {
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
      throw new Error(`unsafe table identifier: ${name}`);
    }
    return name;
  }
}
export {
  SHELL_ERROR_REPLY_PREFIXES,
  classifyTurnOutcome,
  createConsoleAlertSink,
  createD1TurnHealthSource,
  createGuardedAlertSink,
  createMemoryThrottleStore,
  createMultiAlertSink,
  createThrottledAlertSink,
  createTurnHealthLifecycle,
  createWebhookAlertSink,
  describeReason,
  sweepSilentFailures,
  turnAlert
};
//# sourceMappingURL=index.js.map