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$/);