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.
84 lines
4.8 KiB
C#
84 lines
4.8 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
|
|
|
/// <summary>How jobs and times are shown (the <c>schedule</c> tool, <c>/jobs</c>, zone-change replies). Invariant culture, 24 h.</summary>
|
|
public static class JobFormat
|
|
{
|
|
public static string Icon(JobKind kind) => kind == JobKind.Prompt ? "💬" : "🔍";
|
|
|
|
/// <summary><c>27 Sep 09:00</c> in <paramref name="zone"/>.</summary>
|
|
public static string DayTime(DateTimeOffset at, TimeZoneInfo zone) =>
|
|
TimeZoneInfo.ConvertTime(at, zone).ToString("d MMM HH:mm", CultureInfo.InvariantCulture);
|
|
|
|
/// <summary><c>14:10</c> when on the same local day as <paramref name="now"/>, else <see cref="DayTime"/>.</summary>
|
|
public static string When(DateTimeOffset at, TimeZoneInfo zone, DateTimeOffset now)
|
|
{
|
|
var local = TimeZoneInfo.ConvertTime(at, zone);
|
|
return local.Date == TimeZoneInfo.ConvertTime(now, zone).Date
|
|
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
|
|
: DayTime(at, zone);
|
|
}
|
|
|
|
/// <summary><c>Sat 2026-09-26 14:03</c> (the model-facing form: weekday and full date).</summary>
|
|
public static string Stamp(DateTimeOffset at, TimeZoneInfo zone) =>
|
|
TimeZoneInfo.ConvertTime(at, zone).ToString("ddd yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
|
|
|
public static string ZoneName(TimeZoneInfo zone) => ZoneNames.Name(zone);
|
|
|
|
/// <summary><c>exit 1</c>, <c>exit 1, timed out</c>, <c>timed out</c>, or <c>did not start</c>.</summary>
|
|
public static string FailureReason(JobResult result) => result switch
|
|
{
|
|
{ ExitCode: { } code, TimedOut: true } => $"exit {code.ToString(CultureInfo.InvariantCulture)}, timed out",
|
|
{ ExitCode: { } code } => $"exit {code.ToString(CultureInfo.InvariantCulture)}",
|
|
{ TimedOut: true } => "timed out",
|
|
_ => "did not start",
|
|
};
|
|
|
|
/// <summary><c>`k7m2q` 💬 Call the dentist · once 27 Sep 09:00</c>; cron jobs show the expression and the next run; a failed last run is appended.</summary>
|
|
public static string Line(JobView view, TimeZoneInfo zone, DateTimeOffset now)
|
|
{
|
|
var job = view.Job;
|
|
var sb = new StringBuilder().Append('`').Append(job.Id).Append("` ").Append(Icon(job.Kind)).Append(' ').Append(job.Name);
|
|
if (job.Schedule.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
|
|
else
|
|
{
|
|
sb.Append(" · `").Append(job.Schedule.Cron).Append('`');
|
|
if (view.NextRunAt is { } next) sb.Append(" · next ").Append(When(next, zone, now));
|
|
}
|
|
if (job.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>`skill/job` 💬 Morning digest · `0 8 * * 1-5` · next 08:00</c>; a one-off shows <c>once …</c>; a job that isn't
|
|
/// scheduled shows why (disabled, done, past, over the limit, or the file's problem). <paramref name="withFile"/> adds the path.
|
|
/// </summary>
|
|
public static string SkillLine(SkillJobView view, TimeZoneInfo zone, DateTimeOffset now, bool withFile = false)
|
|
{
|
|
var sb = new StringBuilder().Append('`').Append(view.Id).Append("` ")
|
|
.Append(view.Status == SkillJobStatus.Invalid ? "⚠️" : view.Kind is { } kind ? Icon(kind) : "•");
|
|
var job = view.Id[(view.Id.IndexOf('/', StringComparison.Ordinal) + 1)..];
|
|
var title = view.Description ?? (view.Name != job ? view.Name : null);
|
|
if (title is not null) sb.Append(' ').Append(title);
|
|
if (view.At is { } at) sb.Append(" · once ").Append(DayTime(at, zone));
|
|
else if (view.Cron is { } cron && view.Status != SkillJobStatus.Invalid) sb.Append(" · `").Append(cron).Append('`');
|
|
switch (view.Status)
|
|
{
|
|
case SkillJobStatus.Active when view.Cron is not null && view.NextRunAt is { } next: sb.Append(" · next ").Append(When(next, zone, now)); break;
|
|
case SkillJobStatus.Disabled: sb.Append(" · disabled"); break;
|
|
case SkillJobStatus.Done: sb.Append(" · done"); break;
|
|
case SkillJobStatus.Past: sb.Append(" · not scheduled: at was already past when the file appeared"); break;
|
|
case SkillJobStatus.OverLimit or SkillJobStatus.Invalid: sb.Append(" · error: ").Append(view.Problem); break;
|
|
}
|
|
if (view.Status == SkillJobStatus.Active && view.LastResult is { Ok: false } failed) sb.Append(" · last: failed (").Append(FailureReason(failed)).Append(')');
|
|
if (withFile) sb.Append(" · ").Append(view.File);
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>What to do instead of cancelling a skill job.</summary>
|
|
public static string SkillCancelHint(SkillJobView view) =>
|
|
$"{view.Id} is a skill job, declared by {view.File}. To turn it off, set `enabled: false` in {view.File} (or delete the file).";
|
|
}
|