diff --git a/.claude/worktrees/agent-a09ac2bf b/.claude/worktrees/agent-a09ac2bf
new file mode 160000
index 000000000..6b15d9827
--- /dev/null
+++ b/.claude/worktrees/agent-a09ac2bf
@@ -0,0 +1 @@
+Subproject commit 6b15d9827d464c100b956bec960b344b7b6e19ad
diff --git a/.claude/worktrees/agent-a0e024b5 b/.claude/worktrees/agent-a0e024b5
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a0e024b5
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a10bd4c5 b/.claude/worktrees/agent-a10bd4c5
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a10bd4c5
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a2075206 b/.claude/worktrees/agent-a2075206
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a2075206
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a4519a5e b/.claude/worktrees/agent-a4519a5e
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a4519a5e
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a503735a b/.claude/worktrees/agent-a503735a
new file mode 160000
index 000000000..908619e73
--- /dev/null
+++ b/.claude/worktrees/agent-a503735a
@@ -0,0 +1 @@
+Subproject commit 908619e739bfaec7c9729749cc74bdf8dda53182
diff --git a/.claude/worktrees/agent-a56f8a54 b/.claude/worktrees/agent-a56f8a54
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a56f8a54
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a571129e b/.claude/worktrees/agent-a571129e
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a571129e
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a7d51e97 b/.claude/worktrees/agent-a7d51e97
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a7d51e97
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a85909fa b/.claude/worktrees/agent-a85909fa
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a85909fa
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-a91af20e b/.claude/worktrees/agent-a91af20e
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-a91af20e
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-ac7693db b/.claude/worktrees/agent-ac7693db
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-ac7693db
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-acb49e4f b/.claude/worktrees/agent-acb49e4f
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-acb49e4f
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-ad7eeb67 b/.claude/worktrees/agent-ad7eeb67
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-ad7eeb67
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-ae2506a8 b/.claude/worktrees/agent-ae2506a8
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-ae2506a8
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/.claude/worktrees/agent-aee3b313 b/.claude/worktrees/agent-aee3b313
new file mode 160000
index 000000000..53f294400
--- /dev/null
+++ b/.claude/worktrees/agent-aee3b313
@@ -0,0 +1 @@
+Subproject commit 53f294400ff2c9598d316ee021b00ace1f7f12c0
diff --git a/docs/modules/doctor/architecture.md b/docs/modules/doctor/architecture.md
index d1456477b..2b158507f 100644
--- a/docs/modules/doctor/architecture.md
+++ b/docs/modules/doctor/architecture.md
@@ -14,19 +14,30 @@ Doctor provides a plugin-based diagnostic system that enables:
- **Capability probing** for feature compatibility
- **Evidence collection** for troubleshooting and compliance
-### Scheduler Runtime Surface (run-002 remediation)
+### Scheduler Integration (Sprint 20260408-003)
-Doctor Scheduler now exposes an HTTP management and trend surface at:
-- `GET/POST /api/v1/doctor/scheduler/schedules`
-- `GET/PUT/DELETE /api/v1/doctor/scheduler/schedules/{scheduleId}`
-- `GET /api/v1/doctor/scheduler/schedules/{scheduleId}/executions`
-- `POST /api/v1/doctor/scheduler/schedules/{scheduleId}/execute`
-- `GET /api/v1/doctor/scheduler/trends`
-- `GET /api/v1/doctor/scheduler/trends/checks/{checkId}`
-- `GET /api/v1/doctor/scheduler/trends/categories/{category}`
-- `GET /api/v1/doctor/scheduler/trends/degrading`
+> **The standalone Doctor Scheduler service is deprecated.**
+> Doctor health check scheduling is now handled by the Scheduler service's `DoctorJobPlugin`.
-The default local runtime uses deterministic in-memory repositories with stable ordering for schedule lists, execution history, and trend summaries.
+Doctor schedules are managed via the Scheduler API with `jobKind="doctor"` and plugin-specific
+configuration in `pluginConfig`. Trend data is stored in `scheduler.doctor_trends` (PostgreSQL).
+
+**Scheduler-hosted Doctor endpoints:**
+- `GET /api/v1/scheduler/doctor/trends` -- aggregated trend summaries
+- `GET /api/v1/scheduler/doctor/trends/checks/{checkId}` -- per-check trend data
+- `GET /api/v1/scheduler/doctor/trends/categories/{category}` -- per-category trend data
+- `GET /api/v1/scheduler/doctor/trends/degrading` -- checks with degrading health
+
+**Schedule management** uses the standard Scheduler API at `/api/v1/scheduler/schedules`
+with `jobKind="doctor"` and `pluginConfig` containing Doctor-specific options (mode, categories, alerts).
+
+Three default Doctor schedules are seeded by `SystemScheduleBootstrap`:
+- `doctor-full-daily` (0 4 * * *) -- Full health check
+- `doctor-quick-hourly` (0 * * * *) -- Quick health check
+- `doctor-compliance-weekly` (0 5 * * 0) -- Compliance category audit
+
+The Doctor WebService (`src/Doctor/StellaOps.Doctor.WebService/`) remains the execution engine.
+The plugin communicates with it via HTTP POST to `/api/v1/doctor/run`.
### AdvisoryAI Diagnosis Surface (run-003 remediation)
diff --git a/src/Doctor/AGENTS.md b/src/Doctor/AGENTS.md
index aabea241f..bc0e9e5ef 100644
--- a/src/Doctor/AGENTS.md
+++ b/src/Doctor/AGENTS.md
@@ -67,3 +67,14 @@ Every `IDoctorCheck` implementation MUST have a corresponding documentation arti
- Development: https://localhost:10260, http://localhost:10261
- Local alias: https://doctor.stella-ops.local, http://doctor.stella-ops.local
- Env var: STELLAOPS_DOCTOR_URL
+
+## Doctor Scheduling (DEPRECATED standalone service)
+The standalone `StellaOps.Doctor.Scheduler` service is deprecated as of Sprint 20260408-003.
+Doctor health check scheduling is now handled by the Scheduler service's `DoctorJobPlugin`
+(jobKind="doctor") in `src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/`.
+
+- Schedule CRUD: use the Scheduler API with `jobKind="doctor"` and `pluginConfig` for Doctor-specific options.
+- Trend storage: moved from in-memory to Scheduler's PostgreSQL schema (`scheduler.doctor_trends`).
+- Trend endpoints: served by the Scheduler at `/api/v1/scheduler/doctor/trends/*`.
+- The Doctor WebService remains unchanged as the execution engine for health checks.
+- Source code for the old scheduler is kept for one release cycle before removal.
diff --git a/src/Doctor/StellaOps.Doctor.Scheduler/README.md b/src/Doctor/StellaOps.Doctor.Scheduler/README.md
new file mode 100644
index 000000000..d02478a75
--- /dev/null
+++ b/src/Doctor/StellaOps.Doctor.Scheduler/README.md
@@ -0,0 +1,24 @@
+# StellaOps.Doctor.Scheduler (DEPRECATED)
+
+> **DEPRECATED** as of Sprint 20260408-003. This standalone service is replaced by the
+> `DoctorJobPlugin` in the Scheduler service (`src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/`).
+
+## Migration Summary
+
+| Capability | Before (this service) | After (Scheduler plugin) |
+|---|---|---|
+| Schedule CRUD | In-memory, `/api/v1/doctor/scheduler/schedules` | Scheduler API with `jobKind="doctor"` |
+| Cron evaluation | `DoctorScheduleWorker` background service | Scheduler's existing cron infrastructure |
+| Run execution | `ScheduleExecutor` HTTP calls to Doctor WebService | `DoctorJobPlugin.ExecuteAsync()` |
+| Trend storage | `InMemoryTrendRepository` (volatile) | `scheduler.doctor_trends` PostgreSQL table |
+| Trend endpoints | `/api/v1/doctor/scheduler/trends/*` | `/api/v1/scheduler/doctor/trends/*` |
+
+## What Stays
+
+- **Doctor WebService** (`src/Doctor/StellaOps.Doctor.WebService/`): unchanged, remains the health check execution engine.
+- **Doctor Plugins** (`src/Doctor/__Plugins/`): unchanged, loaded by Doctor WebService.
+
+## Removal Timeline
+
+Source code is kept for one release cycle. The `doctor-scheduler` container is commented out
+in `docker-compose.stella-ops.yml` and disabled in `services-matrix.env`.
diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs
index 3c007962c..b164215fd 100644
--- a/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs
+++ b/src/JobEngine/StellaOps.Scheduler.WebService/Bootstrap/SystemScheduleBootstrap.cs
@@ -10,21 +10,18 @@ namespace StellaOps.Scheduler.WebService.Bootstrap;
///
/// Creates system-managed schedules on startup for each tenant.
/// Missing schedules are inserted; existing ones are left untouched.
-/// Includes both scan schedules and Doctor health check schedules.
///
internal sealed class SystemScheduleBootstrap : BackgroundService
{
private static readonly (string Slug, string Name, string Cron, ScheduleMode Mode, SelectorScope Scope, string JobKind, ImmutableDictionary? PluginConfig)[] SystemSchedules =
[
- // Scan schedules (jobKind = "scan")
("nightly-vuln-scan", "Nightly Vulnerability Scan", "0 2 * * *", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "scan", null),
("advisory-refresh", "Continuous Advisory Refresh", "0 */4 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages, "scan", null),
("weekly-compliance-sweep", "Weekly Compliance Sweep", "0 3 * * 0", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "scan", null),
("epss-score-update", "EPSS Score Update", "0 6 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages, "scan", null),
("reachability-reeval", "Reachability Re-evaluation", "0 5 * * 1-5", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "scan", null),
("registry-sync", "Registry Sync", "0 */2 * * *", ScheduleMode.ContentRefresh, SelectorScope.AllImages, "scan", null),
-
- // Doctor health check schedules (jobKind = "doctor")
+ // Doctor health check schedules (replaces standalone doctor-scheduler seeds)
("doctor-full-daily", "Daily Health Check", "0 4 * * *", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "doctor",
ImmutableDictionary.CreateRange(new KeyValuePair[]
{
@@ -37,7 +34,7 @@ internal sealed class SystemScheduleBootstrap : BackgroundService
new("doctorMode", "quick"),
new("timeoutSeconds", 120),
})),
- ("doctor-compliance-weekly", "Weekly Compliance Audit", "0 5 * * 0", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "doctor",
+ ("doctor-compliance-weekly","Weekly Compliance Audit", "0 5 * * 0", ScheduleMode.AnalysisOnly, SelectorScope.AllImages, "doctor",
ImmutableDictionary.CreateRange(new KeyValuePair[]
{
new("doctorMode", "categories"),
@@ -124,7 +121,7 @@ internal sealed class SystemScheduleBootstrap : BackgroundService
pluginConfig: pluginConfig);
await repository.UpsertAsync(schedule, cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("Created system schedule {ScheduleId} ({Name}, jobKind={JobKind}) for tenant {TenantId}.", scheduleId, name, jobKind, tenantId);
+ _logger.LogInformation("Created system schedule {ScheduleId} ({Name}, kind={JobKind}) for tenant {TenantId}.", scheduleId, name, jobKind, tenantId);
}
}
}
diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs
index 539b202d5..36731af4e 100644
--- a/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs
+++ b/src/JobEngine/StellaOps.Scheduler.WebService/Program.cs
@@ -2,7 +2,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
-using StellaOps.Audit.Emission;
using StellaOps.Auth.Abstractions;
using StellaOps.Localization;
using StellaOps.Auth.ServerIntegration;
@@ -10,7 +9,6 @@ using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Router.AspNet;
-using StellaOps.Scheduler.Plugin;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Persistence.Extensions;
@@ -31,11 +29,18 @@ using StellaOps.Scheduler.WebService.PolicyRuns;
using StellaOps.Scheduler.WebService.PolicySimulations;
using StellaOps.Scheduler.WebService.Runs;
using StellaOps.Scheduler.WebService.Schedules;
+using StellaOps.Scheduler.WebService.Scripts;
using StellaOps.Scheduler.WebService.Exceptions;
using StellaOps.Scheduler.WebService.VulnerabilityResolverJobs;
+using StellaOps.ReleaseOrchestrator.Scripts;
+using StellaOps.ReleaseOrchestrator.Scripts.Persistence;
+using StellaOps.ReleaseOrchestrator.Scripts.Search;
using StellaOps.Scheduler.Worker.Exceptions;
using StellaOps.Scheduler.Worker.Observability;
using StellaOps.Scheduler.Worker.Options;
+using StellaOps.Scheduler.Plugin;
+using StellaOps.Scheduler.Plugin.Scan;
+using StellaOps.Scheduler.Plugin.Doctor;
using System.Linq;
var builder = WebApplication.CreateBuilder(args);
@@ -121,6 +126,16 @@ else
builder.Services.AddSingleton();
builder.Services.AddSingleton();
}
+// Scripts registry (shares the same Postgres options as Scheduler)
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
// Workflow engine HTTP client (starts workflow instances for system schedules)
builder.Services.AddHttpClient((sp, client) =>
{
@@ -176,23 +191,39 @@ var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugin
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
-// Scheduler plugin registry: discover and register ISchedulerJobPlugin implementations
+// Scheduler Plugin Registry: register built-in and assembly-loaded job plugins
var pluginRegistry = new SchedulerPluginRegistry();
-// Register built-in scan plugin (default for all existing schedules)
+// Built-in: ScanJobPlugin (handles jobKind="scan")
var scanPlugin = new ScanJobPlugin();
pluginRegistry.Register(scanPlugin);
-// Discover ISchedulerJobPlugin implementations from assembly-loaded plugins
-var loadResult = PluginHost.LoadPlugins(pluginHostOptions);
-foreach (var pluginAssembly in loadResult.Plugins)
+// Built-in: DoctorJobPlugin (handles jobKind="doctor")
+var doctorPlugin = new DoctorJobPlugin();
+pluginRegistry.Register(doctorPlugin);
+doctorPlugin.ConfigureServices(builder.Services, builder.Configuration);
+
+// Discover assembly-loaded ISchedulerJobPlugin implementations from plugin DLLs
+var pluginLoadResult = StellaOps.Plugin.Hosting.PluginHost.LoadPlugins(pluginHostOptions);
+foreach (var loadedPlugin in pluginLoadResult.Plugins)
{
- var jobPlugins = StellaOps.Plugin.PluginLoader.LoadPlugins(
- new[] { pluginAssembly.Assembly });
- foreach (var jobPlugin in jobPlugins)
+ foreach (var type in loadedPlugin.Assembly.GetTypes())
{
- pluginRegistry.Register(jobPlugin);
- jobPlugin.ConfigureServices(builder.Services, builder.Configuration);
+ if (type.IsAbstract || type.IsInterface || !typeof(ISchedulerJobPlugin).IsAssignableFrom(type))
+ continue;
+
+ if (Activator.CreateInstance(type) is ISchedulerJobPlugin jobPlugin)
+ {
+ try
+ {
+ pluginRegistry.Register(jobPlugin);
+ jobPlugin.ConfigureServices(builder.Services, builder.Configuration);
+ }
+ catch (InvalidOperationException)
+ {
+ // Duplicate JobKind; skip silently (built-in takes precedence)
+ }
+ }
}
}
@@ -281,9 +312,6 @@ builder.Services.AddEndpointsApiExplorer();
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
-// Unified audit emission (posts audit events to Timeline service)
-builder.Services.AddAuditEmission(builder.Configuration);
-
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
@@ -332,11 +360,13 @@ app.MapFailureSignatureEndpoints();
app.MapPolicyRunEndpoints();
app.MapPolicySimulationEndpoints();
app.MapSchedulerEventWebhookEndpoints();
+app.MapScriptsEndpoints();
-// Map plugin-provided endpoints (e.g., Doctor trend endpoints)
-foreach (var (jobKind, _) in pluginRegistry.ListRegistered())
+// Map plugin-registered endpoints (e.g. Doctor trend endpoints)
+var registry = app.Services.GetRequiredService();
+foreach (var (jobKind, _) in registry.ListRegistered())
{
- var plugin = pluginRegistry.Resolve(jobKind);
+ var plugin = registry.Resolve(jobKind);
plugin?.MapEndpoints(app);
}
diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs
index cfec18252..b4f0467c4 100644
--- a/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs
+++ b/src/JobEngine/StellaOps.Scheduler.WebService/Schedules/ScheduleEndpoints.cs
@@ -183,7 +183,7 @@ internal static class ScheduleEndpoints
SchedulerEndpointHelpers.ResolveActorId(httpContext),
SchedulerSchemaVersions.Schedule,
source: request.Source ?? "user",
- jobKind: request.JobKind ?? "scan",
+ jobKind: request.JobKind,
pluginConfig: request.PluginConfig);
await repository.UpsertAsync(schedule, cancellationToken: cancellationToken).ConfigureAwait(false);
diff --git a/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj b/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj
index f477728a2..4d6560e86 100644
--- a/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj
+++ b/src/JobEngine/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj
@@ -12,6 +12,8 @@
+
+
@@ -24,7 +26,7 @@
-
+
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs
index d5aa95eba..95e5b5868 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Models/Schedule.cs
@@ -27,7 +27,7 @@ public sealed record Schedule
ImmutableArray? subscribers = null,
string? schemaVersion = null,
string source = "user",
- string jobKind = "scan",
+ string? jobKind = null,
ImmutableDictionary? pluginConfig = null)
: this(
id,
@@ -73,7 +73,7 @@ public sealed record Schedule
string updatedBy,
string? schemaVersion = null,
string source = "user",
- string jobKind = "scan",
+ string? jobKind = null,
ImmutableDictionary? pluginConfig = null)
{
Id = Validation.EnsureId(id, nameof(id));
@@ -98,7 +98,7 @@ public sealed record Schedule
UpdatedBy = Validation.EnsureSimpleIdentifier(updatedBy, nameof(updatedBy));
SchemaVersion = SchedulerSchemaVersions.EnsureSchedule(schemaVersion);
Source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
- JobKind = string.IsNullOrWhiteSpace(jobKind) ? "scan" : jobKind.Trim().ToLowerInvariant();
+ JobKind = string.IsNullOrWhiteSpace(jobKind) ? "scan" : jobKind.Trim();
PluginConfig = pluginConfig;
if (Selection.TenantId is not null && !string.Equals(Selection.TenantId, TenantId, StringComparison.Ordinal))
@@ -145,15 +145,15 @@ public sealed record Schedule
public string Source { get; } = "user";
///
- /// Identifies which handles this schedule.
- /// Defaults to "scan" for backward compatibility with existing schedules.
+ /// Identifies which plugin handles this schedule's execution (e.g. "scan", "doctor").
+ /// Defaults to "scan" for backward compatibility.
///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string JobKind { get; } = "scan";
///
- /// Plugin-specific configuration stored as JSON. For scan jobs this is null
- /// (mode/selector cover everything). For other job kinds (e.g., "doctor") this
- /// contains plugin-specific settings.
+ /// Plugin-specific configuration stored as JSON. For scan jobs this is null.
+ /// For other plugins (e.g., doctor) this contains plugin-specific settings.
///
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableDictionary? PluginConfig { get; }
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/007_add_schedule_job_kind.sql b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/007_add_schedule_job_kind.sql
new file mode 100644
index 000000000..4e022d1cf
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/007_add_schedule_job_kind.sql
@@ -0,0 +1,16 @@
+-- Migration: 007_add_schedule_job_kind
+-- Adds job_kind and plugin_config columns to support the scheduler plugin architecture.
+-- job_kind routes schedule execution to the correct ISchedulerJobPlugin.
+-- plugin_config stores plugin-specific JSON configuration.
+
+ALTER TABLE scheduler.schedules
+ ADD COLUMN IF NOT EXISTS job_kind TEXT NOT NULL DEFAULT 'scan';
+
+ALTER TABLE scheduler.schedules
+ ADD COLUMN IF NOT EXISTS plugin_config JSONB;
+
+COMMENT ON COLUMN scheduler.schedules.job_kind IS 'Routes to the ISchedulerJobPlugin that handles this schedule (e.g. scan, doctor, policy-sweep).';
+COMMENT ON COLUMN scheduler.schedules.plugin_config IS 'Plugin-specific configuration as JSON. Schema defined by the plugin identified by job_kind.';
+
+-- Index for filtering schedules by job kind (common in UI and API queries).
+CREATE INDEX IF NOT EXISTS idx_schedules_job_kind ON scheduler.schedules(tenant_id, job_kind) WHERE deleted_at IS NULL;
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/008_add_doctor_trends.sql b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/008_add_doctor_trends.sql
new file mode 100644
index 000000000..628a80be4
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/Migrations/008_add_doctor_trends.sql
@@ -0,0 +1,36 @@
+-- Migration: 008_add_doctor_trends
+-- Adds the doctor_trends table for storing health check trend data
+-- previously held in the standalone Doctor Scheduler's in-memory repository.
+
+CREATE TABLE IF NOT EXISTS scheduler.doctor_trends (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ timestamp TIMESTAMPTZ NOT NULL,
+ check_id TEXT NOT NULL,
+ plugin_id TEXT NOT NULL,
+ category TEXT NOT NULL,
+ run_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ health_score INT NOT NULL,
+ duration_ms INT NOT NULL DEFAULT 0,
+ evidence_values JSONB NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_doctor_trends_tenant_check ON scheduler.doctor_trends(tenant_id, check_id, timestamp DESC);
+CREATE INDEX IF NOT EXISTS idx_doctor_trends_tenant_category ON scheduler.doctor_trends(tenant_id, category, timestamp DESC);
+CREATE INDEX IF NOT EXISTS idx_doctor_trends_tenant_run ON scheduler.doctor_trends(tenant_id, run_id);
+CREATE INDEX IF NOT EXISTS idx_doctor_trends_timestamp ON scheduler.doctor_trends(timestamp DESC);
+
+-- BRIN index for time-series queries over large datasets
+CREATE INDEX IF NOT EXISTS brin_doctor_trends_timestamp ON scheduler.doctor_trends USING BRIN(timestamp) WITH (pages_per_range = 128);
+
+COMMENT ON TABLE scheduler.doctor_trends IS 'Health check trend data from Doctor scheduled runs. Migrated from the standalone Doctor Scheduler in-memory repository.';
+
+-- RLS for tenant isolation
+ALTER TABLE scheduler.doctor_trends ENABLE ROW LEVEL SECURITY;
+ALTER TABLE scheduler.doctor_trends FORCE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS doctor_trends_tenant_isolation ON scheduler.doctor_trends;
+CREATE POLICY doctor_trends_tenant_isolation ON scheduler.doctor_trends FOR ALL
+ USING (tenant_id = scheduler_app.require_current_tenant())
+ WITH CHECK (tenant_id = scheduler_app.require_current_tenant());
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/AGENTS.md b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/AGENTS.md
new file mode 100644
index 000000000..37da9377b
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/AGENTS.md
@@ -0,0 +1,48 @@
+# AGENTS - Scheduler Plugin Architecture
+
+## Overview
+
+The Scheduler Plugin system enables non-scanning workloads (health checks, policy sweeps,
+graph builds, etc.) to be scheduled and executed as first-class Scheduler jobs.
+
+## Plugin Contract
+
+Every plugin implements `ISchedulerJobPlugin` from `StellaOps.Scheduler.Plugin.Abstractions`:
+
+| Method | Purpose |
+|---|---|
+| `JobKind` | Unique string identifier stored in `Schedule.JobKind` |
+| `DisplayName` | Human-readable name for UI |
+| `CreatePlanAsync` | Build execution plan from Schedule + Run |
+| `ExecuteAsync` | Execute the plan (called by Worker Host) |
+| `ValidateConfigAsync` | Validate plugin-specific config in `Schedule.PluginConfig` |
+| `GetConfigJsonSchema` | Return JSON schema for UI-driven config forms |
+| `ConfigureServices` | Register plugin DI services at startup |
+| `MapEndpoints` | Register plugin HTTP endpoints |
+
+## Built-in Plugins
+
+| JobKind | Library | Description |
+|---|---|---|
+| `scan` | `StellaOps.Scheduler.Plugin.Scan` | Wraps existing scan logic (zero behavioral change) |
+| `doctor` | `StellaOps.Scheduler.Plugin.Doctor` | Doctor health checks (replaces standalone doctor-scheduler) |
+
+## Adding a New Plugin
+
+1. Create a class library under `StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin./`.
+2. Implement `ISchedulerJobPlugin`.
+3. Reference `StellaOps.Scheduler.Plugin.Abstractions`.
+4. Register in `Program.cs` or drop DLL into `plugins/scheduler/` for assembly-loaded discovery.
+5. Create schedules with `jobKind="your-kind"` and `pluginConfig={...}`.
+
+## Database Schema
+
+- `scheduler.schedules.job_kind` (TEXT, default 'scan') routes to the plugin
+- `scheduler.schedules.plugin_config` (JSONB, nullable) stores plugin-specific config
+- Plugin-specific tables (e.g. `scheduler.doctor_trends`) added via embedded SQL migrations
+
+## Working Directory
+
+- Abstractions: `src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/`
+- Scan plugin: `src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Scan/`
+- Doctor plugin: `src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/`
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/IRunProgressReporter.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/IRunProgressReporter.cs
index 9194aca46..83ffcd578 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/IRunProgressReporter.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/IRunProgressReporter.cs
@@ -4,33 +4,21 @@ namespace StellaOps.Scheduler.Plugin;
///
/// Callback interface for plugins to report progress and update Run state.
-/// Implementations are provided by the Scheduler infrastructure and persist
-/// progress updates to storage.
///
public interface IRunProgressReporter
{
///
- /// Reports progress as a fraction of estimated steps.
+ /// Reports progress as completed/total with an optional message.
///
- /// Number of steps completed so far.
- /// Total number of steps expected.
- /// Optional human-readable progress message.
- /// Cancellation token.
Task ReportProgressAsync(int completed, int total, string? message = null, CancellationToken ct = default);
///
- /// Transitions the Run to a new state (e.g., Running, Completed, Error).
+ /// Transitions the Run to a new state, optionally recording an error.
///
- /// Target state.
- /// Error message when transitioning to Error state.
- /// Cancellation token.
Task TransitionStateAsync(RunState newState, string? error = null, CancellationToken ct = default);
///
/// Appends a log entry to the Run's execution log.
///
- /// Log message.
- /// Log level (info, warn, error).
- /// Cancellation token.
Task AppendLogAsync(string message, string level = "info", CancellationToken ct = default);
}
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerJobPlugin.cs
index cd026f382..dc3503240 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerJobPlugin.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerJobPlugin.cs
@@ -6,10 +6,8 @@ using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Plugin;
///
-/// Defines a pluggable job type for the Scheduler service.
-/// Each implementation handles a specific (e.g., "scan", "doctor", "policy-sweep").
-/// The Scheduler routes cron triggers and manual runs to the correct plugin based on
-/// .
+/// Identifies the kind of job a plugin handles. Used in Schedule.JobKind
+/// to route cron triggers to the correct plugin at execution time.
///
public interface ISchedulerJobPlugin
{
@@ -39,12 +37,12 @@ public interface ISchedulerJobPlugin
///
/// Executes the plan. Called by the Worker Host.
/// Must be idempotent and support cancellation.
- /// Updates Run state via the provided .
+ /// Updates Run state via the provided IRunProgressReporter.
///
Task ExecuteAsync(JobExecutionContext context, CancellationToken ct);
///
- /// Validates plugin-specific configuration stored in .
+ /// Optionally validates plugin-specific configuration stored in Schedule.PluginConfig.
/// Called on schedule create/update.
///
Task ValidateConfigAsync(
@@ -53,7 +51,6 @@ public interface ISchedulerJobPlugin
///
/// Returns the JSON schema for plugin-specific configuration, enabling UI-driven forms.
- /// Returns null if the plugin requires no configuration.
///
string? GetConfigJsonSchema();
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerPluginRegistry.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerPluginRegistry.cs
index c3213a6d1..c93cab350 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerPluginRegistry.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/ISchedulerPluginRegistry.cs
@@ -1,20 +1,17 @@
namespace StellaOps.Scheduler.Plugin;
///
-/// Registry of available scheduler job plugins keyed by .
-/// Used by the Scheduler to route schedule triggers and manual runs to the correct plugin.
+/// Registry for discovering and resolving scheduler job plugins by their JobKind.
///
public interface ISchedulerPluginRegistry
{
///
- /// Registers a plugin. Throws if a plugin with the same
- /// is already registered.
+ /// Registers a plugin. Throws if a plugin with the same JobKind is already registered.
///
void Register(ISchedulerJobPlugin plugin);
///
- /// Resolves the plugin for the given job kind.
- /// Returns null if no plugin is registered for the kind.
+ /// Resolves a plugin by its JobKind. Returns null if no plugin is registered for the kind.
///
ISchedulerJobPlugin? Resolve(string jobKind);
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobConfigValidationResult.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobConfigValidationResult.cs
index 0f9df7ce6..a3696ce47 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobConfigValidationResult.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobConfigValidationResult.cs
@@ -1,21 +1,20 @@
namespace StellaOps.Scheduler.Plugin;
///
-/// Result of plugin configuration validation.
-/// Returned by .
+/// Result of plugin config validation.
///
public sealed record JobConfigValidationResult(
bool IsValid,
IReadOnlyList Errors)
{
///
- /// Returns a successful validation result with no errors.
+ /// A successful validation result with no errors.
///
- public static JobConfigValidationResult Success { get; } = new(true, []);
+ public static JobConfigValidationResult Valid { get; } = new(true, Array.Empty());
///
- /// Creates a failed validation result with the specified errors.
+ /// Creates a failed validation result with the given errors.
///
- public static JobConfigValidationResult Failure(params string[] errors)
+ public static JobConfigValidationResult Invalid(params string[] errors)
=> new(false, errors);
}
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobExecutionContext.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobExecutionContext.cs
index 616fbbc86..28675d4d7 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobExecutionContext.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobExecutionContext.cs
@@ -4,8 +4,6 @@ namespace StellaOps.Scheduler.Plugin;
///
/// Context passed to .
-/// Provides access to the schedule, run, plan, a progress reporter for
-/// updating run state, the DI container, and a deterministic time source.
///
public sealed record JobExecutionContext(
Schedule Schedule,
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlan.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlan.cs
index f48ffb834..e9c11b295 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlan.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlan.cs
@@ -2,8 +2,6 @@ namespace StellaOps.Scheduler.Plugin;
///
/// The plan produced by a plugin. Serialized to JSON and stored on the Run.
-/// Contains the to identify which plugin created it,
-/// a typed payload dictionary, and an estimated step count for progress tracking.
///
public sealed record JobPlan(
string JobKind,
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlanContext.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlanContext.cs
index 3c038cae7..b57dac1ff 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlanContext.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/JobPlanContext.cs
@@ -4,8 +4,6 @@ namespace StellaOps.Scheduler.Plugin;
///
/// Immutable context passed to .
-/// Provides access to the schedule definition, the newly created run record,
-/// the DI container, and a deterministic time source.
///
public sealed record JobPlanContext(
Schedule Schedule,
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/SchedulerPluginRegistry.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/SchedulerPluginRegistry.cs
index c508f0e07..b97854958 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/SchedulerPluginRegistry.cs
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/SchedulerPluginRegistry.cs
@@ -4,7 +4,6 @@ namespace StellaOps.Scheduler.Plugin;
///
/// Thread-safe in-memory registry for scheduler job plugins.
-/// Plugins are registered at startup and resolved at trigger time.
///
public sealed class SchedulerPluginRegistry : ISchedulerPluginRegistry
{
@@ -23,9 +22,9 @@ public sealed class SchedulerPluginRegistry : ISchedulerPluginRegistry
if (!_plugins.TryAdd(plugin.JobKind, plugin))
{
throw new InvalidOperationException(
- $"A scheduler plugin with JobKind '{plugin.JobKind}' is already registered " +
- $"(existing: {_plugins[plugin.JobKind].GetType().FullName}, " +
- $"new: {plugin.GetType().FullName}).");
+ $"A plugin with JobKind '{plugin.JobKind}' is already registered. " +
+ $"Existing: '{_plugins[plugin.JobKind].DisplayName}', " +
+ $"Attempted: '{plugin.DisplayName}'.");
}
}
@@ -46,7 +45,6 @@ public sealed class SchedulerPluginRegistry : ISchedulerPluginRegistry
return _plugins.Values
.OrderBy(p => p.JobKind, StringComparer.OrdinalIgnoreCase)
.Select(p => (p.JobKind, p.DisplayName))
- .ToList()
- .AsReadOnly();
+ .ToArray();
}
}
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/StellaOps.Scheduler.Plugin.Abstractions.csproj b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/StellaOps.Scheduler.Plugin.Abstractions.csproj
index 9028cbe90..62fbb3cb5 100644
--- a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/StellaOps.Scheduler.Plugin.Abstractions.csproj
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/StellaOps.Scheduler.Plugin.Abstractions.csproj
@@ -7,7 +7,7 @@
true
StellaOps.Scheduler.Plugin
StellaOps.Scheduler.Plugin.Abstractions
- Plugin contract abstractions for the StellaOps Scheduler job plugin architecture
+ Plugin abstraction contracts for the StellaOps Scheduler job plugin system.
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobOptions.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobOptions.cs
new file mode 100644
index 000000000..6757e8206
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobOptions.cs
@@ -0,0 +1,32 @@
+namespace StellaOps.Scheduler.Plugin.Doctor;
+
+///
+/// Configuration options for the Doctor job plugin.
+///
+public sealed class DoctorJobOptions
+{
+ ///
+ /// Configuration section name.
+ ///
+ public const string SectionName = "Scheduler:Doctor";
+
+ ///
+ /// URL of the Doctor WebService API.
+ ///
+ public string DoctorApiUrl { get; set; } = "http://doctor.stella-ops.local";
+
+ ///
+ /// Default timeout for Doctor runs (in seconds).
+ ///
+ public int DefaultTimeoutSeconds { get; set; } = 300;
+
+ ///
+ /// How long to retain trend data (in days).
+ ///
+ public int TrendDataRetentionDays { get; set; } = 365;
+
+ ///
+ /// Polling interval when waiting for Doctor run completion (in seconds).
+ ///
+ public int PollIntervalSeconds { get; set; } = 2;
+}
diff --git a/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs
new file mode 100644
index 000000000..2d6839b5b
--- /dev/null
+++ b/src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Doctor/DoctorJobPlugin.cs
@@ -0,0 +1,551 @@
+using System.Diagnostics;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using StellaOps.Scheduler.Models;
+
+namespace StellaOps.Scheduler.Plugin.Doctor;
+
+///
+/// Doctor health check job plugin for the Scheduler.
+/// Replaces the standalone doctor-scheduler service by executing Doctor runs
+/// via the Doctor WebService HTTP API and storing trend data in the Scheduler's database.
+///
+public sealed class DoctorJobPlugin : ISchedulerJobPlugin
+{
+ ///
+ public string JobKind => "doctor";
+
+ ///
+ public string DisplayName => "Doctor Health Checks";
+
+ ///
+ public Version Version { get; } = new(1, 0, 0);
+
+ ///
+ public Task CreatePlanAsync(JobPlanContext context, CancellationToken ct)
+ {
+ var config = DoctorScheduleConfig.FromPluginConfig(context.Schedule.PluginConfig);
+
+ var payload = new Dictionary
+ {
+ ["doctorMode"] = config.DoctorMode,
+ ["categories"] = config.Categories,
+ ["plugins"] = config.Plugins,
+ ["timeoutSeconds"] = config.TimeoutSeconds,
+ ["scheduleId"] = context.Schedule.Id,
+ };
+
+ var plan = new JobPlan(
+ JobKind: "doctor",
+ Payload: payload,
+ EstimatedSteps: 3); // trigger, poll, store trends
+
+ return Task.FromResult(plan);
+ }
+
+ ///
+ public async Task ExecuteAsync(JobExecutionContext context, CancellationToken ct)
+ {
+ var logger = context.Services.GetService()?.CreateLogger();
+ var httpClientFactory = context.Services.GetRequiredService();
+ var options = context.Services.GetRequiredService>().Value;
+ var trendRepository = context.Services.GetService();
+
+ var config = DoctorScheduleConfig.FromPluginConfig(context.Schedule.PluginConfig);
+ var httpClient = httpClientFactory.CreateClient("DoctorApi");
+
+ logger?.LogInformation(
+ "Executing Doctor health check for schedule {ScheduleId} in {Mode} mode",
+ context.Schedule.Id, config.DoctorMode);
+
+ await context.Reporter.ReportProgressAsync(0, 3, "Triggering Doctor run", ct).ConfigureAwait(false);
+
+ // Step 1: Trigger Doctor run
+ var runId = await TriggerDoctorRunAsync(httpClient, options, config, logger, ct).ConfigureAwait(false);
+
+ await context.Reporter.ReportProgressAsync(1, 3, $"Doctor run {runId} triggered, waiting for completion", ct).ConfigureAwait(false);
+ await context.Reporter.AppendLogAsync($"Doctor run triggered: {runId}", "info", ct).ConfigureAwait(false);
+
+ // Step 2: Poll for completion
+ var (status, summary) = await WaitForRunCompletionAsync(httpClient, options, runId, config, logger, ct).ConfigureAwait(false);
+
+ await context.Reporter.ReportProgressAsync(2, 3, $"Doctor run completed with status: {status}", ct).ConfigureAwait(false);
+ await context.Reporter.AppendLogAsync(
+ $"Doctor run {runId} completed: {summary.PassedChecks}/{summary.TotalChecks} passed, health={summary.HealthScore}",
+ status == "fail" ? "error" : "info", ct).ConfigureAwait(false);
+
+ // Step 3: Store trend data
+ if (trendRepository is not null)
+ {
+ await StoreTrendDataAsync(httpClient, options, trendRepository, runId,
+ context.Schedule.TenantId, context.TimeProvider, logger, ct).ConfigureAwait(false);
+ }
+
+ await context.Reporter.ReportProgressAsync(3, 3, "Trend data stored", ct).ConfigureAwait(false);
+
+ // Check for alert conditions
+ if (config.Alerts?.Enabled == true)
+ {
+ var shouldAlert = (config.Alerts.AlertOnFail && status == "fail") ||
+ (config.Alerts.AlertOnWarn && status == "warn");
+ if (shouldAlert)
+ {
+ await context.Reporter.AppendLogAsync(
+ $"Alert condition met: status={status}, alertOnFail={config.Alerts.AlertOnFail}, alertOnWarn={config.Alerts.AlertOnWarn}",
+ "warning", ct).ConfigureAwait(false);
+ }
+ }
+
+ // Transition to completed or error based on Doctor results
+ if (status == "fail" && summary.FailedChecks > 0)
+ {
+ await context.Reporter.TransitionStateAsync(
+ RunState.Completed,
+ $"Doctor run completed with {summary.FailedChecks} failed checks",
+ ct).ConfigureAwait(false);
+ }
+ else
+ {
+ await context.Reporter.TransitionStateAsync(RunState.Completed, ct: ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ public Task ValidateConfigAsync(
+ IReadOnlyDictionary pluginConfig,
+ CancellationToken ct)
+ {
+ var errors = new List();
+
+ if (pluginConfig.TryGetValue("doctorMode", out var modeObj) && modeObj is string mode)
+ {
+ var validModes = new[] { "full", "quick", "categories", "plugins" };
+ if (!validModes.Contains(mode, StringComparer.OrdinalIgnoreCase))
+ {
+ errors.Add($"Invalid doctorMode '{mode}'. Valid values: {string.Join(", ", validModes)}");
+ }
+
+ if (string.Equals(mode, "categories", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!pluginConfig.TryGetValue("categories", out var catObj) || catObj is not JsonElement cats || cats.GetArrayLength() == 0)
+ {
+ errors.Add("At least one category is required when doctorMode is 'categories'.");
+ }
+ }
+
+ if (string.Equals(mode, "plugins", StringComparison.OrdinalIgnoreCase))
+ {
+ if (!pluginConfig.TryGetValue("plugins", out var plugObj) || plugObj is not JsonElement plugs || plugs.GetArrayLength() == 0)
+ {
+ errors.Add("At least one plugin is required when doctorMode is 'plugins'.");
+ }
+ }
+ }
+
+ return Task.FromResult(errors.Count > 0
+ ? JobConfigValidationResult.Invalid([.. errors])
+ : JobConfigValidationResult.Valid);
+ }
+
+ ///
+ public string? GetConfigJsonSchema()
+ {
+ return """
+ {
+ "type": "object",
+ "properties": {
+ "doctorMode": {
+ "type": "string",
+ "enum": ["full", "quick", "categories", "plugins"],
+ "default": "full"
+ },
+ "categories": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "plugins": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "timeoutSeconds": {
+ "type": "integer",
+ "minimum": 30,
+ "maximum": 3600,
+ "default": 300
+ },
+ "alerts": {
+ "type": "object",
+ "properties": {
+ "enabled": { "type": "boolean", "default": true },
+ "alertOnFail": { "type": "boolean", "default": true },
+ "alertOnWarn": { "type": "boolean", "default": false },
+ "alertOnStatusChange": { "type": "boolean", "default": true },
+ "channels": { "type": "array", "items": { "type": "string" } },
+ "minSeverity": { "type": "string", "default": "Fail" }
+ }
+ }
+ }
+ }
+ """;
+ }
+
+ ///
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection(DoctorJobOptions.SectionName));
+
+ services.AddHttpClient("DoctorApi", (sp, client) =>
+ {
+ var opts = sp.GetRequiredService>().Value;
+ client.BaseAddress = new Uri(opts.DoctorApiUrl);
+ client.Timeout = TimeSpan.FromSeconds(opts.DefaultTimeoutSeconds + 30);
+ });
+ }
+
+ ///
+ public void MapEndpoints(IEndpointRouteBuilder routes)
+ {
+ var group = routes.MapGroup("/api/v1/scheduler/doctor")
+ .WithTags("Doctor", "Scheduler");
+
+ group.MapGet("/trends", async (
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ HttpContext httpContext,
+ IDoctorTrendRepository? trendRepository,
+ TimeProvider timeProvider) =>
+ {
+ if (trendRepository is null)
+ {
+ return Results.Json(new { summaries = Array.Empty