Skip to content

Commit a194a6f

Browse files
committed
chore(sync): snapshot local changes
1 parent a564fdc commit a194a6f

7 files changed

Lines changed: 865 additions & 10 deletions

File tree

src/GsdOrchestrator.Tests/GsdStateMachineTests.cs

Lines changed: 198 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ private static IWorkflowState MakeState(WorkflowState from, WorkflowState to)
4040
var state = Substitute.For<IWorkflowState>();
4141
state.State.Returns(from);
4242
state.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
43-
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>() with { CurrentState = to }));
43+
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>().Transition(to)));
4444
return state;
4545
}
4646

@@ -84,15 +84,17 @@ public async Task RunAsync_StateThrowsException_ContextTransitionsToFailed()
8484
}
8585

8686
[Fact]
87-
public async Task RunAsync_NoHandlerForState_ThrowsInvalidOperationException()
87+
public async Task RunAsync_NoHandlerForState_ReturnsFailedContext()
8888
{
8989
var checkpoints = Substitute.For<ICheckpointStore>();
9090

9191
// No states registered — Idle has no handler, throws before SaveAsync
9292
var sut = BuildSut(checkpoints, []);
9393

94-
await Assert.ThrowsAsync<InvalidOperationException>(
95-
() => sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None));
94+
var ctx = await sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None);
95+
96+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
97+
Assert.Equal(TerminalStopReason.Unknown, ctx.StopReason);
9698
}
9799

98100
[Fact]
@@ -116,6 +118,197 @@ await checkpoints.Received(3).SaveAsync(
116118
Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>());
117119
}
118120

121+
[Fact]
122+
public async Task RunAsync_FailedState_RecordsRollbackOriginAndReason()
123+
{
124+
var checkpoints = Substitute.For<ICheckpointStore>();
125+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
126+
.Returns(Task.CompletedTask);
127+
128+
var idleState = Substitute.For<IWorkflowState>();
129+
idleState.State.Returns(WorkflowState.Idle);
130+
idleState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
131+
.ThrowsAsync(new InvalidOperationException("simulated failure"));
132+
133+
var sut = BuildSut(checkpoints, [idleState]);
134+
135+
var ctx = await sut.RunAsync("owner", "repo", 1, triageModeOnly: false, CancellationToken.None);
136+
137+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
138+
Assert.NotNull(ctx.SdlcRun);
139+
Assert.Equal("simulated failure", ctx.SdlcRun!.RollbackReason);
140+
Assert.Equal("understand", ctx.SdlcRun.RollbackOrigin);
141+
Assert.Equal(1, ctx.SdlcRun.NoProgressCount);
142+
}
143+
144+
[Fact]
145+
public async Task RunAsync_AdvancesSdlcBudgets_DuringExecution()
146+
{
147+
var checkpoints = Substitute.For<ICheckpointStore>();
148+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
149+
.Returns(Task.CompletedTask);
150+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
151+
.Returns(Task.CompletedTask);
152+
153+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Done);
154+
var sut = BuildSut(checkpoints, [idleState]);
155+
156+
var ctx = await sut.RunAsync("owner", "repo", 11, triageModeOnly: false, CancellationToken.None);
157+
158+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
159+
Assert.NotNull(ctx.SdlcRun);
160+
Assert.Equal(1, ctx.SdlcRun!.AttemptCount);
161+
Assert.Equal(1, ctx.SdlcRun.IterationCount);
162+
Assert.Equal(1, ctx.SdlcRun.ModelCallCount);
163+
}
164+
165+
[Fact]
166+
public async Task ResumeAsync_FailedState_RequestsRollbackAndResumes()
167+
{
168+
var checkpoints = Substitute.For<ICheckpointStore>();
169+
var savedCtx = new GsdWorkflowContext
170+
{
171+
WorkflowId = "rollback-wf",
172+
CurrentState = WorkflowState.Analyzing,
173+
Issue = new IssueContext(7, "Test issue", "", [], "owner", "repo", "main")
174+
}.WithSdlcRun("Rollback test");
175+
checkpoints.LoadAsync("rollback-wf", Arg.Any<CancellationToken>())
176+
.Returns(savedCtx);
177+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
178+
.Returns(Task.CompletedTask);
179+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
180+
.Returns(Task.CompletedTask);
181+
182+
var analyzingState = Substitute.For<IWorkflowState>();
183+
analyzingState.State.Returns(WorkflowState.Analyzing);
184+
analyzingState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
185+
.ThrowsAsync(new InvalidOperationException("verify failed"));
186+
187+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Done);
188+
var sut = BuildSut(checkpoints, [idleState, analyzingState]);
189+
190+
var ctx = await sut.ResumeAsync("rollback-wf", CancellationToken.None);
191+
192+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
193+
Assert.NotNull(ctx.SdlcRun);
194+
Assert.Equal("understand", ctx.SdlcRun!.RollbackOrigin);
195+
Assert.Equal("verify failed", ctx.SdlcRun.RollbackReason);
196+
Assert.Null(ctx.PendingRollback);
197+
}
198+
199+
[Fact]
200+
public async Task RunAsync_DoneState_RecordsMemoryCandidateAndTerminalOutcome()
201+
{
202+
var checkpoints = Substitute.For<ICheckpointStore>();
203+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
204+
.Returns(Task.CompletedTask);
205+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
206+
.Returns(Task.CompletedTask);
207+
208+
var idleState = MakeState(WorkflowState.Idle, WorkflowState.Done);
209+
var sut = BuildSut(checkpoints, [idleState]);
210+
211+
var ctx = await sut.RunAsync("owner", "repo", 12, triageModeOnly: false, CancellationToken.None);
212+
213+
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
214+
Assert.NotNull(ctx.SdlcRun);
215+
Assert.Equal("passed", ctx.SdlcRun!.TerminalOutcome);
216+
Assert.NotNull(ctx.SdlcRun.MemoryCandidateRecords);
217+
Assert.NotEmpty(ctx.SdlcRun.MemoryCandidateRecords!);
218+
Assert.Equal("update-memory", ctx.SdlcRun.MemoryCandidateRecords![0].PhaseId);
219+
}
220+
221+
[Fact]
222+
public async Task RunAsync_GoalAttemptBudgetExceeded_SetsTypedStopReason()
223+
{
224+
var checkpoints = Substitute.For<ICheckpointStore>();
225+
var savedCtx = new GsdWorkflowContext
226+
{
227+
WorkflowId = "wf-budget",
228+
CurrentState = WorkflowState.Idle,
229+
Issue = new IssueContext(1, "Budget", "", [], "owner", "repo", "main"),
230+
SdlcRun = SdlcRunRecord.Create("wf-budget", "Budget", SdlcProfile.CasSdlcV1) with
231+
{
232+
AttemptCount = SdlcProfile.CasSdlcV1.GoalAttempts + 1
233+
}
234+
};
235+
checkpoints.LoadAsync("wf-budget", Arg.Any<CancellationToken>())
236+
.Returns(savedCtx);
237+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
238+
.Returns(Task.CompletedTask);
239+
240+
var idleState = Substitute.For<IWorkflowState>();
241+
idleState.State.Returns(WorkflowState.Idle);
242+
idleState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
243+
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>().Transition(WorkflowState.Done)));
244+
245+
var sut = BuildSut(checkpoints, [idleState]);
246+
247+
var ctx = await sut.ResumeAsync("wf-budget", CancellationToken.None);
248+
249+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
250+
Assert.Equal(TerminalStopReason.BudgetExhausted, ctx.StopReason);
251+
}
252+
253+
[Fact]
254+
public async Task RunAsync_RuntimeMinuteBudgetExceeded_SetsTypedStopReason()
255+
{
256+
var checkpoints = Substitute.For<ICheckpointStore>();
257+
var savedCtx = new GsdWorkflowContext
258+
{
259+
WorkflowId = "wf-runtime",
260+
CurrentState = WorkflowState.Idle,
261+
Issue = new IssueContext(1, "Runtime", "", [], "owner", "repo", "main"),
262+
SdlcRun = SdlcRunRecord.Create("wf-runtime", "Runtime", SdlcProfile.CasSdlcV1) with
263+
{
264+
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-(SdlcProfile.CasSdlcV1.RuntimeMinutes + 1))
265+
}
266+
};
267+
checkpoints.LoadAsync("wf-runtime", Arg.Any<CancellationToken>())
268+
.Returns(savedCtx);
269+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
270+
.Returns(Task.CompletedTask);
271+
272+
var idleState = Substitute.For<IWorkflowState>();
273+
idleState.State.Returns(WorkflowState.Idle);
274+
idleState.ExecuteAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>())
275+
.Returns(ci => Task.FromResult(ci.Arg<GsdWorkflowContext>().Transition(WorkflowState.Done)));
276+
277+
var sut = BuildSut(checkpoints, [idleState]);
278+
279+
var ctx = await sut.ResumeAsync("wf-runtime", CancellationToken.None);
280+
281+
Assert.Equal(WorkflowState.Failed, ctx.CurrentState);
282+
Assert.Equal(TerminalStopReason.RuntimeExceeded, ctx.StopReason);
283+
}
284+
285+
[Fact]
286+
public void RecordTerminalOutcome_IsIdempotent()
287+
{
288+
var run = SdlcRunRecord.Create("wf-terminal", "Terminal", SdlcProfile.CasSdlcV1);
289+
290+
var once = run.RecordTerminalOutcome("passed");
291+
var twice = once.RecordTerminalOutcome("failed");
292+
293+
Assert.Equal("passed", once.TerminalOutcome);
294+
Assert.Equal("passed", twice.TerminalOutcome);
295+
}
296+
297+
[Fact]
298+
public void RecordVerification_PersistsInvalidatedPhases()
299+
{
300+
var run = SdlcRunRecord.Create("wf-invalidated", "Invalidated", SdlcProfile.CasSdlcV1);
301+
var updated = run.RecordVerification(new SdlcVerificationRecord(
302+
"research",
303+
"repo-verifier",
304+
Passed: false,
305+
InvalidatedPhaseIds: ["understand", "research"],
306+
Reason: "missing evidence"));
307+
308+
Assert.Equal(["understand", "research"], updated.InvalidatedPhaseIds);
309+
Assert.Single(updated.Verifications!);
310+
}
311+
119312
[Fact]
120313
public async Task ResumeAsync_CheckpointExists_ResumesFromSavedState()
121314
{
@@ -175,4 +368,5 @@ public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptio
175368
await Assert.ThrowsAsync<OperationCanceledException>(
176369
() => sut.RunAsync("owner", "repo", 1, triageModeOnly: false, cts.Token));
177370
}
371+
178372
}

src/GsdOrchestrator.Tests/States/CheckpointStoreTests.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ public async Task SaveAsync_ThenLoadAsync_RoundTripsContext()
8888
{
8989
WorkflowId = "wf-roundtrip",
9090
Issue = new IssueContext(7, "Round trip", "body", [], "owner", "repo", "main"),
91-
CurrentState = WorkflowState.Branching
91+
CurrentState = WorkflowState.Branching,
92+
SdlcRun = SdlcRunRecord.Create("wf-roundtrip", "Round trip", SdlcProfile.CasSdlcV1)
9293
};
9394
await _store.SaveAsync(ctx);
9495

@@ -97,6 +98,34 @@ public async Task SaveAsync_ThenLoadAsync_RoundTripsContext()
9798
Assert.NotNull(loaded);
9899
Assert.Equal(WorkflowState.Branching, loaded!.CurrentState);
99100
Assert.Equal(7, loaded.Issue!.Number);
101+
Assert.NotNull(loaded.SdlcRun);
102+
Assert.Equal("wf-roundtrip", loaded.SdlcRun!.RunId);
103+
Assert.Equal("understand", loaded.SdlcRun.CurrentPhaseId);
104+
}
105+
106+
[Fact]
107+
public async Task SaveAsync_ThenLoadAsync_PreservesVerificationOutcome()
108+
{
109+
var ctx = new GsdWorkflowContext
110+
{
111+
WorkflowId = "wf-verification",
112+
Issue = new IssueContext(7, "Verification", "body", [], "owner", "repo", "main"),
113+
SdlcRun = SdlcRunRecord.Create("wf-verification", "Verification", SdlcProfile.CasSdlcV1)
114+
}.WithSdlcVerification(new SdlcVerificationOutcome(
115+
"research",
116+
"repo-verifier",
117+
Passed: false,
118+
InvalidatedPhaseIds: ["understand"],
119+
Reason: "research evidence missing"));
120+
121+
await _store.SaveAsync(ctx);
122+
var loaded = await _store.LoadAsync("wf-verification");
123+
124+
Assert.NotNull(loaded);
125+
Assert.NotNull(loaded!.SdlcRun);
126+
Assert.Equal("understand", loaded.SdlcRun!.RollbackOrigin);
127+
Assert.Equal("research evidence missing", loaded.SdlcRun.RollbackReason);
128+
Assert.Equal(1, loaded.SdlcRun.NoProgressCount);
100129
}
101130

102131
// CHECKPOINT-02: missing checkpoint returns null
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using GsdOrchestrator.Workflows.Models;
2+
using Xunit;
3+
4+
namespace GsdOrchestrator.Tests.States;
5+
6+
public class SdlcProfileTests
7+
{
8+
[Fact]
9+
public void CasSdlcV1_ContainsTwelvePhases_InFiveBatches()
10+
{
11+
var profile = SdlcProfile.CasSdlcV1;
12+
13+
Assert.Equal("cas-sdlc-v1", profile.ProfileId);
14+
Assert.Equal("v1.1", profile.ProfileVersion);
15+
Assert.Equal(12, profile.Phases.Count);
16+
Assert.Contains(profile.Phases, phase => phase.Id == "understand" && phase.Batch == SdlcBatch.Discovery);
17+
Assert.Contains(profile.Phases, phase => phase.Id == "risk-assessment" && phase.Batch == SdlcBatch.Design);
18+
Assert.Contains(profile.Phases, phase => phase.Id == "implement" && phase.Batch == SdlcBatch.Change);
19+
Assert.Contains(profile.Phases, phase => phase.Id == "review" && phase.Batch == SdlcBatch.Assurance);
20+
Assert.Contains(profile.Phases, phase => phase.Id == "finished" && phase.Batch == SdlcBatch.Closure);
21+
}
22+
23+
[Fact]
24+
public void GsdWorkflowContext_DefaultsToCasSdlcProfile()
25+
{
26+
var ctx = new GsdWorkflowContext();
27+
28+
Assert.Equal("1.0", ctx.SchemaVersion);
29+
Assert.Equal(SdlcProfile.CasSdlcV1, ctx.SdlcProfile);
30+
}
31+
32+
[Fact]
33+
public void Transition_KeepsPhaseIdentityButResetsWorkflowState()
34+
{
35+
var ctx = new GsdWorkflowContext().WithSdlcRun("Improve the loop");
36+
37+
var next = ctx.Transition(WorkflowState.Branching);
38+
39+
Assert.NotNull(next.SdlcRun);
40+
Assert.Equal("understand", next.SdlcRun!.CurrentPhaseId);
41+
Assert.Equal("repo-verifier", next.SdlcRun.CurrentVerifier);
42+
Assert.Equal(WorkflowState.Branching, next.CurrentState);
43+
}
44+
45+
[Fact]
46+
public void WithSdlcVerification_UpdatesPhaseAndRollbackMetadata()
47+
{
48+
var ctx = new GsdWorkflowContext().WithSdlcRun("Improve the loop");
49+
var next = ctx.WithSdlcVerification(new SdlcVerificationOutcome(
50+
"research",
51+
"repo-verifier",
52+
Passed: false,
53+
InvalidatedPhaseIds: ["understand"],
54+
Reason: "evidence missing"));
55+
56+
Assert.NotNull(next.SdlcRun);
57+
Assert.Equal("research", next.SdlcRun!.CurrentPhaseId);
58+
Assert.Equal(SdlcPhaseStatus.Failed, next.SdlcRun.CurrentPhaseStatus);
59+
Assert.Equal("understand", next.SdlcRun.RollbackOrigin);
60+
Assert.Equal("evidence missing", next.SdlcRun.RollbackReason);
61+
}
62+
63+
[Fact]
64+
public void ResolveRollbackOrigin_UsesInvalidatedPhaseWhenProvided()
65+
{
66+
var profile = SdlcProfile.CasSdlcV1;
67+
68+
var rollbackOrigin = profile.ResolveRollbackOrigin("verify", ["research", "analyze"]);
69+
70+
Assert.Equal("research", rollbackOrigin);
71+
}
72+
73+
[Fact]
74+
public void NextPhaseId_ReturnsNextSequentialPhase()
75+
{
76+
var profile = SdlcProfile.CasSdlcV1;
77+
78+
Assert.Equal("research", profile.NextPhaseId("understand"));
79+
Assert.Equal("finished", profile.NextPhaseId("update-memory"));
80+
Assert.Null(profile.NextPhaseId("finished"));
81+
}
82+
}

0 commit comments

Comments
 (0)