fix(worker): cap run-log read size and harden run-history tests
- GetTaskLog reads at most last 256 KB; prepends truncation marker if file exceeds cap - Wrap temp-file cleanup in finally block to prevent leak on assertion failure - Add GetRun_NotFound_Throws, GetTaskLog_RunExistsButNoLogPath_Throws, and GetTaskLog_LargeFile_ReturnsTruncatedTail tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,8 @@ public sealed class RunHistoryMcpTools
|
|||||||
return ToDto(run);
|
return ToDto(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int MaxLogBytes = 256 * 1024;
|
||||||
|
|
||||||
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
|
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
|
||||||
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
|
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -40,7 +42,17 @@ public sealed class RunHistoryMcpTools
|
|||||||
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
|
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
|
||||||
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||||
throw new InvalidOperationException("No log available for the latest run.");
|
throw new InvalidOperationException("No log available for the latest run.");
|
||||||
|
|
||||||
|
var totalBytes = new FileInfo(run.LogPath).Length;
|
||||||
|
if (totalBytes <= MaxLogBytes)
|
||||||
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
||||||
|
|
||||||
|
var buffer = new byte[MaxLogBytes];
|
||||||
|
await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin);
|
||||||
|
var read = await fs.ReadAsync(buffer, cancellationToken);
|
||||||
|
var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read);
|
||||||
|
return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RunDto ToDto(TaskRunEntity r) => new(
|
private static RunDto ToDto(TaskRunEntity r) => new(
|
||||||
|
|||||||
@@ -76,9 +76,71 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
|||||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
string content;
|
||||||
|
try
|
||||||
Assert.Equal("hello log", content);
|
{
|
||||||
|
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
File.Delete(logPath);
|
File.Delete(logPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Assert.Equal("hello log", content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRun_NotFound_Throws()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.GetRun("missing", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskLog_RunExistsButNoLogPath_Throws()
|
||||||
|
{
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId);
|
||||||
|
await _runs.AddAsync(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||||
|
IsRetry = false, Prompt = "p", LogPath = null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail()
|
||||||
|
{
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
await SeedTaskAsync(taskId);
|
||||||
|
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
|
||||||
|
|
||||||
|
// Write 300 KB so it exceeds the 256 KB cap
|
||||||
|
var chunk = new string('A', 1024);
|
||||||
|
await using (var w = new StreamWriter(logPath, append: false))
|
||||||
|
for (var i = 0; i < 300; i++)
|
||||||
|
await w.WriteAsync(chunk);
|
||||||
|
|
||||||
|
await _runs.AddAsync(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||||
|
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
string content;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(logPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.StartsWith("[truncated:", content);
|
||||||
|
Assert.True(content.Length < 300 * 1024);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user