relay-flow 0.3.8-alpha → 0.3.10-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 +27 -19
- package/cmd/relay-flow/commands_test.go +440 -21
- package/cmd/relay-flow/main.go +203 -37
- package/cmd/relay-flow/observability_test.go +11 -0
- package/cmd/relay-flow/onboarding.go +1 -1
- package/cmd/relay-flow/onboarding_test.go +1 -1
- package/cmd/relay-flow/scenario_test.go +26 -10
- package/cmd/relay-flow/serve.go +27 -1
- package/internal/execution/goworkflows/activities.go +11 -0
- package/internal/execution/goworkflows/cancellation_test.go +50 -0
- package/internal/execution/goworkflows/engine.go +278 -9
- package/internal/execution/goworkflows/fakes_test.go +30 -1
- package/internal/execution/goworkflows/interpreter.go +18 -1
- package/internal/execution/goworkflows/ownership_test.go +259 -0
- package/internal/execution/goworkflows/projection.go +8 -0
- package/internal/execution/goworkflows/recovery_test.go +563 -1
- package/internal/execution/projection/detail_test.go +38 -0
- package/internal/execution/projection/projection.go +80 -15
- package/internal/execution/temporal/interpreter.go +2 -1
- package/internal/execution/temporal/recovery.go +68 -36
- package/internal/harness/harness.go +1 -0
- package/internal/harness/opencode/opencode.go +5 -0
- package/internal/harness/opencode/opencode_test.go +17 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +6 -0
- package/internal/harness/pi/task_env_test.go +16 -0
- package/internal/recover/recover.go +30 -1
- package/internal/repo/binding_test.go +63 -0
- package/internal/repo/repo.go +47 -6
- package/internal/router/router.go +39 -5
- package/internal/router/router_test.go +67 -1
- package/internal/run/manager.go +90 -3
- package/internal/run/run_manager_test.go +74 -0
- package/internal/server/api_test.go +13 -0
- package/internal/server/client.go +7 -0
- package/internal/server/fixture_test.go +5 -0
- package/internal/server/server.go +32 -3
- package/internal/task/beads/beads.go +201 -3
- package/internal/task/beads/beads_test.go +172 -0
- package/internal/task/jira/effects_test.go +86 -0
- package/internal/task/jira/filters_test.go +103 -0
- package/internal/task/jira/helpers_test.go +11 -2
- package/internal/task/jira/jira.go +228 -4
- package/internal/task/jira/normalize.go +30 -15
- package/internal/task/jira/rest/client.go +17 -2
- package/internal/task/jira/transition_defaults_test.go +3 -1
- package/internal/task/task.go +66 -0
- package/internal/workflow/report.go +3 -0
- package/internal/workflow/report_test.go +16 -0
- package/internal/workflow/workflow.go +24 -0
- package/internal/workflow/workflow_test.go +58 -1
- package/package.json +1 -1
|
@@ -46,6 +46,14 @@ func (p *RunProjection) updateState(ctx context.Context, id run.ID, state run.St
|
|
|
46
46
|
return p.shared().UpdateState(ctx, id, state, lastErr, finished)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
func (p *RunProjection) beginCancellation(ctx context.Context, id run.ID, reason string) (run.Run, error) {
|
|
50
|
+
return p.shared().BeginCancellation(ctx, id, reason)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func (p *RunProjection) updateStateIf(ctx context.Context, id run.ID, expected, state run.State, lastErr string, finished *time.Time) (bool, error) {
|
|
54
|
+
return p.shared().UpdateStateIf(ctx, id, expected, state, lastErr, finished)
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
func (p *RunProjection) updateRetry(ctx context.Context, id run.ID, status *run.RetryStatus) error {
|
|
50
58
|
return p.shared().UpdateRetry(ctx, id, status)
|
|
51
59
|
}
|
|
@@ -9,6 +9,8 @@ import (
|
|
|
9
9
|
"testing"
|
|
10
10
|
"time"
|
|
11
11
|
|
|
12
|
+
"github.com/cschleiden/go-workflows/backend/history"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
12
14
|
"github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
|
|
13
15
|
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
14
16
|
recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
|
|
@@ -200,14 +202,19 @@ func TestCancelRun(t *testing.T) {
|
|
|
200
202
|
// Exactly one parent cancellation comment with the stable marker.
|
|
201
203
|
wantMarker := string(rid) + ":cancellation"
|
|
202
204
|
var cancelComments int
|
|
205
|
+
var cancellationBody string
|
|
203
206
|
for _, c := range sys.commentBodies("PAY-101") {
|
|
204
207
|
if c.Marker == wantMarker {
|
|
205
208
|
cancelComments++
|
|
209
|
+
cancellationBody = c.Body
|
|
206
210
|
}
|
|
207
211
|
}
|
|
208
212
|
if cancelComments != 1 {
|
|
209
213
|
t.Fatalf("cancellation comments = %d, want 1 with marker %q", cancelComments, wantMarker)
|
|
210
214
|
}
|
|
215
|
+
if !strings.Contains(cancellationBody, "Run canceled: no longer needed") {
|
|
216
|
+
t.Fatalf("cancellation comment = %q, want persisted operator reason", cancellationBody)
|
|
217
|
+
}
|
|
211
218
|
|
|
212
219
|
// Mailbox statuses/history unchanged: the in-flight coding mailbox was
|
|
213
220
|
// not completed by cancellation.
|
|
@@ -220,6 +227,482 @@ func TestCancelRun(t *testing.T) {
|
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
func TestCancelRunFinalizesWhenWorkflowInstanceIsMissing(t *testing.T) {
|
|
231
|
+
log := newEventLog()
|
|
232
|
+
sys := newFakeTaskSystem(log)
|
|
233
|
+
fr := newFakeRunner(log)
|
|
234
|
+
deps := goworkflows.Dependencies{
|
|
235
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
236
|
+
Runtime: &run.RuntimePolicy{},
|
|
237
|
+
}
|
|
238
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
239
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
240
|
+
if err != nil {
|
|
241
|
+
t.Fatal(err)
|
|
242
|
+
}
|
|
243
|
+
if err := first.Start(context.Background()); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
247
|
+
if err != nil {
|
|
248
|
+
t.Fatal(err)
|
|
249
|
+
}
|
|
250
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
251
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
252
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
253
|
+
})
|
|
254
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
255
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
256
|
+
t.Fatal(err)
|
|
257
|
+
}
|
|
258
|
+
cancel()
|
|
259
|
+
|
|
260
|
+
// Simulate the cancellation crash boundary: the relay projection has a
|
|
261
|
+
// run, but the active go-workflows instance is gone.
|
|
262
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
263
|
+
if err != nil {
|
|
264
|
+
t.Fatal(err)
|
|
265
|
+
}
|
|
266
|
+
if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
|
|
267
|
+
db.Close()
|
|
268
|
+
t.Fatal(err)
|
|
269
|
+
}
|
|
270
|
+
if err := db.Close(); err != nil {
|
|
271
|
+
t.Fatal(err)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
275
|
+
if err != nil {
|
|
276
|
+
t.Fatal(err)
|
|
277
|
+
}
|
|
278
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
279
|
+
sys.failComments = true
|
|
280
|
+
if err := second.CancelRun(context.Background(), rid, "operator canceled"); err == nil {
|
|
281
|
+
t.Fatal("CancelRun succeeded despite a cancellation-comment failure")
|
|
282
|
+
}
|
|
283
|
+
failed, err := second.GetRun(context.Background(), rid)
|
|
284
|
+
if err != nil {
|
|
285
|
+
t.Fatal(err)
|
|
286
|
+
}
|
|
287
|
+
if failed.State != run.StateCanceling || failed.LastError != "operator canceled" {
|
|
288
|
+
t.Fatalf("failed cancellation projection = %+v, want canceling with original reason", failed)
|
|
289
|
+
}
|
|
290
|
+
sys.failComments = false
|
|
291
|
+
if err := second.CancelRun(context.Background(), rid, "replacement reason"); err != nil {
|
|
292
|
+
t.Fatalf("CancelRun with missing workflow instance: %v", err)
|
|
293
|
+
}
|
|
294
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
295
|
+
if err != nil {
|
|
296
|
+
t.Fatal(err)
|
|
297
|
+
}
|
|
298
|
+
if got.State != run.StateCanceled {
|
|
299
|
+
t.Fatalf("state = %q, want canceled", got.State)
|
|
300
|
+
}
|
|
301
|
+
active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
|
|
302
|
+
if err != nil {
|
|
303
|
+
t.Fatal(err)
|
|
304
|
+
}
|
|
305
|
+
if active {
|
|
306
|
+
t.Fatal("canceled run is still counted as an active workflow")
|
|
307
|
+
}
|
|
308
|
+
if fr.liveTerminals() != 0 {
|
|
309
|
+
t.Fatalf("missing-instance cancellation left %d live terminals", fr.liveTerminals())
|
|
310
|
+
}
|
|
311
|
+
comments := sys.commentBodies("PAY-101")
|
|
312
|
+
if len(comments) != 1 || comments[0].Marker != string(rid)+":cancellation" {
|
|
313
|
+
t.Fatalf("cancellation comments = %#v, want one stable cancellation marker", comments)
|
|
314
|
+
}
|
|
315
|
+
if err := second.CancelRun(context.Background(), rid, "repeated cancellation"); err != nil {
|
|
316
|
+
t.Fatalf("repeated cancellation: %v", err)
|
|
317
|
+
}
|
|
318
|
+
if got, err := second.GetRun(context.Background(), rid); err != nil || got.State != run.StateCanceled {
|
|
319
|
+
t.Fatalf("repeated cancellation changed state: run=%+v err=%v", got, err)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
func TestStartReconcilesCancelingRunWithoutWorkflowInstance(t *testing.T) {
|
|
324
|
+
log := newEventLog()
|
|
325
|
+
sys := newFakeTaskSystem(log)
|
|
326
|
+
fr := newFakeRunner(log)
|
|
327
|
+
deps := goworkflows.Dependencies{
|
|
328
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
329
|
+
Runtime: &run.RuntimePolicy{},
|
|
330
|
+
}
|
|
331
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
332
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
333
|
+
if err != nil {
|
|
334
|
+
t.Fatal(err)
|
|
335
|
+
}
|
|
336
|
+
if err := first.Start(context.Background()); err != nil {
|
|
337
|
+
t.Fatal(err)
|
|
338
|
+
}
|
|
339
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
340
|
+
if err != nil {
|
|
341
|
+
t.Fatal(err)
|
|
342
|
+
}
|
|
343
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
344
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
345
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
346
|
+
})
|
|
347
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
348
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
349
|
+
t.Fatal(err)
|
|
350
|
+
}
|
|
351
|
+
cancel()
|
|
352
|
+
|
|
353
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
354
|
+
if err != nil {
|
|
355
|
+
t.Fatal(err)
|
|
356
|
+
}
|
|
357
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
|
|
358
|
+
string(run.StateCanceling), "operator canceled", string(rid)); err != nil {
|
|
359
|
+
db.Close()
|
|
360
|
+
t.Fatal(err)
|
|
361
|
+
}
|
|
362
|
+
if _, err := db.Exec(`DELETE FROM instances WHERE id = ?`, string(rid)); err != nil {
|
|
363
|
+
db.Close()
|
|
364
|
+
t.Fatal(err)
|
|
365
|
+
}
|
|
366
|
+
if err := db.Close(); err != nil {
|
|
367
|
+
t.Fatal(err)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
371
|
+
if err != nil {
|
|
372
|
+
t.Fatal(err)
|
|
373
|
+
}
|
|
374
|
+
if err := second.Start(context.Background()); err != nil {
|
|
375
|
+
t.Fatal(err)
|
|
376
|
+
}
|
|
377
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
378
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
379
|
+
if err != nil {
|
|
380
|
+
t.Fatal(err)
|
|
381
|
+
}
|
|
382
|
+
if got.State != run.StateCanceled {
|
|
383
|
+
t.Fatalf("startup reconciliation state = %q, want canceled", got.State)
|
|
384
|
+
}
|
|
385
|
+
active, err := second.HasActiveWorkflow(context.Background(), "basicFlow")
|
|
386
|
+
if err != nil {
|
|
387
|
+
t.Fatal(err)
|
|
388
|
+
}
|
|
389
|
+
if active {
|
|
390
|
+
t.Fatal("startup-reconciled run is still counted as active")
|
|
391
|
+
}
|
|
392
|
+
if len(sys.commentBodies("PAY-101")) != 1 {
|
|
393
|
+
t.Fatalf("startup reconciliation comments = %d, want one", len(sys.commentBodies("PAY-101")))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
func TestStartRetriesCancellationForExistingWorkflowInstance(t *testing.T) {
|
|
398
|
+
log := newEventLog()
|
|
399
|
+
sys := newFakeTaskSystem(log)
|
|
400
|
+
fr := newFakeRunner(log)
|
|
401
|
+
deps := goworkflows.Dependencies{
|
|
402
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
403
|
+
Runtime: &run.RuntimePolicy{},
|
|
404
|
+
}
|
|
405
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
406
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
407
|
+
if err != nil {
|
|
408
|
+
t.Fatal(err)
|
|
409
|
+
}
|
|
410
|
+
if err := first.Start(context.Background()); err != nil {
|
|
411
|
+
t.Fatal(err)
|
|
412
|
+
}
|
|
413
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
414
|
+
if err != nil {
|
|
415
|
+
t.Fatal(err)
|
|
416
|
+
}
|
|
417
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
418
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
419
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
420
|
+
})
|
|
421
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
422
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
423
|
+
t.Fatal(err)
|
|
424
|
+
}
|
|
425
|
+
cancel()
|
|
426
|
+
|
|
427
|
+
// Leave the engine instance active but persist the cancellation request as
|
|
428
|
+
// if the process died after the projection write and before the cancel RPC.
|
|
429
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
430
|
+
if err != nil {
|
|
431
|
+
t.Fatal(err)
|
|
432
|
+
}
|
|
433
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ? WHERE id = ?`,
|
|
434
|
+
string(run.StateCanceling), "startup cancellation", string(rid)); err != nil {
|
|
435
|
+
db.Close()
|
|
436
|
+
t.Fatal(err)
|
|
437
|
+
}
|
|
438
|
+
if err := db.Close(); err != nil {
|
|
439
|
+
t.Fatal(err)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
443
|
+
if err != nil {
|
|
444
|
+
t.Fatal(err)
|
|
445
|
+
}
|
|
446
|
+
if err := second.Start(context.Background()); err != nil {
|
|
447
|
+
t.Fatal(err)
|
|
448
|
+
}
|
|
449
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
450
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
451
|
+
r, _ := second.GetRun(context.Background(), rid)
|
|
452
|
+
return r.State == run.StateCanceled
|
|
453
|
+
})
|
|
454
|
+
if len(sys.commentBodies("PAY-101")) != 1 {
|
|
455
|
+
t.Fatalf("startup cancellation comments = %d, want one", len(sys.commentBodies("PAY-101")))
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
func TestStartDistinguishesFinishedWorkflowFromMissingWorkflow(t *testing.T) {
|
|
460
|
+
log := newEventLog()
|
|
461
|
+
sys := newFakeTaskSystem(log)
|
|
462
|
+
fr := newFakeRunner(log)
|
|
463
|
+
deps := goworkflows.Dependencies{
|
|
464
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
465
|
+
Runtime: &run.RuntimePolicy{},
|
|
466
|
+
}
|
|
467
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
468
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
469
|
+
if err != nil {
|
|
470
|
+
t.Fatal(err)
|
|
471
|
+
}
|
|
472
|
+
if err := first.Start(context.Background()); err != nil {
|
|
473
|
+
t.Fatal(err)
|
|
474
|
+
}
|
|
475
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
476
|
+
if err != nil {
|
|
477
|
+
t.Fatal(err)
|
|
478
|
+
}
|
|
479
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
480
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
481
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
482
|
+
})
|
|
483
|
+
if _, err := first.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end"))); err != nil {
|
|
484
|
+
t.Fatal(err)
|
|
485
|
+
}
|
|
486
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
487
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
488
|
+
return r.State == run.StateCompleted
|
|
489
|
+
})
|
|
490
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
491
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
492
|
+
if err != nil {
|
|
493
|
+
return false
|
|
494
|
+
}
|
|
495
|
+
defer db.Close()
|
|
496
|
+
var state int
|
|
497
|
+
if err := db.QueryRow(`SELECT state FROM instances WHERE id = ?`, string(rid)).Scan(&state); err != nil {
|
|
498
|
+
return false
|
|
499
|
+
}
|
|
500
|
+
return state != 0
|
|
501
|
+
})
|
|
502
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
503
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
504
|
+
t.Fatal(err)
|
|
505
|
+
}
|
|
506
|
+
cancel()
|
|
507
|
+
|
|
508
|
+
// Corrupt only the relay projection into canceling. The finished engine
|
|
509
|
+
// row/history must win; this must not create a cancellation comment.
|
|
510
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
511
|
+
if err != nil {
|
|
512
|
+
t.Fatal(err)
|
|
513
|
+
}
|
|
514
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
|
|
515
|
+
string(run.StateCanceling), "late cancellation", string(rid)); err != nil {
|
|
516
|
+
db.Close()
|
|
517
|
+
t.Fatal(err)
|
|
518
|
+
}
|
|
519
|
+
// Make the engine fixture unambiguously finished while retaining its
|
|
520
|
+
// history, so startup must inspect the terminal event rather than treating
|
|
521
|
+
// the row as a missing active execution.
|
|
522
|
+
if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
|
|
523
|
+
db.Close()
|
|
524
|
+
t.Fatal(err)
|
|
525
|
+
}
|
|
526
|
+
if err := db.Close(); err != nil {
|
|
527
|
+
t.Fatal(err)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
531
|
+
if err != nil {
|
|
532
|
+
t.Fatal(err)
|
|
533
|
+
}
|
|
534
|
+
if err := second.Start(context.Background()); err != nil {
|
|
535
|
+
t.Fatal(err)
|
|
536
|
+
}
|
|
537
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
538
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
539
|
+
if err != nil {
|
|
540
|
+
t.Fatal(err)
|
|
541
|
+
}
|
|
542
|
+
if got.State != run.StateCompleted {
|
|
543
|
+
t.Fatalf("finished workflow was reconciled to %q, want completed", got.State)
|
|
544
|
+
}
|
|
545
|
+
if comments := sys.commentBodies("PAY-101"); len(comments) != 0 {
|
|
546
|
+
t.Fatalf("finished workflow received cancellation comments: %#v", comments)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
func TestStartReconcilesCanceledWorkflowHistoryAsCanceled(t *testing.T) {
|
|
551
|
+
log := newEventLog()
|
|
552
|
+
sys := newFakeTaskSystem(log)
|
|
553
|
+
fr := newFakeRunner(log)
|
|
554
|
+
deps := goworkflows.Dependencies{
|
|
555
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
556
|
+
Runtime: &run.RuntimePolicy{},
|
|
557
|
+
}
|
|
558
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
559
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
560
|
+
if err != nil {
|
|
561
|
+
t.Fatal(err)
|
|
562
|
+
}
|
|
563
|
+
if err := first.Start(context.Background()); err != nil {
|
|
564
|
+
t.Fatal(err)
|
|
565
|
+
}
|
|
566
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
567
|
+
if err != nil {
|
|
568
|
+
t.Fatal(err)
|
|
569
|
+
}
|
|
570
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
571
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
572
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
573
|
+
})
|
|
574
|
+
if err := first.CancelRun(context.Background(), rid, "history cancellation"); err != nil {
|
|
575
|
+
t.Fatal(err)
|
|
576
|
+
}
|
|
577
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
578
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
579
|
+
return r.State == run.StateCanceled
|
|
580
|
+
})
|
|
581
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
582
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
583
|
+
t.Fatal(err)
|
|
584
|
+
}
|
|
585
|
+
cancel()
|
|
586
|
+
|
|
587
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
588
|
+
if err != nil {
|
|
589
|
+
t.Fatal(err)
|
|
590
|
+
}
|
|
591
|
+
if _, err := db.Exec(`UPDATE relay_runs SET state = ?, last_error = ?, finished_at = NULL WHERE id = ?`,
|
|
592
|
+
string(run.StateCanceling), "history cancellation", string(rid)); err != nil {
|
|
593
|
+
db.Close()
|
|
594
|
+
t.Fatal(err)
|
|
595
|
+
}
|
|
596
|
+
if _, err := db.Exec(`UPDATE instances SET state = 2, completed_at = CURRENT_TIMESTAMP WHERE id = ?`, string(rid)); err != nil {
|
|
597
|
+
db.Close()
|
|
598
|
+
t.Fatal(err)
|
|
599
|
+
}
|
|
600
|
+
if err := db.Close(); err != nil {
|
|
601
|
+
t.Fatal(err)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
605
|
+
if err != nil {
|
|
606
|
+
t.Fatal(err)
|
|
607
|
+
}
|
|
608
|
+
if err := second.Start(context.Background()); err != nil {
|
|
609
|
+
t.Fatal(err)
|
|
610
|
+
}
|
|
611
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
612
|
+
got, err := second.GetRun(context.Background(), rid)
|
|
613
|
+
if err != nil {
|
|
614
|
+
t.Fatal(err)
|
|
615
|
+
}
|
|
616
|
+
if got.State != run.StateCanceled {
|
|
617
|
+
t.Fatalf("canceled workflow history was reconciled to %q, want canceled", got.State)
|
|
618
|
+
}
|
|
619
|
+
if comments := sys.commentBodies("PAY-101"); len(comments) != 1 {
|
|
620
|
+
t.Fatalf("cancellation comments = %d, want one idempotent comment", len(comments))
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
func TestRepeatedCancellationDoesNotAppendCancellationEvents(t *testing.T) {
|
|
625
|
+
log := newEventLog()
|
|
626
|
+
sys := newFakeTaskSystem(log)
|
|
627
|
+
fr := newFakeRunner(log)
|
|
628
|
+
deps := goworkflows.Dependencies{
|
|
629
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
630
|
+
Runtime: &run.RuntimePolicy{},
|
|
631
|
+
}
|
|
632
|
+
dbPath := filepath.Join(t.TempDir(), "state.db")
|
|
633
|
+
first, err := goworkflows.New(dbPath, deps)
|
|
634
|
+
if err != nil {
|
|
635
|
+
t.Fatal(err)
|
|
636
|
+
}
|
|
637
|
+
if err := first.Start(context.Background()); err != nil {
|
|
638
|
+
t.Fatal(err)
|
|
639
|
+
}
|
|
640
|
+
rid, err := startRun(first, linearWorkflow(false))
|
|
641
|
+
if err != nil {
|
|
642
|
+
t.Fatal(err)
|
|
643
|
+
}
|
|
644
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
645
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
646
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
647
|
+
})
|
|
648
|
+
sys.failComments = true
|
|
649
|
+
if err := first.CancelRun(context.Background(), rid, "first reason"); err != nil {
|
|
650
|
+
t.Fatal(err)
|
|
651
|
+
}
|
|
652
|
+
waitFor(t, 15*time.Second, func() bool {
|
|
653
|
+
r, _ := first.GetRun(context.Background(), rid)
|
|
654
|
+
return r.State == run.StateCanceling
|
|
655
|
+
})
|
|
656
|
+
if err := first.CancelRun(context.Background(), rid, "second reason"); err != nil {
|
|
657
|
+
t.Fatal(err)
|
|
658
|
+
}
|
|
659
|
+
if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
|
|
660
|
+
t.Fatalf("cancellation events after repeated cancel = %d, want 1", got)
|
|
661
|
+
}
|
|
662
|
+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
663
|
+
if err := first.Shutdown(shutdownCtx); err != nil {
|
|
664
|
+
t.Fatal(err)
|
|
665
|
+
}
|
|
666
|
+
cancel()
|
|
667
|
+
|
|
668
|
+
second, err := goworkflows.New(dbPath, deps)
|
|
669
|
+
if err != nil {
|
|
670
|
+
t.Fatal(err)
|
|
671
|
+
}
|
|
672
|
+
if err := second.Start(context.Background()); err != nil {
|
|
673
|
+
t.Fatal(err)
|
|
674
|
+
}
|
|
675
|
+
defer func() { _ = second.Shutdown(context.Background()) }()
|
|
676
|
+
// Let the already-persisted cancellation event drive cleanup after the
|
|
677
|
+
// restart; startup must not append another event.
|
|
678
|
+
sys.failComments = false
|
|
679
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
680
|
+
r, _ := second.GetRun(context.Background(), rid)
|
|
681
|
+
return r.State == run.StateCanceled
|
|
682
|
+
})
|
|
683
|
+
if got := countWorkflowEvents(t, dbPath, rid, history.EventType_WorkflowExecutionCanceled); got != 1 {
|
|
684
|
+
t.Fatalf("cancellation events after restart = %d, want 1", got)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
func countWorkflowEvents(t *testing.T, dbPath string, id run.ID, eventType history.EventType) int {
|
|
689
|
+
t.Helper()
|
|
690
|
+
db, err := sql.Open("sqlite", dbPath)
|
|
691
|
+
if err != nil {
|
|
692
|
+
t.Fatal(err)
|
|
693
|
+
}
|
|
694
|
+
defer db.Close()
|
|
695
|
+
var count int
|
|
696
|
+
if err := db.QueryRow(`
|
|
697
|
+
SELECT
|
|
698
|
+
(SELECT COUNT(*) FROM history WHERE instance_id = ? AND event_type = ?)
|
|
699
|
+
+ (SELECT COUNT(*) FROM pending_events WHERE instance_id = ? AND event_type = ?)`,
|
|
700
|
+
string(id), int(eventType), string(id), int(eventType)).Scan(&count); err != nil {
|
|
701
|
+
t.Fatal(err)
|
|
702
|
+
}
|
|
703
|
+
return count
|
|
704
|
+
}
|
|
705
|
+
|
|
223
706
|
func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
|
|
224
707
|
log := newEventLog()
|
|
225
708
|
sys := newFakeTaskSystem(log)
|
|
@@ -876,7 +1359,7 @@ func TestDatabaseFileIsOwnerOnly(t *testing.T) {
|
|
|
876
1359
|
// task.System/runner.Runner plus the real engine and the real RunManager.
|
|
877
1360
|
// Rewritten in 6.3 from a test-local copy so the production wiring is
|
|
878
1361
|
// actually covered.
|
|
879
|
-
func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys
|
|
1362
|
+
func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys task.System, fr *fakeRunner, wf workflow.Workflow) error {
|
|
880
1363
|
reg := repo.NewRegistry()
|
|
881
1364
|
reg.Replace(&repo.Repo{
|
|
882
1365
|
Name: "payments",
|
|
@@ -894,6 +1377,85 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
|
|
|
894
1377
|
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
895
1378
|
}
|
|
896
1379
|
|
|
1380
|
+
type mismatchedRecoveryTaskSystem struct{ *fakeTaskSystem }
|
|
1381
|
+
|
|
1382
|
+
type legacyRecoveryTaskSystem struct{ *fakeTaskSystem }
|
|
1383
|
+
|
|
1384
|
+
func (s *legacyRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
1385
|
+
return func(task.Ticket) bool { return false }, nil
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
func (s *mismatchedRecoveryTaskSystem) CompileOwnershipFilter(config.RawValues) (func(task.Ticket) bool, error) {
|
|
1389
|
+
// Let the router resolve the claim; the current-provider read below is the
|
|
1390
|
+
// mismatch gate being exercised by this recovery regression.
|
|
1391
|
+
return func(task.Ticket) bool { return true }, nil
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
func (s *mismatchedRecoveryTaskSystem) ValidateOwnership(context.Context, task.TicketRef, string, config.RawValues) error {
|
|
1395
|
+
return &task.OwnershipMismatchError{Ticket: "PAY-101", Workflow: "basicFlow"}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
func TestServeRecoverSkipsOwnerMismatchBeforeMutations(t *testing.T) {
|
|
1399
|
+
log := newEventLog()
|
|
1400
|
+
base := newFakeTaskSystem(log)
|
|
1401
|
+
sys := &mismatchedRecoveryTaskSystem{fakeTaskSystem: base}
|
|
1402
|
+
sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
|
|
1403
|
+
fr := newFakeRunner(log)
|
|
1404
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
1405
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
1406
|
+
})
|
|
1407
|
+
wf := linearWorkflow(false)
|
|
1408
|
+
reg := repo.NewRegistry()
|
|
1409
|
+
reg.Replace(&repo.Repo{Name: "payments", Path: "/srv/payments", TaskSystem: sys})
|
|
1410
|
+
if err := reg.BindWorkflows([]*workflow.Workflow{&wf}); err != nil {
|
|
1411
|
+
t.Fatal(err)
|
|
1412
|
+
}
|
|
1413
|
+
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
1414
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
1415
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
1416
|
+
}
|
|
1417
|
+
if err := recoverpkg.FromTaskSystem(context.Background(), reg, fr, rm, specsFor); err != nil {
|
|
1418
|
+
t.Fatalf("recovery failed on owner mismatch: %v", err)
|
|
1419
|
+
}
|
|
1420
|
+
if len(fr.closedRun) != 0 {
|
|
1421
|
+
t.Fatalf("CloseTerminals calls = %v, want none", fr.closedRun)
|
|
1422
|
+
}
|
|
1423
|
+
if base.hasCommentCount() != 0 {
|
|
1424
|
+
t.Fatalf("HasComment calls = %d, want none before owner rejection", base.hasCommentCount())
|
|
1425
|
+
}
|
|
1426
|
+
if len(base.resets) != 0 {
|
|
1427
|
+
t.Fatalf("ResetForRecovery calls = %v, want none", base.resets)
|
|
1428
|
+
}
|
|
1429
|
+
for _, event := range log.all() {
|
|
1430
|
+
if strings.HasPrefix(event, "ensureMailboxes:") || strings.HasPrefix(event, "createMailbox:") {
|
|
1431
|
+
t.Fatalf("mailbox mutation after owner mismatch: %v", log.all())
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
|
|
1435
|
+
t.Fatalf("durable runs after owner mismatch = %v, %v; want none", runs, err)
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
func TestServeRecoverSkipsLegacyClaimWithoutProvenance(t *testing.T) {
|
|
1440
|
+
log := newEventLog()
|
|
1441
|
+
base := newFakeTaskSystem(log)
|
|
1442
|
+
sys := &legacyRecoveryTaskSystem{fakeTaskSystem: base}
|
|
1443
|
+
sys.parentsToRecover = []task.Ticket{{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}}
|
|
1444
|
+
fr := newFakeRunner(log)
|
|
1445
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
1446
|
+
Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
|
|
1447
|
+
})
|
|
1448
|
+
if err := recoverTickets(context.Background(), engine, sys, fr, linearWorkflow(false)); err != nil {
|
|
1449
|
+
t.Fatalf("legacy-claim recovery failed: %v", err)
|
|
1450
|
+
}
|
|
1451
|
+
if len(fr.closedRun) != 0 || base.hasCommentCount() != 0 || len(base.resets) != 0 {
|
|
1452
|
+
t.Fatalf("legacy claim caused recovery side effects: closed=%v comments=%d resets=%v", fr.closedRun, base.hasCommentCount(), base.resets)
|
|
1453
|
+
}
|
|
1454
|
+
if runs, err := engine.ListRuns(context.Background(), run.Filter{Repo: "payments", Workflow: "basicFlow", Ticket: "PAY-101"}); err != nil || len(runs) != 0 {
|
|
1455
|
+
t.Fatalf("legacy claim durable runs = %v, %v; want none", runs, err)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
897
1459
|
func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
898
1460
|
log := newEventLog()
|
|
899
1461
|
sys := newFakeTaskSystem(log)
|
|
@@ -206,6 +206,44 @@ func TestCompletedRunFinalizesActiveStepWhenFinalUpsertIsMissing(t *testing.T) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
func TestCancellationFencePreservesReasonAndRejectsLateCompletion(t *testing.T) {
|
|
210
|
+
ctx := context.Background()
|
|
211
|
+
p, _ := openProjection(t)
|
|
212
|
+
start := projectionStart("cancel-fence", "PAY-CANCEL-FENCE")
|
|
213
|
+
if err := p.InsertStart(ctx, start, time.Now().UTC()); err != nil {
|
|
214
|
+
t.Fatal(err)
|
|
215
|
+
}
|
|
216
|
+
first, err := p.BeginCancellation(ctx, start.ID, "first reason")
|
|
217
|
+
if err != nil {
|
|
218
|
+
t.Fatal(err)
|
|
219
|
+
}
|
|
220
|
+
if first.State != run.StateCanceling || first.LastError != "first reason" {
|
|
221
|
+
t.Fatalf("first cancellation = %+v", first)
|
|
222
|
+
}
|
|
223
|
+
second, err := p.BeginCancellation(ctx, start.ID, "replacement reason")
|
|
224
|
+
if err != nil {
|
|
225
|
+
t.Fatal(err)
|
|
226
|
+
}
|
|
227
|
+
if second.State != run.StateCanceling || second.LastError != "first reason" {
|
|
228
|
+
t.Fatalf("repeated cancellation overwrote reason: %+v", second)
|
|
229
|
+
}
|
|
230
|
+
if err := p.UpdateState(ctx, start.ID, run.StateCompleted, "", nil); err != nil {
|
|
231
|
+
t.Fatal(err)
|
|
232
|
+
}
|
|
233
|
+
stillCanceling, err := p.Get(ctx, start.ID)
|
|
234
|
+
if err != nil {
|
|
235
|
+
t.Fatal(err)
|
|
236
|
+
}
|
|
237
|
+
if stillCanceling.State != run.StateCanceling || stillCanceling.LastError != "first reason" {
|
|
238
|
+
t.Fatalf("late completion overwrote cancellation: %+v", stillCanceling)
|
|
239
|
+
}
|
|
240
|
+
finished := time.Now().UTC()
|
|
241
|
+
updated, err := p.UpdateStateIf(ctx, start.ID, run.StateCanceling, run.StateCanceled, "", &finished)
|
|
242
|
+
if err != nil || !updated {
|
|
243
|
+
t.Fatalf("canceling finalization updated=%v err=%v", updated, err)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
209
247
|
func TestCanceledRunFinalizesCurrentStepTiming(t *testing.T) {
|
|
210
248
|
ctx := context.Background()
|
|
211
249
|
p, _ := openProjection(t)
|