fix: harden live workflow log streaming
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+69
-2
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user