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.
This commit is contained in:
EugeneTes
2026-10-02 07:29:48 +00:00
parent 28c6f90d91
commit 28bbfbc759
19 changed files with 609 additions and 58 deletions

View File

@@ -109,7 +109,7 @@ if (ClaudeInstallation.Locate(Environment.GetEnvironmentVariable) is { } claude)
logger.LogInformation("Using Claude Code at {Path}, model {Model}", claude, options.Model);
else
logger.LogWarning("Claude Code (claude) was not found on PATH; requests will fail until it is installed and logged in. Send /claude-code-doctor to the bot for details.");
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? scheduling.StorePath : "off");
logger.LogInformation("Scheduler: {State}", scheduling.Enabled ? "on, jobs in each chat's .volcanic/scheduler/jobs.json" : "off");
logger.LogInformation("Default time zone: {Zone}", TimeZoneIds.Name(options.ConfiguredTimeZone() ?? TimeZoneInfo.Local));
logger.LogInformation("Allowed users: {Users}. Workspaces under {Root}", string.Join(", ", options.AllowedUserIds), options.WorkspaceRoot);

View File

@@ -10,7 +10,7 @@ public sealed class SchedulerHostedService(JobScheduler scheduler, TelegramJobDe
public async Task StartAsync(CancellationToken cancellationToken)
{
await scheduler.StartAsync(delivery, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Scheduler started; jobs in {Path}", scheduler.StorePath);
logger.LogInformation("Scheduler started with {Count} job(s)", scheduler.Count);
}
public Task StopAsync(CancellationToken cancellationToken) => scheduler.StopAsync(cancellationToken);

View File

@@ -19,11 +19,11 @@ public static class SchedulerSetup
new() { Command = "cancel", Description = "Cancel a scheduled job: /cancel <id>" },
];
/// <summary>The <c>Scheduler</c> section; an empty <c>StorePath</c> becomes <c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
/// <summary>The <c>Scheduler</c> section; an empty <c>LegacyStorePath</c> becomes <c>&lt;agentDir&gt;/scheduler/jobs.json</c>.</summary>
public static SchedulerOptions Options(IConfiguration section, string agentDir)
{
var options = section.Get<SchedulerOptions>() ?? new SchedulerOptions();
if (string.IsNullOrWhiteSpace(options.StorePath)) options.StorePath = SchedulerOptions.DefaultStorePath(agentDir);
if (string.IsNullOrWhiteSpace(options.LegacyStorePath)) options.LegacyStorePath = SchedulerOptions.DefaultLegacyStorePath(agentDir);
return options;
}

View File

@@ -55,6 +55,12 @@ public sealed class TelegramJobDelivery(ChatRegistry chats, ChatSettingsStore se
public string WorkspaceFor(string target) => chats.WorkspaceFor(ChatIdOf(target));
/// <summary>Every chat with a workspace directory (<c>&lt;WorkspaceRoot&gt;/&lt;chatId&gt;</c>).</summary>
public IEnumerable<string> KnownTargets() =>
Directory.Exists(options.WorkspaceRoot)
? Directory.EnumerateDirectories(options.WorkspaceRoot).Select(Path.GetFileName).OfType<string>().Where(name => TryChatIdOf(name, out _)).ToList()
: [];
/// <summary>Private chat id = user id: the chat is served while its user is allow-listed.</summary>
public bool IsDeliverable(string target) =>
TryChatIdOf(target, out var chatId) && options.IsAllowed(chatId);