1. ArgumentList (fix injection): ClaudeArgsBuilder.Build() now returns IReadOnlyList<string>; ClaudeProcess populates ProcessStartInfo.ArgumentList instead of Arguments, so values like system prompts are never shell-split. DailyPrepPrompt, RefinePrompt, and WeekReportService migrated similarly. All IClaudeProcess fakes updated. 2. ContinueAsync exception guard: wrap RunOnceAsync in try/catch matching the RunAsync pattern so an unexpected exception never leaves the task stuck in Running status. 3. Planning chain cascade: OnChildFinishedAsync now calls CancelAsync on the immediate blocked successor when a child fails or is cancelled, triggering a recursive cascade that clears the entire remaining chain instead of leaving it wedged. 4. FailAsync guard: restrict valid source states to Running and Queued; WaitingForReview -> Failed is now rejected, preventing an invalid transition that could corrupt the review workflow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
101 lines
3.9 KiB
C#
101 lines
3.9 KiB
C#
using System.Text.Json;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Report.Interfaces;
|
|
using ClaudeDo.Worker.Runner;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClaudeDo.Worker.Report;
|
|
|
|
public sealed class WeekReportService : IWeekReportService
|
|
{
|
|
private static readonly string[] DefaultExcludes = { @"C:\Private" };
|
|
private const string NoActivity = "_No activity in this period._";
|
|
|
|
private readonly IClaudeHistoryReader _reader;
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IClaudeProcess _claude;
|
|
private readonly ILogger<WeekReportService> _logger;
|
|
|
|
public WeekReportService(
|
|
IClaudeHistoryReader reader,
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
IClaudeProcess claude,
|
|
ILogger<WeekReportService> logger)
|
|
{
|
|
_reader = reader;
|
|
_dbFactory = dbFactory;
|
|
_claude = claude;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<string?> GetStoredAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var row = await new WeekReportRepository(ctx).GetByRangeAsync(start, end, ct);
|
|
return row?.Markdown;
|
|
}
|
|
|
|
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
|
{
|
|
string[] excludes;
|
|
string model;
|
|
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
|
|
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
|
{
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
|
excludes = ParseExcludes(settings.ReportExcludedPaths);
|
|
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
|
|
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
|
|
}
|
|
|
|
var activity = await _reader.ReadAsync(start, end, excludes, ct);
|
|
var notesByDay = noteRows
|
|
.GroupBy(n => n.Date)
|
|
.ToDictionary(g => g.Key, g => g.Select(n => n.Text).ToList());
|
|
|
|
string markdown;
|
|
var hasActivity = activity.Any(r => r.Days.Any(d => d.Prompts.Count > 0 || d.Summaries.Count > 0));
|
|
if (!hasActivity && notesByDay.Count == 0)
|
|
{
|
|
markdown = NoActivity;
|
|
}
|
|
else
|
|
{
|
|
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
|
|
// Guard against argument injection via the model setting: model aliases/ids are
|
|
// alphanumerics, dashes and dots only.
|
|
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
|
|
if (safeModel.Length == 0) safeModel = "sonnet";
|
|
IReadOnlyList<string> args =
|
|
[
|
|
"-p", "--output-format", "stream-json", "--verbose",
|
|
"--permission-mode", "auto",
|
|
"--model", safeModel,
|
|
];
|
|
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
|
if (!result.IsSuccess)
|
|
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report.");
|
|
markdown = result.ResultMarkdown!;
|
|
}
|
|
|
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
|
await new WeekReportRepository(ctx).UpsertAsync(start, end, markdown, ct);
|
|
|
|
return markdown;
|
|
}
|
|
|
|
private static string[] ParseExcludes(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return DefaultExcludes;
|
|
try
|
|
{
|
|
var list = JsonSerializer.Deserialize<List<string>>(json);
|
|
return list is { Count: > 0 } ? list.ToArray() : DefaultExcludes;
|
|
}
|
|
catch (JsonException) { return DefaultExcludes; }
|
|
}
|
|
}
|