Files
Playa/tests/Volcanic.AgenticCore.Extensions.Tests/Scheduler/Support/SchedulerFakes.cs
EugeneTes 28bbfbc759 feat(scheduler): store jobs per chat and import VolcanicSecretary job files
Each chat's jobs live in <workspace>/.volcanic/scheduler/jobs.json. On start the
scheduler loads every chat's file, moves the retired shared
<agentDir>/scheduler/jobs.json into them and renames it .migrated-<ts>.
A VolcanicSecretary jobs.json (bare array) in a chat's folder is converted
in place for that chat; the original is kept as .secretary-<ts>.

Also adds the VolcanicSecretary data migration plan.
2026-10-02 07:29:48 +00:00

193 lines
8.8 KiB
C#

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
using Volcanic.AgenticCore.Extensions.Scheduler;
namespace Volcanic.AgenticCore.Extensions.Tests.Scheduler.Support;
/// <summary>Records every delivery call; can hold sends and prompt runs open, fail sends, and switch a target's zone.</summary>
internal sealed class FakeJobDelivery : IJobDelivery
{
private readonly Lock _lock = new();
private readonly List<(string Target, string Text)> _texts = [];
private readonly List<(string Target, string Prompt)> _prompts = [];
private readonly List<string> _calls = [];
public Dictionary<string, TimeZoneInfo> Zones { get; } = new();
public TimeZoneInfo DefaultZone { get; set; } = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public HashSet<string> Undeliverable { get; } = [];
/// <summary>Targets whose <see cref="TimeZoneFor"/> throws (a store entry the host can't map).</summary>
public HashSet<string> BadTargets { get; } = [];
public TaskCompletionSource? HoldTexts { get; set; }
public TaskCompletionSource? HoldPrompts { get; set; }
public Exception? FailSends { get; set; }
public string WorkspaceRoot { get; set; } = Path.Combine(Path.GetTempPath(), "fake-workspaces");
public IReadOnlyList<(string Target, string Text)> Texts { get { lock (_lock) return _texts.ToList(); } }
public IReadOnlyList<(string Target, string Prompt)> Prompts { get { lock (_lock) return _prompts.ToList(); } }
/// <summary>"SendText:111", "RunPrompt:111", "WorkspaceFor:111", "TimeZoneFor:111", "IsDeliverable:111".</summary>
public IReadOnlyList<string> Calls { get { lock (_lock) return _calls.ToList(); } }
public async Task SendTextAsync(string target, string markdown, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("SendText:" + target); _texts.Add((target, markdown)); }
if (HoldTexts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
}
public async Task RunPromptAsync(string target, string prompt, CancellationToken cancellationToken)
{
lock (_lock) { _calls.Add("RunPrompt:" + target); _prompts.Add((target, prompt)); }
if (HoldPrompts is { } hold) await hold.Task.WaitAsync(cancellationToken);
if (FailSends is { } error) throw error;
}
public string WorkspaceFor(string target)
{
lock (_lock) _calls.Add("WorkspaceFor:" + target);
return Path.Combine(WorkspaceRoot, target);
}
public IEnumerable<string> KnownTargets() =>
Directory.Exists(WorkspaceRoot) ? Directory.EnumerateDirectories(WorkspaceRoot).Select(d => Path.GetFileName(d)) : [];
public bool IsDeliverable(string target)
{
lock (_lock) _calls.Add("IsDeliverable:" + target);
return !Undeliverable.Contains(target);
}
public TimeZoneInfo TimeZoneFor(string target)
{
lock (_lock) _calls.Add("TimeZoneFor:" + target);
if (BadTargets.Contains(target)) throw new FormatException($"bad target {target}");
return Zones.GetValueOrDefault(target, DefaultZone);
}
}
/// <summary>Returns queued outcomes (then <see cref="Fallback"/>); can hold runs open, honouring or ignoring cancellation.</summary>
internal sealed class FakeScriptRunner : IScriptRunner
{
private readonly Lock _lock = new();
private readonly Queue<ScriptOutcome> _outcomes = new();
private readonly List<ScriptRequest> _requests = [];
private int _cancellations;
public ScriptOutcome Fallback { get; set; } = new ScriptOutcome.Silent();
public TaskCompletionSource? Hold { get; set; }
public bool IgnoreCancellation { get; set; }
public IReadOnlyList<ScriptRequest> Requests { get { lock (_lock) return _requests.ToList(); } }
public int CancellationsSeen => Volatile.Read(ref _cancellations);
public FakeScriptRunner Then(params ScriptOutcome[] outcomes)
{
lock (_lock) foreach (var o in outcomes) _outcomes.Enqueue(o);
return this;
}
public async Task<ScriptOutcome> RunAsync(ScriptRequest request, CancellationToken cancellationToken)
{
ScriptOutcome outcome;
lock (_lock)
{
_requests.Add(request);
outcome = _outcomes.Count > 0 ? _outcomes.Dequeue() : Fallback;
}
if (Hold is { } hold)
{
if (IgnoreCancellation)
{
using var _ = cancellationToken.Register(() => Interlocked.Increment(ref _cancellations));
await hold.Task;
}
else
{
try { await hold.Task.WaitAsync(cancellationToken); }
catch (OperationCanceledException) { Interlocked.Increment(ref _cancellations); throw; }
}
}
return outcome;
}
}
internal sealed class FakeRedactor(params string[] secrets) : ISecretRedactor
{
public string Redact(string text) => secrets.Aggregate(text, (t, s) => t.Replace(s, "[REDACTED]", StringComparison.Ordinal));
}
/// <summary>A <see cref="JobScheduler"/> on a <see cref="FakeTimeProvider"/> at Sat 2026-09-26 14:03 Berlin, a temp store and the fakes.</summary>
internal sealed class SchedulerHarness : IAsyncDisposable
{
public static readonly DateTimeOffset Start = new(2026, 9, 26, 12, 3, 0, TimeSpan.Zero);
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
public static readonly TimeZoneInfo Tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
public SchedulerHarness(DateTimeOffset? start = null, Action<SchedulerOptions>? configure = null)
{
Time = new FakeTimeProvider(start ?? Start);
Options = new SchedulerOptions { LegacyStorePath = Path.Combine(Root, "scheduler", "jobs.json") };
configure?.Invoke(Options);
Delivery.WorkspaceRoot = Path.Combine(Root, "workspaces");
Scheduler = new JobScheduler(Options, Runner, Redactor, Time, NullLogger<JobScheduler>.Instance);
}
public string Root { get; } = Directory.CreateTempSubdirectory("scheduler-").FullName;
public FakeTimeProvider Time { get; }
public SchedulerOptions Options { get; }
public FakeJobDelivery Delivery { get; } = new();
public FakeScriptRunner Runner { get; } = new();
public FakeRedactor Redactor { get; } = new("hunter2-secret");
public JobScheduler Scheduler { get; }
/// <summary>Target 111's file.</summary>
public JobStore Store => StoreFor("111");
public JobStore StoreFor(string target) => new(JobStore.PathIn(Path.Combine(Delivery.WorkspaceRoot, target)), Time, NullLogger.Instance, target);
/// <summary>The retired shared file.</summary>
public JobStore LegacyStore => new(Options.LegacyStorePath!, Time, NullLogger.Instance);
public Task StartAsync() => Scheduler.StartAsync(Delivery, CancellationToken.None);
public Task<JobView> CreateAsync(NewJob job, string target = "111") => Scheduler.CreateAsync(target, job, CancellationToken.None);
/// <summary>Advance the clock and wait for the tick and every fire it started (don't use while a fire is held open).</summary>
public async Task AdvanceAsync(TimeSpan by)
{
Time.Advance(by);
await Scheduler.IdleAsync();
}
public async ValueTask DisposeAsync()
{
Runner.Hold?.TrySetResult();
Delivery.HoldTexts?.TrySetResult();
Delivery.HoldPrompts?.TrySetResult();
await Scheduler.StopAsync(CancellationToken.None);
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
}
}
/// <summary>The context a tool gets; the schedule tool reads nothing from it (the zone comes from the scheduler).</summary>
internal sealed class SchedulerStubContext : IExtensionContext
{
public string Cwd => Path.GetTempPath();
public string SessionId => "scheduler-test";
public bool HasUi => false;
public IUserInterface Ui => NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry => throw new NotSupportedException();
public Model? Model => null;
public ThinkingLevel ThinkingLevel => ThinkingLevel.Off;
public CancellationToken Signal => CancellationToken.None;
public ILogger Logger => NullLogger.Instance;
public bool IsIdle() => true;
public void Abort() { }
public bool HasPendingMessages() => false;
public ContextUsage? GetContextUsage() => null;
public string GetSystemPrompt() => "";
public void Compact(CompactOptions? options = null) { }
}