Skip to content

Commit 59ea429

Browse files
feat: scheduled-tools pause / stop-all backend
Implements the deterministic backend for the pause / stop-all additions in docs/design/scheduled-tools.md. Schema: - tool-relationship.json: add optional `paused` (bool) and `last-started` (RFC 3339 UTC date-time). - user-computer-profile.json: add optional `scheduled-tools-paused` (bool). ScheduledToolHost: - Read host `scheduled-tools-paused` first; start no runs while paused. - Skip relationships whose own `paused` is true. - Evaluate schedules against `tool-relationship.last-started` (replacing the previous tool-execution-result start-time scan) and stamp `last-started` immediately before launching a run. - Per-execution linked CancellationTokenSource and StopAllRunningExecutions() to cancel in-flight runs. ScheduledToolPauseStateService: reads/writes the persisted `scheduled-tools-paused` flag, caches and raises PauseStateChanged on change, and on pause requests stop-all cancellation through the host. Tests: host-paused blocks all runs, per-relationship pause skips only that relationship, last-started is recorded and gates re-runs, stop-all cancels a running tool; pause-state persistence survives across service instances, change event fires only on transitions, and pausing cancels a running tool. Deferred (UI / run-loop): pause-icon binding, boolean left-right toggle editor, tool-relationship entity-type view fields, and the periodic ScheduledToolRunner + MainWindowViewModel wiring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91841f6 commit 59ea429

6 files changed

Lines changed: 539 additions & 90 deletions

File tree

Phantom.Workspaces.Data.Core/JsonSchemas/tool-relationship.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@
4848
"target"
4949
],
5050
"unevaluatedProperties": false
51+
},
52+
"paused": {
53+
"type": "boolean",
54+
"description": "When true, this relationship is skipped even if its schedule is due."
55+
},
56+
"last-started": {
57+
"type": "string",
58+
"format": "date-time",
59+
"description": "RFC 3339 UTC date-time set by the host immediately before a run is launched; the due-check indicator for whether to start a new run."
5160
}
5261
}
5362
}

Phantom.Workspaces.Data.Core/JsonSchemas/user-computer-profile.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
"home-directory": {
6060
"type": "string",
6161
"description": "Home directory path for this user on this computer"
62+
},
63+
"scheduled-tools-paused": {
64+
"type": "boolean",
65+
"description": "When true, the scheduled tools host on this profile starts no new runs and stops in-flight runs (persisted Stop all / Pause state)."
6266
}
6367
},
6468
"required": [

Phantom.Workspaces.Tests/ScheduledToolHostTests.cs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,22 @@ public Task<WorkspaceToolExecutionResult> ExecuteAsync(WorkspaceToolExecutionCon
4646
}
4747
}
4848

49+
private sealed class BlockingTool : IWorkspaceTool
50+
{
51+
private readonly TaskCompletionSource started;
52+
53+
public BlockingTool(TaskCompletionSource started) => this.started = started;
54+
55+
public string ToolType => "stub";
56+
57+
public async Task<WorkspaceToolExecutionResult> ExecuteAsync(WorkspaceToolExecutionContext context)
58+
{
59+
this.started.SetResult();
60+
await Task.Delay(Timeout.Infinite, context.CancellationToken);
61+
return new WorkspaceToolExecutionResult();
62+
}
63+
}
64+
4965
private static readonly string[] HostName = ["computer", "this-machine"];
5066

5167
private static async Task AddEntityAsync(IDataAccessLayer dataAccessLayer, Guid id, string json)
@@ -99,6 +115,27 @@ await AddEntityAsync(dataAccessLayer, relationshipId,
99115
""");
100116
}
101117

118+
private static async Task AddRelationshipAsync(
119+
IDataAccessLayer dataAccessLayer,
120+
Guid relationshipId,
121+
Guid toolId,
122+
Guid scheduleId,
123+
Guid hostId,
124+
string nameSuffix,
125+
bool paused)
126+
{
127+
var pausedJson = paused ? ", \"paused\": true" : string.Empty;
128+
await AddEntityAsync(dataAccessLayer, relationshipId,
129+
$$"""
130+
{
131+
"entity-id": "{{relationshipId}}",
132+
"entity-types": ["entity", "tool-relationship"],
133+
"names": [["tool-relationships","{{nameSuffix}}"]],
134+
"participants": { "tool": "{{toolId}}", "schedule": ["{{scheduleId}}"], "target": ["{{hostId}}"] }{{pausedJson}}
135+
}
136+
""");
137+
}
138+
102139
[Fact]
103140
public async Task RunDueTools_RunsDueTool_AndRecordsResult()
104141
{
@@ -205,6 +242,116 @@ await SeedScenarioAsync(dataAccessLayer, hostId, userId, computerId, toolId, sch
205242
Assert.Equal(1, tool.RunCount);
206243
}
207244

245+
[Fact]
246+
public async Task RunDueTools_WhenHostPaused_DoesNotRunAnyTool()
247+
{
248+
var dataAccessLayer = new InMemoryDataAccessLayer();
249+
var hostId = Guid.NewGuid();
250+
var userId = Guid.NewGuid();
251+
var computerId = Guid.NewGuid();
252+
var toolId = Guid.NewGuid();
253+
var scheduleId = Guid.NewGuid();
254+
var relationshipId = Guid.NewGuid();
255+
256+
await SeedScenarioAsync(dataAccessLayer, hostId, userId, computerId, toolId, scheduleId, relationshipId,
257+
"""{ "frequency": "00:00:01Z", "days-of-week": [], "start-at": [] }""");
258+
259+
var tool = new RecordingTool();
260+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([tool]), timeProvider: new FixedTimeProvider());
261+
262+
// Persist the host stop-all/pause flag on the profile entity through the real write path.
263+
await new ScheduledToolPauseStateService(dataAccessLayer, host).SetPausedAsync(new EntityId(hostId), paused: true);
264+
265+
var ranCount = await host.RunDueToolsAsync(new EntityId(hostId), HostName);
266+
267+
Assert.Equal(0, ranCount);
268+
Assert.Equal(0, tool.RunCount);
269+
}
270+
271+
[Fact]
272+
public async Task RunDueTools_WhenRelationshipPaused_SkipsThatRelationshipButRunsOthers()
273+
{
274+
var dataAccessLayer = new InMemoryDataAccessLayer();
275+
var hostId = Guid.NewGuid();
276+
var userId = Guid.NewGuid();
277+
var computerId = Guid.NewGuid();
278+
var toolId = Guid.NewGuid();
279+
var scheduleId = Guid.NewGuid();
280+
var runnableRelationshipId = Guid.NewGuid();
281+
var pausedRelationshipId = Guid.NewGuid();
282+
283+
// The first relationship is runnable; add a second paused relationship bound to the same tool.
284+
await SeedScenarioAsync(dataAccessLayer, hostId, userId, computerId, toolId, scheduleId, runnableRelationshipId,
285+
"""{ "frequency": "00:00:01Z", "days-of-week": [], "start-at": [] }""");
286+
await AddRelationshipAsync(dataAccessLayer, pausedRelationshipId, toolId, scheduleId, hostId, "paused", paused: true);
287+
288+
var tool = new RecordingTool();
289+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([tool]), timeProvider: new FixedTimeProvider());
290+
291+
var ranCount = await host.RunDueToolsAsync(new EntityId(hostId), HostName);
292+
293+
// Only the non-paused relationship ran.
294+
Assert.Equal(1, ranCount);
295+
Assert.Equal(1, tool.RunCount);
296+
}
297+
298+
[Fact]
299+
public async Task RunDueTools_RecordsLastStartedOnRelationship_AndUsesItForDueCheck()
300+
{
301+
var dataAccessLayer = new InMemoryDataAccessLayer();
302+
var hostId = Guid.NewGuid();
303+
var userId = Guid.NewGuid();
304+
var computerId = Guid.NewGuid();
305+
var toolId = Guid.NewGuid();
306+
var scheduleId = Guid.NewGuid();
307+
var relationshipId = Guid.NewGuid();
308+
309+
// Hourly interval; the fixed clock does not advance between runs.
310+
await SeedScenarioAsync(dataAccessLayer, hostId, userId, computerId, toolId, scheduleId, relationshipId,
311+
"""{ "frequency": "01:00:00Z", "days-of-week": [], "start-at": [] }""");
312+
313+
var tool = new RecordingTool();
314+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([tool]), timeProvider: new FixedTimeProvider());
315+
316+
Assert.Equal(1, await host.RunDueToolsAsync(new EntityId(hostId), HostName));
317+
318+
// The host stamped last-started on the relationship before the run.
319+
var relationship = Assert.Single(await QueryByTypeAsync(dataAccessLayer, "tool-relationship"));
320+
Assert.True(relationship.TryGetProperty("last-started", out var lastStarted));
321+
Assert.Equal(JsonValueKind.String, lastStarted.ValueKind);
322+
323+
// The recorded last-started keeps the hourly schedule from running again at the same clock.
324+
Assert.Equal(0, await host.RunDueToolsAsync(new EntityId(hostId), HostName));
325+
Assert.Equal(1, tool.RunCount);
326+
}
327+
328+
[Fact]
329+
public async Task StopAllRunningExecutions_CancelsRunningTool()
330+
{
331+
var dataAccessLayer = new InMemoryDataAccessLayer();
332+
var hostId = Guid.NewGuid();
333+
var userId = Guid.NewGuid();
334+
var computerId = Guid.NewGuid();
335+
var toolId = Guid.NewGuid();
336+
var scheduleId = Guid.NewGuid();
337+
var relationshipId = Guid.NewGuid();
338+
339+
await SeedScenarioAsync(dataAccessLayer, hostId, userId, computerId, toolId, scheduleId, relationshipId,
340+
"""{ "frequency": "00:00:01Z", "days-of-week": [], "start-at": [] }""");
341+
342+
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
343+
var tool = new BlockingTool(started);
344+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([tool]), timeProvider: new FixedTimeProvider());
345+
346+
var runTask = host.RunDueToolsAsync(new EntityId(hostId), HostName);
347+
348+
// Deterministically wait until the tool has started and is registered as running.
349+
await started.Task;
350+
host.StopAllRunningExecutions();
351+
352+
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => runTask);
353+
}
354+
208355
private static async Task<IReadOnlyList<JsonElement>> QueryByTypeAsync(IDataAccessLayer dataAccessLayer, string entityType)
209356
{
210357
var result = await dataAccessLayer.QueryAsync(new QueryRequest
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.Json;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Phantom.Workspaces.Data;
8+
using Phantom.Workspaces.Data.Offline;
9+
using Phantom.Workspaces.ScheduledTools;
10+
using Phantom.Workspaces.Tools;
11+
using Xunit;
12+
13+
namespace Phantom.Workspaces.Tests;
14+
15+
public sealed class ScheduledToolPauseStateServiceTests
16+
{
17+
private sealed class FixedTimeProvider : TimeProvider
18+
{
19+
public DateTimeOffset Now { get; set; } = new(2026, 6, 17, 9, 30, 0, TimeSpan.Zero);
20+
21+
public override DateTimeOffset GetUtcNow() => this.Now;
22+
}
23+
24+
private sealed class BlockingTool : IWorkspaceTool
25+
{
26+
private readonly TaskCompletionSource started;
27+
28+
public BlockingTool(TaskCompletionSource started) => this.started = started;
29+
30+
public string ToolType => "stub";
31+
32+
public async Task<WorkspaceToolExecutionResult> ExecuteAsync(WorkspaceToolExecutionContext context)
33+
{
34+
this.started.SetResult();
35+
await Task.Delay(Timeout.Infinite, context.CancellationToken);
36+
return new WorkspaceToolExecutionResult();
37+
}
38+
}
39+
40+
private static readonly string[] HostName = ["computer", "this-machine"];
41+
42+
private static async Task AddEntityAsync(IDataAccessLayer dataAccessLayer, Guid id, string json)
43+
{
44+
using var document = JsonDocument.Parse(json);
45+
var result = await dataAccessLayer.UpdateAsync(new UpdateRequest
46+
{
47+
UpdateMetadata = new UpdateMetadata { Comment = new Markdown { Text = "seed" } },
48+
Changes =
49+
[
50+
new EntityChange
51+
{
52+
EntityId = new EntityId(id),
53+
ConcurrencyTag = null,
54+
Data = document.RootElement.Clone(),
55+
EntityChangeMode = EntityChangeMode.Replace,
56+
},
57+
],
58+
});
59+
Assert.DoesNotContain(result.EntityResults, r => r.UpdateState == UpdateState.Failed);
60+
}
61+
62+
private static async Task AddHostProfileAsync(IDataAccessLayer dataAccessLayer, Guid hostId, bool? paused = null)
63+
{
64+
var pausedJson = paused is null ? string.Empty : $", \"scheduled-tools-paused\": {(paused.Value ? "true" : "false")}";
65+
await AddEntityAsync(dataAccessLayer, hostId,
66+
$$"""{ "entity-id": "{{hostId}}", "entity-types": ["entity", "user-computer-profile"], "names": [["computer-user-profiles","users","username","test-user","computers","hostname","this-machine"]], "user-reference": ["users","username","test-user"], "computer-reference": ["computers","hostname","this-machine"]{{pausedJson}} }""");
67+
}
68+
69+
private static ScheduledToolPauseStateService CreateService(IDataAccessLayer dataAccessLayer)
70+
{
71+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([]), timeProvider: new FixedTimeProvider());
72+
return new ScheduledToolPauseStateService(dataAccessLayer, host);
73+
}
74+
75+
[Fact]
76+
public async Task RefreshAsync_DefaultsToFalse_WhenFlagAbsent()
77+
{
78+
var dataAccessLayer = new InMemoryDataAccessLayer();
79+
var hostId = Guid.NewGuid();
80+
await AddHostProfileAsync(dataAccessLayer, hostId);
81+
82+
var service = CreateService(dataAccessLayer);
83+
84+
Assert.False(await service.RefreshAsync(new EntityId(hostId)));
85+
Assert.False(service.IsPaused);
86+
}
87+
88+
[Fact]
89+
public async Task SetPausedAsync_PersistsState_ThatSurvivesAcrossServiceInstances()
90+
{
91+
var dataAccessLayer = new InMemoryDataAccessLayer();
92+
var hostId = Guid.NewGuid();
93+
await AddHostProfileAsync(dataAccessLayer, hostId);
94+
95+
await CreateService(dataAccessLayer).SetPausedAsync(new EntityId(hostId), paused: true);
96+
97+
// A fresh service instance (simulating an app restart) reads the persisted flag.
98+
var restartedService = CreateService(dataAccessLayer);
99+
Assert.True(await restartedService.RefreshAsync(new EntityId(hostId)));
100+
Assert.True(restartedService.IsPaused);
101+
}
102+
103+
[Fact]
104+
public async Task SetPausedAsync_RaisesPauseStateChanged_OnlyWhenStateChanges()
105+
{
106+
var dataAccessLayer = new InMemoryDataAccessLayer();
107+
var hostId = Guid.NewGuid();
108+
await AddHostProfileAsync(dataAccessLayer, hostId);
109+
110+
var service = CreateService(dataAccessLayer);
111+
var changeCount = 0;
112+
service.PauseStateChanged += (_, _) => changeCount++;
113+
114+
await service.SetPausedAsync(new EntityId(hostId), paused: true);
115+
await service.SetPausedAsync(new EntityId(hostId), paused: true);
116+
await service.SetPausedAsync(new EntityId(hostId), paused: false);
117+
118+
Assert.Equal(2, changeCount);
119+
}
120+
121+
[Fact]
122+
public async Task SetPausedAsync_True_CancelsRunningTool()
123+
{
124+
var dataAccessLayer = new InMemoryDataAccessLayer();
125+
var hostId = Guid.NewGuid();
126+
var userId = Guid.NewGuid();
127+
var computerId = Guid.NewGuid();
128+
var toolId = Guid.NewGuid();
129+
var scheduleId = Guid.NewGuid();
130+
var relationshipId = Guid.NewGuid();
131+
132+
await AddEntityAsync(dataAccessLayer, userId,
133+
$$"""{ "entity-id": "{{userId}}", "entity-types": ["entity", "user"], "names": [["users","username","test-user"]] }""");
134+
await AddEntityAsync(dataAccessLayer, computerId,
135+
$$"""{ "entity-id": "{{computerId}}", "entity-types": ["entity", "computer"], "names": [["computers","hostname","this-machine"]] }""");
136+
await AddHostProfileAsync(dataAccessLayer, hostId);
137+
await AddEntityAsync(dataAccessLayer, toolId,
138+
$$"""{ "entity-id": "{{toolId}}", "entity-types": ["entity", "tool"], "names": [["tools","stub"]], "tool-type": "stub" }""");
139+
await AddEntityAsync(dataAccessLayer, scheduleId,
140+
$$"""{ "entity-id": "{{scheduleId}}", "entity-types": ["entity", "schedule"], "names": [["schedule","test"]], "repeat": { "frequency": "00:00:01Z", "days-of-week": [], "start-at": [] } }""");
141+
await AddEntityAsync(dataAccessLayer, relationshipId,
142+
$$"""{ "entity-id": "{{relationshipId}}", "entity-types": ["entity", "tool-relationship"], "names": [["tool-relationships","test"]], "participants": { "tool": "{{toolId}}", "schedule": ["{{scheduleId}}"], "target": ["{{hostId}}"] } }""");
143+
144+
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
145+
var host = new ScheduledToolHost(dataAccessLayer, new ScheduledToolRegistry([new BlockingTool(started)]), timeProvider: new FixedTimeProvider());
146+
var service = new ScheduledToolPauseStateService(dataAccessLayer, host);
147+
148+
var runTask = host.RunDueToolsAsync(new EntityId(hostId), HostName);
149+
await started.Task;
150+
151+
await service.SetPausedAsync(new EntityId(hostId), paused: true);
152+
153+
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => runTask);
154+
Assert.True(service.IsPaused);
155+
}
156+
}

0 commit comments

Comments
 (0)