diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js
index 3f8e591..90ca5c6 100644
--- a/static/js/cookbookRunning.js
+++ b/static/js/cookbookRunning.js
@@ -33,6 +33,74 @@ function _taskBadge(task) {
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
}
+function _shouldOfferCrashReport(task) {
+ if (!task) return false;
+ if (task._unreachable && task.type === 'serve') return true;
+ return ['error', 'crashed', 'failed'].includes(task.status);
+}
+
+function _redactCrashReportText(text) {
+ if (!text) return '';
+ return String(text)
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi, '$1[redacted]')
+ .replace(/\b(hf_[A-Za-z0-9]{16,})\b/g, '[redacted-hf-token]')
+ .replace(/\b(sk-[A-Za-z0-9_-]{16,})\b/g, '[redacted-api-key]')
+ .replace(/\b(xox[baprs]-[A-Za-z0-9-]{16,})\b/g, '[redacted-slack-token]')
+ .replace(/\b(AIza[0-9A-Za-z_-]{20,})\b/g, '[redacted-google-key]')
+ .replace(/\b((?:HF_TOKEN|HUGGING_FACE_HUB_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|BRAVE_API_KEY|TAVILY_API_KEY|SERPER_API_KEY|GOOGLE_API_KEY|API_KEY|TOKEN|PASSWORD)\s*=\s*)(['"]?)[^\s'"\\]+/gi, '$1$2[redacted]')
+ .replace(/\b(--(?:api-key|token|hf-token|password)\s+)([^\s]+)/gi, '$1[redacted]');
+}
+
+function _lastLines(text, count = 160) {
+ const clean = _redactCrashReportText(text || '').trimEnd();
+ if (!clean) return '(no captured output)';
+ return clean.split('\n').slice(-count).join('\n');
+}
+
+function _codeFence(text) {
+ return String(text || '').replace(/```/g, '` ` `');
+}
+
+function _taskHostLabel(task) {
+ if (!task?.remoteHost) return 'local';
+ return task.remoteHost + (task.sshPort ? `:${task.sshPort}` : '');
+}
+
+function _taskPort(task) {
+ const cmd = task?.payload?._cmd || '';
+ const match = cmd.match(/--port\s+(\d+)/);
+ return match ? match[1] : '';
+}
+
+function _buildCrashReport(task, outputText) {
+ const capturedOutput = outputText || task?.output || '';
+ const cmd = _redactCrashReportText(task?.payload?._cmd || '');
+ const diag = _diagnose(capturedOutput);
+ const started = task?.ts ? new Date(task.ts).toISOString() : '';
+ const report = [
+ '## Odysseus Cookbook crash report',
+ '',
+ 'Please review this report for secrets before posting it publicly.',
+ '',
+ '### Task',
+ `- ID: \`${task?.sessionId || task?.id || 'unknown'}\``,
+ `- Type: \`${task?.type || 'unknown'}\``,
+ `- Status: \`${task?._unreachable ? 'unreachable' : (task?.status || 'unknown')}\``,
+ `- Model/repo: \`${task?.payload?.repo_id || task?.name || 'unknown'}\``,
+ `- Host: \`${_taskHostLabel(task)}\``,
+ ];
+ if (task?.platform) report.push(`- Platform: \`${task.platform}\``);
+ if (started) report.push(`- Started: \`${started}\``);
+ const port = _taskPort(task);
+ if (port) report.push(`- Port: \`${port}\``);
+ if (diag?.message) report.push(`- Diagnosis: ${diag.message}`);
+ if (cmd) {
+ report.push('', '### Command', '```bash', _codeFence(cmd), '```');
+ }
+ report.push('', '### Last captured output', '```text', _codeFence(_lastLines(capturedOutput)), '```');
+ return report.join('\n');
+}
+
// Shared state/functions injected by init()
let _envState;
let _sshCmd;
@@ -1660,6 +1728,13 @@ export function _renderRunningTab() {
_copyText(tmuxAttach);
}});
}
+ if (_shouldOfferCrashReport(task)) {
+ items.push({ label: 'Copy crash report', action: 'copy-crash-report', custom: () => {
+ const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
+ _copyText(_buildCrashReport(task, out));
+ uiModule.showToast('Copied crash report');
+ }});
+ }
// Copy the last 50 lines of the task's output/log.
items.push({ label: 'Copy last 50 lines', action: 'copy-log', custom: () => {
const out = (el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
@@ -1683,6 +1758,7 @@ export function _renderRunningTab() {
'register-endpoint': '',
save: '',
'copy-tmux': '',
+ 'copy-crash-report': '',
'copy-log': '',
kill: '',
cancel: '',