Files
Playa/extensions/Volcanic.AgenticCore.Extensions.Scheduler/SkillJobs.cs
EugeneTes 6e3241fed9 feat(scheduler): scheduled jobs inside skills (.volcanic/skills/<skill>/jobs/<job>.md)
Skill directories may now declare prompt or watcher jobs as files (cron or one-off at).
The file is the source of truth: the scheduler re-reads every chat's jobs/ every minute,
around agent runs and before /jobs and /skills, and schedules, re-plans or removes them.
Ids are <skill>/<job>; run state lives in .volcanic/scheduler/skill-jobs-state.json, never
in jobs.json. Watchers run from the skill directory with VOLCANIC_WORKSPACE and
VOLCANIC_SKILL_DIR. Broken files are shown with the reason in /jobs, /skills, schedule list
and the new <skill_jobs> prompt section; cancel answers with the file to edit. Every part of
a skill directory is optional; file reading and frontmatter parsing move to Abstractions.
2026-10-14 12:54:40 +00:00

182 lines
9.1 KiB
C#

using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Abstractions.Skills;
namespace Volcanic.AgenticCore.Extensions.Scheduler;
/// <summary>
/// Where a skill job comes from. <see cref="File"/> is relative to the workspace (for messages and hints),
/// <see cref="Directory"/> the absolute skill directory (the watcher's cwd). <see cref="AtText"/> is the file's <c>at</c>
/// as written: a fired one-off is done until it changes.
/// </summary>
public sealed record SkillJobSource(string File, string Directory, string? Description, string? AtText);
/// <summary>What became of a skill job file on the last sync.</summary>
public enum SkillJobStatus
{
/// <summary>Scheduled.</summary>
Active,
/// <summary><c>enabled: false</c>.</summary>
Disabled,
/// <summary>The file is broken; <see cref="SkillJobView.Problem"/> says why.</summary>
Invalid,
/// <summary>A one-off that has fired; it stays done until its <c>at</c> changes.</summary>
Done,
/// <summary>A one-off whose <c>at</c> was already past when the bot first saw it: never fired.</summary>
Past,
/// <summary>Beyond <see cref="SchedulerOptions.MaxSkillJobsPerTarget"/>.</summary>
OverLimit,
}
/// <summary>One skill job file as <c>/jobs</c>, the <c>schedule</c> tool and the prompt show it.</summary>
public sealed record SkillJobView(string Id, string File, string Name, string? Description, JobKind? Kind, string? Cron, DateTimeOffset? At,
SkillJobStatus Status, string? Problem, DateTimeOffset? NextRunAt, JobResult? LastResult);
/// <summary>A job file read and checked: the job to schedule (when it is well formed and enabled) or why not.</summary>
internal sealed record ParsedSkillJob(SkillJobView View, ScheduledJob? Job);
/// <summary>
/// The format of <c>&lt;workspace&gt;/.volcanic/skills/&lt;skill&gt;/jobs/&lt;job&gt;.md</c>: frontmatter <c>kind</c>
/// (prompt|watcher), exactly one of <c>cron</c> and <c>at</c>, <c>command</c> (watcher), <c>timeoutSeconds</c>,
/// <c>enabled</c>, <c>name</c>, <c>description</c>; the body is a prompt job's text.
/// </summary>
internal static class SkillJobParser
{
public const int MaxTextLength = 16 * 1024;
public static ParsedSkillJob Parse(SkillJobFile file, string workspace, string target, TimeZoneInfo zone, DateTimeOffset now)
{
var relative = Path.GetRelativePath(workspace, file.Path).Replace('\\', '/');
var fields = file.Fields;
var name = Field("name") is { } n ? Collapse(n) : file.Job;
var description = Field("description") is { } d ? Collapse(d) : null;
var cronText = Field("cron");
var atText = Field("at");
JobKind? kind = Field("kind")?.ToLowerInvariant() switch { "prompt" => JobKind.Prompt, "watcher" => JobKind.Watcher, _ => null };
DateTimeOffset? at = null;
SkillJobView View(SkillJobStatus status, string? problem) =>
new(file.Id, relative, name, description, kind, cronText, at, status, problem, null, null);
ParsedSkillJob Invalid(string problem) => new(View(SkillJobStatus.Invalid, problem), null);
if (file.Error is { } error) return Invalid(error);
if (Field("enabled") is { } enabledText)
{
switch (enabledText.ToLowerInvariant())
{
case "false" or "no" or "off": return new ParsedSkillJob(View(SkillJobStatus.Disabled, null), null);
case "true" or "yes" or "on": break;
default: return Invalid($"enabled must be true or false, not \"{enabledText}\".");
}
}
if (Field("kind") is not { } kindText) return Invalid("kind is required: prompt or watcher.");
if (kind is null) return Invalid($"kind must be prompt or watcher, not \"{kindText}\".");
if (name.Length > JobValidation.MaxNameLength) return Invalid($"name is longer than {JobValidation.MaxNameLength} characters.");
if ((cronText is null) == (atText is null)) return Invalid("set exactly one of cron (recurring) or at (one-off).");
int? timeout = null;
if (Field("timeoutSeconds") is { } timeoutText)
{
if (!int.TryParse(timeoutText, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds))
return Invalid($"timeoutSeconds must be a whole number of seconds, not \"{timeoutText}\".");
timeout = seconds;
}
var text = kind == JobKind.Prompt ? file.Body : null; // a watcher's body is a note for people
try
{
JobValidation.CheckFields(kind.Value, text, Field("command"), timeout, MaxTextLength);
if (cronText is not null) Schedules.ParseCron(cronText);
else at = Schedules.ParseAt(atText!, zone);
}
catch (JobValidationException ex)
{
return Invalid(kind == JobKind.Prompt && string.IsNullOrWhiteSpace(text) ? "a prompt job needs text: write it below the frontmatter." : ex.Message);
}
var job = new ScheduledJob
{
Id = file.Id, Target = target, Name = name, Kind = kind.Value,
Schedule = new JobSchedule(at, cronText?.Trim()), Text = text, Command = kind == JobKind.Watcher ? Field("command") : null,
TimeoutSeconds = timeout, CreatedAt = now,
Skill = new SkillJobSource(relative, Path.GetDirectoryName(Path.GetDirectoryName(file.Path))!, description, atText?.Trim()),
};
return new ParsedSkillJob(View(SkillJobStatus.Active, null), job);
string? Field(string key) => fields.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null;
}
private static string Collapse(string value) => string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
}
/// <summary>The bot's own record of one skill job (never the file): run fields, when it first saw the current <c>at</c>, and which <c>at</c> already fired.</summary>
public sealed record SkillJobState
{
public DateTimeOffset? LastRunAt { get; init; }
public JobResult? LastResult { get; init; }
/// <summary>The <c>at</c> text last seen, and when it was first seen: an <c>at</c> already past by then never fires.</summary>
public string? AtText { get; init; }
public DateTimeOffset? AtSeenAt { get; init; }
/// <summary>The <c>at</c> text of the one-off that fired.</summary>
public string? DoneAt { get; init; }
}
/// <summary>
/// <c>&lt;workspace&gt;/.volcanic/scheduler/skill-jobs-state.json</c>: <c>{ "version": 1, "jobs": { "&lt;skill&gt;/&lt;job&gt;": {...} } }</c>.
/// Written atomically; an unreadable file is set aside and the state starts empty (one-offs may then count as past).
/// </summary>
internal sealed class SkillJobStateStore(string path, TimeProvider time, ILogger logger)
{
public const int Version = 1;
public static string PathIn(string workspace) => Path.Combine(workspace, ".volcanic", "scheduler", "skill-jobs-state.json");
public Dictionary<string, SkillJobState> Load()
{
try
{
if (!File.Exists(path)) return new(StringComparer.Ordinal);
using var document = JsonDocument.Parse(File.ReadAllText(path));
var file = VolcanicJson.Deserialize<StateFile>(document.RootElement);
if (file is { Version: Version, Jobs: not null }) return new(file.Jobs, StringComparer.Ordinal);
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "Reading the skill job state {Path} failed", path);
}
try
{
if (File.Exists(path))
{
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
File.Move(path, aside, overwrite: true);
logger.LogError("The skill job state {Path} could not be read; it was moved to {Aside}", path, aside);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Setting aside the skill job state {Path} failed", path);
}
return new(StringComparer.Ordinal);
}
public async Task SaveAsync(IReadOnlyDictionary<string, SkillJobState> jobs, CancellationToken cancellationToken)
{
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
var json = JsonSerializer.Serialize(new StateFile(Version, jobs.OrderBy(j => j.Key, StringComparer.Ordinal).ToDictionary()), 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;
}
}
private sealed record StateFile(int Version, Dictionary<string, SkillJobState>? Jobs);
}