fix: harden live workflow log streaming

This commit is contained in:
liujing
2026-07-20 20:51:37 +08:00
parent f1b1bbe815
commit 04df7bf47f
3 changed files with 1640 additions and 27 deletions
+57 -3
View File
@@ -1788,6 +1788,31 @@ export function getAssetsHtml(): string {
if (container && parsed.length) { if (container && parsed.length) {
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
parsed.forEach(entry => { parsed.forEach(entry => {
if (entry.type === 'progress') {
// Container already has a progress row from a newer page —
// skip older progress so we never overwrite newest values.
const containerRow = container.querySelector('[data-progress-key="progress"]');
if (containerRow) {
// The container row holds the most recent progress value;
// historical entries must not overwrite it.
return;
}
// No container row — check whether this batch already queued
// one (fragment rows are invisible to container.querySelector).
const fragRow = frag.querySelector('[data-progress-key="progress"]');
if (fragRow) {
// Update fragment-local row — entries arrive in file order
// so the last one is most recent.
const content = fragRow.querySelector('.item-content');
if (content) content.textContent = entry.content || entry.raw || '';
return;
}
// First progress entry we have seen — create a keyed row.
entry._progressKey = 'progress';
const row = buildTimelineRow(entry);
if (row) frag.appendChild(row);
return;
}
const row = buildTimelineRow(entry); const row = buildTimelineRow(entry);
if (row) frag.appendChild(row); if (row) frag.appendChild(row);
}); });
@@ -1899,18 +1924,30 @@ export function getAssetsHtml(): string {
const container = document.getElementById('timeline-items-container'); const container = document.getElementById('timeline-items-container');
if (!container || !entries || !entries.length) return; if (!container || !entries || !entries.length) return;
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
// Track the most recent progress row in THIS batch so multiple progress
// entries arriving in one call still merge into one row.
let batchProgressRow = null;
entries.forEach(entry => { entries.forEach(entry => {
// Merge high-frequency progress counters into one updatable row. // Merge high-frequency progress counters into one updatable row.
if (entry.type === 'progress') { if (entry.type === 'progress') {
const key = 'progress'; const key = 'progress';
entry._progressKey = key; entry._progressKey = key;
const existing = container.querySelector('[data-progress-key="progress"]'); // Check batch-local row first (same call), then container (prior calls).
const existing = batchProgressRow || container.querySelector('[data-progress-key="progress"]');
if (existing) { if (existing) {
const content = existing.querySelector('.item-content'); const content = existing.querySelector('.item-content');
if (content) content.textContent = entry.content || entry.raw || ''; if (content) content.textContent = entry.content || entry.raw || '';
timelineProgressRow = existing; timelineProgressRow = existing;
batchProgressRow = existing;
return; return;
} }
// First progress in this batch create the row.
const row = buildTimelineRow(entry);
if (row) {
batchProgressRow = row;
frag.appendChild(row);
}
return;
} }
const row = buildTimelineRow(entry); const row = buildTimelineRow(entry);
if (row) frag.appendChild(row); if (row) frag.appendChild(row);
@@ -1945,13 +1982,30 @@ export function getAssetsHtml(): string {
container.innerHTML = ''; container.innerHTML = '';
timelineProgressRow = null; timelineProgressRow = null;
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
// Merge high-frequency progress entries into a single updatable row
// (same logic as appendTimelineEntries).
let progressMerged = false;
timelineEntries.forEach(entry => { timelineEntries.forEach(entry => {
if (entry.type === 'progress') entry._progressKey = 'progress'; if (entry.type === 'progress') {
if (!progressMerged) {
progressMerged = true;
entry._progressKey = 'progress';
const row = buildTimelineRow(entry); const row = buildTimelineRow(entry);
if (row) { if (row) {
if (entry.type === 'progress') timelineProgressRow = row; timelineProgressRow = row;
frag.appendChild(row); frag.appendChild(row);
} }
} else {
// Update the existing row's text instead of creating a new one.
if (timelineProgressRow) {
const contentEl = timelineProgressRow.querySelector('.item-content');
if (contentEl) contentEl.textContent = entry.content || entry.raw || '';
}
}
return;
}
const row = buildTimelineRow(entry);
if (row) frag.appendChild(row);
}); });
container.appendChild(frag); container.appendChild(frag);
timelineRenderedCount = timelineEntries.length; timelineRenderedCount = timelineEntries.length;
+1495 -3
View File
File diff suppressed because it is too large Load Diff
+69 -2
View File
@@ -249,7 +249,10 @@ export function readLogSlice(
let usable = buffer; let usable = buffer;
let startOffset = start; let startOffset = start;
// Skip leading continuation bytes when reading mid-file. const isBeforeMode = opts.before !== undefined;
if (!isBeforeMode) {
// Skip leading continuation bytes when reading mid-file (forward/tail only).
if (startOffset > 0) { if (startOffset > 0) {
const skip = leadingIncompleteUtf8Bytes(usable); const skip = leadingIncompleteUtf8Bytes(usable);
if (skip > 0) { if (skip > 0) {
@@ -258,7 +261,7 @@ export function readLogSlice(
} }
} }
// Drop trailing incomplete multi-byte sequence. // Drop trailing incomplete multi-byte sequence (forward/tail only).
const { complete } = splitCompleteUtf8(usable); const { complete } = splitCompleteUtf8(usable);
let contentBuf = complete.length > 0 ? complete : usable; let contentBuf = complete.length > 0 ? complete : usable;
// If complete is empty but we have bytes at EOF, still decode (replacement chars). // If complete is empty but we have bytes at EOF, still decode (replacement chars).
@@ -279,6 +282,70 @@ export function readLogSlice(
}; };
} }
// Before mode: extend the read range backward (up to 3 bytes) so the start
// boundary falls on a complete UTF-8 code point. The end boundary (`end =
// before`) is inherently at a non-continuation byte because it was set from
// an adjacent page's startOffset (either a tail page that already skipped
// leading continuations, or a prior before page that was extended backward
// to a lead byte). This eliminates replacement characters at boundaries
// while keeping startOffset/endOffset continuous with adjacent pages.
let adjustedStart = start;
if (adjustedStart > 0) {
const backtrack = Math.min(adjustedStart, 3);
const probeBuf = Buffer.alloc(backtrack);
const pfd = fs.openSync(logPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
fs.readSync(pfd, probeBuf, 0, backtrack, adjustedStart - backtrack);
} finally {
fs.closeSync(pfd);
}
// Scan backward from the byte just before `start` looking for a lead byte.
for (let i = probeBuf.length - 1; i >= 0; i--) {
if ((probeBuf[i] & 0xC0) === 0xC0) {
// Lead byte found extend start to include the full multi-byte character.
adjustedStart = adjustedStart - backtrack + i;
break;
}
if ((probeBuf[i] & 0x80) === 0x00) {
// ASCII byte the character at `start` starts fresh, no adjustment.
break;
}
// Continuation byte the lead is further back.
}
// If the probe only found continuation bytes (invalid >3B chain),
// leave adjustedStart unchanged; splitCompleteUtf8 will handle it.
}
// Read the adjusted range
const readLen = end - adjustedStart;
const readBuf = Buffer.alloc(readLen);
const rfd = fs.openSync(logPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
try {
fs.readSync(rfd, readBuf, 0, readLen, adjustedStart);
} finally {
fs.closeSync(rfd);
}
// Drop trailing incomplete sequence (e.g. lead byte at EOF whose
// continuation bytes haven't been written yet).
const { complete } = splitCompleteUtf8(readBuf);
let contentBuf = complete.length > 0 ? complete : readBuf;
const contentEnd = adjustedStart + contentBuf.length;
const content = contentBuf.toString("utf8");
return {
content,
startOffset: adjustedStart,
endOffset: contentEnd,
nextOffset: contentEnd,
size,
hasMore: contentEnd < size,
hasMoreBefore: adjustedStart > 0,
};
}
/** /**
* Verify that no segment of targetPath from rootDir downwards is a symlink. * Verify that no segment of targetPath from rootDir downwards is a symlink.
* Blocks all symlinks (including in-root symlink aliases). * Blocks all symlinks (including in-root symlink aliases).