Files
Playa/extensions/Volcanic.AgenticCore.Extensions.Scheduler/JobStore.cs
EugeneTes 8014a0e770 refactor(scheduler): drop the reminder job kind
Jobs are prompts or watchers. A reminder is a prompt the agent passes on, so
it lands in the transcript. Stored reminder jobs load as prompts
("Remind me: <text>"). Also records the Coolify deploy target in CLAUDE.md.
2026-09-29 09:30:40 +00:00

117 lines
4.9 KiB
C#

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning. A job of the
/// retired <c>reminder</c> kind is loaded as a prompt asking the agent to pass the reminder on.
/// </summary>
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
{
public const int Version = 1;
private const string ReminderPrefix = "Remind me: ";
private readonly SemaphoreSlim _writeGate = new(1, 1);
public string Path => path;
public async Task<IReadOnlyList<ScheduledJob>> LoadAsync(CancellationToken cancellationToken)
{
if (!File.Exists(path)) return [];
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
StoredFile? file;
try
{
file = VolcanicJson.Deserialize<StoredFile>(json);
}
catch (JsonException)
{
file = null;
}
if (file is not { Version: Version, Jobs: not null })
{
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(path, aside, overwrite: true);
logger.LogError("The job store {Path} could not be read; it was moved to {Aside} and the scheduler starts empty", path, aside);
return [];
}
var jobs = new List<ScheduledJob>();
var ids = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < file.Jobs.Count; i++)
{
var (job, problem) = Read(file.Jobs[i]);
problem ??= ids.Add(job!.Id) ? null : "duplicate id";
if (problem is not null)
{
logger.LogWarning("Dropped scheduled job #{Index} ({Id}) from {Path}: {Problem}", i, job?.Id, path, problem);
continue;
}
jobs.Add(job!);
}
return jobs;
}
public async Task SaveAsync(IReadOnlyList<ScheduledJob> jobs, CancellationToken cancellationToken)
{
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
try
{
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!);
var json = JsonSerializer.Serialize(new JobFile(Version, [.. jobs]), VolcanicJson.Indented);
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
}
catch
{
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
throw;
}
finally
{
_writeGate.Release();
}
}
/// <summary>One entry of the file: the job, or why it's dropped. An entry that doesn't deserialise only loses itself.</summary>
private static (ScheduledJob? Job, string? Problem) Read(JsonElement entry)
{
if (entry.ValueKind != JsonValueKind.Object) return (null, "not a JSON object");
ScheduledJob? job;
try
{
job = VolcanicJson.Deserialize<ScheduledJob>(FromReminder(entry));
}
catch (JsonException ex)
{
return (null, ex.Message);
}
if (job is null) return (null, "empty entry");
return (job, JobValidation.CheckStored(job));
}
/// <summary>A stored <c>reminder</c> (fixed text sent as is) becomes a prompt; other entries pass through unchanged.</summary>
private static JsonElement FromReminder(JsonElement entry)
{
if (!entry.TryGetProperty("kind", out var kind) || kind.ValueKind != JsonValueKind.String
|| !string.Equals(kind.GetString(), "reminder", StringComparison.OrdinalIgnoreCase))
return entry;
var node = JsonNode.Parse(entry.GetRawText())!.AsObject();
node["kind"] = "prompt";
if (entry.TryGetProperty("text", out var text) && text.ValueKind == JsonValueKind.String && text.GetString() is { } body && !string.IsNullOrWhiteSpace(body)
&& body.Length + ReminderPrefix.Length <= JobValidation.MaxTextLength)
node["text"] = ReminderPrefix + body;
return JsonSerializer.SerializeToElement(node);
}
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);
}