Universal.Operative.Sdk.Scheduling
2.0.0
See the version list below for details.
dotnet add package Universal.Operative.Sdk.Scheduling --version 2.0.0
NuGet\Install-Package Universal.Operative.Sdk.Scheduling -Version 2.0.0
<PackageReference Include="Universal.Operative.Sdk.Scheduling" Version="2.0.0" />
<PackageVersion Include="Universal.Operative.Sdk.Scheduling" Version="2.0.0" />
<PackageReference Include="Universal.Operative.Sdk.Scheduling" />
paket add Universal.Operative.Sdk.Scheduling --version 2.0.0
#r "nuget: Universal.Operative.Sdk.Scheduling, 2.0.0"
#:package Universal.Operative.Sdk.Scheduling@2.0.0
#addin nuget:?package=Universal.Operative.Sdk.Scheduling&version=2.0.0
#tool nuget:?package=Universal.Operative.Sdk.Scheduling&version=2.0.0
About
Universal.Operative.Sdk.Scheduling schedules a prompt to be enqueued into an Universal.Operative.Sdk IOperative at a future time, either once or on a recurring cron schedule — durably: it fires even if this process wasn't running when the schedule came due. There's no in-process/non-durable mode — a schedule with nowhere to durably register would never fire under any circumstances, so this SDK doesn't offer that as an option.
ScheduleService— create/list/cancel/fire logic (validate cron, write the store, durably register/unregister, roll back on failure; resolve a fired id to its prompt, fire it, then stamp or fully cancel) shared by the model-facing tools below, any HTTP admin surface a host wants to mount over the same store, and the host's own trigger endpoint — one definition of each operation, not one per caller.ScheduleCreateTool/ScheduleListTool/ScheduleCancelTool(SchedulerTools.Create) — the model-facing surface, backed by a sharedScheduleService.FileScheduleStore— persists schedules (and their prompt text) as JSON on disk, so they're still there after this process restarts. Bring your ownIScheduleStoreimplementation (e.g. database-backed) for anything beyond one supervised instance reading one file; ids default to a 32-character hex GUID, overridable via its constructor'sidGenerator.ScheduleTriggerHandler— turns a fired schedule's prompt into a submittedIOperativemessage. Pass it toScheduleService.FireAsyncfrom the host's own trigger endpoint (an OS-scheduled task, or a remote dispatcher, hitting a host's own HTTP endpoint) whenever it fires.IDurableScheduleBackend— registers a schedule with whatever actually wakes this process up when it's not running.WindowsTaskSchedulerBackend/SystemdTimerBackend/CrontabBackendregister with the OS's own scheduler (needs a locally-reachable Task Scheduler/systemd/cron);HttpDurableScheduleBackendregisters against a remote HTTP dispatcher instead, for hosts with no local OS scheduler reachable at all (e.g. a sandboxed PaaS container). Either way, the durable registration only ever carries the schedule's opaque id, never its prompt text — the trigger endpoint resolves id → prompt from the store at fire time, so a schedule's content never has to be safe as a shell/command-line/unit-file/HTTP-payload argument beyond that id.
How to Use
Installation
dotnet add package Universal.Operative.Sdk.Scheduling
Wiring it up
TriggerCommand trigger = TriggerCommands.Curl(new Uri("http://127.0.0.1:5099/schedule/trigger"), sharedSecret: "...");
IDurableScheduleBackend backend = IDurableScheduleBackend.ForCurrentPlatform(trigger); // Windows/Linux/other -> the matching backend, pre-wired to trigger
var setup = new SchedulingSetup(new FileScheduleStore(path), backend);
IOperative operative = engineBuilder
.AddBaselineTools()
.AddSchedulerTools(setup)
.BuildOperative(conversation);
ScheduleCreate durably registers atomically — the tool call either writes to the store and registers with backend, or (if registration fails) neither, never a schedule that only exists in one place. Every IDurableScheduleBackend is fully configured at construction time — RegisterAsync needs nothing beyond the schedule itself, so there's no separate "trigger template" to keep in sync with SchedulingSetup.
The host is responsible for the HTTP endpoint itself — this package doesn't take a web-framework dependency to provide one — but not for the logic behind it. Construct a ScheduleService over the same store and backend (once the operative exists, since ScheduleTriggerHandler wraps it) and hand the endpoint's id straight to FireAsync, which resolves it to a prompt, fires it, and applies the bookkeeping firing implies (stamps LastFiredAt for a recurring schedule, or fully cancels — store and durable backend — a one-shot one):
var scheduleService = new ScheduleService(setup.Store, setup.DurableBackend);
var trigger = new ScheduleTriggerHandler(operative);
// In the endpoint handler, given the id from the URL/payload:
ScheduleFireResult result = await scheduleService.FireAsync(id, trigger);
if (!result.Found) { /* already fired, or cancelled independently -- not an error */ }
Check setup.IsWired before mounting the endpoint if you want to fail loudly on a misconfigured SchedulingSetup instead of silently serving a route nothing durably registered against — it's a plain bool, so the check (and what to do on false) is up to the host.
TriggerCommand isn't curl-specific either: TriggerCommands.Curl(...) is just a convenience factory — construct one directly (any executable + arguments) to fire something else entirely. For a URL shape other than Curl's own {endpointBaseUrl}/{id} default (the id in a query string, say), pass a template string instead: TriggerCommands.Curl("https://host.example.net/hooks/fire?scheduleId={id}", sharedSecret: "...") — it must contain {id} somewhere, or every schedule would fire the exact same literal URL.
No locally-reachable OS scheduler (a sandboxed PaaS container)
Swap IDurableScheduleBackend.ForCurrentPlatform(trigger) for HttpDurableScheduleBackend, which registers against a remote HTTP dispatcher over plain HTTPS instead of shelling out to a local schtasks/systemctl/crontab:
IDurableScheduleBackend backend = new HttpDurableScheduleBackend(
dispatcherBaseUrl: new Uri("https://scheduling-dispatcher.example.net"),
dispatcherApiKey: dispatcherApiKey,
operativeSlug: "my-operative",
triggerUrlTemplate: "https://my-operative.example.net/schedule/{id}",
triggerSecret: triggerSecret);
This backend already knows where (and with what secret) to reach this operative from its own constructor, so it needs no TriggerCommand at all — triggerUrlTemplate plays the same role, resolved to a concrete per-schedule URL ({id} substituted for the firing schedule's own id) before it's ever sent to the dispatcher, so the id can go anywhere in the URL — a path segment, a query string — and the dispatcher only ever sees a literal URL to POST to, never a placeholder. It also has no reduced-cron-syntax allow-list the way the OS-level ones do — CanExpress is always true, since it round-trips through the exact same CronExpression parser this SDK's own tools already validate against, not a platform-specific translator.
What a durable backend gets you
ScheduleCancelunregisters both places — the logical record and the durable registration, not just the former.ScheduleListreportsdurablyRegistered: true/falseper schedule, plus anorphanedDurableIdsarray for durable registrations with no matching schedule left in the store (e.g. a store that didn't survive a restart the durable registration did) — these will still fire, hitting the trigger endpoint with an id the store no longer has a prompt for;ScheduleService.FireAsyncreports that asFound: falserather than throwing, but the durable registration itself is still there and worth cleaning up.
Every OS-level backend takes its TriggerCommand as the first constructor argument, with naming overridable via the same constructor (WindowsTaskSchedulerBackend(trigger, taskFolder:), SystemdTimerBackend(trigger, unitDirectory:, unitPrefix:), CrontabBackend(trigger, markerPrefix:)) — set the naming override if more than one app on the same machine uses this package, so their registrations, and their ListAsync results, don't collide. Constructing one directly on the wrong OS throws PlatformNotSupportedException immediately rather than failing later at RegisterAsync.
License
Free for noncommercial use under the Universal.Operative Noncommercial License. Source is closed; commercial use requires a separate license from Andrew Ong.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Universal.Common.Cron (>= 1.0.0)
- Universal.Common.Net.Http (>= 5.1.1)
- Universal.Operative.Sdk (>= 4.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
2.0.0 — BREAKING: this SDK is now durable-only. Removed SchedulerRuntime, InMemoryScheduleStore, and StartScheduler (the in-process "fires only while this exact process stays running" path) — a schedule with nowhere to durably register would never fire under any circumstances once the process restarts, so keeping that as an option was a footgun with no real use case, not a legitimate lightweight mode. SchedulingSetup now requires both an IScheduleStore and an IDurableScheduleBackend (previously optional/defaulted to an in-memory store). ScheduleService, ScheduleCreateTool/ScheduleListTool/ScheduleCancelTool, and SchedulerTools.Create all require a durable backend the same way. ScheduleListItem.DurablyRegistered is now a plain bool (was bool?, null meant "no durable backend configured" — no longer a possible state). IDurableScheduleBackend.RegisterAsync no longer takes a TriggerCommand parameter — every backend (WindowsTaskSchedulerBackend/SystemdTimerBackend/CrontabBackend/HttpDurableScheduleBackend) takes its trigger target as a required constructor argument instead, so it's fully self-contained and RegisterAsync just needs the schedule; IDurableScheduleBackend.ForCurrentPlatform takes a TriggerCommand to pass through. ScheduleService.CreateAsync/CancelAsync no longer swallow OperationCanceledException into a Failed/warning result — cancellation propagates like everywhere else in this SDK; a rollback failure during a registration failure now reports both errors instead of throwing unhandled and losing the original one. IScheduleStore adds GetAsync(id), needed by the new ScheduleService.FireAsync(id, IScheduleTrigger) — the seam a host's trigger endpoint calls into, resolving a fired id to its prompt, firing it, then stamping LastFiredAt (recurring) or fully cancelling, store and durable backend (one-shot). FileScheduleStore's ids default to a full 32-character hex GUID (was an 8-character prefix) and are now generated via an overridable idGenerator constructor parameter. HttpDurableScheduleBackend's triggerBaseUrl parameter is replaced by triggerUrlTemplate (a string containing {id}, resolved to a concrete per-schedule URL before it reaches the dispatcher) — the id is no longer assumed to always be a path suffix the dispatcher appends itself. TriggerCommands.Curl gains an overload taking a full URL template for the same reason, for OS-level backends.