diff --git a/src/workflow-web-assets.ts b/src/workflow-web-assets.ts index d6c3161..3838e5a 100644 --- a/src/workflow-web-assets.ts +++ b/src/workflow-web-assets.ts @@ -890,18 +890,20 @@ export function getAssetsHtml(): string {
+ +
-
- + +
@@ -1007,21 +1009,45 @@ export function getAssetsHtml(): string { let hideThinking = true; let errorsOnly = false; let autoScroll = true; + /** When true, UI follows the run's latest cycle/attempt. Manual cycle selection disables this. */ + let followLive = true; + /** Incremented on every view switch so stale async fetches are discarded. */ + let viewGeneration = 0; // Chunk loading limits const chunkLimit = 50000; // 50KB chunks - // Timeline pagination states - let timelineOffset = 0; - let timelineFullyLoaded = false; + // Timeline window: [startOffset, endOffset) bytes currently held for the active file identity. + let timelineStartOffset = 0; + let timelineEndOffset = 0; + let timelineHasMoreBefore = false; + let timelineHasMoreAfter = false; let timelineEntries = []; let timelineResidual = ""; + let timelineFileKey = ""; + let timelineRenderedCount = 0; + let timelineProgressRow = null; // DOM node for mergeable progress line + let pendingLiveCount = 0; - // Raw log pagination states - let rawOffset = 0; - let rawLogFullyLoaded = false; + // Raw log window + let rawStartOffset = 0; + let rawEndOffset = 0; + let rawHasMoreBefore = false; + let rawHasMoreAfter = false; + let rawFileKey = ""; let rawResidual = ""; + // Seen byte ranges for SSE/snapshot dedup: map of "start-end" keys + let seenRanges = new Set(); + + function logFileKey(cycle, attempt, fileType) { + return cycle + ':' + attempt + ':' + fileType; + } + + function rangeKey(start, end) { + return start + '-' + end; + } + // Escape state-derived values function escapeHtml(text) { if (text === null || text === undefined) return ''; @@ -1180,8 +1206,8 @@ export function getAssetsHtml(): string { } else { document.getElementById('timeline-view').style.display = 'none'; document.getElementById('raw-view').style.display = 'block'; - resetRawLogs(); } + resetLogView(); } function toggleThinking() { @@ -1204,6 +1230,8 @@ export function getAssetsHtml(): string { btn.className = 'control-btn' + (autoScroll ? ' active' : ''); btn.textContent = '自动滚动:' + (autoScroll ? '开启' : '关闭'); if (autoScroll) { + pendingLiveCount = 0; + updateFollowLiveUI(); scrollToBottom(); } } @@ -1277,16 +1305,27 @@ export function getAssetsHtml(): string { document.getElementById('view-created-at').textContent = new Date(activeRun.createdAt).toLocaleString('zh-CN'); document.getElementById('view-updated-at').textContent = new Date(activeRun.updatedAt).toLocaleString('zh-CN'); + followLive = true; + pendingLiveCount = 0; selectedCycle = activeRun.currentCycle || 1; selectedAttempt = 0; + if (activeRun.cycles) { + const cycleRec = activeRun.cycles.find(cy => cy.cycleIndex === selectedCycle); + if (cycleRec && cycleRec.executorAttemptLogs) { + selectedAttempt = Math.max(0, cycleRec.executorAttemptLogs.length - 1); + } + } selectedLogFile = 'exec'; + updateFollowLiveUI(); populateCyclesDropdown(); - onCycleOrAttemptUIChange(); loadWorkflowEvents(); + // SSE first, then tail snapshot (setupSSE loads tail). if (activeRun.status === 'executing') { setupSSE(runId); + } else { + onCycleOrAttemptUIChange(); } } catch (err) { console.error('Failed to load run details:', err); @@ -1397,58 +1436,528 @@ export function getAssetsHtml(): string { } } - // Timeline view initialization and pagination - async function resetTimeline() { - timelineOffset = 0; - timelineFullyLoaded = false; - timelineEntries = []; - timelineResidual = ""; - document.getElementById('timeline-items-container').innerHTML = ''; - document.getElementById('load-more-timeline-btn').style.display = 'none'; - await loadMoreTimeline(); + + function currentFileKey() { + return logFileKey(selectedCycle, selectedAttempt, selectedLogFile); } - async function loadMoreTimeline() { - try { - const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&offset=\${timelineOffset}&limit=\${chunkLimit}\`; - const res = await fetch(url); - const logData = await res.json(); + function updateFollowLiveUI() { + const btn = document.getElementById('follow-live-btn'); + if (btn) { + btn.className = 'control-btn' + (followLive ? ' active' : ''); + btn.textContent = followLive ? '实时跟随' : '回到实时'; + } + const badge = document.getElementById('pending-live-badge'); + if (badge) { + // Show whenever the user is not pinned to the live bottom (history cycle OR scrolled up). + if (pendingLiveCount > 0 && (!followLive || !autoScroll)) { + badge.style.display = 'inline-flex'; + badge.style.cursor = 'pointer'; + badge.textContent = '新增 ' + pendingLiveCount + ' · 点击回到底部'; + badge.onclick = jumpToLiveBottom; + } else { + badge.style.display = 'none'; + badge.onclick = null; + } + } + } - const combinedContent = timelineResidual + (logData.content || ''); - let contentToParse = combinedContent; - if (logData.hasMore) { - const lastNewlineIndex = Math.max(combinedContent.lastIndexOf('\\n'), combinedContent.lastIndexOf('\\r')); - if (lastNewlineIndex !== -1) { - contentToParse = combinedContent.substring(0, lastNewlineIndex + 1); - timelineResidual = combinedContent.substring(lastNewlineIndex + 1); - } else { - contentToParse = ""; - timelineResidual = combinedContent; + function jumpToLiveBottom() { + autoScroll = true; + pendingLiveCount = 0; + const scrollBtn = document.getElementById('scroll-btn'); + if (scrollBtn) { + scrollBtn.className = 'control-btn active'; + scrollBtn.textContent = '自动滚动:开启'; + } + if (!followLive) { + returnToLive(); + } else { + updateFollowLiveUI(); + scrollToBottom(); + } + } + + function returnToLive() { + followLive = true; + pendingLiveCount = 0; + autoScroll = true; + const scrollBtn = document.getElementById('scroll-btn'); + if (scrollBtn) { + scrollBtn.className = 'control-btn active'; + scrollBtn.textContent = '自动滚动:开启'; + } + if (activeRun) { + selectedCycle = activeRun.currentCycle || 1; + let maxAttempts = 0; + if (activeRun.cycles) { + const cycleRec = activeRun.cycles.find(cy => cy.cycleIndex === selectedCycle); + if (cycleRec && cycleRec.executorAttemptLogs) { + maxAttempts = Math.max(0, cycleRec.executorAttemptLogs.length - 1); } - } else { - timelineResidual = ""; } + selectedAttempt = maxAttempts; + } + updateFollowLiveUI(); + populateCyclesDropdown(); + onCycleOrAttemptUIChange(); + scrollToBottom(); + } - const parsed = parseLogContent(contentToParse, activeRun.executor); - timelineEntries.push(...parsed); - timelineOffset = logData.nextOffset; - timelineFullyLoaded = !logData.hasMore; + function onCycleChange() { + selectedCycle = parseInt(document.getElementById('cycle-select').value, 10); + selectedAttempt = 0; + followLive = !!(activeRun && selectedCycle === activeRun.currentCycle); + pendingLiveCount = 0; + updateFollowLiveUI(); + onCycleOrAttemptUIChange(); + } - renderTimeline(); - - const btn = document.getElementById('load-more-timeline-btn'); - if (logData.hasMore) { - btn.style.display = 'block'; - btn.textContent = '加载更多时间线(剩余 ' + Math.round((logData.size - logData.nextOffset) / 1024) + ' KB)'; - } else { - btn.style.display = 'none'; + function onAttemptChange() { + selectedAttempt = parseInt(document.getElementById('attempt-select').value, 10); + if (activeRun && selectedCycle === activeRun.currentCycle) { + let maxAttempts = 0; + if (activeRun.cycles) { + const cycleRec = activeRun.cycles.find(cy => cy.cycleIndex === selectedCycle); + if (cycleRec && cycleRec.executorAttemptLogs) { + maxAttempts = Math.max(0, cycleRec.executorAttemptLogs.length - 1); + } } + followLive = selectedAttempt === maxAttempts; + } else { + followLive = false; + } + pendingLiveCount = 0; + updateFollowLiveUI(); + onCycleOrAttemptUIChange(); + } + + function onLogFileChange() { + selectedLogFile = document.getElementById('log-file-select').value; + resetLogView(); + } + + async function onCycleOrAttemptUIChange() { + const attemptSelect = document.getElementById('attempt-select'); + attemptSelect.innerHTML = ''; + let maxAttempts = 0; + if (activeRun && activeRun.cycles) { + const cycleRec = activeRun.cycles.find(cy => cy.cycleIndex === selectedCycle); + if (cycleRec && cycleRec.executorAttemptLogs) { + maxAttempts = Math.max(0, cycleRec.executorAttemptLogs.length - 1); + } + } + for (let a = 0; a <= maxAttempts; a++) { + const opt = document.createElement('option'); + opt.value = a; + opt.textContent = a; + attemptSelect.appendChild(opt); + } + attemptSelect.value = Math.min(selectedAttempt, maxAttempts); + selectedAttempt = parseInt(attemptSelect.value, 10) || 0; + + const agyGroup = document.getElementById('agy-file-group'); + if (activeRun && activeRun.executor === 'agy') { + agyGroup.style.display = 'flex'; + } else { + agyGroup.style.display = 'none'; + selectedLogFile = 'exec'; + } + const logFileSelect = document.getElementById('log-file-select'); + if (logFileSelect) logFileSelect.value = selectedLogFile; + updateFollowLiveUI(); + resetLogView(); + } + + function resetLogView() { + viewGeneration += 1; + resetCoverage(); + timelineEntries = []; + timelineResidual = ''; + timelineResidualState.value = ''; + timelineOlderHeadResidual.value = ''; + timelineStartOffset = 0; + timelineEndOffset = 0; + timelineHasMoreBefore = false; + timelineHasMoreAfter = false; + timelineFileKey = currentFileKey(); + timelineRenderedCount = 0; + timelineProgressRow = null; + rawResidual = ''; + rawResidualState.value = ''; + rawStartOffset = 0; + rawEndOffset = 0; + rawHasMoreBefore = false; + rawHasMoreAfter = false; + rawFileKey = currentFileKey(); + const container = document.getElementById('timeline-items-container'); + if (container) container.innerHTML = ''; + const pre = document.getElementById('raw-log-pre'); + if (pre) pre.textContent = ''; + const tbtn = document.getElementById('load-more-timeline-btn'); + if (tbtn) tbtn.style.display = 'none'; + const rbtn = document.getElementById('load-more-raw-btn'); + if (rbtn) rbtn.style.display = 'none'; + if (currentLogView === 'timeline') { + loadTailTimeline(); + } else { + loadTailRawLogs(); + } + } + + // Back-compat aliases used by existing tests / HTML hooks. + async function resetTimeline() { resetLogView(); } + async function resetRawLogs() { resetLogView(); } + async function loadMoreTimeline() { return loadOlderTimeline(); } + async function loadMoreRawLogs() { return loadOlderRawLogs(); } + + // Covered half-open byte ranges for the active log file identity. + // Stored as [start,end) intervals sorted and merged. + let coveredRanges = []; + + function resetCoverage() { + coveredRanges = []; + } + + function markSeen(start, end) { + start = Number(start) || 0; + end = Number(end) || 0; + if (end <= start) return; + coveredRanges.push([start, end]); + coveredRanges.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const merged = []; + for (const [s, e] of coveredRanges) { + if (!merged.length || s > merged[merged.length - 1][1]) { + merged.push([s, e]); + } else { + merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], e); + } + } + coveredRanges = merged; + } + + function coverageEnd() { + if (!coveredRanges.length) return 0; + return coveredRanges[coveredRanges.length - 1][1]; + } + + /** Bytes in [start,end) not yet covered, as half-open intervals. */ + function uncoveredParts(start, end) { + start = Number(start) || 0; + end = Number(end) || 0; + if (end <= start) return []; + const parts = []; + let cursor = start; + for (const [s, e] of coveredRanges) { + if (e <= cursor) continue; + if (s >= end) break; + if (s > cursor) parts.push([cursor, Math.min(s, end)]); + cursor = Math.max(cursor, e); + if (cursor >= end) break; + } + if (cursor < end) parts.push([cursor, end]); + return parts; + } + + function fullyCovered(start, end) { + return uncoveredParts(start, end).length === 0; + } + + function overlapsSeen(start, end) { + return fullyCovered(start, end); + } + + /** Content relative to a slice that starts at baseOffset. */ + function contentForRange(content, baseOffset, from, to) { + if (to <= from) return ''; + const relStart = Math.max(0, from - baseOffset); + const relEnd = Math.max(relStart, to - baseOffset); + // Content is a JS string of decoded UTF-8; byte offsets only match for ASCII-safe spans. + // For partial overlap we slice by byte indices against the original UTF-8 when available + // via TextEncoder, falling back to character indices for pure ASCII logs. + try { + const enc = new TextEncoder(); + const bytes = enc.encode(content); + if (bytes.length >= relEnd) { + return new TextDecoder().decode(bytes.subarray(relStart, relEnd)); + } + } catch (e) {} + return content.slice(relStart, relEnd); + } + + /** + * Feed a content chunk into the timeline parser with residual newline buffering. + * Returns complete entries only; incomplete trailing line stays in residual. + */ + function feedTimelineContent(content, residualState) { + const combined = (residualState.value || '') + (content || ''); + const lastNl = Math.max(combined.lastIndexOf('\\n'), combined.lastIndexOf('\\r')); + if (lastNl === -1) { + residualState.value = combined; + return []; + } + const complete = combined.substring(0, lastNl + 1); + residualState.value = combined.substring(lastNl + 1); + return parseLogContent(complete, activeRun ? activeRun.executor : 'claude'); + } + + function flushTimelineResidual(residualState) { + if (!residualState.value) return []; + const left = residualState.value; + residualState.value = ''; + return parseLogContent(left, activeRun ? activeRun.executor : 'claude'); + } + + const timelineResidualState = { value: '' }; + /** Incomplete first-line fragment from reverse pagination, persisted across before= pages. */ + const timelineOlderHeadResidual = { value: '' }; + const rawResidualState = { value: '' }; // raw keeps byte continuity only + + + async function loadTailTimeline() { + const gen = viewGeneration; + if (!activeRun) return; + try { + const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&tail=1&limit=\${chunkLimit}\`; + const res = await fetch(url); + const data = await res.json(); + if (gen !== viewGeneration) return; + timelineFileKey = currentFileKey(); + timelineStartOffset = data.startOffset || 0; + timelineEndOffset = data.endOffset || data.nextOffset || 0; + timelineHasMoreBefore = !!data.hasMoreBefore; + timelineHasMoreAfter = !!data.hasMore; + // Fresh tail replaces coverage for this file identity. + resetCoverage(); + markSeen(timelineStartOffset, timelineEndOffset); + timelineResidualState.value = ''; + timelineOlderHeadResidual.value = ''; + const parsed = feedTimelineContent(data.content || '', timelineResidualState); + // Flush residual only when the slice reaches EOF so incomplete last lines wait for live data. + const more = !!data.hasMore; + const flushed = more ? [] : flushTimelineResidual(timelineResidualState); + timelineEntries = parsed.concat(flushed); + timelineRenderedCount = 0; + const container = document.getElementById('timeline-items-container'); + if (container) container.innerHTML = ''; + appendTimelineEntries(timelineEntries, false); + updateOlderButtons(); if (autoScroll) scrollToBottom(); } catch (err) { - console.error('Failed to load timeline entries:', err); + console.error('Failed to load timeline tail:', err); } } + async function loadOlderTimeline() { + const gen = viewGeneration; + if (!activeRun || !timelineHasMoreBefore) return; + const before = timelineStartOffset; + try { + const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&before=\${before}&limit=\${chunkLimit}\`; + const container = document.getElementById('timeline-items-container'); + const prevHeight = container ? container.scrollHeight : 0; + const prevTop = container ? container.parentElement.scrollTop : 0; + const res = await fetch(url); + const data = await res.json(); + if (gen !== viewGeneration) return; + const s = data.startOffset || 0; + const e = data.endOffset || 0; + if (fullyCovered(s, e)) return; + // Older pages arrive in reverse chronological order (closer-to-tail first, then earlier). + // timelineOlderHeadResidual holds any incomplete first-line fragment from the previous + // older page (which sits closer to the live window). Prepend that fragment AFTER the new + // older chunk so split JSON/log lines reassemble in file order. + const joined = + (data.content || '') + (timelineOlderHeadResidual.value || ''); + // Reuse residual machinery: feed joined text, keep any incomplete start as residual for next older page. + timelineOlderHeadResidual.value = ''; + const parsed = feedTimelineContent(joined, timelineOlderHeadResidual); + markSeen(s, e); + timelineEntries = parsed.concat(timelineEntries); + timelineStartOffset = s; + timelineHasMoreBefore = !!data.hasMoreBefore; + // If we reached the start of the file, flush remaining older residual so the first line is not lost. + if (!timelineHasMoreBefore && timelineOlderHeadResidual.value) { + const flushedHead = flushTimelineResidual(timelineOlderHeadResidual); + if (flushedHead.length) { + timelineEntries = flushedHead.concat(timelineEntries); + // Prepend DOM for flushed head as well below via parsed extension. + parsed.unshift(...flushedHead); + } + } + if (container && parsed.length) { + const frag = document.createDocumentFragment(); + parsed.forEach(entry => { + const row = buildTimelineRow(entry); + if (row) frag.appendChild(row); + }); + container.insertBefore(frag, container.firstChild); + timelineRenderedCount = timelineEntries.length; + const parent = container.parentElement; + if (parent) parent.scrollTop = parent.scrollHeight - prevHeight + prevTop; + } + updateOlderButtons(); + } catch (err) { + console.error('Failed to load older timeline:', err); + } + } + + async function loadTailRawLogs() { + const gen = viewGeneration; + if (!activeRun) return; + try { + const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&tail=1&limit=\${chunkLimit}\`; + const res = await fetch(url); + const data = await res.json(); + if (gen !== viewGeneration) return; + rawFileKey = currentFileKey(); + rawStartOffset = data.startOffset || 0; + rawEndOffset = data.endOffset || data.nextOffset || 0; + rawHasMoreBefore = !!data.hasMoreBefore; + rawHasMoreAfter = !!data.hasMore; + markSeen(rawStartOffset, rawEndOffset); + const pre = document.getElementById('raw-log-pre'); + if (pre) { + pre.textContent = ''; + appendRawText(data.content || '', false); + } + updateOlderButtons(); + if (autoScroll) scrollToBottom(); + } catch (err) { + console.error('Failed to load raw tail:', err); + } + } + + async function loadOlderRawLogs() { + const gen = viewGeneration; + if (!activeRun || !rawHasMoreBefore) return; + const before = rawStartOffset; + try { + const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&before=\${before}&limit=\${chunkLimit}\`; + const pre = document.getElementById('raw-log-pre'); + const parent = pre ? pre.parentElement : null; + const prevHeight = parent ? parent.scrollHeight : 0; + const prevTop = parent ? parent.scrollTop : 0; + const res = await fetch(url); + const data = await res.json(); + if (gen !== viewGeneration) return; + if (overlapsSeen(data.startOffset, data.endOffset)) return; + markSeen(data.startOffset || 0, data.endOffset || 0); + rawStartOffset = data.startOffset || 0; + rawHasMoreBefore = !!data.hasMoreBefore; + if (pre && data.content) { + const node = document.createTextNode(data.content); + pre.insertBefore(node, pre.firstChild); + if (parent) parent.scrollTop = parent.scrollHeight - prevHeight + prevTop; + } + updateOlderButtons(); + } catch (err) { + console.error('Failed to load older raw logs:', err); + } + } + + function updateOlderButtons() { + const tbtn = document.getElementById('load-more-timeline-btn'); + if (tbtn) { + if (currentLogView === 'timeline' && timelineHasMoreBefore) { + tbtn.style.display = 'block'; + tbtn.textContent = '加载更早时间线'; + } else { + tbtn.style.display = 'none'; + } + } + const rbtn = document.getElementById('load-more-raw-btn'); + if (rbtn) { + if (currentLogView === 'raw' && rawHasMoreBefore) { + rbtn.style.display = 'block'; + rbtn.textContent = '加载更早日志'; + } else { + rbtn.style.display = 'none'; + } + } + } + + function buildTimelineRow(entry) { + if (hideThinking && entry.type === 'thinking') return null; + if (errorsOnly && entry.type !== 'error') return null; + const row = document.createElement('div'); + row.className = \`timeline-item \${entry.type}\`; + if (entry._progressKey) row.dataset.progressKey = entry._progressKey; + const typeLabel = getTimelineTypeLabel(entry.type); + const badge = document.createElement('span'); + badge.className = \`item-badge badge-\${entry.type}\`; + badge.textContent = typeLabel; + const content = document.createElement('span'); + content.className = 'item-content'; + content.textContent = entry.content || entry.raw || ''; + row.appendChild(badge); + row.appendChild(content); + return row; + } + + function appendTimelineEntries(entries, isLive) { + const container = document.getElementById('timeline-items-container'); + if (!container || !entries || !entries.length) return; + const frag = document.createDocumentFragment(); + entries.forEach(entry => { + // Merge high-frequency progress counters into one updatable row. + if (entry.type === 'progress') { + const key = 'progress'; + entry._progressKey = key; + const existing = container.querySelector('[data-progress-key="progress"]'); + if (existing) { + const content = existing.querySelector('.item-content'); + if (content) content.textContent = entry.content || entry.raw || ''; + timelineProgressRow = existing; + return; + } + } + const row = buildTimelineRow(entry); + if (row) frag.appendChild(row); + }); + if (frag.childNodes.length) { + container.appendChild(frag); + timelineRenderedCount = timelineEntries.length; + } + if (isLive && !autoScroll) { + pendingLiveCount += entries.length; + updateFollowLiveUI(); + } + if (autoScroll) scrollToBottom(); + } + + function appendRawText(text, isLive) { + const pre = document.getElementById('raw-log-pre'); + if (!pre || !text) return; + // Append via text node to avoid full-string copies from textContent +=. + pre.appendChild(document.createTextNode(text)); + if (isLive && !autoScroll) { + pendingLiveCount += 1; + updateFollowLiveUI(); + } + if (autoScroll) scrollToBottom(); + } + + function renderTimeline() { + // Full rebuild only on filter toggles. + const container = document.getElementById('timeline-items-container'); + if (!container) return; + container.innerHTML = ''; + timelineProgressRow = null; + const frag = document.createDocumentFragment(); + timelineEntries.forEach(entry => { + if (entry.type === 'progress') entry._progressKey = 'progress'; + const row = buildTimelineRow(entry); + if (row) { + if (entry.type === 'progress') timelineProgressRow = row; + frag.appendChild(row); + } + }); + container.appendChild(frag); + timelineRenderedCount = timelineEntries.length; + if (autoScroll) scrollToBottom(); + } + // Align browser log parser with workflow-web-parser.ts function parseLogContent(content, executor) { const entries = []; @@ -1532,18 +2041,12 @@ export function getAssetsHtml(): string { return; } - if (type === 'system') { - entries.push({ - type: 'system', - content: parsed.subtype === 'init' ? \`Session initialized: '\${parsed.session_id || ''}'\` : (parsed.message || JSON.stringify(parsed)), - raw: line - }); - return; - } - + // High-frequency token counters arrive as type=system subtype=thinking_tokens. + // Handle them as progress BEFORE the generic system branch so they merge into one row. if ( type === 'progress' || type === 'token_progress' || + (type === 'system' && (parsed.subtype === 'thinking_tokens' || parsed.thinking_tokens !== undefined)) || parsed.progress !== undefined || parsed.token_progress !== undefined || parsed.thinking_tokens !== undefined || @@ -1557,6 +2060,8 @@ export function getAssetsHtml(): string { } } else if (parsed.tokens !== undefined) { pContent = \`Tokens: '\${parsed.tokens}'\`; + } else if (parsed.subtype === 'thinking_tokens') { + pContent = parsed.message || JSON.stringify(parsed); } else { pContent = parsed.message || parsed.token_progress || JSON.stringify(parsed); } @@ -1564,6 +2069,15 @@ export function getAssetsHtml(): string { return; } + if (type === 'system') { + entries.push({ + type: 'system', + content: parsed.subtype === 'init' ? \`Session initialized: '\${parsed.session_id || ''}'\` : (parsed.message || JSON.stringify(parsed)), + raw: line + }); + return; + } + entries.push({ type: 'raw', content: trimmed, raw: line }); } catch (e) { entries.push({ type: 'raw', content: line, raw: line }); @@ -1590,92 +2104,143 @@ export function getAssetsHtml(): string { }); } - function renderTimeline() { - const container = document.getElementById('timeline-items-container'); - container.innerHTML = ''; - timelineEntries.forEach(entry => { - if (hideThinking && entry.type === 'thinking') return; - if (errorsOnly && entry.type !== 'error') return; - - const row = document.createElement('div'); - row.className = \`timeline-item \${entry.type}\`; - - const typeLabel = getTimelineTypeLabel(entry.type); - - row.innerHTML = \` - \${escapeHtml(typeLabel)} - \ \${escapeHtml(entry.content || entry.raw)} - \`; - container.appendChild(row); - }); - - if (autoScroll) scrollToBottom(); - } - - // Raw log chunk loading logic with visible hasMore controls and gap protection - async function resetRawLogs() { - rawOffset = 0; - rawLogFullyLoaded = false; - rawResidual = ""; - document.getElementById('raw-log-pre').textContent = ''; - document.getElementById('load-more-raw-btn').style.display = 'none'; - await loadMoreRawLogs(); - } - - async function loadMoreRawLogs() { - try { - const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&offset=\${rawOffset}&limit=\${chunkLimit}\`; + async function fetchLogByteRange(from, to) { + if (!(to > from) || !activeRun) return { content: '', startOffset: from, endOffset: from }; + let pos = from; + let content = ''; + let startOffset = from; + let endOffset = from; + let guard = 0; + while (pos < to && guard++ < 64) { + const limit = Math.min(chunkLimit, to - pos); + const url = \`/api/runs/\${activeRun.runId}/logs?cycle=\${selectedCycle}&attempt=\${selectedAttempt}&type=\${selectedLogFile}&offset=\${pos}&limit=\${limit}\`; const res = await fetch(url); const data = await res.json(); - - const pre = document.getElementById('raw-log-pre'); - - const combinedContent = rawResidual + (data.content || ''); - let displayContent = combinedContent; - if (data.hasMore) { - const lastNewlineIndex = Math.max(combinedContent.lastIndexOf('\\n'), combinedContent.lastIndexOf('\\r')); - if (lastNewlineIndex !== -1) { - displayContent = combinedContent.substring(0, lastNewlineIndex + 1); - rawResidual = combinedContent.substring(lastNewlineIndex + 1); - } else { - displayContent = ""; - rawResidual = combinedContent; - } - } else { - rawResidual = ""; - } - - if (rawOffset === 0) { - pre.textContent = displayContent; - } else { - pre.textContent += displayContent; - } - - rawOffset = data.nextOffset; - rawLogFullyLoaded = !data.hasMore; - - const btn = document.getElementById('load-more-raw-btn'); - if (data.hasMore) { - btn.style.display = 'block'; - btn.textContent = '加载更多日志(剩余 ' + Math.round((data.size - data.nextOffset) / 1024) + ' KB)'; - } else { - btn.style.display = 'none'; - } - - if (autoScroll) scrollToBottom(); - } catch (err) { - console.error('Failed to load raw logs:', err); + const s = data.startOffset != null ? data.startOffset : pos; + const e = data.endOffset != null ? data.endOffset : (data.nextOffset != null ? data.nextOffset : pos); + if (!(e > s)) break; + if (!content) startOffset = s; + content += (data.content || ''); + endOffset = e; + if (e <= pos) break; + pos = e; } + return { content, startOffset, endOffset }; + } + + async function applyLiveLogPayload(payload) { + if (!payload || typeof payload !== 'object') return; + const fileType = payload.fileType || 'exec'; + const cycle = payload.cycleIndex; + const attempt = payload.attemptIndex; + // Strict file-identity isolation: never append across files. + if (cycle !== selectedCycle || attempt !== selectedAttempt || fileType !== selectedLogFile) { + if (!followLive || !autoScroll) { + pendingLiveCount += 1; + updateFollowLiveUI(); + } + return; + } + const start = Number(payload.startOffset) || 0; + const end = Number(payload.endOffset) || 0; + if (end <= start) return; + if (fullyCovered(start, end)) return; + + const cursorEnd = currentLogView === 'timeline' ? timelineEndOffset : rawEndOffset; + + // Gap: fill [cursorEnd, start) via forward offset pages so large gaps are not lost. + if (cursorEnd > 0 && start > cursorEnd) { + try { + const gap = await fetchLogByteRange(cursorEnd, start); + if (gap.endOffset > gap.startOffset) { + markSeen(gap.startOffset, gap.endOffset); + if (currentLogView === 'timeline') { + if (gap.endOffset > timelineEndOffset) timelineEndOffset = gap.endOffset; + const parsedGap = feedTimelineContent(gap.content || '', timelineResidualState); + timelineEntries.push(...parsedGap); + appendTimelineEntries(parsedGap, true); + } else { + if (gap.endOffset > rawEndOffset) rawEndOffset = gap.endOffset; + appendRawText(gap.content || '', true); + } + } + } catch (err) { + console.error('Failed to fill log gap:', err); + } + } + + // Only append uncovered sub-ranges of this payload. + const parts = uncoveredParts(start, end); + if (!parts.length) return; + + for (const [from, to] of parts) { + let pieceContent = ''; + let pieceStart = from; + let pieceEnd = to; + if (from === start && to === end) { + pieceContent = payload.content || ''; + } else { + // Partial overlap: re-fetch exact uncovered byte range to avoid slicing multi-byte UTF-8 in a decoded string. + try { + const piece = await fetchLogByteRange(from, to); + pieceContent = piece.content || ''; + pieceStart = piece.startOffset; + pieceEnd = piece.endOffset; + } catch (err) { + console.error('Failed to fetch uncovered log suffix:', err); + continue; + } + } + if (!(pieceEnd > pieceStart)) continue; + markSeen(pieceStart, pieceEnd); + if (currentLogView === 'timeline') { + if (pieceEnd > timelineEndOffset) timelineEndOffset = pieceEnd; + const parsed = feedTimelineContent(pieceContent, timelineResidualState); + timelineEntries.push(...parsed); + appendTimelineEntries(parsed, true); + } else { + if (pieceEnd > rawEndOffset) rawEndOffset = pieceEnd; + appendRawText(pieceContent, true); + } + } + } + + function appendAuditEvent(evt) { + const container = document.getElementById('view-audit-trail'); + if (!container) return; + if (container.querySelector && container.childElementCount === 1 && container.textContent.includes('暂无审计事件')) { + container.innerHTML = ''; + } + const div = document.createElement('div'); + div.className = 'audit-trail-item'; + let cycleSuffix = evt.cycleIndex ? \` [周期 \${evt.cycleIndex}]\` : ''; + let dataStr = evt.data ? \` - \${JSON.stringify(evt.data)}\` : ''; + const strong = document.createElement('strong'); + strong.style.textTransform = 'capitalize'; + strong.style.color = 'var(--accent)'; + strong.textContent = getEventLabel(evt.kind) + cycleSuffix; + const span = document.createElement('span'); + span.className = 'audit-trail-time'; + span.textContent = new Date(evt.timestamp).toLocaleString('zh-CN') + dataStr; + div.appendChild(strong); + div.appendChild(span); + container.appendChild(div); } // Real-time SSE listener function setupSSE(runId) { + if (sseConn) { + try { sseConn.close(); } catch (e) {} + sseConn = null; + } + // Establish SSE first, then load tail snapshot so live appends are not missed. const url = \`/api/runs/\${runId}/stream\`; sseConn = new EventSource(url); sseConn.addEventListener('state', (e) => { const updatedState = JSON.parse(e.data); + const prevCycle = activeRun ? activeRun.currentCycle : null; activeRun = updatedState; const badge = document.getElementById('view-status-badge'); @@ -1687,12 +2252,39 @@ export function getAssetsHtml(): string { document.getElementById('view-current-cycle').textContent = activeRun.currentCycle; document.getElementById('view-updated-at').textContent = new Date(activeRun.updatedAt).toLocaleString('zh-CN'); + // Refresh sidebar list item status/cycle as well. + const idx = runs.findIndex(r => r.runId === activeRun.runId); + if (idx >= 0) { + runs[idx] = Object.assign({}, runs[idx], { + status: activeRun.status, + currentCycle: activeRun.currentCycle, + updatedAt: activeRun.updatedAt, + }); + renderRuns(); + } + populateCyclesDropdown(); - loadWorkflowEvents(); + + if (followLive) { + if (activeRun.currentCycle !== selectedCycle || activeRun.currentCycle !== prevCycle) { + selectedCycle = activeRun.currentCycle || 1; + let maxAttempts = 0; + if (activeRun.cycles) { + const cycleRec = activeRun.cycles.find(cy => cy.cycleIndex === selectedCycle); + if (cycleRec && cycleRec.executorAttemptLogs) { + maxAttempts = Math.max(0, cycleRec.executorAttemptLogs.length - 1); + } + } + selectedAttempt = maxAttempts; + populateCyclesDropdown(); + onCycleOrAttemptUIChange(); + } else { + // Keep attempt dropdown current even within same cycle. + onCycleOrAttemptUIChange(); + } + } - // Close EventSource when state reports any non-executing status to prevent infinite reconnection loop if (activeRun.status !== 'executing') { - console.log('Run status changed to terminal:', activeRun.status, 'Closing SSE connection.'); if (sseConn) { sseConn.close(); sseConn = null; @@ -1701,46 +2293,91 @@ export function getAssetsHtml(): string { }); sseConn.addEventListener('event', (e) => { - loadWorkflowEvents(); + try { + const evt = JSON.parse(e.data); + appendAuditEvent(evt); + } catch (err) { + // Fallback to full reload if payload is unexpected. + loadWorkflowEvents(); + } }); sseConn.addEventListener('log_rotation', (e) => { const rotation = JSON.parse(e.data); - console.log('Log rotation event:', rotation); - if (rotation.cycleIndex === selectedCycle) { - selectedAttempt = rotation.attemptIndex; + if (followLive) { + selectedCycle = rotation.cycleIndex || selectedCycle; + selectedAttempt = rotation.attemptIndex || 0; + if (rotation.fileType) selectedLogFile = rotation.fileType; + populateCyclesDropdown(); onCycleOrAttemptUIChange(); + } else if (rotation.cycleIndex === selectedCycle) { + // Stay on historical view; only note pending live activity. + pendingLiveCount += 1; + updateFollowLiveUI(); } }); sseConn.addEventListener('log_append', (e) => { - if (selectedCycle === activeRun.currentCycle) { - const appendedText = e.data; - - // Gap protection: only append live logs if fully caught up - if (currentLogView === 'timeline') { - if (timelineFullyLoaded) { - const parsed = parseLogContent(appendedText, activeRun.executor); - timelineEntries.push(...parsed); - renderTimeline(); - } - } else { - if (rawLogFullyLoaded) { - const pre = document.getElementById('raw-log-pre'); - pre.textContent += appendedText; - if (autoScroll) scrollToBottom(); - } - } + let payload = null; + try { + payload = JSON.parse(e.data); + } catch (err) { + // Legacy plain-text append: treat as current selection only. + payload = { + cycleIndex: selectedCycle, + attemptIndex: selectedAttempt, + fileType: selectedLogFile, + startOffset: currentLogView === 'timeline' ? timelineEndOffset : rawEndOffset, + endOffset: (currentLogView === 'timeline' ? timelineEndOffset : rawEndOffset) + (e.data ? e.data.length : 0), + content: e.data, + }; } + applyLiveLogPayload(payload); }); sseConn.onerror = (err) => { - // Only log, do not close connection here for transient errors - // EventSource will automatically reconnect if server went down temporarily console.error('SSE connection error:', err); }; + + // After SSE is attached, load the tail snapshot for the active view. + if (currentLogView === 'timeline') loadTailTimeline(); + else loadTailRawLogs(); } + // Pause auto-scroll when the user scrolls up, but keep receiving live data. + function attachScrollPauseHandlers() { + ['timeline-view', 'raw-view'].forEach(id => { + const el = document.getElementById(id); + if (!el || el._scrollPauseBound) return; + el._scrollPauseBound = true; + el.addEventListener('scroll', () => { + const dist = el.scrollHeight - el.scrollTop - el.clientHeight; + if (dist > 80) { + if (autoScroll) { + autoScroll = false; + const btn = document.getElementById('scroll-btn'); + if (btn) { + btn.className = 'control-btn'; + btn.textContent = '自动滚动:关闭'; + } + } + } else if (dist <= 8) { + if (!autoScroll) { + autoScroll = true; + pendingLiveCount = 0; + updateFollowLiveUI(); + const btn = document.getElementById('scroll-btn'); + if (btn) { + btn.className = 'control-btn active'; + btn.textContent = '自动滚动:开启'; + } + } + } + }); + }); + } + attachScrollPauseHandlers(); + document.getElementById('search-box').addEventListener('input', renderRuns); document.getElementById('executor-filter').addEventListener('change', renderRuns); document.getElementById('status-filter').addEventListener('change', renderRuns); diff --git a/src/workflow-web.ts b/src/workflow-web.ts index 6e1c71e..6431293 100644 --- a/src/workflow-web.ts +++ b/src/workflow-web.ts @@ -174,6 +174,111 @@ export function splitCompleteUtf8(buffer: Buffer): { complete: Buffer; residual: return { complete: buffer, residual: Buffer.alloc(0) as Buffer }; } +/** + * Advance past a leading incomplete UTF-8 sequence when reading mid-file. + * Returns the number of leading bytes to skip (0-3). + */ +export function leadingIncompleteUtf8Bytes(buffer: Buffer): number { + if (buffer.length === 0) return 0; + // Continuation bytes 10xxxxxx at the start mean we began mid-codepoint. + let i = 0; + while (i < Math.min(buffer.length, 3) && (buffer[i] & 0xC0) === 0x80) i += 1; + return i; +} + +export type LogSliceResult = { + content: string; + startOffset: number; + endOffset: number; + nextOffset: number; + size: number; + hasMore: boolean; + hasMoreBefore: boolean; +}; + +/** + * Read a UTF-8-safe byte slice of a log file. + * - Forward: offset + limit + * - Tail: last `limit` bytes (tail=true) + * - Reverse: bytes ending at `before` (before exclusive) + */ +export function readLogSlice( + logPath: string, + opts: { offset?: number; limit: number; tail?: boolean; before?: number }, +): LogSliceResult { + const stat = fs.statSync(logPath); + const size = stat.size; + const limit = Math.max(1, opts.limit); + + let start: number; + let end: number; + + if (opts.tail) { + end = size; + start = Math.max(0, size - limit); + } else if (opts.before !== undefined) { + end = Math.max(0, Math.min(opts.before, size)); + start = Math.max(0, end - limit); + } else { + start = Math.max(0, Math.min(opts.offset ?? 0, size)); + end = Math.min(start + limit, size); + } + + if (end <= start || size === 0) { + const emptyStart = opts.before !== undefined ? Math.min(opts.before, size) : (opts.tail ? size : Math.min(opts.offset ?? 0, size)); + return { + content: "", + startOffset: emptyStart, + endOffset: emptyStart, + nextOffset: emptyStart, + size, + hasMore: emptyStart < size, + hasMoreBefore: emptyStart > 0, + }; + } + + const readLength = end - start; + const buffer = Buffer.alloc(readLength); + const fd = fs.openSync(logPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + fs.readSync(fd, buffer, 0, readLength, start); + } finally { + fs.closeSync(fd); + } + + let usable = buffer; + let startOffset = start; + + // Skip leading continuation bytes when reading mid-file. + if (startOffset > 0) { + const skip = leadingIncompleteUtf8Bytes(usable); + if (skip > 0) { + usable = Buffer.from(usable.subarray(skip)); + startOffset += skip; + } + } + + // Drop trailing incomplete multi-byte sequence. + const { complete } = splitCompleteUtf8(usable); + let contentBuf = complete.length > 0 ? complete : usable; + // If complete is empty but we have bytes at EOF, still decode (replacement chars). + if (complete.length === 0 && end >= size && usable.length > 0) { + contentBuf = usable; + } + const endOffset = startOffset + contentBuf.length; + const content = contentBuf.toString("utf8"); + + return { + content, + startOffset, + endOffset, + nextOffset: endOffset, + size, + hasMore: endOffset < size, + hasMoreBefore: startOffset > 0, + }; +} + /** * Verify that no segment of targetPath from rootDir downwards is a symlink. * Blocks all symlinks (including in-root symlink aliases). @@ -354,6 +459,9 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru let lastStateStr = ""; let lastEventsLineCount = 0; let currentLogPath = ""; + let currentLogCycle = 0; + let currentLogAttempt = 0; + let currentLogType: "exec" | "agy" = "exec"; let logBytesRead = 0; let logResidualBuffer = Buffer.alloc(0); @@ -371,9 +479,14 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru } } - // Helper to resolve active log path from state - const getActiveLogPath = (stateObj: any) => { - if (!stateObj || stateObj.currentCycle === undefined) return ""; + // Helper to resolve active log identity from state + const getActiveLogInfo = (stateObj: any): { + path: string; + cycleIndex: number; + attemptIndex: number; + fileType: "exec" | "agy"; + } | null => { + if (!stateObj || stateObj.currentCycle === undefined) return null; const cycle = stateObj.currentCycle; let attempt = 0; const progressFile = path.join(runDir, ".progress.json"); @@ -389,15 +502,20 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru } catch { // ignore } - } else { - if (stateObj.cycles) { - const cyRec = stateObj.cycles.find((cy: any) => cy.cycleIndex === cycle); - if (cyRec && cyRec.executorAttemptLogs) { - attempt = Math.max(0, cyRec.executorAttemptLogs.length - 1); - } + } else if (stateObj.cycles) { + const cyRec = stateObj.cycles.find((cy: any) => cy.cycleIndex === cycle); + if (cyRec && cyRec.executorAttemptLogs) { + attempt = Math.max(0, cyRec.executorAttemptLogs.length - 1); } } - return attemptLogPath(runDir, cycle, attempt); + // Stream the primary executor attempt log (exec). Agy session logs are + // available via the REST API with type=agy and must not be mixed here. + return { + path: attemptLogPath(runDir, cycle, attempt), + cycleIndex: cycle, + attemptIndex: attempt, + fileType: "exec", + }; }; const poll = () => { @@ -437,20 +555,25 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru } if (stateObj) { - const activeLog = getActiveLogPath(stateObj); - if (activeLog && activeLog !== currentLogPath) { - currentLogPath = activeLog; + const activeLog = getActiveLogInfo(stateObj); + if (activeLog && activeLog.path !== currentLogPath) { + currentLogPath = activeLog.path; + currentLogCycle = activeLog.cycleIndex; + currentLogAttempt = activeLog.attemptIndex; + currentLogType = activeLog.fileType; logBytesRead = 0; logResidualBuffer = Buffer.alloc(0); - if (fs.existsSync(activeLog) && !fs.lstatSync(activeLog).isSymbolicLink()) { - logBytesRead = fs.statSync(activeLog).size; + if (fs.existsSync(activeLog.path) && !fs.lstatSync(activeLog.path).isSymbolicLink()) { + logBytesRead = fs.statSync(activeLog.path).size; } pushEvent("log_rotation", JSON.stringify({ - cycleIndex: stateObj.currentCycle, - attemptIndex: stateObj.cycles && stateObj.cycles[stateObj.currentCycle - 1] && stateObj.cycles[stateObj.currentCycle - 1].executorAttemptLogs ? Math.max(0, stateObj.cycles[stateObj.currentCycle - 1].executorAttemptLogs.length - 1) : 0, - logFilePath: activeLog, + cycleIndex: activeLog.cycleIndex, + attemptIndex: activeLog.attemptIndex, + fileType: activeLog.fileType, + logFilePath: activeLog.path, + size: logBytesRead, })); } } @@ -468,6 +591,7 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru fs.readSync(fd, buffer, 0, sizeToRead, logBytesRead); fs.closeSync(fd); + const startOffset = logBytesRead; logBytesRead += sizeToRead; const combined = Buffer.concat([logResidualBuffer, buffer]) as Buffer; @@ -475,7 +599,18 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru logResidualBuffer = residual as any; if (complete.length > 0) { - pushEvent("log_append", complete.toString("utf8")); + const endOffset = startOffset + complete.length - residual.length; + // endOffset is the exclusive byte offset of complete content from original file start: + // startOffset is where this read began; residual is incomplete trailing bytes still buffered. + const completeEnd = logBytesRead - residual.length; + pushEvent("log_append", JSON.stringify({ + cycleIndex: currentLogCycle, + attemptIndex: currentLogAttempt, + fileType: currentLogType, + startOffset: completeEnd - complete.length, + endOffset: completeEnd, + content: complete.toString("utf8"), + })); } } } @@ -497,9 +632,15 @@ function handleSseStream(req: http.IncomingMessage, res: http.ServerResponse, ru const stateStr = fs.readFileSync(fd, "utf8"); fs.closeSync(fd); const stateObj = JSON.parse(stateStr); - currentLogPath = getActiveLogPath(stateObj); - if (currentLogPath && fs.existsSync(currentLogPath) && !fs.lstatSync(currentLogPath).isSymbolicLink()) { - logBytesRead = fs.statSync(currentLogPath).size; + const info = getActiveLogInfo(stateObj); + if (info) { + currentLogPath = info.path; + currentLogCycle = info.cycleIndex; + currentLogAttempt = info.attemptIndex; + currentLogType = info.fileType; + if (currentLogPath && fs.existsSync(currentLogPath) && !fs.lstatSync(currentLogPath).isSymbolicLink()) { + logBytesRead = fs.statSync(currentLogPath).size; + } } } } catch { @@ -677,22 +818,34 @@ export function startWorkflowWebServer(port = 4317): Promise { const cycleStr = parsedUrl.searchParams.get("cycle") || "1"; const attemptStr = parsedUrl.searchParams.get("attempt") || "0"; - const offsetStr = parsedUrl.searchParams.get("offset") || "0"; + const offsetStr = parsedUrl.searchParams.get("offset"); + const beforeStr = parsedUrl.searchParams.get("before"); + const tailStr = parsedUrl.searchParams.get("tail"); const limitStr = parsedUrl.searchParams.get("limit") || "100000"; const cycle = parseInt(cycleStr, 10); const attempt = parseInt(attemptStr, 10); - const offset = parseInt(offsetStr, 10); const limit = parseInt(limitStr, 10); + const wantsTail = tailStr === "1" || tailStr === "true"; + const wantsBefore = beforeStr !== null && beforeStr !== undefined && beforeStr !== ""; if ( !/^\d+$/.test(cycleStr) || cycle < 1 || cycle > 1000 || !/^\d+$/.test(attemptStr) || attempt < 0 || attempt > 1000 || - !/^\d+$/.test(offsetStr) || offset < 0 || offset > 100 * 1024 * 1024 || !/^\d+$/.test(limitStr) || limit < 4 || limit > 10 * 1024 * 1024 ) { return sendError(res, 400, "Bad Request: Invalid numeric parameters"); } + if (wantsBefore) { + if (!/^\d+$/.test(beforeStr!) || parseInt(beforeStr!, 10) < 0 || parseInt(beforeStr!, 10) > 100 * 1024 * 1024) { + return sendError(res, 400, "Bad Request: Invalid before parameter"); + } + } else if (!wantsTail) { + const effectiveOffset = offsetStr ?? "0"; + if (!/^\d+$/.test(effectiveOffset) || parseInt(effectiveOffset, 10) < 0 || parseInt(effectiveOffset, 10) > 100 * 1024 * 1024) { + return sendError(res, 400, "Bad Request: Invalid numeric parameters"); + } + } const type = parsedUrl.searchParams.get("type") || "exec"; if (type !== "exec" && type !== "agy") { @@ -715,49 +868,42 @@ export function startWorkflowWebServer(port = 4317): Promise { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(); } - return sendJson(res, 200, { content: "", nextOffset: 0, size: 0, hasMore: false }); + return sendJson(res, 200, { + content: "", + nextOffset: 0, + startOffset: 0, + endOffset: 0, + size: 0, + hasMore: false, + hasMoreBefore: false, + }); } - let logContent = ""; - let fileSize = 0; - let nextOffset = offset; try { - const stat = fs.statSync(logPath); - fileSize = stat.size; - const start = Math.min(offset, fileSize); - const end = Math.min(start + limit, fileSize); - const readLength = end - start; - - if (readLength > 0) { - const fd = fs.openSync(logPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); - const buffer = Buffer.alloc(readLength); - fs.readSync(fd, buffer, 0, readLength, start); - fs.closeSync(fd); - const { complete } = splitCompleteUtf8(buffer); - if (complete.length === 0) { - logContent = buffer.toString("utf8"); - nextOffset = start + buffer.length; - } else { - logContent = complete.toString("utf8"); - nextOffset = start + complete.length; - } - } else { - nextOffset = end; + const slice = readLogSlice(logPath, { + limit, + ...(wantsTail + ? { tail: true } + : wantsBefore + ? { before: parseInt(beforeStr!, 10) } + : { offset: parseInt(offsetStr ?? "0", 10) }), + }); + if (method === "HEAD") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(); } + return sendJson(res, 200, { + content: slice.content, + nextOffset: slice.nextOffset, + startOffset: slice.startOffset, + endOffset: slice.endOffset, + size: slice.size, + hasMore: slice.hasMore, + hasMoreBefore: slice.hasMoreBefore, + }); } catch (err: any) { return sendError(res, 500, `Error reading log file: ${err.message}`); } - - if (method === "HEAD") { - res.writeHead(200, { "Content-Type": "application/json" }); - return res.end(); - } - return sendJson(res, 200, { - content: logContent, - nextOffset, - size: fileSize, - hasMore: nextOffset < fileSize, - }); } const streamMatch = pathname.match(/^\/api\/runs\/([a-zA-Z0-9_\-\:]+)\/stream$/);