diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs
new file mode 100644
index 00000000..87fde322
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationChatContributor.cs
@@ -0,0 +1,37 @@
+using SharpClaw.Contracts.Chat;
+using SharpClaw.Contracts.Permissions;
+using SharpClaw.Contracts.Providers;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Module-owned that surfaces
+/// active task definitions as agent tools when the agent has the
+/// module-owned
+/// flag granted.
+///
+/// The flag constant is referenced directly from the module's own
+/// permission-key class — no typed accessor shim. The data path goes
+/// through the narrow contract so the
+/// module does not need to depend on TaskToolProvider,
+/// SharpClawDbContext, or any infrastructure type. The clearance
+/// pipeline is invoked through .
+///
+///
+internal sealed class AgentOrchestrationChatContributor(
+ IGlobalFlagEvaluator flagEvaluator,
+ ITaskToolCatalog taskTools) : IChatProcessingContributor
+{
+ public async Task> GetExtraToolsAsync(
+ Guid agentId, CancellationToken ct = default)
+ {
+ var approved = await flagEvaluator.IsApprovedAsync(
+ AgentOrchestrationPermissionKeys.CanInvokeTasksAsTool, agentId, ct);
+
+ if (!approved)
+ return [];
+
+ return await taskTools.GetToolDefinitionsAsync(ct);
+ }
+}
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs
index 7ee8e0e1..1899a875 100644
--- a/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationModule.cs
@@ -5,8 +5,12 @@
using Microsoft.EntityFrameworkCore;
+using SharpClaw.Contracts.Chat;
+using SharpClaw.Contracts.DTOs.Tasks;
using SharpClaw.Contracts.Modules;
using SharpClaw.Contracts.Persistence;
+using SharpClaw.Contracts.Services;
+using SharpClaw.Contracts.Tasks;
using SharpClaw.Modules.AgentOrchestration.Services;
namespace SharpClaw.Modules.AgentOrchestration;
@@ -15,12 +19,21 @@ namespace SharpClaw.Modules.AgentOrchestration;
/// Default module: agent lifecycle (create sub-agent, manage agent),
/// task editing, and skill access. All tools flow through the job pipeline.
///
-public sealed class AgentOrchestrationModule : ISharpClawModule
+public sealed class AgentOrchestrationModule : ISharpClawModule, ITaskParserAware
{
- public string Id => "sharpclaw_agent_orchestration";
+ public const string ModuleIdValue = "sharpclaw_agent_orchestration";
+
+ public string Id => ModuleIdValue;
public string DisplayName => "Agent Orchestration";
public string ToolPrefix => "ao";
+ ///
+ /// Parser extension contributed by the merged Task Scripting subsystem
+ /// (folded into Agent Orchestration). Registers the OnTimer
+ /// event-handler name and the scripting-language primitives.
+ ///
+ public ITaskParserModuleExtension ParserExtension => TaskScriptingParserExtension.Instance;
+
// ═══════════════════════════════════════════════════════════════
// DI Registration
// ═══════════════════════════════════════════════════════════════
@@ -30,6 +43,42 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped(sp => sp.GetRequiredService()
.CreateDbContext());
services.TryAddScoped();
+ services.AddScoped();
+
+ // Module-owned chat contributor: surfaces task definitions as agent
+ // tools when the agent has CanInvokeTasksAsTool. The flag key is read
+ // directly from AgentOrchestrationPermissionKeys; the policy decision
+ // and the data fetch both live in the module.
+ services.AddScoped();
+
+ // Event-bus triggers (Event / TaskCompleted / TaskFailed) — moved here
+ // from core by the trigger-extraction plan. The same instance is
+ // exposed both as a trigger source and as a host event sink.
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+
+ // ── Merged from sharpclaw_task_scripting ───────────────────
+ // Task scripting primitives (declare/assign/conditional/loop/return/
+ // event_handler/evaluate) and runtime control (delay/wait_until_stopped/log).
+ services.AddScoped();
+
+ // Lifecycle triggers (Startup / Shutdown).
+ services.AddSingleton();
+
+ // Task-chain triggers (TaskCompleted / TaskFailed). Same instance is
+ // exposed as a trigger source and as a host event sink so it observes
+ // orchestrator completion events.
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+
+ // ── Scheduled jobs (relocated from core) ───────────────────
+ // Module owns the scheduling logic; the host only exposes
+ // ITaskInstanceLauncher to actually start a task instance.
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddSingleton();
}
// ═══════════════════════════════════════════════════════════════
@@ -105,6 +154,26 @@ public IReadOnlyList GetResourceTypeDescriptors()
public IReadOnlyList GetCliCommands() =>
[
+ new(
+ Name: "schedule",
+ Aliases: [],
+ Scope: ModuleCliScope.TopLevel,
+ Description: "Manage cron scheduled jobs",
+ UsageLines:
+ [
+ "schedule list List all scheduled jobs",
+ "schedule get Show a scheduled job",
+ "schedule create --cron [--timezone ] [--name ]",
+ " Create a cron scheduled job",
+ "schedule update --cron [--timezone ]",
+ " Update cron expression / timezone",
+ "schedule pause Pause a scheduled job",
+ "schedule resume Resume a paused job",
+ "schedule delete Delete a scheduled job",
+ "schedule preview [--timezone ] [--count N]",
+ " Preview next occurrences of a cron expression",
+ ],
+ Handler: HandleScheduleCommandAsync),
new(
Name: "aotask",
Aliases: ["aot"],
@@ -135,6 +204,166 @@ public IReadOnlyList GetCliCommands() =>
Handler: HandleResourceAoSkillCommandAsync),
];
+ private static async Task HandleScheduleCommandAsync(
+ string[] args, IServiceProvider sp, CancellationToken ct)
+ {
+ // args[0] = "schedule" (top-level), args[1] = sub-command.
+ // When forwarded from the legacy "task schedule …" alias the host
+ // dispatcher rewrites args so args[0] is still "schedule".
+ var ids = sp.GetRequiredService();
+ var svc = sp.GetRequiredService();
+
+ if (args.Length < 2)
+ {
+ PrintScheduleUsage();
+ return;
+ }
+
+ var sub = args[1].ToLowerInvariant();
+ switch (sub)
+ {
+ case "list":
+ ids.PrintJson(await svc.ListAsync(ct));
+ break;
+
+ case "get" when args.Length >= 3:
+ {
+ var job = await svc.GetByIdAsync(ids.Resolve(args[2]), ct);
+ if (job is not null) ids.PrintJson(job);
+ else Console.Error.WriteLine("Not found.");
+ break;
+ }
+ case "get":
+ Console.Error.WriteLine("schedule get ");
+ break;
+
+ case "create":
+ {
+ var flags = ParseFlags(args, 2);
+ if (!flags.TryGetValue("cron", out var cronExpr) || string.IsNullOrWhiteSpace(cronExpr))
+ {
+ Console.Error.WriteLine("schedule create --cron [--timezone ] [--name ]");
+ break;
+ }
+
+ Guid? taskId = args.Length >= 3 && Guid.TryParse(args[2], out var tid) ? tid : null;
+ flags.TryGetValue("timezone", out var tz);
+ flags.TryGetValue("name", out var name);
+
+ try
+ {
+ var result = await svc.CreateAsync(new CreateScheduledJobRequest(
+ Name: name ?? cronExpr,
+ TaskDefinitionId: taskId,
+ CronExpression: cronExpr,
+ CronTimezone: tz), ct);
+ ids.PrintJson(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ }
+ break;
+ }
+
+ case "update" when args.Length >= 3:
+ {
+ var jobId = ids.Resolve(args[2]);
+ var flags = ParseFlags(args, 3);
+ flags.TryGetValue("cron", out var cronExpr);
+ flags.TryGetValue("timezone", out var tz);
+
+ try
+ {
+ var result = await svc.UpdateAsync(jobId, new UpdateScheduledJobRequest(
+ CronExpression: cronExpr,
+ CronTimezone: tz), ct);
+ if (result is not null) ids.PrintJson(result);
+ else Console.Error.WriteLine("Not found.");
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ }
+ break;
+ }
+ case "update":
+ Console.Error.WriteLine("schedule update --cron [--timezone ]");
+ break;
+
+ case "pause" when args.Length >= 3:
+ {
+ var result = await svc.PauseAsync(ids.Resolve(args[2]), ct);
+ if (result is not null) ids.PrintJson(result);
+ else Console.Error.WriteLine("Not found.");
+ break;
+ }
+ case "pause":
+ Console.Error.WriteLine("schedule pause ");
+ break;
+
+ case "resume" when args.Length >= 3:
+ {
+ var result = await svc.ResumeAsync(ids.Resolve(args[2]), ct);
+ if (result is not null) ids.PrintJson(result);
+ else Console.Error.WriteLine("Not found.");
+ break;
+ }
+ case "resume":
+ Console.Error.WriteLine("schedule resume ");
+ break;
+
+ case "delete" when args.Length >= 3:
+ {
+ var ok = await svc.DeleteAsync(ids.Resolve(args[2]), ct);
+ Console.WriteLine(ok ? "Done." : "Not found.");
+ break;
+ }
+ case "delete":
+ Console.Error.WriteLine("schedule delete ");
+ break;
+
+ case "preview" when args.Length >= 3:
+ {
+ var expr = args[2];
+ var flags = ParseFlags(args, 3);
+ flags.TryGetValue("timezone", out var tz);
+ var count = flags.TryGetValue("count", out var cStr) && int.TryParse(cStr, out var c) ? c : 10;
+ count = count <= 0 ? 10 : Math.Min(count, 100);
+ try
+ {
+ ids.PrintJson(svc.PreviewExpression(expr, tz, count));
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ }
+ break;
+ }
+ case "preview":
+ Console.Error.WriteLine("schedule preview [--timezone ] [--count N]");
+ break;
+
+ default:
+ Console.Error.WriteLine($"Unknown command: schedule {sub}");
+ PrintScheduleUsage();
+ break;
+ }
+ }
+
+ private static void PrintScheduleUsage()
+ {
+ Console.Error.WriteLine("Usage:");
+ Console.Error.WriteLine(" schedule list List all scheduled jobs");
+ Console.Error.WriteLine(" schedule get Show a scheduled job");
+ Console.Error.WriteLine(" schedule create --cron [--timezone ] [--name ]");
+ Console.Error.WriteLine(" schedule update --cron [--timezone ]");
+ Console.Error.WriteLine(" schedule pause Pause a scheduled job");
+ Console.Error.WriteLine(" schedule resume Resume a paused job");
+ Console.Error.WriteLine(" schedule delete Delete a scheduled job");
+ Console.Error.WriteLine(" schedule preview [--timezone ] [--count N]");
+ }
+
private static async Task HandleResourceAoTaskCommandAsync(
string[] args, IServiceProvider sp, CancellationToken ct)
{
@@ -461,6 +690,8 @@ public IReadOnlyList GetGlobalFlagDescriptors() =>
new("CanCreateSubAgents", "Create Sub-Agents", "Create sub-agents with permissions ≤ the creator's.", "CreateSubAgentAsync"),
new("CanEditAgentHeader", "Edit Agent Header", "Edit the custom chat header of specific agents.", "CanEditAgentHeaderAsync"),
new("CanEditChannelHeader", "Edit Channel Header", "Edit the custom chat header of specific channels.", "CanEditChannelHeaderAsync"),
+ new(AgentOrchestrationPermissionKeys.CanInvokeTasksAsTool, "Invoke Tasks As Tool",
+ "Expose active task definitions in the agent tool list.", "InvokeTaskAsToolAsync"),
];
// ═══════════════════════════════════════════════════════════════
@@ -580,10 +811,30 @@ public async Task ExecuteToolAsync(
// Lifecycle
// ═══════════════════════════════════════════════════════════════
+ private ScheduledJobWorker? _scheduledJobWorker;
+
public Task InitializeAsync(IServiceProvider services, CancellationToken ct)
- => Task.CompletedTask;
+ {
+ _scheduledJobWorker = services.GetService();
+ _scheduledJobWorker?.Start();
+ return Task.CompletedTask;
+ }
+
+ public async Task ShutdownAsync()
+ {
+ if (_scheduledJobWorker is not null)
+ await _scheduledJobWorker.StopAsync();
+ }
- public Task ShutdownAsync() => Task.CompletedTask;
+ // ═══════════════════════════════════════════════════════════════
+ // Endpoint Mapping
+ // ═══════════════════════════════════════════════════════════════
+
+ public void MapEndpoints(object app)
+ {
+ var endpoints = (Microsoft.AspNetCore.Routing.IEndpointRouteBuilder)app;
+ Handlers.ScheduledJobEndpoints.MapScheduledJobEndpoints(endpoints);
+ }
// ═══════════════════════════════════════════════════════════════
// Schema builders
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs
new file mode 100644
index 00000000..93a4008d
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationPermissionKeys.cs
@@ -0,0 +1,13 @@
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Module-owned global-flag keys for the agent-orchestration module.
+///
+public static class AgentOrchestrationPermissionKeys
+{
+ ///
+ /// Grants permission to invoke task definitions as agent tools.
+ /// Agents with this flag see active task definitions in their tool list.
+ ///
+ public const string CanInvokeTasksAsTool = "CanInvokeTasksAsTool";
+}
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs
new file mode 100644
index 00000000..f5d5929c
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationStepDescriptorProvider.cs
@@ -0,0 +1,148 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Contributes chat/output and entity provisioning step descriptors owned by
+/// the agent orchestration module to the central task step registry.
+///
+public sealed class AgentOrchestrationStepDescriptorProvider : ITaskStepDescriptorProvider
+{
+ public string ModuleId => "sharpclaw_agent_orchestration";
+
+ public IReadOnlyList Descriptors { get; } = Build();
+
+ private static TaskStepDescriptor[] Build()
+ {
+ const string owner = "sharpclaw_agent_orchestration";
+ return
+ [
+ // ── Agent interaction ────────────────────────────────────
+ new TaskStepDescriptor
+ {
+ MethodName = "Chat",
+ StepKey = AgentOrchestrationStepKeys.Chat,
+ OwnerId = owner,
+ ExpressionArgIndex = 1,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "ChatStream",
+ StepKey = AgentOrchestrationStepKeys.ChatStream,
+ OwnerId = owner,
+ ExpressionArgIndex = 1,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "ChatToThread",
+ StepKey = AgentOrchestrationStepKeys.ChatToThread,
+ OwnerId = owner,
+ ExpressionArgIndex = 1,
+ },
+
+ // ── Output ──────────────────────────────────────────────
+ new TaskStepDescriptor
+ {
+ MethodName = "Emit",
+ StepKey = AgentOrchestrationStepKeys.Emit,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "ParseResponse",
+ StepKey = AgentOrchestrationStepKeys.ParseResponse,
+ OwnerId = owner,
+ CapturesGenericType = true,
+ },
+
+ // ── Entity lookup / creation ────────────────────────────
+ new TaskStepDescriptor
+ {
+ MethodName = "FindModel",
+ StepKey = AgentOrchestrationStepKeys.FindModel,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "FindProvider",
+ StepKey = AgentOrchestrationStepKeys.FindProvider,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "FindAgent",
+ StepKey = AgentOrchestrationStepKeys.FindAgent,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "CreateAgent",
+ StepKey = AgentOrchestrationStepKeys.CreateAgent,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "CreateThread",
+ StepKey = AgentOrchestrationStepKeys.CreateThread,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+
+ // ── Roles / permissions / channels ──────────────────────
+ new TaskStepDescriptor
+ {
+ MethodName = "CreateRole",
+ StepKey = AgentOrchestrationStepKeys.CreateRole,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "FindRole",
+ StepKey = AgentOrchestrationStepKeys.FindRole,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "SetRolePermissions",
+ StepKey = AgentOrchestrationStepKeys.SetRolePermissions,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "AssignRole",
+ StepKey = AgentOrchestrationStepKeys.AssignRole,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "CreateChannel",
+ StepKey = AgentOrchestrationStepKeys.CreateChannel,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "FindChannel",
+ StepKey = AgentOrchestrationStepKeys.FindChannel,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "AddAllowedAgent",
+ StepKey = AgentOrchestrationStepKeys.AddAllowedAgent,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ ];
+ }
+}
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs
new file mode 100644
index 00000000..8b9cd2fb
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationStepKeys.cs
@@ -0,0 +1,72 @@
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Stable well-known task-step keys owned by the Agent Orchestration module:
+/// chat/output primitives and entity lookup/provisioning operations.
+///
+/// IMPORTANT: The literal string values intentionally match the legacy
+/// core.* values so existing serialized task scripts continue to parse.
+/// Only the C# location of the constants changes; the wire format does not.
+///
+///
+public static class AgentOrchestrationStepKeys
+{
+ // ── Agent interaction ─────────────────────────────────────────────────────
+
+ /// Send a message to an agent and await the full response.
+ public const string Chat = "core.chat";
+
+ /// Send a message to an agent and stream the response.
+ public const string ChatStream = "core.chat_stream";
+
+ /// Send a chat message into a specific thread.
+ public const string ChatToThread = "core.chat_to_thread";
+
+ // ── Output ────────────────────────────────────────────────────────────────
+
+ /// Push a result object to SSE / WebSocket listeners.
+ public const string Emit = "core.emit";
+
+ /// Parse an agent text response into a typed data object.
+ public const string ParseResponse = "core.parse_response";
+
+ // ── Entity lookup / creation ──────────────────────────────────────────────
+
+ /// Find a model by name or custom ID.
+ public const string FindModel = "core.find_model";
+
+ /// Find a provider by name or custom ID.
+ public const string FindProvider = "core.find_provider";
+
+ /// Find an agent by name or custom ID.
+ public const string FindAgent = "core.find_agent";
+
+ /// Create a new agent.
+ public const string CreateAgent = "core.create_agent";
+
+ /// Create a new thread in a channel.
+ public const string CreateThread = "core.create_thread";
+
+ // ── Role / permission / channel provisioning ──────────────────────────────
+
+ /// Create a new role (upsert by name).
+ public const string CreateRole = "core.create_role";
+
+ /// Find a role by name or custom ID.
+ public const string FindRole = "core.find_role";
+
+ /// Set the permission flags on an existing role.
+ public const string SetRolePermissions = "core.set_role_permissions";
+
+ /// Assign a role to an agent.
+ public const string AssignRole = "core.assign_role";
+
+ /// Create a new channel (upsert by custom ID).
+ public const string CreateChannel = "core.create_channel";
+
+ /// Find a channel by title or custom ID.
+ public const string FindChannel = "core.find_channel";
+
+ /// Add an agent to a channel's allowed agents list (idempotent).
+ public const string AddAllowedAgent = "core.add_allowed_agent";
+}
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs
new file mode 100644
index 00000000..7aef8beb
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationTaskStepExecutor.cs
@@ -0,0 +1,207 @@
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Module-side executor for chat / output / provisioning task steps owned by
+/// Agent Orchestration. All real work is delegated to
+/// , an application-host service
+/// resolved from the running task's
+/// scope. This keeps the module free of any direct dependency on Core / EF
+/// types while still owning the step semantics.
+///
+public sealed class AgentOrchestrationTaskStepExecutor : ITaskStepExecutorExtension
+{
+ public string ModuleId => "sharpclaw_agent_orchestration";
+
+ public bool CanExecute(string moduleStepKey) => moduleStepKey switch
+ {
+ AgentOrchestrationStepKeys.Emit
+ or AgentOrchestrationStepKeys.Chat
+ or AgentOrchestrationStepKeys.ChatStream
+ or AgentOrchestrationStepKeys.ChatToThread
+ or AgentOrchestrationStepKeys.ParseResponse
+ or AgentOrchestrationStepKeys.FindModel
+ or AgentOrchestrationStepKeys.FindProvider
+ or AgentOrchestrationStepKeys.FindAgent
+ or AgentOrchestrationStepKeys.FindRole
+ or AgentOrchestrationStepKeys.FindChannel
+ or AgentOrchestrationStepKeys.CreateAgent
+ or AgentOrchestrationStepKeys.CreateThread
+ or AgentOrchestrationStepKeys.CreateRole
+ or AgentOrchestrationStepKeys.SetRolePermissions
+ or AgentOrchestrationStepKeys.AssignRole
+ or AgentOrchestrationStepKeys.CreateChannel
+ or AgentOrchestrationStepKeys.AddAllowedAgent => true,
+ _ => false,
+ };
+
+ public async Task ExecuteAsync(
+ string moduleStepKey,
+ ITaskStepExecutionContext context,
+ IReadOnlyList? arguments,
+ string? expression,
+ string? resultVariable)
+ {
+ if (moduleStepKey == AgentOrchestrationStepKeys.Emit)
+ {
+ await context.WriteOutputAsync(expression);
+ return true;
+ }
+
+ var bridge = context.Services.GetRequiredService();
+ var ct = context.CancellationToken;
+ var taskName = string.Empty;
+
+ switch (moduleStepKey)
+ {
+ case AgentOrchestrationStepKeys.Chat:
+ {
+ var agentId = ParseGuidArg(arguments, 0);
+ var content = await bridge.ChatAsync(
+ context.InstanceId, taskName, expression ?? string.Empty, agentId, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = content;
+ break;
+ }
+ case AgentOrchestrationStepKeys.ChatStream:
+ {
+ var agentId = ParseGuidArg(arguments, 0);
+ var content = await bridge.ChatStreamAsync(
+ context.InstanceId, taskName, expression ?? string.Empty, agentId, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = content;
+ break;
+ }
+ case AgentOrchestrationStepKeys.ChatToThread:
+ {
+ if (arguments is null || arguments.Count < 1 || !Guid.TryParse(arguments[0], out var threadId))
+ throw new InvalidOperationException(
+ "ChatToThread requires a thread ID as first argument.");
+ var agentId = ParseGuidArg(arguments, 2);
+ var content = await bridge.ChatToThreadAsync(
+ context.InstanceId, taskName, threadId, expression ?? string.Empty, agentId, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = content;
+ break;
+ }
+ case AgentOrchestrationStepKeys.ParseResponse:
+ {
+ var typeName = arguments is { Count: > 0 } ? arguments[0] : null;
+ var parsed = bridge.ParseStructuredResponse(
+ context.InstanceId, expression ?? string.Empty, typeName);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = parsed;
+ break;
+ }
+ case AgentOrchestrationStepKeys.FindModel:
+ StoreFindResult(resultVariable,
+ await bridge.FindModelAsync(expression ?? string.Empty, ct), context);
+ break;
+ case AgentOrchestrationStepKeys.FindProvider:
+ StoreFindResult(resultVariable,
+ await bridge.FindProviderAsync(expression ?? string.Empty, ct), context);
+ break;
+ case AgentOrchestrationStepKeys.FindAgent:
+ StoreFindResult(resultVariable,
+ await bridge.FindAgentAsync(expression ?? string.Empty, ct), context);
+ break;
+ case AgentOrchestrationStepKeys.FindRole:
+ StoreFindResult(resultVariable,
+ await bridge.FindRoleAsync(expression ?? string.Empty, ct), context);
+ break;
+ case AgentOrchestrationStepKeys.FindChannel:
+ StoreFindResult(resultVariable,
+ await bridge.FindChannelAsync(expression ?? string.Empty, ct), context);
+ break;
+ case AgentOrchestrationStepKeys.CreateAgent:
+ {
+ var name = arguments is { Count: > 0 } ? arguments[0] : "Task Agent";
+ var modelId = ParseGuidArg(arguments, 1) ?? Guid.Empty;
+ var systemPrompt = arguments is { Count: > 2 } ? arguments[2] : null;
+ var customId = arguments is { Count: > 3 } ? arguments[3] : null;
+ var id = await bridge.CreateAgentAsync(
+ context.InstanceId, name, modelId, systemPrompt, customId, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = id.ToString();
+ break;
+ }
+ case AgentOrchestrationStepKeys.CreateThread:
+ {
+ var channelId = ParseGuidArg(arguments, 0);
+ var threadName = arguments is { Count: > 1 } ? arguments[1] : null;
+ var id = await bridge.CreateThreadAsync(context.InstanceId, channelId, threadName, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = id.ToString();
+ break;
+ }
+ case AgentOrchestrationStepKeys.CreateRole:
+ {
+ var id = await bridge.CreateRoleAsync(expression ?? string.Empty, ct);
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = id.ToString();
+ await context.AppendLogAsync($"CreateRole '{expression}' → {id}");
+ break;
+ }
+ case AgentOrchestrationStepKeys.SetRolePermissions:
+ {
+ if (!Guid.TryParse(expression, out var roleId))
+ throw new InvalidOperationException($"SetRolePermissions: invalid role ID '{expression}'.");
+ var flagsJson = arguments is { Count: > 1 } ? arguments[1] : null;
+ await bridge.SetRolePermissionsAsync(roleId, flagsJson ?? string.Empty, ct);
+ await context.AppendLogAsync($"SetRolePermissions {roleId}");
+ break;
+ }
+ case AgentOrchestrationStepKeys.AssignRole:
+ {
+ if (!Guid.TryParse(expression, out var agentId))
+ throw new InvalidOperationException($"AssignRole: invalid agent ID '{expression}'.");
+ var roleId = ParseGuidArg(arguments, 1)
+ ?? throw new InvalidOperationException("AssignRole: invalid role ID.");
+ await bridge.AssignRoleAsync(agentId, roleId, ct);
+ await context.AppendLogAsync($"AssignRole agent={agentId} role={roleId}");
+ break;
+ }
+ case AgentOrchestrationStepKeys.CreateChannel:
+ {
+ var title = expression ?? string.Empty;
+ var agentId = ParseGuidArg(arguments, 1)
+ ?? throw new InvalidOperationException("CreateChannel: invalid agent ID.");
+ var customId = arguments is { Count: > 2 } ? arguments[2] : null;
+ var channelId = await bridge.CreateChannelAsync(
+ context.InstanceId, title, agentId, customId, ct);
+
+ if (context.ChannelId == Guid.Empty)
+ context.SetChannelId(channelId);
+
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = channelId.ToString();
+ break;
+ }
+ case AgentOrchestrationStepKeys.AddAllowedAgent:
+ {
+ if (!Guid.TryParse(expression, out var agentId))
+ throw new InvalidOperationException($"AddAllowedAgent: invalid agent ID '{expression}'.");
+ var channelId = ParseGuidArg(arguments, 1);
+ await bridge.AddAllowedAgentAsync(context.InstanceId, agentId, channelId, ct);
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ private static Guid? ParseGuidArg(IReadOnlyList? args, int index)
+ {
+ if (args is null || index >= args.Count) return null;
+ return Guid.TryParse(args[index], out var g) ? g : null;
+ }
+
+ private static void StoreFindResult(string? resultVariable, Guid? id, ITaskStepExecutionContext context)
+ {
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = id?.ToString();
+ }
+}
diff --git a/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs b/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs
new file mode 100644
index 00000000..cd8ac506
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/AgentOrchestrationTriggerKeys.cs
@@ -0,0 +1,21 @@
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Trigger-key constants owned by the sharpclaw_agent_orchestration
+/// module. String values are persisted verbatim in
+/// TaskTriggerBindingDB.Kind and in serialized task scripts.
+///
+public static class AgentOrchestrationTriggerKeys
+{
+ /// Generic host-bus event with a SharpClawEventType filter.
+ public const string Event = "Event";
+
+ // Parameter names persisted into TaskTriggerDefinition.Parameters.
+ // Preserved verbatim to remain wire-compatible with serialized scripts.
+
+ /// Comma-separated list of SharpClawEventType flag names.
+ public const string EventType = "EventType";
+
+ /// Optional substring filter applied to SourceId / Summary.
+ public const string EventFilter = "EventFilter";
+}
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs b/DefaultModules/AgentOrchestration/EventBusTriggerSource.cs
similarity index 60%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs
rename to DefaultModules/AgentOrchestration/EventBusTriggerSource.cs
index 12de1923..786661a5 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/EventBusTriggerSource.cs
+++ b/DefaultModules/AgentOrchestration/EventBusTriggerSource.cs
@@ -1,20 +1,24 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using Microsoft.Extensions.Logging;
-using SharpClaw.Application.Core.Modules;
+
using SharpClaw.Contracts.Modules;
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.AgentOrchestration;
///
/// Trigger source that fires on SharpClaw host events
-/// (, ,
-/// ).
-/// Implements to receive events from the
-/// .
+/// (,
+/// ,
+/// ).
+/// Implements so the host dispatcher
+/// delivers events directly to it.
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
///
public sealed class EventBusTriggerSource(
- ModuleEventDispatcher dispatcher,
+ ISharpClawEventSinkRegistry sinkRegistry,
ILogger logger) : ITaskTriggerSource, ISharpClawEventSink
{
private IReadOnlyList _contexts = [];
@@ -22,22 +26,38 @@ public sealed class EventBusTriggerSource(
// ── ITaskTriggerSource ────────────────────────────────────────
public IReadOnlyList TriggerKeys { get; } =
- [WellKnownTriggerKeys.Event, WellKnownTriggerKeys.TaskCompleted, WellKnownTriggerKeys.TaskFailed];
+ [
+ AgentOrchestrationTriggerKeys.Event,
+ ];
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
_contexts = contexts;
- dispatcher.InvalidateSinkCache();
+ sinkRegistry.InvalidateCache();
return Task.CompletedTask;
}
public Task StopAsync()
{
_contexts = [];
- dispatcher.InvalidateSinkCache();
+ sinkRegistry.InvalidateCache();
return Task.CompletedTask;
}
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) => def.TriggerKey switch
+ {
+ AgentOrchestrationTriggerKeys.Event =>
+ def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType),
+ _ => null,
+ };
+
+ ///
+ public string? GetBindingFilter(TaskTriggerDefinition def) =>
+ def.TriggerKey == AgentOrchestrationTriggerKeys.Event
+ ? def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter)
+ : null;
+
// ── ISharpClawEventSink ───────────────────────────────────────
public SharpClawEventType SubscribedEvents =>
@@ -67,26 +87,17 @@ public async Task OnEventAsync(SharpClawEvent evt, CancellationToken ct)
private static bool MatchesContext(ITaskTriggerSourceContext ctx, SharpClawEvent evt)
{
var def = ctx.Definition;
- switch (def.TriggerKey)
- {
- case WellKnownTriggerKeys.TaskCompleted:
- if (!evt.Type.HasFlag(SharpClawEventType.JobCompleted)) return false;
- break;
- case WellKnownTriggerKeys.TaskFailed:
- if (!evt.Type.HasFlag(SharpClawEventType.JobFailed)) return false;
- break;
- case WellKnownTriggerKeys.Event:
- if (string.IsNullOrWhiteSpace(def.EventType)) return false;
- // EventType may be a comma-separated list of SharpClawEventType flag names
- if (!MatchesEventTypeFilter(def.EventType, evt.Type)) return false;
- // Optional filter on SourceId / Summary
- if (!string.IsNullOrWhiteSpace(def.EventFilter) &&
- !MatchesEventFilter(def.EventFilter, evt))
- return false;
- break;
- default:
- return false;
- }
+ if (def.TriggerKey != AgentOrchestrationTriggerKeys.Event) return false;
+
+ var eventType =
+ def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventType);
+ if (string.IsNullOrWhiteSpace(eventType)) return false;
+ if (!MatchesEventTypeFilter(eventType, evt.Type)) return false;
+ var filter =
+ def.Parameters.GetValueOrDefault(AgentOrchestrationTriggerKeys.EventFilter);
+ if (!string.IsNullOrWhiteSpace(filter) &&
+ !MatchesEventFilter(filter, evt))
+ return false;
return true;
}
diff --git a/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs b/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs
new file mode 100644
index 00000000..890e5994
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/Handlers/ScheduledJobEndpoints.cs
@@ -0,0 +1,107 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+using SharpClaw.Contracts.DTOs.Tasks;
+using SharpClaw.Contracts.Services;
+
+namespace SharpClaw.Modules.AgentOrchestration.Handlers;
+
+// ═══════════════════════════════════════════════════════════════════
+// Scheduled jobs /scheduled-jobs
+// Owned by the AgentOrchestration module. Delegates to the
+// host-supplied IScheduledJobService contract.
+// ═══════════════════════════════════════════════════════════════════
+
+public static class ScheduledJobEndpoints
+{
+ public static IEndpointRouteBuilder MapScheduledJobEndpoints(this IEndpointRouteBuilder routes)
+ {
+ var group = routes.MapGroup("/scheduled-jobs");
+
+ group.MapPost("/", async (
+ CreateScheduledJobRequest request, IScheduledJobService svc, CancellationToken ct) =>
+ {
+ try
+ {
+ var result = await svc.CreateAsync(request, ct);
+ return Results.Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.UnprocessableEntity(new { error = ex.Message });
+ }
+ });
+
+ group.MapGet("/", async (IScheduledJobService svc, CancellationToken ct)
+ => Results.Ok(await svc.ListAsync(ct)));
+
+ group.MapGet("/{jobId:guid}", async (Guid jobId, IScheduledJobService svc, CancellationToken ct) =>
+ {
+ var job = await svc.GetByIdAsync(jobId, ct);
+ return job is not null ? Results.Ok(job) : Results.NotFound();
+ });
+
+ group.MapPut("/{jobId:guid}", async (
+ Guid jobId, UpdateScheduledJobRequest request,
+ IScheduledJobService svc, CancellationToken ct) =>
+ {
+ try
+ {
+ var result = await svc.UpdateAsync(jobId, request, ct);
+ return result is not null ? Results.Ok(result) : Results.NotFound();
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.UnprocessableEntity(new { error = ex.Message });
+ }
+ });
+
+ group.MapDelete("/{jobId:guid}", async (
+ Guid jobId, IScheduledJobService svc, CancellationToken ct)
+ => await svc.DeleteAsync(jobId, ct) ? Results.NoContent() : Results.NotFound());
+
+ // ── Pause / Resume ─────────────────────────────────────────
+
+ group.MapPost("/{jobId:guid}/pause", async (
+ Guid jobId, IScheduledJobService svc, CancellationToken ct) =>
+ {
+ var result = await svc.PauseAsync(jobId, ct);
+ return result is not null ? Results.Ok(result) : Results.NotFound();
+ });
+
+ group.MapPost("/{jobId:guid}/resume", async (
+ Guid jobId, IScheduledJobService svc, CancellationToken ct) =>
+ {
+ var result = await svc.ResumeAsync(jobId, ct);
+ return result is not null ? Results.Ok(result) : Results.NotFound();
+ });
+
+ // ── Preview endpoints ──────────────────────────────────────
+
+ group.MapGet("/{jobId:guid}/preview", async (
+ Guid jobId, IScheduledJobService svc, CancellationToken ct, int count = 10) =>
+ {
+ count = count <= 0 ? 10 : Math.Min(count, 100);
+ var result = await svc.PreviewJobAsync(jobId, count, ct);
+ return result is not null ? Results.Ok(result) : Results.NotFound();
+ });
+
+ group.MapGet("/preview", (
+ IScheduledJobService svc, string expression, string? timezone = null, int count = 10) =>
+ {
+ count = count <= 0 ? 10 : Math.Min(count, 100);
+ try
+ {
+ var result = svc.PreviewExpression(expression, timezone, count);
+ return Results.Ok(result);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return Results.UnprocessableEntity(new { error = ex.Message });
+ }
+ });
+
+ return routes;
+ }
+}
diff --git a/SharpClaw.Application.Core/Services/CronEvaluator.cs b/DefaultModules/AgentOrchestration/Services/CronEvaluator.cs
similarity index 81%
rename from SharpClaw.Application.Core/Services/CronEvaluator.cs
rename to DefaultModules/AgentOrchestration/Services/CronEvaluator.cs
index 17a8f722..2cb6afb3 100644
--- a/SharpClaw.Application.Core/Services/CronEvaluator.cs
+++ b/DefaultModules/AgentOrchestration/Services/CronEvaluator.cs
@@ -1,6 +1,6 @@
using Cronos;
-namespace SharpClaw.Application.Services;
+namespace SharpClaw.Modules.AgentOrchestration.Services;
///
/// Thin wrapper around the Cronos library. All cron parsing and
@@ -42,8 +42,7 @@ public static bool TryParse(string expression, out string? error)
ArgumentNullException.ThrowIfNull(expression);
var tz = ResolveTimezone(timezone);
- var next = Parse(expression).GetNextOccurrence(after, tz);
- return next;
+ return Parse(expression).GetNextOccurrence(after, tz);
}
///
@@ -73,13 +72,8 @@ public static IEnumerable GetNextOccurrences(
}
}
- // ─────────────────────────────────────────────────────────────
- // Private helpers
- // ─────────────────────────────────────────────────────────────
-
private static CronExpression Parse(string expression)
{
- // Six space-separated fields → second-resolution format.
var format = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length == 6
? CronFormat.IncludeSeconds
: CronFormat.Standard;
diff --git a/SharpClaw.Application.Core/Services/ScheduledJobService.cs b/DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs
similarity index 72%
rename from SharpClaw.Application.Core/Services/ScheduledJobService.cs
rename to DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs
index 9584d5ec..cde09653 100644
--- a/SharpClaw.Application.Core/Services/ScheduledJobService.cs
+++ b/DefaultModules/AgentOrchestration/Services/ScheduledJobService.cs
@@ -1,38 +1,38 @@
using System.Text.Json;
+
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SharpClaw.Application.Infrastructure.Models.Jobs;
+
using SharpClaw.Contracts.DTOs.Tasks;
using SharpClaw.Contracts.Enums;
-using SharpClaw.Infrastructure.Persistence;
+using SharpClaw.Contracts.Services;
+using SharpClaw.Modules.AgentOrchestration.Models;
-namespace SharpClaw.Application.Services;
+namespace SharpClaw.Modules.AgentOrchestration.Services;
///
-/// CRUD and lifecycle management for entities.
-/// Cron validation is enforced on create and update.
+/// Module-owned implementation of .
+/// Lives in SharpClaw.Modules.AgentOrchestration: scheduling is the
+/// module's responsibility, the host only exposes the means to start
+/// task/job runs.
///
public sealed class ScheduledJobService(
- SharpClawDbContext db,
- ILogger logger)
+ AgentOrchestrationDbContext db,
+ ILogger logger) : IScheduledJobService
{
- // ═══════════════════════════════════════════════════════════════
- // Validation constants
- // ═══════════════════════════════════════════════════════════════
+ public const string ErrBothSchedules = "SCHED001";
+ public const string ErrInvalidCron = "SCHED002";
+ public const string ErrInvalidTz = "SCHED003";
+ public const string WarnNeverFires = "SCHED004";
- public const string ErrBothSchedules = "SCHED001";
- public const string ErrInvalidCron = "SCHED002";
- public const string ErrInvalidTz = "SCHED003";
- public const string WarnNeverFires = "SCHED004";
+ CronPreviewResponse IScheduledJobService.PreviewExpression(
+ string expression, string? timezone, int count)
+ => PreviewExpression(expression, timezone, count);
// ═══════════════════════════════════════════════════════════════
// CRUD
// ═══════════════════════════════════════════════════════════════
- ///
- /// Validate cron fields and persist a new scheduled job.
- /// Throws on hard validation errors.
- ///
public async Task CreateAsync(
CreateScheduledJobRequest request,
CancellationToken ct = default)
@@ -66,7 +66,7 @@ public async Task CreateAsync(
MaxRetries = request.MaxRetries,
};
- db.ScheduledTasks.Add(entity);
+ db.ScheduledJobs.Add(entity);
await db.SaveChangesAsync(ct);
return ToResponse(entity);
}
@@ -74,29 +74,25 @@ public async Task CreateAsync(
public async Task GetByIdAsync(
Guid id, CancellationToken ct = default)
{
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
return entity is null ? null : ToResponse(entity);
}
public async Task> ListAsync(
CancellationToken ct = default)
- => await db.ScheduledTasks
+ => await db.ScheduledJobs
.OrderBy(j => j.NextRunAt)
.Select(j => ToResponse(j))
.ToListAsync(ct);
- ///
- /// Partial update. Validates cron fields if expression or timezone changes.
- ///
public async Task UpdateAsync(
Guid id, UpdateScheduledJobRequest request, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
if (entity is null) return null;
- // Determine effective schedule values after potential update
var effectiveCron = request.CronExpression ?? entity.CronExpression;
var effectiveTz = request.CronTimezone ?? entity.CronTimezone;
var effectiveInterval = request.RepeatInterval ?? entity.RepeatInterval;
@@ -114,13 +110,13 @@ public async Task> ListAsync(
entity.NextRunAt = nextRunAt;
}
- if (request.Name is not null) entity.Name = request.Name;
- if (request.RepeatInterval.HasValue) entity.RepeatInterval = request.RepeatInterval;
- if (request.CronExpression is not null) entity.CronExpression = request.CronExpression;
- if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone;
- if (request.MissedFirePolicy.HasValue) entity.MissedFirePolicy = request.MissedFirePolicy.Value;
- if (request.MaxRetries.HasValue) entity.MaxRetries = request.MaxRetries.Value;
- if (request.CallerAgentId.HasValue) entity.CallerAgentId = request.CallerAgentId;
+ if (request.Name is not null) entity.Name = request.Name;
+ if (request.RepeatInterval.HasValue) entity.RepeatInterval = request.RepeatInterval;
+ if (request.CronExpression is not null) entity.CronExpression = request.CronExpression;
+ if (request.CronTimezone is not null) entity.CronTimezone = request.CronTimezone;
+ if (request.MissedFirePolicy.HasValue) entity.MissedFirePolicy = request.MissedFirePolicy.Value;
+ if (request.MaxRetries.HasValue) entity.MaxRetries = request.MaxRetries.Value;
+ if (request.CallerAgentId.HasValue) entity.CallerAgentId = request.CallerAgentId;
if (request.ParameterValues is not null)
entity.ParameterValuesJson = request.ParameterValues.Count > 0
? JsonSerializer.Serialize(request.ParameterValues)
@@ -132,10 +128,10 @@ public async Task> ListAsync(
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
if (entity is null) return false;
- db.ScheduledTasks.Remove(entity);
+ db.ScheduledJobs.Remove(entity);
await db.SaveChangesAsync(ct);
return true;
}
@@ -147,7 +143,7 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
public async Task PauseAsync(
Guid id, CancellationToken ct = default)
{
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
if (entity is null) return null;
if (entity.Status == ScheduledTaskStatus.Pending)
@@ -162,14 +158,13 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
public async Task ResumeAsync(
Guid id, CancellationToken ct = default)
{
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
if (entity is null) return null;
if (entity.Status == ScheduledTaskStatus.Paused)
{
entity.Status = ScheduledTaskStatus.Pending;
- // Re-derive NextRunAt if the schedule has passed while paused
if (!string.IsNullOrEmpty(entity.CronExpression))
{
var next = CronEvaluator.GetNextOccurrence(
@@ -195,17 +190,13 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
}
// ═══════════════════════════════════════════════════════════════
- // Preview (stateless)
+ // Preview
// ═══════════════════════════════════════════════════════════════
- ///
- /// Compute upcoming occurrences for a stored job's cron expression.
- /// Returns null when the job does not exist or has no cron expression.
- ///
public async Task PreviewJobAsync(
Guid id, int count = 10, CancellationToken ct = default)
{
- var entity = await db.ScheduledTasks.FindAsync([id], ct);
+ var entity = await db.ScheduledJobs.FindAsync([id], ct);
if (entity is null || string.IsNullOrEmpty(entity.CronExpression))
return null;
@@ -217,10 +208,6 @@ public async Task DeleteAsync(Guid id, CancellationToken ct = default)
return new CronPreviewResponse(entity.CronExpression, entity.CronTimezone, occurrences);
}
- ///
- /// Stateless preview — no DB access.
- /// Throws for invalid expression or timezone.
- ///
public static CronPreviewResponse PreviewExpression(
string expression, string? timezone = null, int count = 10)
{
@@ -250,11 +237,6 @@ public static CronPreviewResponse PreviewExpression(
// Validation helper
// ═══════════════════════════════════════════════════════════════
- ///
- /// Validates cron-related fields and returns the effective NextRunAt
- /// together with any non-fatal warning messages.
- /// Throws on hard errors.
- ///
internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings)
ValidateCronFields(
string? cronExpression,
@@ -264,19 +246,16 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings)
{
var warnings = new List();
- // SCHED001: both schedules set
if (!string.IsNullOrEmpty(cronExpression) && repeatInterval.HasValue)
throw new InvalidOperationException(
$"{ErrBothSchedules}: CronExpression and RepeatInterval are mutually exclusive.");
if (!string.IsNullOrEmpty(cronExpression))
{
- // SCHED002: invalid cron
if (!CronEvaluator.TryParse(cronExpression, out var cronErr))
throw new InvalidOperationException(
$"{ErrInvalidCron}: {cronErr}");
- // SCHED003: invalid timezone
if (!string.IsNullOrWhiteSpace(cronTimezone))
{
try { TimeZoneInfo.FindSystemTimeZoneById(cronTimezone); }
@@ -287,13 +266,11 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings)
}
}
- // Auto-derive NextRunAt when not supplied
var next = CronEvaluator.GetNextOccurrence(
cronExpression, DateTimeOffset.UtcNow, cronTimezone);
if (next is null)
{
- // SCHED004: expression never fires — warn, allow creation
warnings.Add(
$"{WarnNeverFires}: Expression '{cronExpression}' has no future " +
"occurrences from now. The job will never fire unless updated.");
@@ -304,15 +281,10 @@ internal static (DateTimeOffset nextRunAt, IReadOnlyList warnings)
return (suppliedNextRunAt ?? next.Value, warnings);
}
- // Interval or one-shot
var effective = suppliedNextRunAt ?? DateTimeOffset.UtcNow;
return (effective, warnings);
}
- // ═══════════════════════════════════════════════════════════════
- // Projection
- // ═══════════════════════════════════════════════════════════════
-
internal static ScheduledJobResponse ToResponse(ScheduledJobDB e) => new(
e.Id, e.Name, e.Status, e.NextRunAt,
e.RepeatInterval, e.CronExpression, e.CronTimezone, e.MissedFirePolicy,
diff --git a/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs b/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs
new file mode 100644
index 00000000..59707d0d
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/Services/ScheduledJobWorker.cs
@@ -0,0 +1,175 @@
+using System.Text.Json;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+using SharpClaw.Contracts.Enums;
+using SharpClaw.Contracts.Tasks;
+using SharpClaw.Modules.AgentOrchestration.Models;
+
+namespace SharpClaw.Modules.AgentOrchestration.Services;
+
+///
+/// Module-owned scheduler worker. Polls module-side
+/// rows and dispatches their bound task definitions through the host-supplied
+/// contract. Started by the module's
+/// InitializeAsync; stopped by ShutdownAsync.
+///
+public sealed class ScheduledJobWorker(
+ IServiceScopeFactory scopeFactory,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(15);
+
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _runner;
+
+ public void Start()
+ {
+ if (_runner is not null) return;
+ _runner = Task.Run(() => RunLoopAsync(_cts.Token));
+ }
+
+ public async Task StopAsync()
+ {
+ if (_runner is null) return;
+ try { _cts.Cancel(); } catch { }
+ try { await _runner.ConfigureAwait(false); }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "ScheduledJobWorker shutdown produced an exception.");
+ }
+ _runner = null;
+ }
+
+ private async Task RunLoopAsync(CancellationToken ct)
+ {
+ logger.LogInformation("ScheduledJobWorker started.");
+
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await ProcessDueJobsAsync(ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogError(ex, "Error in scheduled job processing loop.");
+ }
+
+ await Task.Delay(PollInterval, ct);
+ }
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ }
+ }
+
+ internal async Task ProcessDueJobsAsync(CancellationToken ct)
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var now = DateTimeOffset.UtcNow;
+
+ var dueJobs = await db.ScheduledJobs
+ .Where(t => t.Status == ScheduledTaskStatus.Pending && t.NextRunAt <= now)
+ .ToListAsync(ct);
+
+ var missedThreshold = TimeSpan.FromMinutes(
+ configuration.GetValue("Scheduler:MissedFireThresholdMinutes", 60));
+
+ foreach (var job in dueJobs)
+ {
+ job.Status = ScheduledTaskStatus.Running;
+ job.LastRunAt = now;
+ await db.SaveChangesAsync(ct);
+
+ try
+ {
+ logger.LogInformation("Executing scheduled job {Name} ({Id}).", job.Name, job.Id);
+
+ if (job.TaskDefinitionId.HasValue)
+ {
+ var launcher = scope.ServiceProvider.GetRequiredService();
+
+ Dictionary? paramValues = null;
+ if (!string.IsNullOrEmpty(job.ParameterValuesJson))
+ paramValues = JsonSerializer.Deserialize>(
+ job.ParameterValuesJson);
+
+ var instanceId = await launcher.LaunchAsync(
+ job.TaskDefinitionId.Value,
+ paramValues,
+ job.CallerAgentId,
+ ct);
+
+ logger.LogInformation(
+ "Scheduled job {Name} ({Id}) launched task instance {InstanceId}.",
+ job.Name, job.Id, instanceId);
+ }
+
+ job.Status = ScheduledTaskStatus.Completed;
+ job.RetryCount = 0;
+ job.LastError = null;
+
+ bool wasMissed = (now - job.NextRunAt) > missedThreshold;
+ if (job.MissedFirePolicy == MissedFirePolicy.Skip && wasMissed)
+ {
+ AdvanceNextRunAt(job, now);
+ await db.SaveChangesAsync(ct);
+ continue;
+ }
+
+ AdvanceNextRunAt(job, now);
+
+ logger.LogInformation("Scheduled job {Name} ({Id}) completed.", job.Name, job.Id);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ job.RetryCount++;
+ job.LastError = ex.Message;
+
+ if (job.RetryCount < job.MaxRetries)
+ {
+ job.Status = ScheduledTaskStatus.Pending;
+ job.NextRunAt = now.AddSeconds(30 * job.RetryCount);
+ logger.LogWarning(ex, "Scheduled job {Name} failed (attempt {Attempt}/{Max}), retrying.",
+ job.Name, job.RetryCount, job.MaxRetries);
+ }
+ else
+ {
+ job.Status = ScheduledTaskStatus.Failed;
+ logger.LogError(ex, "Scheduled job {Name} failed permanently after {Max} attempts.",
+ job.Name, job.MaxRetries);
+ }
+ }
+
+ await db.SaveChangesAsync(ct);
+ }
+ }
+
+ private static void AdvanceNextRunAt(ScheduledJobDB job, DateTimeOffset now)
+ {
+ if (!string.IsNullOrEmpty(job.CronExpression))
+ {
+ var next = CronEvaluator.GetNextOccurrence(
+ job.CronExpression, now, job.CronTimezone);
+
+ job.Status = next.HasValue ? ScheduledTaskStatus.Pending
+ : ScheduledTaskStatus.Completed;
+ job.NextRunAt = next ?? job.NextRunAt;
+ }
+ else if (job.RepeatInterval.HasValue)
+ {
+ job.Status = ScheduledTaskStatus.Pending;
+ job.NextRunAt = now.Add(job.RepeatInterval.Value);
+ }
+ }
+}
diff --git a/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj b/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj
index fa8ab93b..c4e50793 100644
--- a/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj
+++ b/DefaultModules/AgentOrchestration/SharpClaw.Modules.AgentOrchestration.csproj
@@ -23,6 +23,10 @@
+
+
+
+
diff --git a/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs b/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..ee20598b
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/TaskScripting/AgentOrchestrationTriggerAttributeHandlers.cs
@@ -0,0 +1,129 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Module-owned implementations
+/// for the trigger-attribute family claimed by
+/// sharpclaw_agent_orchestration:
+/// [Schedule], [OnStartup], [OnShutdown],
+/// [OnTaskCompleted], [OnTaskFailed], [OnTrigger].
+///
+/// Each handler returns a shaped
+/// identically to what the legacy core switch produced, so the parser's
+/// downstream BuildTriggerParameters mirroring and
+/// [ConcurrencyPolicy] override pass remain wire-compatible.
+///
+///
+internal static class AgentOrchestrationTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["Schedule"] = new ScheduleHandler(),
+ ["OnStartup"] = new OnStartupHandler(),
+ ["OnShutdown"] = new OnShutdownHandler(),
+ ["OnTaskCompleted"] = new OnTaskCompletedHandler(),
+ ["OnTaskFailed"] = new OnTaskFailedHandler(),
+ ["OnTrigger"] = new OnTriggerHandler(),
+ ["OnEvent"] = new OnEventHandler(),
+ };
+
+ private sealed class OnEventHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var eventType = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(eventType))
+ p[AgentOrchestrationTriggerKeys.EventType] = eventType;
+ var filter = context.GetNamedStringArg("Filter");
+ if (!string.IsNullOrEmpty(filter))
+ p[AgentOrchestrationTriggerKeys.EventFilter] = filter;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = AgentOrchestrationTriggerKeys.Event,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class ScheduleHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var expr = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(expr))
+ p[TaskScriptingTriggerKeys.CronExpression] = expr;
+ var tz = context.GetNamedStringArg("Timezone");
+ if (!string.IsNullOrEmpty(tz))
+ p[TaskScriptingTriggerKeys.CronTimezone] = tz;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = TaskScriptingTriggerKeys.Cron,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class OnStartupHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) =>
+ new() { TriggerKey = TaskScriptingTriggerKeys.Startup };
+ }
+
+ private sealed class OnShutdownHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context) =>
+ new() { TriggerKey = TaskScriptingTriggerKeys.Shutdown };
+ }
+
+ private sealed class OnTaskCompletedHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var src = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(src))
+ p[TaskScriptingTriggerKeys.SourceTaskName] = src;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = TaskScriptingTriggerKeys.TaskCompleted,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class OnTaskFailedHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var src = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(src))
+ p[TaskScriptingTriggerKeys.SourceTaskName] = src;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = TaskScriptingTriggerKeys.TaskFailed,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class OnTriggerHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var filter = context.GetNamedStringArg("Filter");
+ if (!string.IsNullOrEmpty(filter))
+ p[TaskScriptingTriggerKeys.CustomSourceFilter] = filter;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = context.GetStringArg(0),
+ Parameters = p,
+ };
+ }
+ }
+}
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs b/DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs
similarity index 59%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs
rename to DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs
index 131e5176..9ee80cd4 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/LifecycleTriggerSource.cs
+++ b/DefaultModules/AgentOrchestration/TaskScripting/LifecycleTriggerSource.cs
@@ -1,16 +1,19 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using Microsoft.Extensions.Hosting;
-using SharpClaw.Application.Infrastructure.Tasks;
using Microsoft.Extensions.Logging;
-using SharpClaw.Application.Infrastructure.Tasks;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.AgentOrchestration;
///
-/// Fires once on application startup () and
-/// registers a shutdown callback () via
+/// Fires once on application startup
+/// () and registers a shutdown
+/// callback () via
/// .
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
///
public sealed class LifecycleTriggerSource(
IHostApplicationLifetime lifetime,
@@ -21,17 +24,17 @@ public sealed class LifecycleTriggerSource(
private CancellationTokenRegistration _stopReg;
public IReadOnlyList TriggerKeys { get; } =
- [WellKnownTriggerKeys.Startup, WellKnownTriggerKeys.Shutdown];
+ [TaskScriptingTriggerKeys.Startup, TaskScriptingTriggerKeys.Shutdown];
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
_contexts = contexts;
_startReg = lifetime.ApplicationStarted.Register(() =>
- _ = Task.Run(() => FireMatchingAsync(WellKnownTriggerKeys.Startup)));
+ _ = Task.Run(() => FireMatchingAsync(TaskScriptingTriggerKeys.Startup)));
_stopReg = lifetime.ApplicationStopping.Register(() =>
- _ = Task.Run(() => FireMatchingAsync(WellKnownTriggerKeys.Shutdown)));
+ _ = Task.Run(() => FireMatchingAsync(TaskScriptingTriggerKeys.Shutdown)));
return Task.CompletedTask;
}
@@ -44,6 +47,14 @@ public Task StopAsync()
return Task.CompletedTask;
}
+ ///
+ ///
+ /// Lifecycle bindings have no per-definition discriminator — every binding
+ /// of a given kind fires on every host event.
+ /// Returning matches the legacy registrar fallback.
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) => null;
+
private async Task FireMatchingAsync(string key)
{
foreach (var ctx in _contexts.Where(c => c.Definition.TriggerKey == key))
diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs
new file mode 100644
index 00000000..738bc262
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskChainTriggerSource.cs
@@ -0,0 +1,79 @@
+using Microsoft.Extensions.Logging;
+
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Trigger source that fires when another task definition completes
+/// () or fails
+/// (). Receives host events
+/// through .
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
+///
+public sealed class TaskChainTriggerSource(
+ ISharpClawEventSinkRegistry sinkRegistry,
+ ILogger logger) : ITaskTriggerSource, ISharpClawEventSink
+{
+ private IReadOnlyList _contexts = [];
+
+ public IReadOnlyList TriggerKeys { get; } =
+ [
+ TaskScriptingTriggerKeys.TaskCompleted,
+ TaskScriptingTriggerKeys.TaskFailed,
+ ];
+
+ public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
+ {
+ _contexts = contexts;
+ sinkRegistry.InvalidateCache();
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync()
+ {
+ _contexts = [];
+ sinkRegistry.InvalidateCache();
+ return Task.CompletedTask;
+ }
+
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) =>
+ def.Parameters.GetValueOrDefault(TaskScriptingTriggerKeys.SourceTaskName);
+
+ // ── ISharpClawEventSink ───────────────────────────────────────
+
+ public SharpClawEventType SubscribedEvents =>
+ SharpClawEventType.JobCompleted | SharpClawEventType.JobFailed;
+
+ public async Task OnEventAsync(SharpClawEvent evt, CancellationToken ct)
+ {
+ foreach (var ctx in _contexts)
+ {
+ if (!MatchesContext(ctx, evt)) continue;
+
+ try
+ {
+ await ctx.FireAsync(ct: ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex,
+ "TaskChainTriggerSource failed to fire context for definition {Id}.",
+ ctx.TaskDefinitionId);
+ }
+ }
+ }
+
+ private static bool MatchesContext(ITaskTriggerSourceContext ctx, SharpClawEvent evt) =>
+ ctx.Definition.TriggerKey switch
+ {
+ TaskScriptingTriggerKeys.TaskCompleted => evt.Type.HasFlag(SharpClawEventType.JobCompleted),
+ TaskScriptingTriggerKeys.TaskFailed => evt.Type.HasFlag(SharpClawEventType.JobFailed),
+ _ => false,
+ };
+}
diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs
new file mode 100644
index 00000000..4fbb6151
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingParserExtension.cs
@@ -0,0 +1,63 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Registers the Task Scripting module's event-handler names with the parser.
+/// The lifecycle OnTimer handler is contributed here as a module-owned
+/// trigger; the parser stores the trigger key on
+/// TaskStepDefinition.ModuleTriggerKey.
+///
+public sealed class TaskScriptingParserExtension : ITaskParserModuleExtension
+{
+ public static readonly TaskScriptingParserExtension Instance = new();
+
+ ///
+ /// Stable trigger key recorded on the parsed step's
+ /// ModuleTriggerKey for OnTimer handlers.
+ ///
+ public const string TimerTriggerKey = "sharpclaw.task_scripting.timer";
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnTimer"] = (TimerTriggerKey, "sharpclaw_agent_orchestration"),
+ };
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ ///
+ /// Wire-format step-key strings for the statement-shaped scripting
+ /// primitives the parser emits directly. Sourced from
+ /// so the module is the single
+ /// source of truth; core defines no statement step-key constants.
+ ///
+ ///
+ /// Trigger-attribute handlers owned by this module. Phase 2 of the
+ /// trigger-attribute migration: [Schedule], [OnStartup],
+ /// [OnShutdown], [OnTaskCompleted], [OnTaskFailed],
+ /// and [OnTrigger] are claimed here. The parser routes matching
+ /// attribute occurrences through these handlers before falling back to
+ /// its built-in switch.
+ ///
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ AgentOrchestrationTriggerAttributeHandlers.All;
+
+ public TaskParserPrimitives? Primitives { get; } = new()
+ {
+ DeclareVariable = TaskScriptingStepKeys.DeclareVariable,
+ Assign = TaskScriptingStepKeys.Assign,
+ EventHandler = TaskScriptingStepKeys.EventHandler,
+ Conditional = TaskScriptingStepKeys.Conditional,
+ Loop = TaskScriptingStepKeys.Loop,
+ Return = TaskScriptingStepKeys.Return,
+ Delay = TaskScriptingStepKeys.Delay,
+ Evaluate = TaskScriptingStepKeys.Evaluate,
+ Log = TaskScriptingStepKeys.Log,
+ ParseResponse = TaskScriptingStepKeys.ParseResponse,
+ };
+}
diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs
new file mode 100644
index 00000000..ed2b183b
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepDescriptorProvider.cs
@@ -0,0 +1,53 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Contributes the scripting-language and runtime-control step descriptors
+/// owned by the task scripting module to the central task step registry.
+///
+public sealed class TaskScriptingStepDescriptorProvider : ITaskStepDescriptorProvider
+{
+ public string ModuleId => "sharpclaw_agent_orchestration";
+
+ public IReadOnlyList Descriptors { get; } = Build();
+
+ private static TaskStepDescriptor[] Build()
+ {
+ var owner = "sharpclaw_agent_orchestration";
+ return
+ [
+ // ── Statement constructs (registered by key only) ────────────
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.DeclareVariable, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Assign, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.EventHandler, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Conditional, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Loop, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Return, OwnerId = owner },
+ new TaskStepDescriptor { StepKey = TaskScriptingStepKeys.Evaluate, OwnerId = owner },
+
+ // ── Runtime control ────────────────────────────────────────
+ new TaskStepDescriptor
+ {
+ MethodName = "Delay",
+ StepKey = TaskScriptingStepKeys.Delay,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "WaitUntilStopped",
+ StepKey = TaskScriptingStepKeys.WaitUntilStopped,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "Log",
+ StepKey = TaskScriptingStepKeys.Log,
+ OwnerId = owner,
+ FirstArgIsExpression = true,
+ },
+ ];
+ }
+}
diff --git a/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs
new file mode 100644
index 00000000..dda326ed
--- /dev/null
+++ b/DefaultModules/AgentOrchestration/TaskScripting/TaskScriptingStepExecutor.cs
@@ -0,0 +1,246 @@
+using System.Text.Json;
+
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.AgentOrchestration;
+
+///
+/// Module-side executor that owns the Task Scripting language primitives:
+/// declare/assign, evaluate, log, delay, wait-until-stopped, return, and the
+/// control-flow constructs (conditional, loop, event handler).
+///
+/// Implements so it can drive
+/// nested control flow through
+/// while remaining oblivious to the orchestrator's internal types.
+///
+///
+public sealed class TaskScriptingStepExecutor : ITaskStepInvocationExecutor
+{
+ public string ModuleId => "sharpclaw_agent_orchestration";
+
+ public bool CanExecute(string moduleStepKey) => moduleStepKey switch
+ {
+ TaskScriptingStepKeys.DeclareVariable
+ or TaskScriptingStepKeys.Assign
+ or TaskScriptingStepKeys.Log
+ or TaskScriptingStepKeys.Delay
+ or TaskScriptingStepKeys.WaitUntilStopped
+ or TaskScriptingStepKeys.Conditional
+ or TaskScriptingStepKeys.Loop
+ or TaskScriptingStepKeys.EventHandler
+ or TaskScriptingStepKeys.Return
+ or TaskScriptingStepKeys.Evaluate => true,
+ _ => false,
+ };
+
+ ///
+ /// Resolved-argument path is unused — every Task Scripting primitive needs
+ /// raw step access (nested bodies, unresolved expressions, handler bodies).
+ /// The orchestrator routes us through .
+ ///
+ public Task ExecuteAsync(
+ string moduleStepKey,
+ ITaskStepExecutionContext context,
+ IReadOnlyList? arguments,
+ string? expression,
+ string? resultVariable) => Task.FromResult(true);
+
+ public async Task ExecuteInvocationAsync(
+ ITaskStepInvocation step,
+ ITaskStepExecutionContext context)
+ {
+ switch (step.StepKey)
+ {
+ case TaskScriptingStepKeys.DeclareVariable:
+ case TaskScriptingStepKeys.Assign:
+ context.Variables[step.VariableName ?? ""] = step.RawExpression;
+ return TaskStepResult.Continue;
+
+ case TaskScriptingStepKeys.Evaluate:
+ if (step.ResultVariable is not null)
+ context.Variables[step.ResultVariable] = step.RawExpression;
+ return TaskStepResult.Continue;
+
+ case TaskScriptingStepKeys.Log:
+ {
+ var message = step.RawExpression is null
+ ? string.Empty
+ : context.ResolveExpression(step.RawExpression);
+ await context.AppendLogAsync(message);
+ return TaskStepResult.Continue;
+ }
+
+ case TaskScriptingStepKeys.Delay:
+ {
+ var resolved = step.RawExpression is null
+ ? null
+ : context.ResolveExpression(step.RawExpression);
+ if (int.TryParse(resolved, out var delayMs))
+ await DelayWithPauseAsync(delayMs, context);
+ return TaskStepResult.Continue;
+ }
+
+ case TaskScriptingStepKeys.WaitUntilStopped:
+ try
+ {
+ await Task.Delay(Timeout.Infinite, context.CancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected — task was stopped.
+ }
+ return TaskStepResult.Continue;
+
+ case TaskScriptingStepKeys.Return:
+ return TaskStepResult.Return;
+
+ case TaskScriptingStepKeys.Conditional:
+ {
+ var branch = context.EvaluateCondition(step.RawExpression)
+ ? step.Body
+ : step.ElseBody;
+ if (branch is null) return TaskStepResult.Continue;
+ return await context.ExecuteStepsAsync(branch, context.CancellationToken);
+ }
+
+ case TaskScriptingStepKeys.Loop:
+ return await ExecuteLoopAsync(step, context);
+
+ case TaskScriptingStepKeys.EventHandler:
+ {
+ if (step.ModuleTriggerKey is null)
+ throw new InvalidOperationException(
+ "EventHandler step requires a ModuleTriggerKey.");
+ context.RegisterEventHandler(
+ step.ModuleTriggerKey,
+ step.HandlerParameter,
+ step.Body ?? []);
+ await context.AppendLogAsync(
+ $"Registered event handler: {step.ModuleTriggerKey}");
+ return TaskStepResult.Continue;
+ }
+ }
+
+ return TaskStepResult.Continue;
+ }
+
+ // ── Loop helpers ─────────────────────────────────────────────────
+
+ private static async Task ExecuteLoopAsync(
+ ITaskStepInvocation step, ITaskStepExecutionContext context)
+ {
+ var ct = context.CancellationToken;
+ var isForEach = step.VariableName is not null;
+
+ if (isForEach)
+ {
+ foreach (var item in EnumerateLoopValues(step, context))
+ {
+ ct.ThrowIfCancellationRequested();
+ await context.WaitIfPausedAsync();
+ if (step.VariableName is not null)
+ context.Variables[step.VariableName] = item;
+ if (step.Body is null) continue;
+ var result = await context.ExecuteStepsAsync(step.Body, ct);
+ if (result == TaskStepResult.Return) return TaskStepResult.Return;
+ }
+ return TaskStepResult.Continue;
+ }
+
+ while (context.EvaluateCondition(step.RawExpression))
+ {
+ ct.ThrowIfCancellationRequested();
+ await context.WaitIfPausedAsync();
+ if (step.Body is null) continue;
+ var result = await context.ExecuteStepsAsync(step.Body, ct);
+ if (result == TaskStepResult.Return) return TaskStepResult.Return;
+ }
+ return TaskStepResult.Continue;
+ }
+
+ private static IEnumerable
-internal sealed class ComputerUseParserExtension : ITaskParserModuleExtension
+public sealed class ComputerUseParserExtension : ITaskParserModuleExtension
{
public static readonly ComputerUseParserExtension Instance = new();
@@ -21,4 +22,7 @@ internal sealed class ComputerUseParserExtension : ITaskParserModuleExtension
public IReadOnlySet SingleArgExpressionMethods { get; } =
new HashSet();
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ ComputerUseTriggerAttributeHandlers.All;
}
diff --git a/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs b/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs
new file mode 100644
index 00000000..57452cff
--- /dev/null
+++ b/DefaultModules/ComputerUse/Contracts/IShortcutLauncher.cs
@@ -0,0 +1,31 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.ComputerUse.Contracts;
+
+///
+/// Module-owned contract for OS shortcut creation and removal.
+/// The launcher reads shortcut metadata
+/// (ShortcutLabel, ShortcutIcon, ShortcutCategory)
+/// from using
+/// rather than from the
+/// typed shim properties on .
+///
+public interface IShortcutLauncher
+{
+ ///
+ /// Writes the OS shortcut (.lnk / .desktop) and stub launcher for the
+ /// given trigger definition.
+ ///
+ Task WriteShortcutAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default);
+
+ ///
+ /// Rewrites the shortcut file for an existing task (label, icon, category).
+ /// Leaves the stub launcher untouched.
+ ///
+ Task RefreshShortcutsAsync(TaskTriggerDefinition definition, string customId, CancellationToken ct = default);
+
+ ///
+ /// Deletes all shortcut files and the stub launcher for .
+ ///
+ Task RemoveShortcutsAsync(string customId, CancellationToken ct = default);
+}
diff --git a/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs b/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs
index baa9df21..81280188 100644
--- a/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs
+++ b/DefaultModules/ComputerUse/Services/ShortcutLauncherService.cs
@@ -3,6 +3,8 @@
using System.Text;
using Microsoft.Extensions.Logging;
using SharpClaw.Contracts.Tasks;
+using SharpClaw.Modules.ComputerUse.Contracts;
+using SharpClaw.Modules.ComputerUse.Triggers;
namespace SharpClaw.Modules.ComputerUse.Services;
@@ -21,7 +23,7 @@ namespace SharpClaw.Modules.ComputerUse.Services;
/// macOS is not supported. When called on macOS a TASK441 warning is
/// logged and the method returns without throwing.
///
-public sealed class ShortcutLauncherService(ILogger logger) : IShortcutLauncherService
+public sealed class ShortcutLauncherService(ILogger logger) : IShortcutLauncher
{
// ── Directory layout ─────────────────────────────────────────
// All files are placed under %LOCALAPPDATA%\SharpClaw\Shortcuts on Windows
@@ -213,15 +215,16 @@ private async Task WriteWindowsShortcutAsync(
System.Reflection.BindingFlags.SetProperty, null, shortcut,
[stubPath]);
- var label = definition.ShortcutLabel ?? customId;
+ var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId;
shortcutType.InvokeMember("Description",
System.Reflection.BindingFlags.SetProperty, null, shortcut,
[label]);
- if (!string.IsNullOrWhiteSpace(definition.ShortcutIcon))
+ var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon);
+ if (!string.IsNullOrWhiteSpace(icon))
shortcutType.InvokeMember("IconLocation",
System.Reflection.BindingFlags.SetProperty, null, shortcut,
- [definition.ShortcutIcon]);
+ [icon]);
shortcutType.InvokeMember("Save",
System.Reflection.BindingFlags.InvokeMethod, null, shortcut, null);
@@ -260,11 +263,12 @@ private async Task WriteLinuxDesktopEntryAsync(
Directory.CreateDirectory(DesktopApplicationsDirectory);
var desktopPath = Path.Combine(DesktopApplicationsDirectory, $"sharpclaw-{safe}.desktop");
- var label = definition.ShortcutLabel ?? customId;
- var icon = definition.ShortcutIcon ?? "utilities-terminal";
- var category = string.IsNullOrWhiteSpace(definition.ShortcutCategory)
+ var label = ReadParam(definition, OsShortcutTriggerKeys.ShortcutLabel) ?? customId;
+ var icon = ReadParam(definition, OsShortcutTriggerKeys.ShortcutIcon) ?? "utilities-terminal";
+ var rawCategory = ReadParam(definition, OsShortcutTriggerKeys.ShortcutCategory);
+ var category = string.IsNullOrWhiteSpace(rawCategory)
? "Utility;"
- : $"{definition.ShortcutCategory};";
+ : $"{rawCategory};";
var sb = new StringBuilder();
sb.AppendLine("[Desktop Entry]");
@@ -353,6 +357,24 @@ private static void MakeExecutable(string path)
}
}
+ ///
+ /// Reads a shortcut parameter from .
+ /// Returns when the key is absent or the value is
+ /// blank. The launcher reads exclusively from Parameters so the
+ /// shim typed properties on can be
+ /// removed in a later phase.
+ ///
+ private static string? ReadParam(TaskTriggerDefinition definition, string key)
+ {
+ if (definition.Parameters.TryGetValue(key, out var value)
+ && !string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ return null;
+ }
+
/// Sanitizes a custom ID for use as a file name.
internal static string Sanitize(string customId)
{
diff --git a/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..9102e26d
--- /dev/null
+++ b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerAttributeHandlers.cs
@@ -0,0 +1,206 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.ComputerUse.Triggers;
+
+///
+/// Module-owned implementations
+/// for the desktop / OS trigger-attribute family claimed by
+/// sharpclaw_computer_use:
+/// [OnProcessStarted], [OnProcessStopped],
+/// [OnWindowFocused], [OnWindowBlurred], [OnHotkey],
+/// [OnSystemIdle], [OnSystemActive], [OnScreenLocked],
+/// [OnScreenUnlocked], [OnDeviceConnected],
+/// [OnDeviceDisconnected], and [OsShortcut].
+///
+/// Behavior preserved verbatim from the legacy core parser switch,
+/// including TASK420 (macOS-incompatible attribute warning) and TASK429
+/// (hotkey combination validation).
+///
+///
+internal static class ComputerUseTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnProcessStarted"] = new ProcessHandler("ProcessStarted"),
+ ["OnProcessStopped"] = new ProcessHandler("ProcessStopped"),
+ ["OnWindowFocused"] = new WindowHandler("WindowFocused"),
+ ["OnWindowBlurred"] = new WindowHandler("WindowBlurred"),
+ ["OnHotkey"] = new HotkeyHandler(),
+ ["OnSystemIdle"] = new SystemIdleHandler(),
+ ["OnSystemActive"] = new MacIncompatibleHandler("SystemActive"),
+ ["OnScreenLocked"] = new MacIncompatibleHandler("ScreenLocked"),
+ ["OnScreenUnlocked"] = new MacIncompatibleHandler("ScreenUnlocked"),
+ ["OnDeviceConnected"] = new DeviceHandler("DeviceConnected"),
+ ["OnDeviceDisconnected"]= new DeviceHandler("DeviceDisconnected"),
+ ["OsShortcut"] = new OsShortcutHandler(),
+ };
+
+ private static void EmitMacWarning(TaskTriggerAttributeContext ctx)
+ {
+ ctx.Report(
+ TaskTriggerAttributeDiagnosticSeverity.Warning,
+ "TASK420",
+ $"[{ctx.AttributeName}] is not supported on macOS and will be ignored at runtime on that platform.");
+ }
+
+ private static TaskTriggerDefinition WithProcess(string triggerKey, TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var name = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(name))
+ p[ComputerUseTriggerKeys.ProcessName] = name;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = triggerKey,
+ Parameters = p,
+ };
+ }
+
+ private sealed class ProcessHandler(string triggerKey) : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ EmitMacWarning(context);
+ return WithProcess(triggerKey, context);
+ }
+ }
+
+ private sealed class WindowHandler(string triggerKey) : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ EmitMacWarning(context);
+ return WithProcess(triggerKey, context);
+ }
+ }
+
+ private sealed class MacIncompatibleHandler(string triggerKey) : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ EmitMacWarning(context);
+ return new TaskTriggerDefinition { TriggerKey = triggerKey };
+ }
+ }
+
+ private sealed class HotkeyHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ EmitMacWarning(context);
+ var combo = context.GetStringArg(0);
+ if (!IsHotkeyComboValid(combo))
+ {
+ context.Report(
+ TaskTriggerAttributeDiagnosticSeverity.Error,
+ "TASK429",
+ $"[OnHotkey] key combination \"{combo}\" could not be parsed. " +
+ "Expected format: \"Modifier+Key\" (e.g. \"Ctrl+Shift+F10\").");
+ }
+ var p = new Dictionary(StringComparer.Ordinal);
+ if (!string.IsNullOrEmpty(combo))
+ p[ComputerUseTriggerKeys.HotkeyCombo] = combo;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = ComputerUseTriggerKeys.Hotkey,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class SystemIdleHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ EmitMacWarning(context);
+ var minutes = context.GetNamedIntArg("Minutes") ?? context.GetIntArg(0);
+ var p = new Dictionary(StringComparer.Ordinal);
+ if (minutes.HasValue)
+ p[ComputerUseTriggerKeys.IdleMinutes] = minutes.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = ComputerUseTriggerKeys.SystemIdle,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class DeviceHandler(string triggerKey) : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var cls = context.GetNamedStringArg("Class");
+ if (!string.IsNullOrEmpty(cls))
+ p[ComputerUseTriggerKeys.DeviceClass] = cls;
+ var pattern = context.GetNamedStringArg("Pattern");
+ if (!string.IsNullOrEmpty(pattern))
+ p[ComputerUseTriggerKeys.DeviceNamePattern] = pattern;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = triggerKey,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class OsShortcutHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var label = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(label))
+ p[OsShortcutTriggerKeys.ShortcutLabel] = label;
+ var icon = context.GetNamedStringArg("Icon");
+ if (!string.IsNullOrEmpty(icon))
+ p[OsShortcutTriggerKeys.ShortcutIcon] = icon;
+ var category = context.GetNamedStringArg("Category");
+ if (!string.IsNullOrEmpty(category))
+ p[OsShortcutTriggerKeys.ShortcutCategory] = category;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = OsShortcutTriggerKeys.OsShortcut,
+ Parameters = p,
+ };
+ }
+ }
+
+ // ── Hotkey validation (verbatim from TaskScriptParser) ────────
+
+ private static readonly HashSet KnownModifiers =
+ ["Ctrl", "Alt", "Shift", "Win", "Meta", "Control", "Windows"];
+
+ private static readonly HashSet KnownKeys =
+ ["F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12",
+ "A","B","C","D","E","F","G","H","I","J","K","L","M",
+ "N","O","P","Q","R","S","T","U","V","W","X","Y","Z",
+ "0","1","2","3","4","5","6","7","8","9",
+ "Space","Enter","Tab","Escape","Backspace","Delete","Insert",
+ "Home","End","PageUp","PageDown","Up","Down","Left","Right",
+ "NumPad0","NumPad1","NumPad2","NumPad3","NumPad4",
+ "NumPad5","NumPad6","NumPad7","NumPad8","NumPad9",
+ "Multiply","Add","Subtract","Divide","Decimal",
+ "OemSemicolon","OemPlus","OemComma","OemMinus","OemPeriod",
+ "OemOpenBrackets","OemCloseBrackets","OemPipe","OemQuotes","OemBackslash",
+ "PrintScreen","Pause","ScrollLock","CapsLock","NumLock"];
+
+ private static bool IsHotkeyComboValid(string? combo)
+ {
+ if (string.IsNullOrWhiteSpace(combo))
+ return false;
+
+ var parts = combo.Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ return false;
+
+ for (var i = 0; i < parts.Length - 1; i++)
+ {
+ if (!KnownModifiers.Contains(parts[i]))
+ return false;
+ }
+
+ return KnownKeys.Contains(parts[^1]);
+ }
+}
diff --git a/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs
new file mode 100644
index 00000000..8e1da8cd
--- /dev/null
+++ b/DefaultModules/ComputerUse/Triggers/ComputerUseTriggerKeys.cs
@@ -0,0 +1,33 @@
+namespace SharpClaw.Modules.ComputerUse.Triggers;
+
+///
+/// Trigger and parameter keys owned by the computer-use module's desktop
+/// trigger family (process, window-focus, hotkey, idle, screen-lock,
+/// device). String values mirror the legacy TaskTriggerDefinition
+/// property names verbatim so persisted binding rows and serialized
+/// scripts continue to round-trip after the typed-property surface is
+/// removed.
+///
+public static class ComputerUseTriggerKeys
+{
+ // Trigger keys persisted in TaskTriggerBindingDB.Kind.
+ public const string ProcessStarted = "ProcessStarted";
+ public const string ProcessStopped = "ProcessStopped";
+ public const string WindowFocused = "WindowFocused";
+ public const string WindowBlurred = "WindowBlurred";
+ public const string Hotkey = "Hotkey";
+ public const string SystemIdle = "SystemIdle";
+ public const string SystemActive = "SystemActive";
+ public const string ScreenLocked = "ScreenLocked";
+ public const string ScreenUnlocked = "ScreenUnlocked";
+ public const string DeviceConnected = "DeviceConnected";
+ public const string DeviceDisconnected = "DeviceDisconnected";
+
+ // Parameter names — must match the legacy TaskTriggerDefinition
+ // property names so existing on-disk JSON keeps deserialising.
+ public const string ProcessName = "ProcessName";
+ public const string HotkeyCombo = "HotkeyCombo";
+ public const string IdleMinutes = "IdleMinutes";
+ public const string DeviceClass = "DeviceClass";
+ public const string DeviceNamePattern = "DeviceNamePattern";
+}
diff --git a/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs
index 257cfd82..d15db899 100644
--- a/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs
+++ b/DefaultModules/ComputerUse/Triggers/DeviceTriggerSource.cs
@@ -155,7 +155,7 @@ private async Task FireMatchingAsync(string triggerKey, string deviceId)
{
foreach (var ctx in _contexts.Where(c => c.Definition.TriggerKey == triggerKey))
{
- var pattern = ctx.Definition.DeviceNamePattern;
+ var pattern = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.DeviceNamePattern);
if (!string.IsNullOrWhiteSpace(pattern) &&
!deviceId.Contains(pattern, StringComparison.OrdinalIgnoreCase))
continue;
diff --git a/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs
index a03e810c..65019f3a 100644
--- a/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs
+++ b/DefaultModules/ComputerUse/Triggers/HotkeyTriggerSource.cs
@@ -63,7 +63,7 @@ private async Task StartWindowsHotkeysAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var combo = ctx.Definition.HotkeyCombo;
+ var combo = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.HotkeyCombo);
if (string.IsNullOrWhiteSpace(combo)) continue;
if (!TryParseHotkey(combo, out var modifiers, out var vk))
diff --git a/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs
index 15d0a69e..1681bdc0 100644
--- a/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs
+++ b/DefaultModules/ComputerUse/Triggers/IdleTriggerSource.cs
@@ -66,17 +66,22 @@ private async Task PollAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var threshold = (ctx.Definition.IdleMinutes ?? 5) * 60;
+ var minutes = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5;
+ var threshold = minutes * 60;
var isIdle = idleSeconds >= threshold;
- if (ctx.Definition.TriggerKey == "SystemIdle" && !wasIdle && isIdle)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.SystemIdle && !wasIdle && isIdle)
await FireAsync(ctx);
- if (ctx.Definition.TriggerKey == "SystemActive" && wasIdle && !isIdle)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.SystemActive && wasIdle && !isIdle)
await FireAsync(ctx);
}
- wasIdle = _contexts.Any(c => GetIdleSeconds() >= (c.Definition.IdleMinutes ?? 5) * 60);
+ wasIdle = _contexts.Any(c =>
+ {
+ var m = TryParseInt(c.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.IdleMinutes)) ?? 5;
+ return GetIdleSeconds() >= m * 60;
+ });
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
@@ -137,6 +142,10 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx)
}
}
+ private static int? TryParseInt(string? value) =>
+ int.TryParse(value, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null;
+
[StructLayout(LayoutKind.Sequential)]
private struct LASTINPUTINFO
{
diff --git a/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs
new file mode 100644
index 00000000..5a0f5d9a
--- /dev/null
+++ b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerKeys.cs
@@ -0,0 +1,33 @@
+namespace SharpClaw.Modules.ComputerUse.Triggers;
+
+///
+/// Module-owned trigger-key and parameter-name constants for the
+/// OsShortcut trigger. The string values must remain byte-identical
+/// to the legacy core constants — they are persisted into
+/// TaskTriggerBindingDB.Kind and into
+/// TaskTriggerDefinition.Parameters keys, and existing serialized
+/// task scripts must round-trip without rewrites.
+///
+public static class OsShortcutTriggerKeys
+{
+ /// Trigger key persisted in TaskTriggerBindingDB.Kind.
+ public const string OsShortcut = "OsShortcut";
+
+ ///
+ /// TaskTriggerDefinition.Parameters key for the user-visible
+ /// shortcut label (window title / desktop entry Name).
+ ///
+ public const string ShortcutLabel = "ShortcutLabel";
+
+ ///
+ /// TaskTriggerDefinition.Parameters key for the icon path or
+ /// stock icon name passed to the OS shell when registering the shortcut.
+ ///
+ public const string ShortcutIcon = "ShortcutIcon";
+
+ ///
+ /// TaskTriggerDefinition.Parameters key for the freedesktop /
+ /// Start menu category the shortcut appears under.
+ ///
+ public const string ShortcutCategory = "ShortcutCategory";
+}
diff --git a/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs
new file mode 100644
index 00000000..a3df8059
--- /dev/null
+++ b/DefaultModules/ComputerUse/Triggers/OsShortcutTriggerSource.cs
@@ -0,0 +1,86 @@
+using Microsoft.Extensions.Logging;
+
+using SharpClaw.Contracts.Tasks;
+using SharpClaw.Modules.ComputerUse.Contracts;
+
+namespace SharpClaw.Modules.ComputerUse.Triggers;
+
+///
+/// Module-owned trigger source for OsShortcut. The OS shortcut
+/// trigger is unusual in that the persisted binding row itself looks
+/// identical to any other trigger binding — the registrar's default
+/// TaskTriggerBindingDB upsert path handles persistence — but
+/// creating or removing a binding must also write or delete an OS-level
+/// shortcut file (.lnk on Windows, .desktop on Linux).
+///
+/// The source therefore opts out of binding-persistence ownership
+/// ( is left at
+/// the default ) and instead implements
+/// so the registrar fires
+/// the file-side action immediately after each binding row is added or
+/// removed.
+///
+public sealed class OsShortcutTriggerSource(
+ IShortcutLauncher launcher,
+ ILogger logger)
+ : ITaskTriggerSource, ITaskTriggerBindingSideEffect
+{
+ ///
+ public string TriggerKey => OsShortcutTriggerKeys.OsShortcut;
+
+ ///
+ public Task StartAsync(IReadOnlyList contexts, CancellationToken ct) =>
+ Task.CompletedTask;
+
+ ///
+ public Task StopAsync() => Task.CompletedTask;
+
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def)
+ {
+ if (def.Parameters.TryGetValue(OsShortcutTriggerKeys.ShortcutLabel, out var label) &&
+ !string.IsNullOrWhiteSpace(label))
+ {
+ return label;
+ }
+
+ return null;
+ }
+
+ // Explicit interface implementation: TriggerKey is shared with
+ // ITaskTriggerSource and identical, so we just route to it.
+ string ITaskTriggerBindingSideEffect.TriggerKey => TriggerKey;
+
+ ///
+ public async Task OnBindingCreatedAsync(
+ TaskDefinitionDescriptor definition,
+ TaskTriggerDefinition trigger,
+ TaskTriggerBindingDescriptor binding,
+ CancellationToken ct)
+ {
+ var customId = definition.Name;
+ if (string.IsNullOrWhiteSpace(customId))
+ {
+ logger.LogWarning(
+ "OsShortcut binding for definition {DefinitionId} has no task name; skipping shortcut write.",
+ definition.Id);
+ return;
+ }
+
+ await launcher.WriteShortcutAsync(trigger, customId, ct);
+ }
+
+ ///
+ public async Task OnBindingRemovedAsync(
+ TaskTriggerBindingDescriptor binding,
+ CancellationToken ct)
+ {
+ var customId = binding.TriggerValue;
+ if (string.IsNullOrWhiteSpace(customId))
+ {
+ customId = binding.TaskDefinitionId.ToString();
+ }
+
+ await launcher.RemoveShortcutsAsync(customId, ct);
+ }
+}
diff --git a/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs
index afba3db3..ede5b954 100644
--- a/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs
+++ b/DefaultModules/ComputerUse/Triggers/ProcessTriggerSource.cs
@@ -67,16 +67,16 @@ private async Task PollAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var name = ctx.Definition.ProcessName;
+ var name = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName);
if (string.IsNullOrWhiteSpace(name)) continue;
var wasRunning = seen.Contains(name);
var isRunning = runningSet.Contains(name);
- if (ctx.Definition.TriggerKey == "ProcessStarted" && !wasRunning && isRunning)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.ProcessStarted && !wasRunning && isRunning)
await FireAsync(ctx);
- if (ctx.Definition.TriggerKey == "ProcessStopped" && wasRunning && !isRunning)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.ProcessStopped && wasRunning && !isRunning)
await FireAsync(ctx);
}
diff --git a/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs b/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs
index 2bbf2aad..9d8847d4 100644
--- a/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs
+++ b/DefaultModules/ComputerUse/Triggers/WindowFocusTriggerSource.cs
@@ -67,7 +67,7 @@ private async Task PollAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var processName = ctx.Definition.ProcessName;
+ var processName = ctx.Definition.Parameters.GetValueOrDefault(ComputerUseTriggerKeys.ProcessName);
if (string.IsNullOrWhiteSpace(processName)) continue;
var isFocused = !string.IsNullOrEmpty(activeTitle) &&
@@ -76,10 +76,10 @@ private async Task PollAsync(CancellationToken ct)
var wasFocused = lastFocused.GetValueOrDefault(processName, false);
lastFocused[processName] = isFocused;
- if (ctx.Definition.TriggerKey == "WindowFocused" && !wasFocused && isFocused)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.WindowFocused && !wasFocused && isFocused)
await FireAsync(ctx);
- if (ctx.Definition.TriggerKey == "WindowBlurred" && wasFocused && !isFocused)
+ if (ctx.Definition.TriggerKey == ComputerUseTriggerKeys.WindowBlurred && wasFocused && !isFocused)
await FireAsync(ctx);
}
}
diff --git a/DefaultModules/ContextTools/ContextToolsChatContributor.cs b/DefaultModules/ContextTools/ContextToolsChatContributor.cs
new file mode 100644
index 00000000..e0120b8e
--- /dev/null
+++ b/DefaultModules/ContextTools/ContextToolsChatContributor.cs
@@ -0,0 +1,20 @@
+using SharpClaw.Contracts.Chat;
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Modules.ContextTools.Services;
+
+namespace SharpClaw.Modules.ContextTools;
+
+///
+/// Module-owned that surfaces
+/// the ContextTools cross-thread visibility policy to the host chat
+/// pipeline. The permission key (CanReadCrossThreadHistory) and
+/// the underlying query both live in this module; core only invokes
+/// and never names the wire string.
+///
+internal sealed class ContextToolsChatContributor(ContextDataReader dataReader)
+ : IChatProcessingContributor
+{
+ public Task> GetAccessibleThreadsAsync(
+ Guid agentId, Guid currentChannelId, CancellationToken ct = default)
+ => dataReader.GetAccessibleThreadsAsync(agentId, currentChannelId, ct);
+}
diff --git a/DefaultModules/ContextTools/ContextToolsModule.cs b/DefaultModules/ContextTools/ContextToolsModule.cs
index 87d1f0f3..52c94cb3 100644
--- a/DefaultModules/ContextTools/ContextToolsModule.cs
+++ b/DefaultModules/ContextTools/ContextToolsModule.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
+using SharpClaw.Contracts.Chat;
using SharpClaw.Contracts.Modules;
using SharpClaw.Modules.ContextTools.Services;
@@ -25,7 +26,9 @@ public sealed class ContextToolsModule : ISharpClawModule
public void ConfigureServices(IServiceCollection services)
{
+ services.TryAddScoped();
services.TryAddScoped();
+ services.AddScoped();
}
// ═══════════════════════════════════════════════════════════════
@@ -40,7 +43,7 @@ public void ConfigureServices(IServiceCollection services)
public IReadOnlyList GetGlobalFlagDescriptors() =>
[
- new("CanReadCrossThreadHistory", "Read Cross-Thread History", "Read conversation history from other threads/channels.", "ReadCrossThreadHistoryAsync"),
+ new(ContextToolsPermissionKeys.CanReadCrossThreadHistory, "Read Cross-Thread History", "Read conversation history from other threads/channels.", "ReadCrossThreadHistoryAsync"),
];
// ═══════════════════════════════════════════════════════════════
diff --git a/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs b/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs
new file mode 100644
index 00000000..49ed18d4
--- /dev/null
+++ b/DefaultModules/ContextTools/ContextToolsPermissionKeys.cs
@@ -0,0 +1,19 @@
+namespace SharpClaw.Modules.ContextTools;
+
+///
+/// Module-owned global-flag keys for the context-tools module.
+///
+/// The string values are the canonical wire identifiers persisted in
+/// GlobalFlagDB.FlagKey. They are exposed to host/core code only
+/// through the
+/// contract so that core never names them inline.
+///
+///
+public static class ContextToolsPermissionKeys
+{
+ ///
+ /// Grants permission to read conversation history from threads on
+ /// channels other than the active one.
+ ///
+ public const string CanReadCrossThreadHistory = "CanReadCrossThreadHistory";
+}
diff --git a/DefaultModules/ContextTools/Services/ContextDataReader.cs b/DefaultModules/ContextTools/Services/ContextDataReader.cs
new file mode 100644
index 00000000..66156051
--- /dev/null
+++ b/DefaultModules/ContextTools/Services/ContextDataReader.cs
@@ -0,0 +1,99 @@
+using Microsoft.EntityFrameworkCore;
+
+using SharpClaw.Contracts.Enums;
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Persistence;
+
+namespace SharpClaw.Modules.ContextTools.Services;
+
+///
+/// A single chat message returned by .
+/// Module-internal record; never crosses the host boundary.
+///
+internal sealed record ChatMessageSummary(
+ string Role,
+ string Content,
+ string Sender,
+ DateTimeOffset Timestamp);
+
+///
+/// Module-owned read service that backs the ContextTools inline tools.
+/// Reads directly from the host's
+/// surface so the cross-thread visibility policy and the
+/// CanReadCrossThreadHistory permission key live in the module
+/// that owns them.
+///
+internal sealed class ContextDataReader(ISharpClawDataContext data)
+{
+ public Task ThreadExistsAsync(Guid threadId, Guid channelId, CancellationToken ct = default) =>
+ data.ChatThreads.AnyAsync(t => t.Id == threadId && t.ChannelId == channelId, ct);
+
+ public async Task> GetThreadMessagesAsync(
+ Guid threadId, int maxMessages, CancellationToken ct = default)
+ {
+ return await data.ChatMessages
+ .Where(m => m.ThreadId == threadId)
+ .OrderByDescending(m => m.CreatedAt)
+ .Take(maxMessages)
+ .OrderBy(m => m.CreatedAt)
+ .Select(m => new ChatMessageSummary(
+ m.Role,
+ m.Content,
+ m.SenderUsername ?? m.SenderAgentName ?? "unknown",
+ m.CreatedAt))
+ .ToListAsync(ct);
+ }
+
+ public async Task> GetAccessibleThreadsAsync(
+ Guid agentId, Guid currentChannelId, CancellationToken ct = default)
+ {
+ var agentWithRole = await data.Agents
+ .Include(a => a.Role)
+ .ThenInclude(r => r!.PermissionSet)
+ .ThenInclude(ps => ps!.GlobalFlags)
+ .FirstOrDefaultAsync(a => a.Id == agentId, ct);
+
+ var agentPs = agentWithRole?.Role?.PermissionSet;
+ var crossThreadKey = ContextToolsPermissionKeys.CanReadCrossThreadHistory;
+ if (agentPs is null || !agentPs.GlobalFlags.Any(f => f.FlagKey == crossThreadKey))
+ return [];
+
+ var isIndependent = (agentPs.GlobalFlags
+ .FirstOrDefault(f => f.FlagKey == crossThreadKey)
+ ?.Clearance ?? PermissionClearance.Unset) == PermissionClearance.Independent;
+
+ var channels = await data.Channels
+ .Include(c => c.AllowedAgents)
+ .Include(c => c.PermissionSet)
+ .ThenInclude(ps => ps!.GlobalFlags)
+ .Include(c => c.AgentContext)
+ .ThenInclude(ctx => ctx!.PermissionSet)
+ .ThenInclude(ps => ps!.GlobalFlags)
+ .Where(c => c.Id != currentChannelId)
+ .Where(c => c.AgentId == agentId || c.AllowedAgents.Any(a => a.Id == agentId))
+ .ToListAsync(ct);
+
+ if (!isIndependent)
+ {
+ channels = channels
+ .Where(c =>
+ {
+ var effectivePs = c.PermissionSet ?? c.AgentContext?.PermissionSet;
+ return effectivePs?.GlobalFlags.Any(f => f.FlagKey == crossThreadKey) == true;
+ })
+ .ToList();
+ }
+
+ if (channels.Count == 0)
+ return [];
+
+ var channelIds = channels.Select(c => c.Id).ToList();
+ var channelTitles = channels.ToDictionary(c => c.Id, c => c.Title);
+
+ return await data.ChatThreads
+ .Where(t => channelIds.Contains(t.ChannelId))
+ .OrderByDescending(t => t.UpdatedAt)
+ .Select(t => new ThreadSummary(t.Id, t.Name, t.ChannelId, channelTitles[t.ChannelId]))
+ .ToListAsync(ct);
+ }
+}
diff --git a/DefaultModules/ContextTools/Services/ContextToolsService.cs b/DefaultModules/ContextTools/Services/ContextToolsService.cs
index 352f263f..fe87ed5b 100644
--- a/DefaultModules/ContextTools/Services/ContextToolsService.cs
+++ b/DefaultModules/ContextTools/Services/ContextToolsService.cs
@@ -1,14 +1,12 @@
using System.Text.Json;
-using SharpClaw.Contracts.Modules;
-
namespace SharpClaw.Modules.ContextTools.Services;
///
/// Wraps cross-thread context and utility operations for the
/// Context Tools module (inline tools).
///
-internal sealed class ContextToolsService(IContextDataReader dataReader)
+internal sealed class ContextToolsService(ContextDataReader dataReader)
{
public static async Task WaitAsync(
JsonElement parameters, CancellationToken ct)
diff --git a/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs b/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs
index 14612e11..3dbb26be 100644
--- a/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs
+++ b/DefaultModules/DatabaseAccess/DatabaseAccessModule.cs
@@ -23,8 +23,10 @@ namespace SharpClaw.Modules.DatabaseAccess;
/// Supports PostgreSQL, MySQL, SQLite, and MSSQL with read-only
/// safety by default.
///
-public sealed class DatabaseAccessModule : ISharpClawModule
+public sealed class DatabaseAccessModule : ISharpClawModule, ITaskParserAware
{
+ public ITaskParserModuleExtension ParserExtension => DatabaseAccessParserExtension.Instance;
+
public string Id => "sharpclaw_database_access";
public string DisplayName => "Database Access";
public string ToolPrefix => "db";
diff --git a/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs b/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs
new file mode 100644
index 00000000..09f7aba4
--- /dev/null
+++ b/DefaultModules/DatabaseAccess/DatabaseAccessParserExtension.cs
@@ -0,0 +1,22 @@
+using SharpClaw.Contracts.Tasks;
+using SharpClaw.Modules.DatabaseAccess.Triggers;
+
+namespace SharpClaw.Modules.DatabaseAccess;
+
+/// Parser extension exposing module-owned trigger-attribute handlers.
+public sealed class DatabaseAccessParserExtension : ITaskParserModuleExtension
+{
+ public static readonly DatabaseAccessParserExtension Instance = new();
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ DatabaseAccessTriggerAttributeHandlers.All;
+}
diff --git a/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..dee5bbc6
--- /dev/null
+++ b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerAttributeHandlers.cs
@@ -0,0 +1,57 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.DatabaseAccess.Triggers;
+
+///
+/// Module-owned for
+/// [OnQueryReturnsRows]. Behavior preserved verbatim from the legacy
+/// core parser switch, including the TASK431 SELECT COUNT(*) shape warning.
+///
+internal static class DatabaseAccessTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnQueryReturnsRows"] = new OnQueryReturnsRowsHandler(),
+ };
+
+ private sealed class OnQueryReturnsRowsHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var query = context.GetStringArg(0);
+ var interval = context.GetNamedIntArg("PollInterval");
+
+ if (!IsSelectCountQuery(query))
+ {
+ context.Report(
+ TaskTriggerAttributeDiagnosticSeverity.Warning,
+ "TASK431",
+ "[OnQueryReturnsRows] query should be a SELECT COUNT(*) expression " +
+ "to avoid unintended side-effects.");
+ }
+
+ var p = new Dictionary(StringComparer.Ordinal);
+ if (!string.IsNullOrEmpty(query))
+ p[DatabaseAccessTriggerKeys.SqlQuery] = query;
+ if (interval.HasValue)
+ p[DatabaseAccessTriggerKeys.QueryPollIntervalSecs] = interval.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = DatabaseAccessTriggerKeys.QueryReturnsRows,
+ Parameters = p,
+ };
+ }
+
+ private static bool IsSelectCountQuery(string? query)
+ {
+ if (string.IsNullOrWhiteSpace(query))
+ return false;
+
+ var normalized = query.Replace('\n', ' ').Replace('\r', ' ');
+ var upper = normalized.Trim().ToUpperInvariant();
+ return upper.StartsWith("SELECT COUNT(", StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs
new file mode 100644
index 00000000..dd3db1c7
--- /dev/null
+++ b/DefaultModules/DatabaseAccess/Triggers/DatabaseAccessTriggerKeys.cs
@@ -0,0 +1,17 @@
+namespace SharpClaw.Modules.DatabaseAccess.Triggers;
+
+///
+/// Trigger and parameter keys owned by the database-access module's
+/// query-rows trigger. String values mirror the legacy
+/// TaskTriggerDefinition property names verbatim so persisted
+/// binding rows and serialized scripts continue to round-trip.
+///
+public static class DatabaseAccessTriggerKeys
+{
+ public const string QueryReturnsRows = "QueryReturnsRows";
+
+ // Parameter names — must match the legacy TaskTriggerDefinition
+ // property names so existing on-disk JSON keeps deserialising.
+ public const string SqlQuery = "SqlQuery";
+ public const string QueryPollIntervalSecs = "QueryPollIntervalSecs";
+}
diff --git a/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs b/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs
index 8feca5ee..0b372d46 100644
--- a/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs
+++ b/DefaultModules/DatabaseAccess/Triggers/QueryRowsTriggerSource.cs
@@ -53,7 +53,7 @@ private async Task PollAsync(CancellationToken ct)
try
{
var minInterval = _contexts
- .Select(c => c.Definition.QueryPollIntervalSecs ?? 60)
+ .Select(c => TryParseInt(c.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.QueryPollIntervalSecs)) ?? 60)
.DefaultIfEmpty(60)
.Min();
@@ -61,7 +61,7 @@ private async Task PollAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var sql = ctx.Definition.SqlQuery;
+ var sql = ctx.Definition.Parameters.GetValueOrDefault(DatabaseAccessTriggerKeys.SqlQuery);
if (string.IsNullOrWhiteSpace(sql)) continue;
try
@@ -93,4 +93,8 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx)
"QueryRowsTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId);
}
}
+
+ private static int? TryParseInt(string? value) =>
+ int.TryParse(value, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null;
}
diff --git a/DefaultModules/EditorCommon/Services/EditorBridgeService.cs b/DefaultModules/EditorCommon/Services/EditorBridgeService.cs
index 71e72786..565a98e3 100644
--- a/DefaultModules/EditorCommon/Services/EditorBridgeService.cs
+++ b/DefaultModules/EditorCommon/Services/EditorBridgeService.cs
@@ -15,7 +15,7 @@ namespace SharpClaw.Modules.EditorCommon.Services;
/// connected editor and waits for responses.
///
/// On connect the service auto-creates (or reuses) an
-///
+///
/// resource so agents can reference it immediately. On disconnect the
/// ConnectionId is cleared but the resource persists for
/// permission/default references.
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs b/DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs
similarity index 70%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs
rename to DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs
index 87b4be85..e73c6d65 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/FileChangedTriggerSource.cs
+++ b/DefaultModules/FilesystemTriggers/FileChangedTriggerSource.cs
@@ -1,12 +1,18 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using Microsoft.Extensions.Logging;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.FilesystemTriggers;
///
/// Trigger source that fires when files under a watched path are created,
/// changed, deleted, or renamed via .
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim. The persisted trigger-key value
+/// () remains
+/// "FileChanged" so existing binding rows continue to round-trip.
+///
///
public sealed class FileChangedTriggerSource(
ILogger logger) : ITaskTriggerSource, IAsyncDisposable
@@ -14,7 +20,7 @@ public sealed class FileChangedTriggerSource(
private readonly List _watchers = [];
private IReadOnlyList _contexts = [];
- public string TriggerKey => WellKnownTriggerKeys.FileChanged;
+ public string TriggerKey => FilesystemTriggerKeys.FileChanged;
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
@@ -23,7 +29,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel
foreach (var ctx in contexts)
{
- var path = ctx.Definition.WatchPath;
+ var path = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath);
if (string.IsNullOrWhiteSpace(path))
{
logger.LogWarning(
@@ -33,7 +39,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel
}
var dir = Path.GetDirectoryName(path) ?? path;
- var pattern = ctx.Definition.FilePattern ?? "*";
+ var pattern = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FilePattern) ?? "*";
if (!Directory.Exists(dir))
{
@@ -48,7 +54,9 @@ public Task StartAsync(IReadOnlyList contexts, Cancel
EnableRaisingEvents = false,
};
- var events = ctx.Definition.FileEvents == 0 ? FileWatchEvent.Any : ctx.Definition.FileEvents;
+ var rawEvents = ctx.Definition.Parameters.GetValueOrDefault(FilesystemTriggerKeys.FileEvents);
+ var events = ParseFileEvents(rawEvents);
+ if (events == 0) events = FileWatchEvent.Any;
if (events.HasFlag(FileWatchEvent.Created)) watcher.Created += (_, _) => Fire(ctx);
if (events.HasFlag(FileWatchEvent.Changed)) watcher.Changed += (_, _) => Fire(ctx);
@@ -69,6 +77,12 @@ public Task StopAsync()
return Task.CompletedTask;
}
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) =>
+ def.TriggerKey == FilesystemTriggerKeys.FileChanged
+ ? def.Parameters.GetValueOrDefault(FilesystemTriggerKeys.WatchPath)
+ : null;
+
public ValueTask DisposeAsync()
{
StopWatchers();
@@ -104,4 +118,7 @@ private void Fire(ITaskTriggerSourceContext ctx) =>
ctx.TaskDefinitionId);
}
});
+
+ private static FileWatchEvent ParseFileEvents(string? raw) =>
+ Enum.TryParse(raw, ignoreCase: true, out var parsed) ? parsed : 0;
}
diff --git a/SharpClaw.Contracts/Tasks/FileWatchEvent.cs b/DefaultModules/FilesystemTriggers/FileWatchEvent.cs
similarity index 84%
rename from SharpClaw.Contracts/Tasks/FileWatchEvent.cs
rename to DefaultModules/FilesystemTriggers/FileWatchEvent.cs
index 2ecf2e2c..3e320cd6 100644
--- a/SharpClaw.Contracts/Tasks/FileWatchEvent.cs
+++ b/DefaultModules/FilesystemTriggers/FileWatchEvent.cs
@@ -1,4 +1,4 @@
-namespace SharpClaw.Contracts.Tasks;
+namespace SharpClaw.Modules.FilesystemTriggers;
///
/// File-system events that the [OnFileChanged] trigger watches for.
diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..829b8a8e
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/FilesystemTriggerAttributeHandlers.cs
@@ -0,0 +1,39 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.FilesystemTriggers;
+
+///
+/// Module-owned for
+/// [OnFileChanged]. Behavior preserved verbatim from the legacy
+/// core parser switch.
+///
+internal static class FilesystemTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnFileChanged"] = new OnFileChangedHandler(),
+ };
+
+ private sealed class OnFileChangedHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var watchPath = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(watchPath))
+ p[FilesystemTriggerKeys.WatchPath] = watchPath;
+ var pattern = context.GetNamedStringArg("Pattern");
+ if (!string.IsNullOrEmpty(pattern))
+ p[FilesystemTriggerKeys.FilePattern] = pattern;
+ var events = context.GetNamedEnumArg("Events") ?? FileWatchEvent.Any;
+ if (events != default)
+ p[FilesystemTriggerKeys.FileEvents] = events.ToString();
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = FilesystemTriggerKeys.FileChanged,
+ Parameters = p,
+ };
+ }
+ }
+}
diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs
new file mode 100644
index 00000000..bfabced8
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/FilesystemTriggerKeys.cs
@@ -0,0 +1,17 @@
+namespace SharpClaw.Modules.FilesystemTriggers;
+
+///
+/// Trigger and parameter keys owned by the filesystem-triggers module.
+/// String values are persisted verbatim in binding rows and serialized
+/// scripts.
+///
+public static class FilesystemTriggerKeys
+{
+ /// Trigger-key value persisted in TaskTriggerBindingDB.Kind.
+ public const string FileChanged = "FileChanged";
+
+ // Parameter names — must match TaskTriggerDefinition property names.
+ public const string WatchPath = "WatchPath";
+ public const string FilePattern = "FilePattern";
+ public const string FileEvents = "FileEvents";
+}
diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs
new file mode 100644
index 00000000..dcbf8b44
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/FilesystemTriggersModule.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.FilesystemTriggers;
+
+///
+/// Default module that owns the FileChanged task trigger.
+/// Scaffolded in Phase 1 of the trigger-extraction plan; the
+/// FileChangedTriggerSource implementation is moved into this
+/// module in Phase 3.
+///
+public sealed class FilesystemTriggersModule : ISharpClawModule, ITaskParserAware
+{
+ public ITaskParserModuleExtension ParserExtension => FilesystemTriggersParserExtension.Instance;
+
+ public string Id => "sharpclaw_filesystem_triggers";
+ public string DisplayName => "Filesystem Triggers";
+ public string ToolPrefix => "fstrigger";
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddSingleton();
+ }
+
+ public IReadOnlyList GetToolDefinitions() => [];
+
+ public Task ExecuteToolAsync(
+ string toolName,
+ JsonElement parameters,
+ AgentJobContext job,
+ IServiceProvider scopedServices,
+ CancellationToken ct) =>
+ throw new InvalidOperationException(
+ $"FilesystemTriggers module has no job-pipeline tools. Unknown: '{toolName}'.");
+}
diff --git a/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs b/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs
new file mode 100644
index 00000000..35c0f312
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/FilesystemTriggersParserExtension.cs
@@ -0,0 +1,21 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.FilesystemTriggers;
+
+/// Parser extension exposing module-owned trigger-attribute handlers.
+public sealed class FilesystemTriggersParserExtension : ITaskParserModuleExtension
+{
+ public static readonly FilesystemTriggersParserExtension Instance = new();
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ FilesystemTriggerAttributeHandlers.All;
+}
diff --git a/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj b/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj
new file mode 100644
index 00000000..fc6c8441
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/SharpClaw.Modules.FilesystemTriggers.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DefaultModules/FilesystemTriggers/module.json b/DefaultModules/FilesystemTriggers/module.json
new file mode 100644
index 00000000..c8252584
--- /dev/null
+++ b/DefaultModules/FilesystemTriggers/module.json
@@ -0,0 +1,17 @@
+{
+ "id": "sharpclaw_filesystem_triggers",
+ "displayName": "Filesystem Triggers",
+ "version": "1.0.0",
+ "toolPrefix": "fstrigger",
+ "entryAssembly": "SharpClaw.Modules.FilesystemTriggers",
+ "minHostVersion": "1.0.0",
+ "author": "SharpClaw Team",
+ "description": "Owns the FileChanged task trigger and the underlying FileSystemWatcher-based trigger source.",
+ "license": "AGPL-3.0",
+ "platforms": null,
+ "enabled": true,
+ "defaultEnabled": true,
+ "executionTimeoutSeconds": 60,
+ "exports": [],
+ "requires": []
+}
diff --git a/DefaultModules/Http/HttpModule.cs b/DefaultModules/Http/HttpModule.cs
new file mode 100644
index 00000000..34d1de0a
--- /dev/null
+++ b/DefaultModules/Http/HttpModule.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Http;
+
+///
+/// Default module that owns the task-script HTTP request step
+/// (HttpGet/HttpPost/HttpPut/HttpDelete → http_request).
+///
+public sealed class HttpModule : ISharpClawModule, ITaskParserAware
+{
+ public ITaskParserModuleExtension ParserExtension => HttpParserExtension.Instance;
+
+ public string Id => "sharpclaw_http";
+ public string DisplayName => "HTTP";
+ public string ToolPrefix => "http";
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
+ }
+
+ public IReadOnlyList GetToolDefinitions() => [];
+
+ public Task ExecuteToolAsync(
+ string toolName,
+ JsonElement parameters,
+ AgentJobContext job,
+ IServiceProvider scopedServices,
+ CancellationToken ct) =>
+ throw new InvalidOperationException(
+ $"HTTP module has no job-pipeline tools. Unknown: '{toolName}'.");
+}
diff --git a/DefaultModules/Http/HttpParserExtension.cs b/DefaultModules/Http/HttpParserExtension.cs
new file mode 100644
index 00000000..97fee14d
--- /dev/null
+++ b/DefaultModules/Http/HttpParserExtension.cs
@@ -0,0 +1,21 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Http;
+
+/// Parser extension exposing module-owned trigger-attribute handlers.
+public sealed class HttpParserExtension : ITaskParserModuleExtension
+{
+ public static readonly HttpParserExtension Instance = new();
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ HttpTriggerAttributeHandlers.All;
+}
diff --git a/DefaultModules/Http/HttpStepDescriptorProvider.cs b/DefaultModules/Http/HttpStepDescriptorProvider.cs
new file mode 100644
index 00000000..64f199a9
--- /dev/null
+++ b/DefaultModules/Http/HttpStepDescriptorProvider.cs
@@ -0,0 +1,55 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Http;
+
+///
+/// Contributes the HTTP-request step descriptors owned by the HTTP module
+/// to the central task step registry. The four script verbs (HttpGet,
+/// HttpPost, HttpPut, HttpDelete) all share a single step key.
+///
+public sealed class HttpStepDescriptorProvider : ITaskStepDescriptorProvider
+{
+ public string ModuleId => "sharpclaw_http";
+
+ public IReadOnlyList Descriptors { get; } = Build();
+
+ private static TaskStepDescriptor[] Build()
+ {
+ const string owner = "sharpclaw_http";
+ return
+ [
+ new TaskStepDescriptor
+ {
+ MethodName = "HttpGet",
+ StepKey = HttpStepKeys.HttpRequest,
+ OwnerId = owner,
+ PrefixArgument = "GET",
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "HttpPost",
+ StepKey = HttpStepKeys.HttpRequest,
+ OwnerId = owner,
+ PrefixArgument = "POST",
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "HttpPut",
+ StepKey = HttpStepKeys.HttpRequest,
+ OwnerId = owner,
+ PrefixArgument = "PUT",
+ FirstArgIsExpression = true,
+ },
+ new TaskStepDescriptor
+ {
+ MethodName = "HttpDelete",
+ StepKey = HttpStepKeys.HttpRequest,
+ OwnerId = owner,
+ PrefixArgument = "DELETE",
+ FirstArgIsExpression = true,
+ },
+ ];
+ }
+}
diff --git a/DefaultModules/Http/HttpStepKeys.cs b/DefaultModules/Http/HttpStepKeys.cs
new file mode 100644
index 00000000..1ae2a81a
--- /dev/null
+++ b/DefaultModules/Http/HttpStepKeys.cs
@@ -0,0 +1,16 @@
+namespace SharpClaw.Modules.Http;
+
+///
+/// Stable well-known step keys owned by the HTTP module.
+///
+/// IMPORTANT: The literal string value intentionally matches the legacy
+/// core.http_request value so existing serialized task scripts
+/// continue to parse. Only the C# location of the constant changes; the
+/// wire format does not.
+///
+///
+public static class HttpStepKeys
+{
+ /// Make an HTTP request (shared by HttpGet/HttpPost/HttpPut/HttpDelete).
+ public const string HttpRequest = "core.http_request";
+}
diff --git a/DefaultModules/Http/HttpTaskStepExecutor.cs b/DefaultModules/Http/HttpTaskStepExecutor.cs
new file mode 100644
index 00000000..06cbb64f
--- /dev/null
+++ b/DefaultModules/Http/HttpTaskStepExecutor.cs
@@ -0,0 +1,71 @@
+using System.Net.Http;
+using System.Text;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Http;
+
+///
+/// Module-side executor for the HTTP request task step
+/// (). Resolves
+/// from the running task's scoped
+/// services and performs the request without any direct dependency on
+/// Core or Infrastructure types.
+///
+///
+/// The HTTP step descriptors declare the verb via
+/// TaskStepDescriptor.PrefixArgument; the parser prepends it to
+/// the parsed step's argument list so the host-side step contract
+/// stays free of HTTP-specific properties.
+///
+public sealed class HttpTaskStepExecutor : ITaskStepExecutorExtension
+{
+ public string ModuleId => "sharpclaw_http";
+
+ public bool CanExecute(string moduleStepKey) =>
+ moduleStepKey == HttpStepKeys.HttpRequest;
+
+ public async Task ExecuteAsync(
+ string moduleStepKey,
+ ITaskStepExecutionContext context,
+ IReadOnlyList? arguments,
+ string? expression,
+ string? resultVariable)
+ {
+ if (moduleStepKey != HttpStepKeys.HttpRequest)
+ return false;
+
+ var url = expression ?? string.Empty;
+
+ // The parser prepends descriptor.PrefixArgument (the HTTP verb) as
+ // the first argument; arguments[1..] are the call's resolved
+ // positional arguments (arg[1] = URL, arg[2] = body when present).
+ var method = arguments is { Count: > 0 } ? arguments[0].ToUpperInvariant() : "GET";
+
+ var factory = context.Services.GetRequiredService();
+ using var client = factory.CreateClient("TaskOrchestrator");
+
+ using var request = new HttpRequestMessage(new HttpMethod(method), url);
+ if (method is "POST" or "PUT")
+ {
+ var body = arguments is { Count: > 2 }
+ ? arguments[2]
+ : arguments is { Count: > 1 }
+ ? arguments[1]
+ : string.Empty;
+ request.Content = new StringContent(body, Encoding.UTF8, "application/json");
+ }
+
+ using var response = await client.SendAsync(request, context.CancellationToken);
+ var content = await response.Content.ReadAsStringAsync(context.CancellationToken);
+
+ await context.AppendLogAsync($"HTTP {method} {url} → {(int)response.StatusCode}");
+
+ if (resultVariable is not null)
+ context.Variables[resultVariable] = content;
+
+ return true;
+ }
+}
diff --git a/DefaultModules/Http/HttpTriggerAttributeHandlers.cs b/DefaultModules/Http/HttpTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..a50761ce
--- /dev/null
+++ b/DefaultModules/Http/HttpTriggerAttributeHandlers.cs
@@ -0,0 +1,41 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Http;
+
+///
+/// Module-owned for
+/// [OnWebhook]. Behavior preserved verbatim from the legacy core
+/// parser switch. The cross-attribute [WebhookSecret]/[OnWebhook]
+/// presence check (TASK428) remains in the core parser because it spans
+/// multiple attributes on the class.
+///
+internal static class HttpTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnWebhook"] = new OnWebhookHandler(),
+ };
+
+ private sealed class OnWebhookHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var route = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(route))
+ p[HttpTriggerKeys.WebhookRoute] = route;
+ var secret = context.GetNamedStringArg("Secret");
+ if (!string.IsNullOrEmpty(secret))
+ p[HttpTriggerKeys.WebhookSecretEnvVar] = secret;
+ var sigHeader = context.GetNamedStringArg("SignatureHeader");
+ if (!string.IsNullOrEmpty(sigHeader))
+ p[HttpTriggerKeys.WebhookSignatureHeader] = sigHeader;
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = HttpTriggerKeys.Webhook,
+ Parameters = p,
+ };
+ }
+ }
+}
diff --git a/DefaultModules/Http/HttpTriggerKeys.cs b/DefaultModules/Http/HttpTriggerKeys.cs
new file mode 100644
index 00000000..fb924c15
--- /dev/null
+++ b/DefaultModules/Http/HttpTriggerKeys.cs
@@ -0,0 +1,17 @@
+namespace SharpClaw.Modules.Http;
+
+///
+/// Trigger and parameter keys owned by the HTTP module.
+/// String values are persisted verbatim in binding rows and serialized
+/// scripts.
+///
+public static class HttpTriggerKeys
+{
+ /// Trigger-key value persisted in TaskTriggerBindingDB.Kind.
+ public const string Webhook = "Webhook";
+
+ // Parameter names — must match TaskTriggerDefinition property names.
+ public const string WebhookRoute = "WebhookRoute";
+ public const string WebhookSecretEnvVar = "WebhookSecretEnvVar";
+ public const string WebhookSignatureHeader = "WebhookSignatureHeader";
+}
diff --git a/DefaultModules/Http/SharpClaw.Modules.Http.csproj b/DefaultModules/Http/SharpClaw.Modules.Http.csproj
new file mode 100644
index 00000000..9a0929a2
--- /dev/null
+++ b/DefaultModules/Http/SharpClaw.Modules.Http.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs b/DefaultModules/Http/WebhookTriggerSource.cs
similarity index 83%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs
rename to DefaultModules/Http/WebhookTriggerSource.cs
index 123c63a1..0d8c13b0 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/WebhookTriggerSource.cs
+++ b/DefaultModules/Http/WebhookTriggerSource.cs
@@ -1,22 +1,27 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+
using Microsoft.Extensions.Logging;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.Http;
///
-/// Trigger source for bindings.
+/// Trigger source for bindings.
/// Registers one dynamic POST route per active binding at startup via
/// , and maintains an in-memory
/// registry so that disabled bindings return 404 without requiring a
/// route to be unregistered (minimal-API routes cannot be removed at
/// runtime).
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
///
-public sealed class WebhookTriggerSource : ITaskTriggerSource
+public sealed class WebhookTriggerSource : ITaskTriggerSource, IWebhookTriggerHost
{
private readonly ILogger _logger;
private IWebhookRouteRegistrar? _routeRegistrar;
@@ -46,7 +51,7 @@ public void SetRouteRegistrar(IWebhookRouteRegistrar registrar)
// ── ITaskTriggerSource ────────────────────────────────────────
///
- public string TriggerKey => WellKnownTriggerKeys.Webhook;
+ public string TriggerKey => HttpTriggerKeys.Webhook;
///
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
@@ -56,7 +61,8 @@ public Task StartAsync(IReadOnlyList contexts, Cancel
foreach (var ctx in contexts)
{
var def = ctx.Definition;
- if (string.IsNullOrWhiteSpace(def.WebhookRoute))
+ var route = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute);
+ if (string.IsNullOrWhiteSpace(route))
{
_logger.LogWarning(
"Webhook binding for task {TaskId} has no WebhookRoute — skipping.",
@@ -64,7 +70,7 @@ public Task StartAsync(IReadOnlyList contexts, Cancel
continue;
}
- var routePath = NormalizeRoute(def.WebhookRoute);
+ var routePath = NormalizeRoute(route);
_activeRoutes[routePath] = ctx;
// Register the route once; subsequent bindings to the same path
@@ -87,6 +93,12 @@ public Task StopAsync()
return Task.CompletedTask;
}
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) =>
+ def.TriggerKey == HttpTriggerKeys.Webhook
+ ? def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookRoute)
+ : null;
+
// ── Public helpers ────────────────────────────────────────────
///
@@ -105,20 +117,22 @@ public async Task HandleRequestAsync(
var def = ctx.Definition;
// HMAC-SHA256 validation
- if (!string.IsNullOrWhiteSpace(def.WebhookSecretEnvVar))
+ var secretEnvVar = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSecretEnvVar);
+ if (!string.IsNullOrWhiteSpace(secretEnvVar))
{
- var secret = Environment.GetEnvironmentVariable(def.WebhookSecretEnvVar);
+ var secret = Environment.GetEnvironmentVariable(secretEnvVar);
if (string.IsNullOrEmpty(secret))
{
_logger.LogWarning(
"Webhook {Route}: secret env var '{Var}' is not set — rejecting request.",
- routePath, def.WebhookSecretEnvVar);
+ routePath, secretEnvVar);
return 401;
}
- var sigHeader = string.IsNullOrWhiteSpace(def.WebhookSignatureHeader)
+ var configuredSigHeader = def.Parameters.GetValueOrDefault(HttpTriggerKeys.WebhookSignatureHeader);
+ var sigHeader = string.IsNullOrWhiteSpace(configuredSigHeader)
? DefaultSignatureHeader
- : def.WebhookSignatureHeader;
+ : configuredSigHeader;
var providedSig = ExtractHeader(headersJson, sigHeader);
if (!ValidateHmac(body, secret, providedSig))
diff --git a/DefaultModules/Http/module.json b/DefaultModules/Http/module.json
new file mode 100644
index 00000000..cf58f363
--- /dev/null
+++ b/DefaultModules/Http/module.json
@@ -0,0 +1,17 @@
+{
+ "id": "sharpclaw_http",
+ "displayName": "HTTP",
+ "version": "1.0.0",
+ "toolPrefix": "http",
+ "entryAssembly": "SharpClaw.Modules.Http",
+ "minHostVersion": "1.0.0",
+ "author": "SharpClaw Team",
+ "description": "Owns the task-script HTTP request step (HttpGet/HttpPost/HttpPut/HttpDelete → http_request).",
+ "license": "AGPL-3.0",
+ "platforms": null,
+ "enabled": true,
+ "defaultEnabled": true,
+ "executionTimeoutSeconds": 60,
+ "exports": [],
+ "requires": []
+}
diff --git a/DefaultModules/Metrics/BuiltInMetricProviders.cs b/DefaultModules/Metrics/BuiltInMetricProviders.cs
new file mode 100644
index 00000000..42550f8d
--- /dev/null
+++ b/DefaultModules/Metrics/BuiltInMetricProviders.cs
@@ -0,0 +1,36 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Metrics;
+
+/// Reports the number of pending (Queued) agent jobs (Queue.PendingJobCount).
+public sealed class PendingJobCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider
+{
+ public string MetricName => "Queue.PendingJobCount";
+ public string Description => "Number of agent jobs currently in the Queued state.";
+
+ public Task GetValueAsync(CancellationToken ct) =>
+ host.GetPendingJobCountAsync(ct);
+}
+
+/// Reports the number of pending (Queued) task instances (Queue.PendingTaskCount).
+public sealed class PendingTaskCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider
+{
+ public string MetricName => "Queue.PendingTaskCount";
+ public string Description => "Number of task instances currently in the Queued state.";
+
+ public Task GetValueAsync(CancellationToken ct) =>
+ host.GetPendingTaskCountAsync(ct);
+}
+
+///
+/// Reports the number of scheduled jobs with a past NextRunAt
+/// that have not yet run (Scheduler.PendingJobCount).
+///
+public sealed class SchedulerPendingJobCountMetricProvider(IHostQueueMetrics host) : ITaskMetricProvider
+{
+ public string MetricName => "Scheduler.PendingJobCount";
+ public string Description => "Number of scheduled jobs past their NextRunAt time that have not been triggered.";
+
+ public Task GetValueAsync(CancellationToken ct) =>
+ host.GetSchedulerPendingJobCountAsync(ct);
+}
diff --git a/DefaultModules/Metrics/MetricTriggerKeys.cs b/DefaultModules/Metrics/MetricTriggerKeys.cs
new file mode 100644
index 00000000..109dca5f
--- /dev/null
+++ b/DefaultModules/Metrics/MetricTriggerKeys.cs
@@ -0,0 +1,16 @@
+namespace SharpClaw.Modules.Metrics;
+
+///
+/// Trigger and parameter keys owned by the metrics module. String values
+/// are persisted verbatim in binding rows and serialized scripts.
+///
+public static class MetricTriggerKeys
+{
+ public const string MetricThreshold = "MetricThreshold";
+
+ // Parameter names — must match TaskTriggerDefinition property names.
+ public const string Source = "MetricSource";
+ public const string Threshold = "MetricThreshold";
+ public const string Direction = "MetricDirection";
+ public const string PollIntervalSecs = "MetricPollIntervalSecs";
+}
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs b/DefaultModules/Metrics/MetricTriggerSource.cs
similarity index 74%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs
rename to DefaultModules/Metrics/MetricTriggerSource.cs
index f7107187..7b99537c 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/MetricTriggerSource.cs
+++ b/DefaultModules/Metrics/MetricTriggerSource.cs
@@ -1,8 +1,8 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using Microsoft.Extensions.Logging;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.Metrics;
///
/// Fires when the value of a named metric crosses a configured threshold.
@@ -17,7 +17,7 @@ public sealed class MetricTriggerSource(
private Task? _pollTask;
private IReadOnlyList _contexts = [];
- public string TriggerKey => WellKnownTriggerKeys.MetricThreshold;
+ public string TriggerKey => MetricTriggerKeys.MetricThreshold;
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
@@ -45,6 +45,8 @@ public async Task StopAsync()
_contexts = [];
}
+ public string? GetBindingValue(TaskTriggerDefinition t) => t.Parameters.GetValueOrDefault(MetricTriggerKeys.Source);
+
public async ValueTask DisposeAsync() => await StopAsync();
private async Task PollAsync(CancellationToken ct)
@@ -61,10 +63,11 @@ private async Task PollAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
var def = ctx.Definition;
- if (string.IsNullOrWhiteSpace(def.MetricSource)) continue;
+ var source = def.Parameters.GetValueOrDefault(MetricTriggerKeys.Source);
+ if (string.IsNullOrWhiteSpace(source)) continue;
var provider = _providers.FirstOrDefault(p =>
- string.Equals(p.MetricName, def.MetricSource, StringComparison.OrdinalIgnoreCase));
+ string.Equals(p.MetricName, source, StringComparison.OrdinalIgnoreCase));
if (provider is null) continue;
@@ -76,8 +79,9 @@ private async Task PollAsync(CancellationToken ct)
continue;
}
- var threshold = def.MetricThreshold ?? 0;
- var crossed = def.MetricDirection switch
+ var threshold = TryParseDouble(def.Parameters.GetValueOrDefault(MetricTriggerKeys.Threshold)) ?? 0;
+ var direction = ParseDirection(def.Parameters.GetValueOrDefault(MetricTriggerKeys.Direction));
+ var crossed = direction switch
{
ThresholdDirection.Above => value > threshold,
ThresholdDirection.Below => value < threshold,
@@ -109,4 +113,13 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx)
"MetricTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId);
}
}
+
+ private static double? TryParseDouble(string? value) =>
+ double.TryParse(value, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : null;
+
+ private static ThresholdDirection ParseDirection(string? value) =>
+ Enum.TryParse(value, ignoreCase: true, out var parsed)
+ ? parsed
+ : default;
}
diff --git a/DefaultModules/Metrics/MetricsModule.cs b/DefaultModules/Metrics/MetricsModule.cs
new file mode 100644
index 00000000..85c514f7
--- /dev/null
+++ b/DefaultModules/Metrics/MetricsModule.cs
@@ -0,0 +1,44 @@
+using System.Text.Json;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Metrics;
+
+///
+/// Default module that owns the MetricThreshold task trigger and the
+/// built-in implementations. Scaffolded in
+/// Phase 1 of the trigger-extraction plan; MetricTriggerSource and the
+/// three providers from BuiltInMetricProviders.cs move here in Phase 4.
+///
+public sealed class MetricsModule : ISharpClawModule, ITaskParserAware
+{
+ public ITaskParserModuleExtension ParserExtension => MetricsParserExtension.Instance;
+
+ public string Id => "sharpclaw_metrics";
+ public string DisplayName => "Metrics";
+ public string ToolPrefix => "metric";
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ // Built-in providers consume IHostQueueMetrics (forwarded from the host)
+ // so the module does not depend on SharpClawDbContext directly.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ public IReadOnlyList GetToolDefinitions() => [];
+
+ public Task ExecuteToolAsync(
+ string toolName,
+ JsonElement parameters,
+ AgentJobContext job,
+ IServiceProvider scopedServices,
+ CancellationToken ct) =>
+ throw new InvalidOperationException(
+ $"Metrics module has no job-pipeline tools. Unknown: '{toolName}'.");
+}
diff --git a/DefaultModules/Metrics/MetricsParserExtension.cs b/DefaultModules/Metrics/MetricsParserExtension.cs
new file mode 100644
index 00000000..bc1190f7
--- /dev/null
+++ b/DefaultModules/Metrics/MetricsParserExtension.cs
@@ -0,0 +1,21 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Metrics;
+
+/// Parser extension exposing module-owned trigger-attribute handlers.
+public sealed class MetricsParserExtension : ITaskParserModuleExtension
+{
+ public static readonly MetricsParserExtension Instance = new();
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ MetricsTriggerAttributeHandlers.All;
+}
diff --git a/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs b/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..01a35a2d
--- /dev/null
+++ b/DefaultModules/Metrics/MetricsTriggerAttributeHandlers.cs
@@ -0,0 +1,41 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.Metrics;
+
+///
+/// Module-owned for
+/// [OnMetricThreshold]. Behavior preserved verbatim from the legacy
+/// core parser switch.
+///
+internal static class MetricsTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnMetricThreshold"] = new OnMetricThresholdHandler(),
+ };
+
+ private sealed class OnMetricThresholdHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var source = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(source))
+ p[MetricTriggerKeys.Source] = source;
+ var threshold = context.GetNamedDoubleArg("Threshold");
+ if (threshold.HasValue)
+ p[MetricTriggerKeys.Threshold] = threshold.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ var direction = context.GetNamedEnumArg("Direction") ?? ThresholdDirection.Either;
+ p[MetricTriggerKeys.Direction] = direction.ToString();
+ var pollInterval = context.GetNamedIntArg("PollInterval");
+ if (pollInterval.HasValue)
+ p[MetricTriggerKeys.PollIntervalSecs] = pollInterval.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = MetricTriggerKeys.MetricThreshold,
+ Parameters = p,
+ };
+ }
+ }
+}
diff --git a/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj b/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj
new file mode 100644
index 00000000..3b476f27
--- /dev/null
+++ b/DefaultModules/Metrics/SharpClaw.Modules.Metrics.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DefaultModules/Metrics/module.json b/DefaultModules/Metrics/module.json
new file mode 100644
index 00000000..59e23aa7
--- /dev/null
+++ b/DefaultModules/Metrics/module.json
@@ -0,0 +1,17 @@
+{
+ "id": "sharpclaw_metrics",
+ "displayName": "Metrics",
+ "version": "1.0.0",
+ "toolPrefix": "metric",
+ "entryAssembly": "SharpClaw.Modules.Metrics",
+ "minHostVersion": "1.0.0",
+ "author": "SharpClaw Team",
+ "description": "Owns the MetricThreshold task trigger and the built-in metric providers.",
+ "license": "AGPL-3.0",
+ "platforms": null,
+ "enabled": true,
+ "defaultEnabled": true,
+ "executionTimeoutSeconds": 60,
+ "exports": [],
+ "requires": []
+}
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs b/DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs
similarity index 74%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs
rename to DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs
index a70ce50c..5f2c58b1 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/HostProbeTriggerSource.cs
+++ b/DefaultModules/NetworkTriggers/HostProbeTriggerSource.cs
@@ -1,13 +1,19 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
+
using Microsoft.Extensions.Logging;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.NetworkTriggers;
///
/// Fires when a monitored host becomes reachable or unreachable, checked via
/// TCP connect or ICMP ping on a timer.
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
///
public sealed class HostProbeTriggerSource(
ILogger logger) : ITaskTriggerSource, IAsyncDisposable
@@ -17,7 +23,7 @@ public sealed class HostProbeTriggerSource(
private IReadOnlyList _contexts = [];
public IReadOnlyList TriggerKeys { get; } =
- [WellKnownTriggerKeys.HostReachable, WellKnownTriggerKeys.HostUnreachable];
+ [NetworkTriggerKeys.HostReachable, NetworkTriggerKeys.HostUnreachable];
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
@@ -45,6 +51,14 @@ public async Task StopAsync()
_contexts = [];
}
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) => def.TriggerKey switch
+ {
+ NetworkTriggerKeys.HostReachable or NetworkTriggerKeys.HostUnreachable =>
+ def.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName),
+ _ => null,
+ };
+
public async ValueTask DisposeAsync() => await StopAsync();
private async Task ProbeLoopAsync(CancellationToken ct)
@@ -60,20 +74,20 @@ private async Task ProbeLoopAsync(CancellationToken ct)
foreach (var ctx in _contexts)
{
- var host = ctx.Definition.HostName;
+ var host = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostName);
if (string.IsNullOrWhiteSpace(host)) continue;
- var port = ctx.Definition.HostPort;
+ var port = TryParseInt(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.HostPort));
var key = port.HasValue ? $"{host}:{port}" : host;
var reachable = await IsReachableAsync(host, port, ct);
var wasReachable = lastState.GetValueOrDefault(key, !reachable);
lastState[key] = reachable;
- if (ctx.Definition.TriggerKey == WellKnownTriggerKeys.HostReachable && !wasReachable && reachable)
+ if (ctx.Definition.TriggerKey == NetworkTriggerKeys.HostReachable && !wasReachable && reachable)
await FireAsync(ctx);
- if (ctx.Definition.TriggerKey == WellKnownTriggerKeys.HostUnreachable && wasReachable && !reachable)
+ if (ctx.Definition.TriggerKey == NetworkTriggerKeys.HostUnreachable && wasReachable && !reachable)
await FireAsync(ctx);
}
}
@@ -118,4 +132,8 @@ private async Task FireAsync(ITaskTriggerSourceContext ctx)
"HostProbeTriggerSource failed to fire context for definition {Id}.", ctx.TaskDefinitionId);
}
}
+
+ private static int? TryParseInt(string? value) =>
+ int.TryParse(value, System.Globalization.NumberStyles.Integer,
+ System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null;
}
diff --git a/SharpClaw.Contracts/Tasks/NetworkState.cs b/DefaultModules/NetworkTriggers/NetworkState.cs
similarity index 77%
rename from SharpClaw.Contracts/Tasks/NetworkState.cs
rename to DefaultModules/NetworkTriggers/NetworkState.cs
index a91e9926..e69dc949 100644
--- a/SharpClaw.Contracts/Tasks/NetworkState.cs
+++ b/DefaultModules/NetworkTriggers/NetworkState.cs
@@ -1,4 +1,4 @@
-namespace SharpClaw.Contracts.Tasks;
+namespace SharpClaw.Modules.NetworkTriggers;
/// Network availability state used by the [OnNetworkChanged] trigger.
public enum NetworkState { Any, Connected, Disconnected }
diff --git a/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs b/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs
new file mode 100644
index 00000000..10c88436
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/NetworkTriggerAttributeHandlers.cs
@@ -0,0 +1,59 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.NetworkTriggers;
+
+///
+/// Module-owned implementations
+/// for the network trigger-attribute family:
+/// [OnHostReachable], [OnHostUnreachable],
+/// [OnNetworkChanged]. Behavior preserved verbatim from the legacy
+/// core parser switch.
+///
+internal static class NetworkTriggerAttributeHandlers
+{
+ public static IReadOnlyDictionary All { get; } =
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["OnHostReachable"] = new HostHandler(NetworkTriggerKeys.HostReachable),
+ ["OnHostUnreachable"] = new HostHandler(NetworkTriggerKeys.HostUnreachable),
+ ["OnNetworkChanged"] = new NetworkChangedHandler(),
+ };
+
+ private sealed class HostHandler(string triggerKey) : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var host = context.GetStringArg(0);
+ if (!string.IsNullOrEmpty(host))
+ p[NetworkTriggerKeys.HostName] = host;
+ var port = context.GetNamedIntArg("Port");
+ if (port.HasValue)
+ p[NetworkTriggerKeys.HostPort] = port.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = triggerKey,
+ Parameters = p,
+ };
+ }
+ }
+
+ private sealed class NetworkChangedHandler : ITaskTriggerAttributeHandler
+ {
+ public TaskTriggerDefinition? Handle(TaskTriggerAttributeContext context)
+ {
+ var p = new Dictionary(StringComparer.Ordinal);
+ var ssid = context.GetNamedStringArg("Ssid");
+ if (!string.IsNullOrEmpty(ssid))
+ p[NetworkTriggerKeys.NetworkSsid] = ssid;
+ var state = context.GetNamedEnumArg("State") ?? NetworkState.Any;
+ if (state != default)
+ p[NetworkTriggerKeys.NetworkState] = state.ToString();
+ return new TaskTriggerDefinition
+ {
+ TriggerKey = NetworkTriggerKeys.NetworkChanged,
+ Parameters = p,
+ };
+ }
+ }
+}
diff --git a/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs b/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs
new file mode 100644
index 00000000..9ab4e580
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/NetworkTriggerKeys.cs
@@ -0,0 +1,19 @@
+namespace SharpClaw.Modules.NetworkTriggers;
+
+///
+/// Trigger and parameter keys owned by the network-triggers module.
+/// String values are persisted verbatim in binding rows and serialized
+/// scripts.
+///
+public static class NetworkTriggerKeys
+{
+ public const string HostReachable = "HostReachable";
+ public const string HostUnreachable = "HostUnreachable";
+ public const string NetworkChanged = "NetworkChanged";
+
+ // Parameter names — must match TaskTriggerDefinition property names.
+ public const string HostName = "HostName";
+ public const string HostPort = "HostPort";
+ public const string NetworkSsid = "NetworkSsid";
+ public const string NetworkState = "NetworkState";
+}
diff --git a/SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs b/DefaultModules/NetworkTriggers/NetworkTriggerSource.cs
similarity index 73%
rename from SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs
rename to DefaultModules/NetworkTriggers/NetworkTriggerSource.cs
index da4da09a..2a9c892a 100644
--- a/SharpClaw.Application.Core/Services/Triggers/Sources/NetworkTriggerSource.cs
+++ b/DefaultModules/NetworkTriggers/NetworkTriggerSource.cs
@@ -1,13 +1,18 @@
-using SharpClaw.Application.Infrastructure.Tasks;
using System.Net.NetworkInformation;
+
using Microsoft.Extensions.Logging;
+
using SharpClaw.Contracts.Tasks;
-namespace SharpClaw.Application.Core.Services.Triggers.Sources;
+namespace SharpClaw.Modules.NetworkTriggers;
///
/// Fires when network availability changes, matching the optional SSID and
/// declared on the binding.
+///
+/// Moved out of SharpClaw.Application.Core by the trigger-extraction
+/// plan; behavior is preserved verbatim.
+///
///
public sealed class NetworkTriggerSource(
ILogger logger) : ITaskTriggerSource, IDisposable
@@ -15,7 +20,7 @@ public sealed class NetworkTriggerSource(
private IReadOnlyList _contexts = [];
private bool _subscribed;
- public string TriggerKey => WellKnownTriggerKeys.NetworkChanged;
+ public string TriggerKey => NetworkTriggerKeys.NetworkChanged;
public Task StartAsync(IReadOnlyList contexts, CancellationToken ct)
{
@@ -42,6 +47,12 @@ public Task StopAsync()
return Task.CompletedTask;
}
+ ///
+ public string? GetBindingValue(TaskTriggerDefinition def) =>
+ def.TriggerKey == NetworkTriggerKeys.NetworkChanged
+ ? def.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid)
+ : null;
+
public void Dispose() => StopAsync().GetAwaiter().GetResult();
private void OnAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
@@ -51,13 +62,13 @@ private void OnAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs
{
foreach (var ctx in _contexts)
{
- var requiredState = ctx.Definition.NetworkState;
+ var requiredState = ParseState(ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkState));
if (requiredState == NetworkState.Connected && !connected) continue;
if (requiredState == NetworkState.Disconnected && connected) continue;
// SSID filtering is platform-specific; skip if not checkable
- var ssid = ctx.Definition.NetworkSsid;
+ var ssid = ctx.Definition.Parameters.GetValueOrDefault(NetworkTriggerKeys.NetworkSsid);
if (!string.IsNullOrWhiteSpace(ssid) && !TryMatchSsid(ssid))
continue;
@@ -78,4 +89,7 @@ private static bool TryMatchSsid(string expectedSsid)
// Return true so the trigger is not silently suppressed on unsupported platforms.
return true;
}
+
+ private static NetworkState ParseState(string? value) =>
+ Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : default;
}
diff --git a/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs b/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs
new file mode 100644
index 00000000..6496d76b
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/NetworkTriggersModule.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+
+using Microsoft.Extensions.DependencyInjection;
+
+using SharpClaw.Contracts.Modules;
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.NetworkTriggers;
+
+///
+/// Default module that owns the HostReachable, HostUnreachable,
+/// and NetworkChanged task triggers. Scaffolded in Phase 1 of the
+/// trigger-extraction plan; trigger source implementations move here in
+/// Phase 3.
+///
+public sealed class NetworkTriggersModule : ISharpClawModule, ITaskParserAware
+{
+ public ITaskParserModuleExtension ParserExtension => NetworkTriggersParserExtension.Instance;
+
+ public string Id => "sharpclaw_network_triggers";
+ public string DisplayName => "Network Triggers";
+ public string ToolPrefix => "nettrigger";
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ public IReadOnlyList GetToolDefinitions() => [];
+
+ public Task ExecuteToolAsync(
+ string toolName,
+ JsonElement parameters,
+ AgentJobContext job,
+ IServiceProvider scopedServices,
+ CancellationToken ct) =>
+ throw new InvalidOperationException(
+ $"NetworkTriggers module has no job-pipeline tools. Unknown: '{toolName}'.");
+}
diff --git a/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs b/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs
new file mode 100644
index 00000000..5c1b1a27
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/NetworkTriggersParserExtension.cs
@@ -0,0 +1,21 @@
+using SharpClaw.Contracts.Tasks;
+
+namespace SharpClaw.Modules.NetworkTriggers;
+
+/// Parser extension exposing module-owned trigger-attribute handlers.
+public sealed class NetworkTriggersParserExtension : ITaskParserModuleExtension
+{
+ public static readonly NetworkTriggersParserExtension Instance = new();
+
+ public IReadOnlyDictionary StepKeyMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary EventTriggerMappings { get; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlySet SingleArgExpressionMethods { get; } =
+ new HashSet(StringComparer.Ordinal);
+
+ public IReadOnlyDictionary TriggerAttributeHandlers { get; } =
+ NetworkTriggerAttributeHandlers.All;
+}
diff --git a/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj b/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj
new file mode 100644
index 00000000..f8f6b55f
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/SharpClaw.Modules.NetworkTriggers.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DefaultModules/NetworkTriggers/module.json b/DefaultModules/NetworkTriggers/module.json
new file mode 100644
index 00000000..74329cfe
--- /dev/null
+++ b/DefaultModules/NetworkTriggers/module.json
@@ -0,0 +1,17 @@
+{
+ "id": "sharpclaw_network_triggers",
+ "displayName": "Network Triggers",
+ "version": "1.0.0",
+ "toolPrefix": "nettrigger",
+ "entryAssembly": "SharpClaw.Modules.NetworkTriggers",
+ "minHostVersion": "1.0.0",
+ "author": "SharpClaw Team",
+ "description": "Owns the HostReachable, HostUnreachable, and NetworkChanged task triggers.",
+ "license": "AGPL-3.0",
+ "platforms": null,
+ "enabled": true,
+ "defaultEnabled": true,
+ "executionTimeoutSeconds": 60,
+ "exports": [],
+ "requires": []
+}
diff --git a/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs b/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs
index 57154f84..240809cd 100644
--- a/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs
+++ b/DefaultModules/Providers/Google/Clients/GoogleVertexAIApiClient.cs
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using SharpClaw.Contracts.Providers;
+using SharpClaw.Providers.Common;
namespace SharpClaw.Modules.Providers.Google.Clients;
diff --git a/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs b/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs
index 4dd33c66..dff70779 100644
--- a/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs
+++ b/DefaultModules/Providers/LlamaSharp/Cli/LocalModelCliCommand.cs
@@ -2,6 +2,7 @@
using SharpClaw.Contracts.DTOs.LocalModels;
using SharpClaw.Contracts.Modules;
using SharpClaw.Contracts.Providers;
+using SharpClaw.Providers.Common;
using SharpClaw.Modules.Providers.LlamaSharp.Services;
namespace SharpClaw.Modules.Providers.LlamaSharp.Cli;
diff --git a/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs b/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs
index 38b9b092..138daa73 100644
--- a/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs
+++ b/DefaultModules/Transcription/Clients/GroqTranscriptionApiClient.cs
@@ -1,4 +1,5 @@
using SharpClaw.Contracts.Providers;
+using SharpClaw.Providers.Common;
namespace SharpClaw.Modules.Transcription.Clients;
diff --git a/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs b/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs
index 9d90fb78..8a9a87c6 100644
--- a/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs
+++ b/DefaultModules/Transcription/Clients/OpenAiTranscriptionApiClient.cs
@@ -4,6 +4,7 @@
using System.Text.Json.Serialization;
using SharpClaw.Contracts.Providers;
+using SharpClaw.Providers.Common;
using SharpClaw.Modules.SystemAudio.Audio;
namespace SharpClaw.Modules.Transcription.Clients;
diff --git a/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj b/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj
index 46416c82..1a564fb4 100644
--- a/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj
+++ b/DefaultModules/Transcription/SharpClaw.Modules.Transcription.csproj
@@ -16,6 +16,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 401e8fb3..d1c4794a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -37,7 +37,7 @@
-
+
diff --git a/SharpClaw.Application.API/Cli/CliDispatcher.cs b/SharpClaw.Application.API/Cli/CliDispatcher.cs
index 7e1dfb06..628fe748 100644
--- a/SharpClaw.Application.API/Cli/CliDispatcher.cs
+++ b/SharpClaw.Application.API/Cli/CliDispatcher.cs
@@ -28,6 +28,7 @@
using SharpClaw.Utils.Security;
using SharpClaw.Contracts.Enums;
using SharpClaw.Contracts.Providers;
+using SharpClaw.Providers.Common;
using SharpClaw.Infrastructure.Persistence;
using SharpClaw.Application.Core.Services.Triggers;
using SharpClaw.Contracts.Tasks;
@@ -2309,9 +2310,7 @@ private static int ParseDaysFlag(string[] args, int startIndex)
"task schedule ... Manage cron scheduled jobs (see: task schedule)",
"task trigger-sources List registered task trigger sources",
"task triggers enable Enable all trigger bindings for a task",
- "task triggers disable Disable all trigger bindings for a task",
- "task shortcuts install Create or refresh OS shortcut for a task",
- "task shortcuts remove Remove OS shortcut files for a task");
+ "task triggers disable Disable all trigger bindings for a task");
return Results.Ok();
}
@@ -2320,10 +2319,24 @@ private static int ParseDaysFlag(string[] args, int startIndex)
var chatSvc = sp.GetRequiredService();
if (sub == "schedule")
- return await HandleTaskScheduleCommand(args, sp);
+ {
+ // Forward "task schedule …" to the module-owned top-level
+ // "schedule" command (registered by the AgentOrchestration
+ // module). The legacy "task schedule" alias is preserved for
+ // back-compat; the handler is module code.
+ var registry = sp.GetRequiredService();
+ var resolved = registry.TryResolveTopLevelCommandWithModule("schedule");
+ if (resolved is null)
+ {
+ Console.Error.WriteLine("Scheduled-job CLI not registered. Is the AgentOrchestration module loaded?");
+ return Results.Ok();
+ }
- if (sub == "shortcuts")
- return await HandleTaskShortcutsCommand(args, sp);
+ // Drop the leading "task" verb so the module sees args[0] = "schedule".
+ var forwarded = args.AsSpan(1).ToArray();
+ await InvokeModuleCliHandlerAsync(resolved.Value.ModuleId, resolved.Value.Command, forwarded, sp, registry);
+ return Results.Ok();
+ }
if (sub == "triggers")
return await HandleTaskTriggersCommand(args, sp);
@@ -2453,166 +2466,6 @@ private static IResult HandleTaskTriggerSources(IServiceProvider sp)
return TaskTriggerHandlers.ListTriggerSources(sources);
}
- private static async Task HandleTaskShortcutsCommand(
- string[] args, IServiceProvider sp)
- {
- // args[0] = "task", args[1] = "shortcuts", args[2] = sub-command
- if (args.Length < 3)
- {
- PrintUsage(
- "task shortcuts install Create or refresh OS shortcut for a task",
- "task shortcuts remove Remove OS shortcut files for a task");
- return Results.Ok();
- }
-
- var sub = args[2].ToLowerInvariant();
- var svc = sp.GetRequiredService();
- var shortcuts = sp.GetRequiredService();
-
- switch (sub)
- {
- case "install" when args.Length >= 4:
- {
- var taskId = CliIdMap.Resolve(args[3]);
- var result = await TaskShortcutHandlers.Install(taskId, svc, shortcuts, default);
- if (result is Microsoft.AspNetCore.Http.HttpResults.NotFound)
- Console.Error.WriteLine("Task not found.");
- else if (result is Microsoft.AspNetCore.Http.HttpResults.UnprocessableEntity ue)
- Console.Error.WriteLine(ue.Value);
- else
- Console.WriteLine("Shortcut installed.");
- return Results.Ok();
- }
-
- case "install":
- return UsageResult("task shortcuts install ");
-
- case "remove" when args.Length >= 4:
- {
- var taskId = CliIdMap.Resolve(args[3]);
- var result = await TaskShortcutHandlers.Remove(taskId, svc, shortcuts, default);
- if (result is Microsoft.AspNetCore.Http.HttpResults.NotFound)
- Console.Error.WriteLine("Task not found.");
- else
- Console.WriteLine("Shortcut removed.");
- return Results.Ok();
- }
-
- case "remove":
- return UsageResult("task shortcuts remove ");
-
- default:
- return UsageResult($"Unknown sub-command: task shortcuts {sub}. Try 'task shortcuts' for usage.");
- }
- }
-
- private static async Task HandleTaskScheduleCommand(
- string[] args, IServiceProvider sp)
- {
- // args[0] = "task", args[1] = "schedule", args[2] = sub-command
- var svc = sp.GetRequiredService();
-
- if (args.Length < 3)
- {
- PrintUsage(
- "task schedule list List all scheduled jobs",
- "task schedule get Show a scheduled job",
- "task schedule create --cron [--timezone ] [--name ]",
- " Create a cron scheduled job",
- "task schedule update --cron [--timezone ]",
- " Update cron expression / timezone",
- "task schedule pause Pause a scheduled job",
- "task schedule resume Resume a paused job",
- "task schedule delete Delete a scheduled job",
- "task schedule preview [--timezone ] [--count N]",
- " Preview next occurrences of a cron expression");
- return Results.Ok();
- }
-
- var sub = args[2].ToLowerInvariant();
-
- switch (sub)
- {
- case "list":
- return Results.Ok(await svc.ListAsync());
-
- case "get" when args.Length >= 4:
- return await ScheduledJobHandlers.GetById(CliIdMap.Resolve(args[3]), svc, default);
-
- case "get":
- return UsageResult("task schedule get ");
-
- case "create":
- {
- var flags = ParseFlags(args, 3);
- if (!flags.TryGetValue("cron", out var cronExpr))
- return UsageResult("task schedule create --cron [--timezone ] [--name ]");
-
- Guid? taskId = args.Length >= 4 && Guid.TryParse(args[3], out var tid) ? tid : null;
- flags.TryGetValue("timezone", out var tz);
- flags.TryGetValue("name", out var name);
-
- var request = new CreateScheduledJobRequest(
- Name: name ?? cronExpr,
- TaskDefinitionId: taskId,
- CronExpression: cronExpr,
- CronTimezone: tz);
-
- return await ScheduledJobHandlers.Create(request, svc, default);
- }
-
- case "update" when args.Length >= 4:
- {
- var jobId = CliIdMap.Resolve(args[3]);
- var flags = ParseFlags(args, 4);
- flags.TryGetValue("cron", out var cronExpr);
- flags.TryGetValue("timezone", out var tz);
-
- var request = new UpdateScheduledJobRequest(
- CronExpression: cronExpr,
- CronTimezone: tz);
-
- return await ScheduledJobHandlers.Update(jobId, request, svc, default);
- }
-
- case "update":
- return UsageResult("task schedule update --cron [--timezone ]");
-
- case "pause" when args.Length >= 4:
- return await ScheduledJobHandlers.Pause(CliIdMap.Resolve(args[3]), svc, default);
-
- case "pause":
- return UsageResult("task schedule pause ");
-
- case "resume" when args.Length >= 4:
- return await ScheduledJobHandlers.Resume(CliIdMap.Resolve(args[3]), svc, default);
-
- case "resume":
- return UsageResult("task schedule resume ");
-
- case "delete" when args.Length >= 4:
- return await ScheduledJobHandlers.Delete(CliIdMap.Resolve(args[3]), svc, default);
-
- case "delete":
- return UsageResult("task schedule delete ");
-
- case "preview" when args.Length >= 4:
- {
- var expr = args[3];
- var flags = ParseFlags(args, 4);
- flags.TryGetValue("timezone", out var tz);
- int count = flags.TryGetValue("count", out var cStr) && int.TryParse(cStr, out var c) ? c : 10;
- return ScheduledJobHandlers.PreviewExpression(expr, tz, count);
- }
-
- case "preview":
- return UsageResult("task schedule preview [--timezone ] [--count N]");
-
- default:
- return UsageResult($"Unknown sub-command: task schedule {sub}. Try 'task schedule' for usage.");
- }
- }
-
private static async Task