relay-flow 0.0.1 → 0.2.0-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
package goworkflows_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"database/sql"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
13
|
+
recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/task"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// 3.14, 3.17-3.21, 3.23-3.26 (engine level): identity/recovery, roll-forward,
|
|
22
|
+
// conflict/blocked, cancellation, terminal reconcile, database lifecycle,
|
|
23
|
+
// recover, retention, projection. Fakes in fakes_test.go.
|
|
24
|
+
|
|
25
|
+
// --- 3.14: fresh visit identity on a fresh run ---
|
|
26
|
+
|
|
27
|
+
func TestFreshRunGeneratesFreshVisitIDs(t *testing.T) {
|
|
28
|
+
// Two independent runs (separate execution state) for the same
|
|
29
|
+
// deterministic run ID generate non-colliding nodeVisitIDs; a fresh run
|
|
30
|
+
// after explicit recovery must not reuse a stale visit ID.
|
|
31
|
+
log := newEventLog()
|
|
32
|
+
sys := newFakeTaskSystem(log)
|
|
33
|
+
fr := newFakeRunner(log)
|
|
34
|
+
wf := linearWorkflow(false)
|
|
35
|
+
|
|
36
|
+
db1 := filepath.Join(t.TempDir(), "state.db")
|
|
37
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
|
|
38
|
+
e1, err := goworkflows.New(db1, deps)
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
if err := e1.Start(context.Background()); err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
rid, _ := startRun(e1, wf)
|
|
46
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
47
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
48
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
49
|
+
})
|
|
50
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
51
|
+
firstVisit := r.CurrentNodeVisitID
|
|
52
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
53
|
+
_ = e1.Shutdown(ctx)
|
|
54
|
+
cancel()
|
|
55
|
+
|
|
56
|
+
db2 := filepath.Join(t.TempDir(), "state.db")
|
|
57
|
+
e2, err := goworkflows.New(db2, deps)
|
|
58
|
+
if err != nil {
|
|
59
|
+
t.Fatal(err)
|
|
60
|
+
}
|
|
61
|
+
if err := e2.Start(context.Background()); err != nil {
|
|
62
|
+
t.Fatal(err)
|
|
63
|
+
}
|
|
64
|
+
defer func() {
|
|
65
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
66
|
+
defer cc()
|
|
67
|
+
_ = e2.Shutdown(c)
|
|
68
|
+
}()
|
|
69
|
+
if _, err := e2.EnsureRun(context.Background(), run.Start{
|
|
70
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
71
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
72
|
+
}); err != nil {
|
|
73
|
+
t.Fatal(err)
|
|
74
|
+
}
|
|
75
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
76
|
+
r, err := e2.GetRun(context.Background(), rid)
|
|
77
|
+
return err == nil && r.CurrentNodeVisitID != ""
|
|
78
|
+
})
|
|
79
|
+
r2, _ := e2.GetRun(context.Background(), rid)
|
|
80
|
+
if r2.CurrentNodeVisitID == firstVisit {
|
|
81
|
+
t.Fatal("fresh run collided on a prior nodeVisitID; visit IDs must be fresh/random")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestVisitIDStableAcrossNormalRestart(t *testing.T) {
|
|
86
|
+
// The nodeVisitID is generated once per node entry through a durable
|
|
87
|
+
// replay-safe side effect: a normal Shutdown + New + Start on the SAME
|
|
88
|
+
// database must return the SAME visit ID for the in-progress visit.
|
|
89
|
+
log := newEventLog()
|
|
90
|
+
sys := newFakeTaskSystem(log)
|
|
91
|
+
fr := newFakeRunner(log)
|
|
92
|
+
wf := linearWorkflow(false)
|
|
93
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
94
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
|
|
95
|
+
|
|
96
|
+
e1, err := goworkflows.New(db, deps)
|
|
97
|
+
if err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
if err := e1.Start(context.Background()); err != nil {
|
|
101
|
+
t.Fatal(err)
|
|
102
|
+
}
|
|
103
|
+
rid, _ := startRun(e1, wf)
|
|
104
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
105
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
106
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
107
|
+
})
|
|
108
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
109
|
+
visit := r.CurrentNodeVisitID
|
|
110
|
+
|
|
111
|
+
e2 := restartEngine(t, db, deps, e1)
|
|
112
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
113
|
+
r, err := e2.GetRun(context.Background(), rid)
|
|
114
|
+
return err == nil && r.CurrentNodeVisitID != ""
|
|
115
|
+
})
|
|
116
|
+
r2, _ := e2.GetRun(context.Background(), rid)
|
|
117
|
+
if r2.CurrentNodeVisitID != visit {
|
|
118
|
+
t.Fatalf("visit changed across normal restart: %q -> %q; must be replay-stable", visit, r2.CurrentNodeVisitID)
|
|
119
|
+
}
|
|
120
|
+
// The unfinished run RESUMES work on the same db after restart: the
|
|
121
|
+
// engine re-drives the in-progress node (re-ensures its terminal and
|
|
122
|
+
// resumes the harness), it does not abandon the run.
|
|
123
|
+
if r2.State == run.StateCanceled || r2.State == run.StateCompleted {
|
|
124
|
+
t.Fatalf("unfinished run %s in terminal state %q after restart; it must resume", rid, r2.State)
|
|
125
|
+
}
|
|
126
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
127
|
+
return log.count("buildCommand:") > 0 && fr.liveTerminals() > 0
|
|
128
|
+
})
|
|
129
|
+
// The resumed visit can still complete normally: submit the report for
|
|
130
|
+
// the configured end route and the run completes.
|
|
131
|
+
if _, err := e2.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
132
|
+
t.Fatalf("resumed run rejected its report: %v", err)
|
|
133
|
+
}
|
|
134
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
135
|
+
r, _ := e2.GetRun(context.Background(), rid)
|
|
136
|
+
return r.State == run.StateCompleted
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- 3.17: roll-forward recovery across crash boundaries ---
|
|
141
|
+
|
|
142
|
+
// restart reopens the engine on the same DB (a crash/restart boundary).
|
|
143
|
+
func restartEngine(t *testing.T, db string, deps goworkflows.Dependencies, old *goworkflows.Engine) *goworkflows.Engine {
|
|
144
|
+
t.Helper()
|
|
145
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
146
|
+
_ = old.Shutdown(ctx) // crash boundary: durable state persists
|
|
147
|
+
cancel()
|
|
148
|
+
e, err := goworkflows.New(db, deps)
|
|
149
|
+
if err != nil {
|
|
150
|
+
t.Fatal(err)
|
|
151
|
+
}
|
|
152
|
+
if err := e.Start(context.Background()); err != nil {
|
|
153
|
+
t.Fatal(err)
|
|
154
|
+
}
|
|
155
|
+
t.Cleanup(func() {
|
|
156
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
157
|
+
defer cc()
|
|
158
|
+
_ = e.Shutdown(c)
|
|
159
|
+
})
|
|
160
|
+
return e
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func TestCancelRun(t *testing.T) {
|
|
164
|
+
log := newEventLog()
|
|
165
|
+
sys := newFakeTaskSystem(log)
|
|
166
|
+
fr := newFakeRunner(log)
|
|
167
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
168
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
169
|
+
Runtime: &run.RuntimePolicy{},
|
|
170
|
+
})
|
|
171
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
172
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
173
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
174
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
175
|
+
})
|
|
176
|
+
if fr.liveTerminals() == 0 {
|
|
177
|
+
t.Fatal("no live terminal before cancellation")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if err := engine.CancelRun(context.Background(), rid, "no longer needed"); err != nil {
|
|
181
|
+
t.Fatalf("CancelRun failed: %v", err)
|
|
182
|
+
}
|
|
183
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
184
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
185
|
+
return r.State == run.StateCanceled
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Run-owned terminals closed, environment/workspace preserved.
|
|
189
|
+
if fr.liveTerminals() != 0 {
|
|
190
|
+
t.Fatal("cancellation left live terminals")
|
|
191
|
+
}
|
|
192
|
+
if log.count("closeTerminal:PAY-101:coding") != 1 {
|
|
193
|
+
t.Fatalf("direct terminal closes = %v, want coding terminal closed once", log.all())
|
|
194
|
+
}
|
|
195
|
+
if len(fr.envs) != 1 {
|
|
196
|
+
t.Fatal("cancellation removed the environment; workspace/code must be preserved")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Exactly one parent cancellation comment with the stable marker.
|
|
200
|
+
wantMarker := string(rid) + ":cancellation"
|
|
201
|
+
var cancelComments int
|
|
202
|
+
for _, c := range sys.commentBodies("PAY-101") {
|
|
203
|
+
if c.Marker == wantMarker {
|
|
204
|
+
cancelComments++
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if cancelComments != 1 {
|
|
208
|
+
t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Mailbox statuses/history unchanged: the in-flight coding mailbox was
|
|
212
|
+
// not completed by cancellation.
|
|
213
|
+
if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
|
|
214
|
+
t.Fatal("cancellation completed the in-flight mailbox; mailbox state must be unchanged")
|
|
215
|
+
}
|
|
216
|
+
// No further node activity scheduled after cancellation.
|
|
217
|
+
if log.count("ensureTerminal:PAY-101:review") != 0 {
|
|
218
|
+
t.Fatal("activity scheduled after cancellation")
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func TestCancelDuringRunningActivity(t *testing.T) {
|
|
223
|
+
// Cancellation cannot interrupt an already-running activity; it waits
|
|
224
|
+
// for it to return, then runs cancellation cleanup.
|
|
225
|
+
log := newEventLog()
|
|
226
|
+
sys := newFakeTaskSystem(log)
|
|
227
|
+
fr := newFakeRunner(log)
|
|
228
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
229
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
230
|
+
Runtime: &run.RuntimePolicy{},
|
|
231
|
+
})
|
|
232
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
233
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
234
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
235
|
+
return r.CurrentNode == "coding"
|
|
236
|
+
})
|
|
237
|
+
sys.completeSlow = 500 * time.Millisecond
|
|
238
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
239
|
+
t.Fatal(err)
|
|
240
|
+
}
|
|
241
|
+
waitFor(t, 10*time.Second, func() bool { return log.count("completeMailboxStart:") > 0 })
|
|
242
|
+
|
|
243
|
+
if err := engine.CancelRun(context.Background(), rid, "stop"); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
247
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
248
|
+
return r.State == run.StateCanceled
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Cancellation performs no mailbox mutation of its own. Snapshot the
|
|
252
|
+
// mailbox state after the run is canceled (the in-flight activity has
|
|
253
|
+
// already returned by then — cancel waits for it) and assert it stays
|
|
254
|
+
// stable: no further status change or comment is written afterward.
|
|
255
|
+
statusAfterCancel := sys.mailboxStatusOf("PAY-101-coding")
|
|
256
|
+
commentsAfterCancel := len(sys.commentBodies("PAY-101-coding"))
|
|
257
|
+
time.Sleep(300 * time.Millisecond)
|
|
258
|
+
if got := sys.mailboxStatusOf("PAY-101-coding"); got != statusAfterCancel {
|
|
259
|
+
t.Fatalf("mailbox status changed after cancellation: %q -> %q", statusAfterCancel, got)
|
|
260
|
+
}
|
|
261
|
+
if got := len(sys.commentBodies("PAY-101-coding")); got != commentsAfterCancel {
|
|
262
|
+
t.Fatalf("mailbox comments grew after cancellation: %d -> %d", commentsAfterCancel, got)
|
|
263
|
+
}
|
|
264
|
+
// Deterministic ordering via the fake-adapter/runner call log: the
|
|
265
|
+
// in-flight activity returned (completeMailbox) BEFORE terminal cleanup
|
|
266
|
+
// (closeTerminals). Cancellation waited for the running activity.
|
|
267
|
+
events := log.all()
|
|
268
|
+
completeIdx := indexOf(events, "completeMailbox:PAY-101-coding")
|
|
269
|
+
closeIdx := -1
|
|
270
|
+
for i, e := range events {
|
|
271
|
+
if hasPrefix(e, "closeTerminal:") {
|
|
272
|
+
closeIdx = i
|
|
273
|
+
break
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if completeIdx < 0 {
|
|
277
|
+
t.Fatal("running activity never completed before cancellation cleanup")
|
|
278
|
+
}
|
|
279
|
+
if closeIdx < 0 || closeIdx < completeIdx {
|
|
280
|
+
t.Fatalf("terminal cleanup ran before the in-flight activity returned; events=%v", events)
|
|
281
|
+
}
|
|
282
|
+
if log.count("closeTerminal:PAY-101:coding") != 1 {
|
|
283
|
+
t.Fatalf("direct terminal closes = %v, want 1", log.all())
|
|
284
|
+
}
|
|
285
|
+
// Cancellation itself performs no mailbox mutation beyond the single
|
|
286
|
+
// in-flight activity's own completion, and schedules no further work.
|
|
287
|
+
if n := log.count("completeMailbox:PAY-101-coding"); n != 1 {
|
|
288
|
+
t.Fatalf("completeMailbox ran %d times; want exactly 1 (the in-flight activity only, no extra mutation)", n)
|
|
289
|
+
}
|
|
290
|
+
if log.count("ensureTerminal:PAY-101:review") != 0 {
|
|
291
|
+
t.Fatal("activity scheduled after cancellation")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- 3.17: roll-forward recovery across crash boundaries ---
|
|
296
|
+
|
|
297
|
+
func TestCrashImmediatelyAfterReportPersistence(t *testing.T) {
|
|
298
|
+
// Crash after the report+route are persisted but before any comment is
|
|
299
|
+
// written: replay resumes the persisted route without re-asking the
|
|
300
|
+
// agent and runs the first unfinished activity.
|
|
301
|
+
log := newEventLog()
|
|
302
|
+
sys := newFakeTaskSystem(log)
|
|
303
|
+
fr := newFakeRunner(log)
|
|
304
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
305
|
+
wf := linearWorkflow(false)
|
|
306
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
|
|
307
|
+
|
|
308
|
+
e1, err := goworkflows.New(db, deps)
|
|
309
|
+
if err != nil {
|
|
310
|
+
t.Fatal(err)
|
|
311
|
+
}
|
|
312
|
+
if err := e1.Start(context.Background()); err != nil {
|
|
313
|
+
t.Fatal(err)
|
|
314
|
+
}
|
|
315
|
+
rid, _ := startRun(e1, wf)
|
|
316
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
317
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
318
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
319
|
+
})
|
|
320
|
+
// Fail comments so the report+route persist but no summary lands.
|
|
321
|
+
sys.failComments = true
|
|
322
|
+
if _, err := e1.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
323
|
+
t.Fatal(err)
|
|
324
|
+
}
|
|
325
|
+
// Let the report be consumed and the first comment attempt fail.
|
|
326
|
+
waitFor(t, 10*time.Second, func() bool { return log.count("commentFail:") > 0 })
|
|
327
|
+
buildBefore := log.count("buildCommand:")
|
|
328
|
+
|
|
329
|
+
// Crash (Shutdown) then New+Start on the same db.
|
|
330
|
+
e2 := restartEngine(t, db, deps, e1)
|
|
331
|
+
sys.failComments = false
|
|
332
|
+
|
|
333
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
334
|
+
r, _ := e2.GetRun(context.Background(), rid)
|
|
335
|
+
return r.State == run.StateCompleted
|
|
336
|
+
})
|
|
337
|
+
if log.count("buildCommand:") != buildBefore {
|
|
338
|
+
t.Fatal("agent re-asked after crash; persisted route must resume without a new LLM turn")
|
|
339
|
+
}
|
|
340
|
+
if n := log.count("comment:PAY-101-coding"); n != 1 {
|
|
341
|
+
t.Fatalf("summaries = %d, want exactly 1 after roll-forward", n)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
func TestCrashAfterSummaryFeedbackBeforeCompletion(t *testing.T) {
|
|
346
|
+
// Crash after summary+feedback are written but before CompleteMailbox
|
|
347
|
+
// succeeds: replay retains the route and retries only completion. Uses a
|
|
348
|
+
// coding->review route so a FEEDBACK activity exists.
|
|
349
|
+
log := newEventLog()
|
|
350
|
+
sys := newFakeTaskSystem(log)
|
|
351
|
+
fr := newFakeRunner(log)
|
|
352
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
353
|
+
wf := workflow.Workflow{
|
|
354
|
+
Name: "reviewFlow", Repos: []string{"payments"},
|
|
355
|
+
Nodes: map[string]workflow.Node{
|
|
356
|
+
"start": {OnSuccess: []workflow.Route{{Target: "coding"}}},
|
|
357
|
+
"coding": {
|
|
358
|
+
Type: workflow.NodeAgent, Agent: "build", Description: "code",
|
|
359
|
+
OnSuccess: []workflow.Route{{Target: "review"}},
|
|
360
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
361
|
+
},
|
|
362
|
+
"review": {
|
|
363
|
+
Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
|
|
364
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
365
|
+
OnFailure: []workflow.Route{{Target: "coding"}},
|
|
366
|
+
},
|
|
367
|
+
"end": {},
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
|
|
371
|
+
|
|
372
|
+
e1, err := goworkflows.New(db, deps)
|
|
373
|
+
if err != nil {
|
|
374
|
+
t.Fatal(err)
|
|
375
|
+
}
|
|
376
|
+
if err := e1.Start(context.Background()); err != nil {
|
|
377
|
+
t.Fatal(err)
|
|
378
|
+
}
|
|
379
|
+
rid, _ := startRun(e1, wf)
|
|
380
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
381
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
382
|
+
return r.CurrentNode == "coding"
|
|
383
|
+
})
|
|
384
|
+
report := successReport("review")
|
|
385
|
+
report.Feedback = workflow.Feedback{
|
|
386
|
+
ReasonForNextStep: "ready", RequiredActions: "review it",
|
|
387
|
+
RelevantContext: "diff", ExpectedResult: "approval",
|
|
388
|
+
}
|
|
389
|
+
sys.completeFail = 100
|
|
390
|
+
if _, err := e1.SubmitReport(context.Background(), reportRequest(rid, "coding", report)); err != nil {
|
|
391
|
+
t.Fatal(err)
|
|
392
|
+
}
|
|
393
|
+
// Summary to coding AND feedback to review both written; completion fails.
|
|
394
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
395
|
+
return log.count("comment:PAY-101-coding") > 0 &&
|
|
396
|
+
log.count("comment:PAY-101-review") > 0 &&
|
|
397
|
+
log.count("completeMailboxFail:") > 0
|
|
398
|
+
})
|
|
399
|
+
summaryBefore := log.count("comment:PAY-101-coding")
|
|
400
|
+
feedbackBefore := log.count("comment:PAY-101-review")
|
|
401
|
+
|
|
402
|
+
sys.completeFail = 0
|
|
403
|
+
e2 := restartEngine(t, db, deps, e1)
|
|
404
|
+
// Run advances to review after the retried completion.
|
|
405
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
406
|
+
r, _ := e2.GetRun(context.Background(), rid)
|
|
407
|
+
return r.CurrentNode == "review"
|
|
408
|
+
})
|
|
409
|
+
// No duplicated summary or feedback; only the completion was retried.
|
|
410
|
+
if log.count("comment:PAY-101-coding") != summaryBefore {
|
|
411
|
+
t.Fatal("summary rewritten after crash; roll-forward must not repeat it")
|
|
412
|
+
}
|
|
413
|
+
if log.count("comment:PAY-101-review") != feedbackBefore {
|
|
414
|
+
t.Fatal("feedback rewritten after crash; roll-forward must not repeat it")
|
|
415
|
+
}
|
|
416
|
+
if log.count("completeMailbox:PAY-101-coding") < 1 {
|
|
417
|
+
t.Fatal("unfinished CompleteMailbox not retried after crash")
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- 3.18: conflict/blocked ---
|
|
422
|
+
|
|
423
|
+
func TestConflictMarksBlockedThenRecovers(t *testing.T) {
|
|
424
|
+
log := newEventLog()
|
|
425
|
+
sys := newFakeTaskSystem(log)
|
|
426
|
+
sys.completeConflict = true
|
|
427
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
428
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
429
|
+
})
|
|
430
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
431
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
432
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
433
|
+
return r.CurrentNode == "coding"
|
|
434
|
+
})
|
|
435
|
+
if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
436
|
+
t.Fatal(err)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
440
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
441
|
+
return r.State == run.StateBlocked
|
|
442
|
+
})
|
|
443
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
444
|
+
if r.LastError == "" {
|
|
445
|
+
t.Fatal("blocked run exposes no conflict error in LastError")
|
|
446
|
+
}
|
|
447
|
+
if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
|
|
448
|
+
t.Fatal("mailbox completed while state was incompatible; no blind overwrite allowed")
|
|
449
|
+
}
|
|
450
|
+
if log.count("completeMailboxConflict:") < 1 {
|
|
451
|
+
t.Fatal("no conflict retry observed")
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The run stays blocked and keeps retrying with backoff while the
|
|
455
|
+
// conflict persists; it does not complete or overwrite.
|
|
456
|
+
conflictsAfterBlocked := log.count("completeMailboxConflict:")
|
|
457
|
+
waitFor(t, 5*time.Second, func() bool {
|
|
458
|
+
return log.count("completeMailboxConflict:") > conflictsAfterBlocked
|
|
459
|
+
})
|
|
460
|
+
r, _ = engine.GetRun(context.Background(), rid)
|
|
461
|
+
if r.State != run.StateBlocked {
|
|
462
|
+
t.Fatalf("run left blocked while conflict persists: %q", r.State)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
sys.completeConflict = false
|
|
466
|
+
waitFor(t, 60*time.Second, func() bool {
|
|
467
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
468
|
+
return r.State == run.StateCompleted
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- 3.21: terminal reconcile ---
|
|
473
|
+
|
|
474
|
+
func TestTerminalReconcile(t *testing.T) {
|
|
475
|
+
log := newEventLog()
|
|
476
|
+
sys := newFakeTaskSystem(log)
|
|
477
|
+
fr := newFakeRunner(log)
|
|
478
|
+
fh := newFakeHarness(log)
|
|
479
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
480
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
481
|
+
})
|
|
482
|
+
wf := linearWorkflow(false)
|
|
483
|
+
rid, _ := startRun(engine, wf)
|
|
484
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
485
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
486
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
487
|
+
})
|
|
488
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
489
|
+
visit := r.CurrentNodeVisitID
|
|
490
|
+
terminalsBefore := fr.liveTerminals()
|
|
491
|
+
relaunchBefore := log.count("ensureTerminal:PAY-101:coding")
|
|
492
|
+
buildBefore := log.count("buildCommand:")
|
|
493
|
+
inspectBefore := log.count("inspectTerminal:")
|
|
494
|
+
|
|
495
|
+
// Healthy terminal: EnsureRun checks the persisted direct handle and,
|
|
496
|
+
// finding it live, sends no reconcile and relaunches
|
|
497
|
+
// nothing.
|
|
498
|
+
if _, err := engine.EnsureRun(context.Background(), run.Start{
|
|
499
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
500
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
501
|
+
}); err != nil {
|
|
502
|
+
t.Fatal(err)
|
|
503
|
+
}
|
|
504
|
+
time.Sleep(300 * time.Millisecond)
|
|
505
|
+
if log.count("inspectTerminal:") == inspectBefore {
|
|
506
|
+
t.Fatal("repeated EnsureRun never checked the persisted terminal handle")
|
|
507
|
+
}
|
|
508
|
+
if got := log.count("buildCommand:") - buildBefore; got != 0 {
|
|
509
|
+
t.Fatalf("healthy-terminal EnsureRun relaunched the harness %d times; want 0", got)
|
|
510
|
+
}
|
|
511
|
+
if got := log.count("ensureTerminal:PAY-101:coding") - relaunchBefore; got != 0 {
|
|
512
|
+
t.Fatalf("healthy-terminal EnsureRun recreated the terminal %d times; want 0", got)
|
|
513
|
+
}
|
|
514
|
+
r2, _ := engine.GetRun(context.Background(), rid)
|
|
515
|
+
if r2.CurrentNodeVisitID != visit {
|
|
516
|
+
t.Fatal("healthy-terminal EnsureRun changed the current visit")
|
|
517
|
+
}
|
|
518
|
+
if fr.liveTerminals() != terminalsBefore {
|
|
519
|
+
t.Fatal("healthy-terminal EnsureRun relaunched/closed a terminal")
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Terminal died: reconcile relaunches the same visit (same nodeVisitID).
|
|
523
|
+
fr.killTerminals()
|
|
524
|
+
if _, err := engine.EnsureRun(context.Background(), run.Start{
|
|
525
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
526
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
527
|
+
}); err != nil {
|
|
528
|
+
t.Fatal(err)
|
|
529
|
+
}
|
|
530
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
531
|
+
return log.count("ensureTerminal:PAY-101:coding") > relaunchBefore
|
|
532
|
+
})
|
|
533
|
+
r3, _ := engine.GetRun(context.Background(), rid)
|
|
534
|
+
if r3.CurrentNodeVisitID != visit {
|
|
535
|
+
t.Fatalf("reconcile relaunched with new visit %q, want same visit %q", r3.CurrentNodeVisitID, visit)
|
|
536
|
+
}
|
|
537
|
+
if got := log.count("buildCommand:") - buildBefore; got != 1 {
|
|
538
|
+
t.Fatalf("reconcile relaunched harness %d times, want exactly 1 for the same visit", got)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
func TestHITLReconcileRestoresMissingSessionNeverNudgesIdle(t *testing.T) {
|
|
543
|
+
log := newEventLog()
|
|
544
|
+
sys := newFakeTaskSystem(log)
|
|
545
|
+
fr := newFakeRunner(log)
|
|
546
|
+
fh := newFakeHarness(log)
|
|
547
|
+
wf := workflow.Workflow{
|
|
548
|
+
Name: "hitlFlow", Repos: []string{"payments"},
|
|
549
|
+
Nodes: map[string]workflow.Node{
|
|
550
|
+
"start": {OnSuccess: []workflow.Route{{Target: "review"}}},
|
|
551
|
+
"review": {
|
|
552
|
+
Type: workflow.NodeHITL, Agent: "reviewer", Description: "review",
|
|
553
|
+
OnSuccess: []workflow.Route{{Target: "end"}},
|
|
554
|
+
OnFailure: []workflow.Route{{Target: "review"}},
|
|
555
|
+
},
|
|
556
|
+
"end": {},
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
560
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
|
|
561
|
+
})
|
|
562
|
+
rid := identity.NewRunID("payments", "hitlFlow", "PAY-101")
|
|
563
|
+
start := run.Start{
|
|
564
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
565
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
566
|
+
}
|
|
567
|
+
if _, err := engine.EnsureRun(context.Background(), start); err != nil {
|
|
568
|
+
t.Fatal(err)
|
|
569
|
+
}
|
|
570
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
571
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
572
|
+
return r.CurrentNode == "review"
|
|
573
|
+
})
|
|
574
|
+
r, _ := engine.GetRun(context.Background(), rid)
|
|
575
|
+
visit := r.CurrentNodeVisitID
|
|
576
|
+
|
|
577
|
+
// Idle live HITL terminal: reconcile leaves it untouched, no nudge.
|
|
578
|
+
buildBefore := log.count("buildCommand:")
|
|
579
|
+
if _, err := engine.EnsureRun(context.Background(), start); err != nil {
|
|
580
|
+
t.Fatal(err)
|
|
581
|
+
}
|
|
582
|
+
time.Sleep(300 * time.Millisecond)
|
|
583
|
+
if fh.reconcileNudge != 0 {
|
|
584
|
+
t.Fatal("idle live HITL session was nudged; HITL reconcile never nudges")
|
|
585
|
+
}
|
|
586
|
+
if log.count("buildCommand:") != buildBefore {
|
|
587
|
+
t.Fatal("idle live HITL session was relaunched")
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Missing session/terminal: reconcile restores it for the same visit,
|
|
591
|
+
// without nudging.
|
|
592
|
+
fr.killTerminals()
|
|
593
|
+
if _, err := engine.EnsureRun(context.Background(), start); err != nil {
|
|
594
|
+
t.Fatal(err)
|
|
595
|
+
}
|
|
596
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
597
|
+
return log.count("buildCommand:") > buildBefore
|
|
598
|
+
})
|
|
599
|
+
r2, _ := engine.GetRun(context.Background(), rid)
|
|
600
|
+
if r2.CurrentNodeVisitID != visit {
|
|
601
|
+
t.Fatal("HITL restore changed the visit")
|
|
602
|
+
}
|
|
603
|
+
if fh.reconcileNudge != 0 {
|
|
604
|
+
t.Fatal("HITL restore nudged the session; restore must not nudge")
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// --- 3.20: report persistence / ack boundary ---
|
|
609
|
+
|
|
610
|
+
func TestAckImpliesDurablePersistenceAcrossRestart(t *testing.T) {
|
|
611
|
+
// A report is acknowledged only after its signal is durably persisted.
|
|
612
|
+
// Proof: submit+ack, then crash, then restart — the run resumes the
|
|
613
|
+
// persisted route without the report being resubmitted, and a post-
|
|
614
|
+
// restart retry of the same visit is a safe duplicate.
|
|
615
|
+
log := newEventLog()
|
|
616
|
+
sys := newFakeTaskSystem(log)
|
|
617
|
+
fr := newFakeRunner(log)
|
|
618
|
+
dir := t.TempDir()
|
|
619
|
+
db := filepath.Join(dir, "state.db")
|
|
620
|
+
wf := linearWorkflow(false)
|
|
621
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log)}
|
|
622
|
+
|
|
623
|
+
e1, err := goworkflows.New(db, deps)
|
|
624
|
+
if err != nil {
|
|
625
|
+
t.Fatal(err)
|
|
626
|
+
}
|
|
627
|
+
if err := e1.Start(context.Background()); err != nil {
|
|
628
|
+
t.Fatal(err)
|
|
629
|
+
}
|
|
630
|
+
rid, _ := startRun(e1, wf)
|
|
631
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
632
|
+
r, _ := e1.GetRun(context.Background(), rid)
|
|
633
|
+
return r.CurrentNode == "coding"
|
|
634
|
+
})
|
|
635
|
+
req := reportRequest(rid, "coding", successReport("end"))
|
|
636
|
+
ack, err := e1.SubmitReport(context.Background(), req)
|
|
637
|
+
if err != nil || !ack.Accepted || ack.Duplicate {
|
|
638
|
+
t.Fatalf("first ack = %+v err=%v", ack, err)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Crash after persistence, restart, resume the persisted route.
|
|
642
|
+
e2 := restartEngine(t, db, deps, e1)
|
|
643
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
644
|
+
r, _ := e2.GetRun(context.Background(), rid)
|
|
645
|
+
return r.State == run.StateCompleted
|
|
646
|
+
})
|
|
647
|
+
// The run advanced using the persisted report; no agent re-ask, no
|
|
648
|
+
// repeated summary.
|
|
649
|
+
if n := log.count("comment:PAY-101-coding"); n != 1 {
|
|
650
|
+
t.Fatalf("summaries after restart = %d, want 1 (persisted route resumed)", n)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// A post-restart retry of the same visit is a safe duplicate.
|
|
654
|
+
commentsBefore := log.count("comment:")
|
|
655
|
+
ack, err = e2.SubmitReport(context.Background(), req)
|
|
656
|
+
if err != nil || !ack.Accepted {
|
|
657
|
+
t.Fatalf("post-restart retry: ack=%+v err=%v", ack, err)
|
|
658
|
+
}
|
|
659
|
+
if log.count("comment:") != commentsBefore {
|
|
660
|
+
t.Fatal("post-restart duplicate caused repeated effects")
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// --- 3.21: terminal reconcile ---
|
|
665
|
+
|
|
666
|
+
func TestHealthyDatabaseMissingRunIsClaimBeforeRun(t *testing.T) {
|
|
667
|
+
// With a healthy DB, a labeled ticket whose deterministic run is missing
|
|
668
|
+
// is a claim-before-run crash; EnsureRun creates it normally. Database
|
|
669
|
+
// loss is never inferred from this.
|
|
670
|
+
log := newEventLog()
|
|
671
|
+
sys := newFakeTaskSystem(log)
|
|
672
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
673
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
674
|
+
})
|
|
675
|
+
// The DB is valid (engine started). The labeled ticket has no run yet.
|
|
676
|
+
rid := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
677
|
+
if _, err := engine.GetRun(context.Background(), rid); err == nil {
|
|
678
|
+
t.Fatal("run unexpectedly exists before EnsureRun")
|
|
679
|
+
}
|
|
680
|
+
created, err := engine.EnsureRun(context.Background(), run.Start{
|
|
681
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: linearWorkflow(false),
|
|
682
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
683
|
+
})
|
|
684
|
+
if err != nil {
|
|
685
|
+
t.Fatal(err)
|
|
686
|
+
}
|
|
687
|
+
if !created {
|
|
688
|
+
t.Fatal("missing claimed run not created in healthy database")
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Normal serve refusing a missing/corrupt database is a STARTUP-WIRING
|
|
693
|
+
// behavior (section 5.5), not an engine-New behavior: the recovery and
|
|
694
|
+
// retention tests legitimately create a fresh database via New on a new path.
|
|
695
|
+
// The refusal is covered at the serve layer by 3.23's serve-fixture test
|
|
696
|
+
// (cmd/relay-flow/commands_test.go); see the recovered-vs-normal distinction
|
|
697
|
+
// in TestRecoverResetsJiraStateFreshRuns.
|
|
698
|
+
func TestNormalServeRequiresExistingDatabase(t *testing.T) {
|
|
699
|
+
// An unusable/corrupt database file (wrong magic) makes New fail because
|
|
700
|
+
// SQLite cannot open it — this is genuine engine-level behavior, distinct
|
|
701
|
+
// from the "missing file" case which New tolerates by creating the db.
|
|
702
|
+
deps := goworkflows.Dependencies{
|
|
703
|
+
Repos: repoRegistryWith("payments", newFakeTaskSystem(newEventLog())),
|
|
704
|
+
Runner: newFakeRunner(newEventLog()), Harness: newFakeHarness(newEventLog()),
|
|
705
|
+
}
|
|
706
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
707
|
+
if err := os.WriteFile(path, []byte("not a sqlite database"), 0600); err != nil {
|
|
708
|
+
t.Fatal(err)
|
|
709
|
+
}
|
|
710
|
+
if _, err := goworkflows.New(path, deps); err == nil {
|
|
711
|
+
t.Fatal("engine start on a corrupt database succeeded; must fail")
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
func TestDatabaseFileIsOwnerOnly(t *testing.T) {
|
|
716
|
+
// 3.29: the engine-created state.db is mode 0600.
|
|
717
|
+
dir := t.TempDir()
|
|
718
|
+
db := filepath.Join(dir, "state.db")
|
|
719
|
+
engine, err := goworkflows.New(db, goworkflows.Dependencies{
|
|
720
|
+
Repos: repoRegistryWith("payments", newFakeTaskSystem(newEventLog())),
|
|
721
|
+
Runner: newFakeRunner(newEventLog()), Harness: newFakeHarness(newEventLog()),
|
|
722
|
+
})
|
|
723
|
+
if err != nil {
|
|
724
|
+
t.Fatal(err)
|
|
725
|
+
}
|
|
726
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
727
|
+
t.Fatal(err)
|
|
728
|
+
}
|
|
729
|
+
defer func() {
|
|
730
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
731
|
+
defer cc()
|
|
732
|
+
_ = engine.Shutdown(c)
|
|
733
|
+
}()
|
|
734
|
+
fi, err := os.Stat(db)
|
|
735
|
+
if err != nil {
|
|
736
|
+
t.Fatalf("state.db not created: %v", err)
|
|
737
|
+
}
|
|
738
|
+
if fi.Mode().Perm() != 0600 {
|
|
739
|
+
t.Fatalf("state.db mode = %o, want 0600", fi.Mode().Perm())
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// --- 3.24: serve --recover ---
|
|
744
|
+
|
|
745
|
+
// recoverTickets drives the REAL section-5.6 recover composition
|
|
746
|
+
// (internal/recover.FromTaskSystem) against the settled seams: fakes behind
|
|
747
|
+
// task.System/runner.Runner plus the real engine and the real RunManager.
|
|
748
|
+
// Rewritten in 6.3 from a test-local copy so the production wiring is
|
|
749
|
+
// actually covered.
|
|
750
|
+
func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTaskSystem, fr *fakeRunner, wf workflow.Workflow) error {
|
|
751
|
+
reg := repo.NewRegistry()
|
|
752
|
+
reg.Replace(&repo.Repo{
|
|
753
|
+
Name: "payments",
|
|
754
|
+
Path: "/srv/payments",
|
|
755
|
+
TaskSystem: sys,
|
|
756
|
+
})
|
|
757
|
+
// The router needs the workflow bound to the repo to resolve wf: claims.
|
|
758
|
+
if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
759
|
+
return err
|
|
760
|
+
}
|
|
761
|
+
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
762
|
+
specsFor := func(w *workflow.Workflow, key string) []task.MailboxSpec {
|
|
763
|
+
return goworkflows.MailboxSpecs(w, key)
|
|
764
|
+
}
|
|
765
|
+
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
769
|
+
log := newEventLog()
|
|
770
|
+
sys := newFakeTaskSystem(log)
|
|
771
|
+
fr := newFakeRunner(log)
|
|
772
|
+
fh := newFakeHarness(log)
|
|
773
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
774
|
+
|
|
775
|
+
sys.parentsToRecover = []task.Ticket{
|
|
776
|
+
{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}},
|
|
777
|
+
{ID: "2", Key: "PAY-102", WorkflowClaims: []string{"wf:basicFlow"}},
|
|
778
|
+
{ID: "3", Key: "PAY-103", WorkflowClaims: []string{"wf:basicFlow"}}, // canceled
|
|
779
|
+
}
|
|
780
|
+
sys.canceledParents = map[string]bool{"PAY-103": true}
|
|
781
|
+
sys.seedMailbox("PAY-101", task.Mailbox{ID: "mb-coding", Key: "PAY-101-coding", Node: "coding"}, []string{"wf:basicFlow"})
|
|
782
|
+
if err := sys.Comment(context.Background(), task.Target{
|
|
783
|
+
Parent: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
784
|
+
Mailbox: mailboxPtr(sys, "PAY-101", "coding"),
|
|
785
|
+
}, "old summary", "old"); err != nil {
|
|
786
|
+
t.Fatal(err)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// A surviving run-owned terminal from before the loss.
|
|
790
|
+
survSpec := runner.RunSpec{
|
|
791
|
+
RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
|
|
792
|
+
RepoName: "payments", RepoPath: "/srv/payments", TicketKey: "PAY-101",
|
|
793
|
+
}
|
|
794
|
+
env, _ := fr.EnsureEnvironment(context.Background(), survSpec)
|
|
795
|
+
_, _ = fr.EnsureTerminal(context.Background(), env, "PAY-101:coding", runner.Command{})
|
|
796
|
+
|
|
797
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh}
|
|
798
|
+
|
|
799
|
+
// Pre-loss: a real run produces a visit ID that must NOT be reused.
|
|
800
|
+
preLossDB := filepath.Join(t.TempDir(), "state.db")
|
|
801
|
+
preEngine, err := goworkflows.New(preLossDB, deps)
|
|
802
|
+
if err != nil {
|
|
803
|
+
t.Fatal(err)
|
|
804
|
+
}
|
|
805
|
+
if err := preEngine.Start(context.Background()); err != nil {
|
|
806
|
+
t.Fatal(err)
|
|
807
|
+
}
|
|
808
|
+
preRid, _ := startRun(preEngine, linearWorkflow(false))
|
|
809
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
810
|
+
r, _ := preEngine.GetRun(context.Background(), preRid)
|
|
811
|
+
return r.CurrentNodeVisitID != ""
|
|
812
|
+
})
|
|
813
|
+
preR, _ := preEngine.GetRun(context.Background(), preRid)
|
|
814
|
+
preLossVisit := preR.CurrentNodeVisitID
|
|
815
|
+
if _, err := preEngine.RegisterNodeSession(context.Background(), run.NodeRuntimeRegistration{
|
|
816
|
+
RunID: preRid, Node: "coding", SessionID: "pre-loss-session",
|
|
817
|
+
}); err != nil {
|
|
818
|
+
t.Fatal(err)
|
|
819
|
+
}
|
|
820
|
+
preRuntime, err := preEngine.GetNodeRuntime(context.Background(), preRid, "coding")
|
|
821
|
+
if err != nil || preRuntime.TerminalID == "" {
|
|
822
|
+
t.Fatalf("pre-loss runtime = %+v, %v", preRuntime, err)
|
|
823
|
+
}
|
|
824
|
+
inspectBeforeRecover := log.count("inspectTerminal:")
|
|
825
|
+
pc, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
826
|
+
_ = preEngine.Shutdown(pc)
|
|
827
|
+
pcancel()
|
|
828
|
+
|
|
829
|
+
// Recovery runs on fresh execution state (the old database is gone).
|
|
830
|
+
engine, err := goworkflows.New(db, deps)
|
|
831
|
+
if err != nil {
|
|
832
|
+
t.Fatalf("engine open for recovery failed: %v", err)
|
|
833
|
+
}
|
|
834
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
835
|
+
t.Fatal(err)
|
|
836
|
+
}
|
|
837
|
+
defer func() {
|
|
838
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
839
|
+
defer cc()
|
|
840
|
+
_ = engine.Shutdown(c)
|
|
841
|
+
}()
|
|
842
|
+
|
|
843
|
+
if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
|
|
844
|
+
t.Fatalf("recoverTickets failed: %v", err)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Fresh deterministic runs starting at the start edge target (coding),
|
|
848
|
+
// with fresh visit IDs that differ from the pre-loss visit.
|
|
849
|
+
for _, key := range []string{"PAY-101", "PAY-102"} {
|
|
850
|
+
rid := identity.NewRunID("payments", "basicFlow", key)
|
|
851
|
+
var rr run.Run
|
|
852
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
853
|
+
var err error
|
|
854
|
+
rr, err = engine.GetRun(context.Background(), rid)
|
|
855
|
+
return err == nil && rr.CurrentNodeVisitID != ""
|
|
856
|
+
})
|
|
857
|
+
if rr.CurrentNode != "coding" {
|
|
858
|
+
t.Fatalf("%s recovered to node %q, want start-edge target coding", key, rr.CurrentNode)
|
|
859
|
+
}
|
|
860
|
+
if rr.CurrentNodeVisitID == preLossVisit {
|
|
861
|
+
t.Fatalf("%s reused the pre-loss nodeVisitID; recovery must generate fresh visit IDs", key)
|
|
862
|
+
}
|
|
863
|
+
rt, err := engine.GetNodeRuntime(context.Background(), rid, "coding")
|
|
864
|
+
if err != nil {
|
|
865
|
+
t.Fatalf("%s runtime: %v", key, err)
|
|
866
|
+
}
|
|
867
|
+
if rt.SessionID == "pre-loss-session" {
|
|
868
|
+
t.Fatalf("%s recovery reused pre-loss session ID", key)
|
|
869
|
+
}
|
|
870
|
+
if rt.TerminalID == preRuntime.TerminalID {
|
|
871
|
+
t.Fatalf("%s recovery reused pre-loss terminal ID %q", key, rt.TerminalID)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if log.count("inspectTerminal:") != inspectBeforeRecover {
|
|
875
|
+
t.Fatalf("recover used pre-loss direct terminal IDs: %v", log.all())
|
|
876
|
+
}
|
|
877
|
+
if log.count("closeTerminals:") == 0 {
|
|
878
|
+
t.Fatalf("recover did not use documented external terminal cleanup: %v", log.all())
|
|
879
|
+
}
|
|
880
|
+
// Canceled parent skipped.
|
|
881
|
+
if _, err := engine.FindRunByTicket(context.Background(), "PAY-103"); err == nil {
|
|
882
|
+
t.Fatal("recovery created a run for a cancellation-marked parent")
|
|
883
|
+
}
|
|
884
|
+
// Exactly the two active labeled non-canceled parents recovered.
|
|
885
|
+
runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments"})
|
|
886
|
+
if err != nil {
|
|
887
|
+
t.Fatal(err)
|
|
888
|
+
}
|
|
889
|
+
if len(runs) != 2 {
|
|
890
|
+
t.Fatalf("recovery created %d runs, want exactly 2", len(runs))
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Surviving terminals closed, environments/workspaces preserved.
|
|
894
|
+
if log.count("closeTerminals:") == 0 {
|
|
895
|
+
t.Fatal("recovery never closed surviving run-owned terminals")
|
|
896
|
+
}
|
|
897
|
+
if len(fr.envs) == 0 {
|
|
898
|
+
t.Fatal("recovery removed runner environments; worktrees/code must be preserved")
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Existing mailbox found (PAY-101), missing one created (PAY-102). The
|
|
902
|
+
// pre-loss run and the recovery pass both legitimately find the seeded
|
|
903
|
+
// mailbox; what must hold is that it is found (>=1) and never recreated.
|
|
904
|
+
if log.count("foundMailbox:PAY-101:coding") < 1 {
|
|
905
|
+
t.Fatal("existing PAY-101 coding mailbox not found/reused")
|
|
906
|
+
}
|
|
907
|
+
if log.count("createMailbox:PAY-101:coding") != 0 {
|
|
908
|
+
t.Fatal("existing PAY-101 coding mailbox recreated")
|
|
909
|
+
}
|
|
910
|
+
if log.count("createMailbox:PAY-102:coding") != 1 {
|
|
911
|
+
t.Fatal("missing PAY-102 coding mailbox not created")
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Mailboxes reset to To Do; comments/labels preserved. The fresh durable
|
|
915
|
+
// run then legitimately re-applies the node status (In Progress), so the
|
|
916
|
+
// observable post-recovery status is either the reset To Do or the fresh
|
|
917
|
+
// run's In Progress — never a stale pre-loss value.
|
|
918
|
+
if len(sys.resets) != 2 {
|
|
919
|
+
t.Fatalf("ResetForRecovery calls = %v, want 2 (PAY-101, PAY-102)", sys.resets)
|
|
920
|
+
}
|
|
921
|
+
for _, key := range []string{"PAY-101-coding", "PAY-102-coding"} {
|
|
922
|
+
if got := sys.mailboxStatusOf(key); got != "To Do" && got != "In Progress" {
|
|
923
|
+
t.Fatalf("mailbox %s status = %q after recovery, want reset To Do or fresh-run In Progress", key, got)
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
// Parent workflow labels preserved (claim identity survives recovery).
|
|
927
|
+
for _, key := range []string{"PAY-101", "PAY-102"} {
|
|
928
|
+
p, ok := sys.parentByKey(key)
|
|
929
|
+
if !ok || len(p.WorkflowClaims) == 0 {
|
|
930
|
+
t.Fatalf("parent %s lost its wf label during recovery", key)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
foundOld := false
|
|
934
|
+
for _, c := range sys.commentBodies("PAY-101-coding") {
|
|
935
|
+
if c.Body == "old summary" {
|
|
936
|
+
foundOld = true
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if !foundOld {
|
|
940
|
+
t.Fatal("recovery dropped existing mailbox comments; must preserve them")
|
|
941
|
+
}
|
|
942
|
+
if len(sys.labelsFor("PAY-101-coding")) == 0 {
|
|
943
|
+
t.Fatal("recovery dropped mailbox wf label; must preserve it")
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// --- 3.25: retention ---
|
|
948
|
+
|
|
949
|
+
func TestRetentionRemovesOldTerminalRunsKeepsOthers(t *testing.T) {
|
|
950
|
+
// The retention clock is driven by finished_at values inserted directly
|
|
951
|
+
// into relay_runs via SQL (the documented projection table), not by an
|
|
952
|
+
// engine-internal clock.
|
|
953
|
+
log := newEventLog()
|
|
954
|
+
sys := newFakeTaskSystem(log)
|
|
955
|
+
db := filepath.Join(t.TempDir(), "state.db")
|
|
956
|
+
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log)}
|
|
957
|
+
engine, err := goworkflows.New(db, deps)
|
|
958
|
+
if err != nil {
|
|
959
|
+
t.Fatal(err)
|
|
960
|
+
}
|
|
961
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
962
|
+
t.Fatal(err)
|
|
963
|
+
}
|
|
964
|
+
defer func() {
|
|
965
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
966
|
+
defer cc()
|
|
967
|
+
_ = engine.Shutdown(c)
|
|
968
|
+
}()
|
|
969
|
+
|
|
970
|
+
// Stop the engine, then INSERT already-aged terminal fixtures (finished_at
|
|
971
|
+
// 31 days back, past the default 30-day retention) directly into
|
|
972
|
+
// relay_runs — the settled retention clock seam (tasks.md 3.25).
|
|
973
|
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
974
|
+
_ = engine.Shutdown(ctx)
|
|
975
|
+
cancel()
|
|
976
|
+
oldCompleted := run.ID("payments/basicFlow/PAY-OLD1")
|
|
977
|
+
oldCanceled := run.ID("payments/basicFlow/PAY-OLD2")
|
|
978
|
+
insertOldTerminalRun(t, db, oldCompleted, run.StateCompleted, "PAY-OLD1", 31*24*time.Hour)
|
|
979
|
+
insertOldTerminalRun(t, db, oldCanceled, run.StateCanceled, "PAY-OLD2", 31*24*time.Hour)
|
|
980
|
+
// Nonterminal fixtures (finished_at NULL) in every other state must be
|
|
981
|
+
// retained regardless of age.
|
|
982
|
+
insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-S1"), run.StateStarting, "PAY-S1")
|
|
983
|
+
insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-R1"), run.StateRunning, "PAY-R1")
|
|
984
|
+
insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-B1"), run.StateBlocked, "PAY-B1")
|
|
985
|
+
insertRelayRun(t, db, run.ID("payments/basicFlow/PAY-C1"), run.StateCanceling, "PAY-C1")
|
|
986
|
+
|
|
987
|
+
// Restart: the retention sweep runs once at startup (pre-poller window).
|
|
988
|
+
// Old terminal fixtures are removed; nonterminal fixtures are preserved.
|
|
989
|
+
engine2, err := goworkflows.New(db, deps)
|
|
990
|
+
if err != nil {
|
|
991
|
+
t.Fatal(err)
|
|
992
|
+
}
|
|
993
|
+
if err := engine2.Start(context.Background()); err != nil {
|
|
994
|
+
t.Fatal(err)
|
|
995
|
+
}
|
|
996
|
+
defer func() {
|
|
997
|
+
c, cc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
998
|
+
defer cc()
|
|
999
|
+
_ = engine2.Shutdown(c)
|
|
1000
|
+
}()
|
|
1001
|
+
|
|
1002
|
+
if _, err := engine2.GetRun(context.Background(), oldCompleted); err == nil {
|
|
1003
|
+
t.Fatal("old completed run not removed by the startup retention sweep")
|
|
1004
|
+
}
|
|
1005
|
+
if _, err := engine2.GetRun(context.Background(), oldCanceled); err == nil {
|
|
1006
|
+
t.Fatal("old canceled run not removed by the startup retention sweep")
|
|
1007
|
+
}
|
|
1008
|
+
for _, kept := range []run.ID{
|
|
1009
|
+
"payments/basicFlow/PAY-S1", "payments/basicFlow/PAY-R1",
|
|
1010
|
+
"payments/basicFlow/PAY-B1", "payments/basicFlow/PAY-C1",
|
|
1011
|
+
} {
|
|
1012
|
+
if _, err := engine2.GetRun(context.Background(), kept); err != nil {
|
|
1013
|
+
t.Fatalf("nonterminal run %s removed by retention: %v", kept, err)
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// --- 3.26: projection (kept passing) ---
|
|
1019
|
+
func TestRunProjectionQueries(t *testing.T) {
|
|
1020
|
+
log := newEventLog()
|
|
1021
|
+
sys := newFakeTaskSystem(log)
|
|
1022
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
1023
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
1024
|
+
})
|
|
1025
|
+
rid, _ := startRun(engine, linearWorkflow(false))
|
|
1026
|
+
|
|
1027
|
+
r, err := engine.GetRun(context.Background(), rid)
|
|
1028
|
+
if err != nil || r.ID != rid {
|
|
1029
|
+
t.Fatalf("GetRun = %+v, %v", r, err)
|
|
1030
|
+
}
|
|
1031
|
+
r, err = engine.FindRunByTicket(context.Background(), "PAY-101")
|
|
1032
|
+
if err != nil || r.ID != rid {
|
|
1033
|
+
t.Fatalf("FindRunByTicket = %+v, %v", r, err)
|
|
1034
|
+
}
|
|
1035
|
+
runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments"})
|
|
1036
|
+
if err != nil || len(runs) != 1 {
|
|
1037
|
+
t.Fatalf("ListRuns = %+v, %v", runs, err)
|
|
1038
|
+
}
|
|
1039
|
+
active, err := engine.HasActiveWorkflow(context.Background(), "basicFlow")
|
|
1040
|
+
if err != nil || !active {
|
|
1041
|
+
t.Fatalf("HasActiveWorkflow = %v, %v", active, err)
|
|
1042
|
+
}
|
|
1043
|
+
active, err = engine.HasActiveRepo(context.Background(), "payments")
|
|
1044
|
+
if err != nil || !active {
|
|
1045
|
+
t.Fatalf("HasActiveRepo = %v, %v", active, err)
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// insertOldTerminalRun inserts an already-aged terminal fixture row directly
|
|
1050
|
+
// into the relay_runs projection — the settled retention clock seam (tasks.md
|
|
1051
|
+
// 3.25: "by inserting old finished_at fixtures into relay_runs").
|
|
1052
|
+
func insertOldTerminalRun(t *testing.T, dbPath string, id run.ID, state run.State, ticketKey string, ago time.Duration) {
|
|
1053
|
+
t.Helper()
|
|
1054
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
1055
|
+
if err != nil {
|
|
1056
|
+
t.Fatal(err)
|
|
1057
|
+
}
|
|
1058
|
+
defer db.Close()
|
|
1059
|
+
finished := time.Now().Add(-ago)
|
|
1060
|
+
res, err := db.Exec(
|
|
1061
|
+
`INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
|
|
1062
|
+
VALUES (?, 'payments', 'basicFlow', ?, ?, ?, ?, ?, ?)`,
|
|
1063
|
+
string(id), ticketKey, ticketKey, string(state), finished.Add(-time.Hour), finished, finished)
|
|
1064
|
+
if err != nil {
|
|
1065
|
+
t.Fatalf("insert aged terminal fixture: %v", err)
|
|
1066
|
+
}
|
|
1067
|
+
if n, _ := res.RowsAffected(); n != 1 {
|
|
1068
|
+
t.Fatalf("insert affected %d rows, want 1", n)
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// insertRelayRun inserts a NON-terminal fixture row (finished_at NULL) so the
|
|
1073
|
+
// retention sweep must preserve it regardless of age.
|
|
1074
|
+
func insertRelayRun(t *testing.T, dbPath string, id run.ID, state run.State, ticketKey string) {
|
|
1075
|
+
t.Helper()
|
|
1076
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
1077
|
+
if err != nil {
|
|
1078
|
+
t.Fatal(err)
|
|
1079
|
+
}
|
|
1080
|
+
defer db.Close()
|
|
1081
|
+
now := time.Now()
|
|
1082
|
+
res, err := db.Exec(
|
|
1083
|
+
`INSERT INTO relay_runs (id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at, finished_at)
|
|
1084
|
+
VALUES (?, 'payments', 'basicFlow', ?, ?, ?, ?, ?, NULL)`,
|
|
1085
|
+
string(id), ticketKey, ticketKey, string(state), now, now)
|
|
1086
|
+
if err != nil {
|
|
1087
|
+
t.Fatalf("insert nonterminal fixture: %v", err)
|
|
1088
|
+
}
|
|
1089
|
+
if n, _ := res.RowsAffected(); n != 1 {
|
|
1090
|
+
t.Fatalf("insert affected %d rows, want 1", n)
|
|
1091
|
+
}
|
|
1092
|
+
}
|