relay-flow 0.2.2-alpha → 0.2.3-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 +38 -3
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +25 -1
- package/cmd/relay-flow/serve.go +12 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -0
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +16 -0
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"os"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"strings"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -219,6 +220,131 @@ func TestCancelRun(t *testing.T) {
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
func TestExplicitRestartCreatesFreshAttemptFromStart(t *testing.T) {
|
|
224
|
+
log := newEventLog()
|
|
225
|
+
sys := newFakeTaskSystem(log)
|
|
226
|
+
fr := newFakeRunner(log)
|
|
227
|
+
fh := newFakeHarness(log)
|
|
228
|
+
repos := repoRegistryWith("payments", sys)
|
|
229
|
+
wf := linearWorkflow(false)
|
|
230
|
+
workflows := &workflow.Registry{}
|
|
231
|
+
workflows.Replace(&wf)
|
|
232
|
+
engine := newEngine(t, goworkflows.Dependencies{
|
|
233
|
+
Repos: repos, Runner: fr, Harness: fh,
|
|
234
|
+
})
|
|
235
|
+
oldID, err := startRun(engine, wf)
|
|
236
|
+
if err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
240
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
241
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
242
|
+
})
|
|
243
|
+
if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
|
|
244
|
+
t.Fatal(err)
|
|
245
|
+
}
|
|
246
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
247
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
248
|
+
return r.State == run.StateCanceled
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
manager := &run.RunManager{
|
|
252
|
+
Executor: engine, Runs: engine, Repos: repos, Workflows: workflows,
|
|
253
|
+
}
|
|
254
|
+
fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
|
|
255
|
+
if err != nil {
|
|
256
|
+
t.Fatalf("RestartByTicket failed: %v", err)
|
|
257
|
+
}
|
|
258
|
+
if fresh.ID == oldID || fresh.LogicalID != oldID || fresh.AttemptID != 2 {
|
|
259
|
+
t.Fatalf("fresh attempt = %+v, want logical=%q attempt=2 and a new ID", fresh, oldID)
|
|
260
|
+
}
|
|
261
|
+
oldAck, err := engine.SubmitReport(context.Background(), reportRequest(oldID, "coding", successReport("end")))
|
|
262
|
+
if err != nil || !oldAck.Accepted || !oldAck.Duplicate {
|
|
263
|
+
t.Fatalf("stale old-attempt report ack=%+v err=%v, want accepted duplicate", oldAck, err)
|
|
264
|
+
}
|
|
265
|
+
if got := string(fresh.ID); got != string(oldID)+"~attempt~2" {
|
|
266
|
+
t.Fatalf("fresh execution ID = %q, want numeric attempt suffix", got)
|
|
267
|
+
}
|
|
268
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
269
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
270
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
current, err := engine.GetRun(context.Background(), fresh.ID)
|
|
274
|
+
if err != nil {
|
|
275
|
+
t.Fatal(err)
|
|
276
|
+
}
|
|
277
|
+
latest, err := engine.FindRunByTicket(context.Background(), "PAY-101")
|
|
278
|
+
if err != nil {
|
|
279
|
+
t.Fatal(err)
|
|
280
|
+
}
|
|
281
|
+
if latest.ID != fresh.ID || latest.AttemptID != 2 || latest.LogicalID != oldID {
|
|
282
|
+
t.Fatalf("ticket lookup = %+v, want latest fresh attempt %q", latest, fresh.ID)
|
|
283
|
+
}
|
|
284
|
+
if current.State != run.StateWaiting && current.State != run.StateRunning {
|
|
285
|
+
t.Fatalf("fresh attempt state = %q, want active node state", current.State)
|
|
286
|
+
}
|
|
287
|
+
if got := log.count("prepareRestart:PAY-101"); got != 1 {
|
|
288
|
+
t.Fatalf("restart preparation calls = %d, want 1", got)
|
|
289
|
+
}
|
|
290
|
+
if len(fr.envs) != 1 {
|
|
291
|
+
t.Fatalf("restart created a second ticket environment: %d", len(fr.envs))
|
|
292
|
+
}
|
|
293
|
+
if fr.liveTerminals() != 1 {
|
|
294
|
+
t.Fatalf("restart left %d live terminals, want one fresh node terminal", fr.liveTerminals())
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
func TestRestartStatusConflictIsVisibleAndRecovers(t *testing.T) {
|
|
299
|
+
log := newEventLog()
|
|
300
|
+
sys := newFakeTaskSystem(log)
|
|
301
|
+
fr := newFakeRunner(log)
|
|
302
|
+
repos := repoRegistryWith("payments", sys)
|
|
303
|
+
wf := linearWorkflow(false)
|
|
304
|
+
workflows := &workflow.Registry{}
|
|
305
|
+
workflows.Replace(&wf)
|
|
306
|
+
engine := newEngine(t, goworkflows.Dependencies{Repos: repos, Runner: fr, Harness: newFakeHarness(log)})
|
|
307
|
+
oldID, err := startRun(engine, wf)
|
|
308
|
+
if err != nil {
|
|
309
|
+
t.Fatal(err)
|
|
310
|
+
}
|
|
311
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
312
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
313
|
+
return r.CurrentNode == "coding"
|
|
314
|
+
})
|
|
315
|
+
if err := engine.CancelRun(context.Background(), oldID, "operator requested restart"); err != nil {
|
|
316
|
+
t.Fatal(err)
|
|
317
|
+
}
|
|
318
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
319
|
+
r, _ := engine.GetRun(context.Background(), oldID)
|
|
320
|
+
return r.State == run.StateCanceled
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
sys.setStartConflict(true)
|
|
324
|
+
manager := &run.RunManager{Executor: engine, Runs: engine, Repos: repos, Workflows: workflows}
|
|
325
|
+
fresh, err := manager.RestartByTicket(context.Background(), "PAY-101")
|
|
326
|
+
if err != nil {
|
|
327
|
+
t.Fatal(err)
|
|
328
|
+
}
|
|
329
|
+
waitFor(t, 30*time.Second, func() bool {
|
|
330
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
331
|
+
return r.State == run.StateBlocked
|
|
332
|
+
})
|
|
333
|
+
blocked, err := engine.GetRun(context.Background(), fresh.ID)
|
|
334
|
+
if err != nil {
|
|
335
|
+
t.Fatal(err)
|
|
336
|
+
}
|
|
337
|
+
if !strings.Contains(blocked.LastError, "Move ticket PAY-101 to an allowed active start status") {
|
|
338
|
+
t.Fatalf("blocked LastError = %q, want actionable start-status guidance", blocked.LastError)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
sys.setStartConflict(false)
|
|
342
|
+
waitFor(t, 60*time.Second, func() bool {
|
|
343
|
+
r, _ := engine.GetRun(context.Background(), fresh.ID)
|
|
344
|
+
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
222
348
|
func TestCancelDuringRunningActivity(t *testing.T) {
|
|
223
349
|
// Cancellation cannot interrupt an already-running activity; it waits
|
|
224
350
|
// for it to return, then runs cancellation cleanup.
|
|
@@ -444,6 +570,9 @@ func TestConflictMarksBlockedThenRecovers(t *testing.T) {
|
|
|
444
570
|
if r.LastError == "" {
|
|
445
571
|
t.Fatal("blocked run exposes no conflict error in LastError")
|
|
446
572
|
}
|
|
573
|
+
if !strings.Contains(r.LastError, "Restore the task-system state required for node coding") {
|
|
574
|
+
t.Fatalf("blocked LastError = %q, want actionable node-state guidance", r.LastError)
|
|
575
|
+
}
|
|
447
576
|
if sys.mailboxStatusOf("PAY-101-coding") == "Done" {
|
|
448
577
|
t.Fatal("mailbox completed while state was incompatible; no blind overwrite allowed")
|
|
449
578
|
}
|
|
@@ -30,7 +30,7 @@ Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
|
30
30
|
|
|
31
31
|
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
|
|
32
32
|
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
|
-
defaultHITLPrompt = `
|
|
33
|
+
defaultHITLPrompt = `Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.`
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
@@ -133,9 +133,9 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
|
|
|
133
133
|
return harness.Session{}, false, nil
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// RenderPrompt renders the selected session prompt, appends
|
|
137
|
-
// instructions for HITL nodes, then renders and appends the node's
|
|
138
|
-
// template.
|
|
136
|
+
// RenderPrompt renders the selected session prompt, appends the harness-owned
|
|
137
|
+
// HITL/TUI instructions for HITL nodes, then renders and appends the node's
|
|
138
|
+
// nudge template.
|
|
139
139
|
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
140
140
|
var tmpl string
|
|
141
141
|
switch kind {
|
|
@@ -14,7 +14,7 @@ import (
|
|
|
14
14
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
const configuredPlugin = "relay-flow-plugin@0.2.
|
|
17
|
+
const configuredPlugin = "relay-flow-plugin@0.2.3-alpha"
|
|
18
18
|
|
|
19
19
|
func TestBuildCommandArgv(t *testing.T) {
|
|
20
20
|
t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
|
|
@@ -61,7 +61,6 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
61
61
|
raw := config.RawValues{
|
|
62
62
|
"initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
|
|
63
63
|
"feedback": "feedback {{mailbox}}",
|
|
64
|
-
"hitl": "hitl {{node}}",
|
|
65
64
|
}
|
|
66
65
|
h, err := harness.New("opencode", raw)
|
|
67
66
|
if err != nil {
|
|
@@ -77,7 +76,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
77
76
|
if err != nil {
|
|
78
77
|
t.Fatal(err)
|
|
79
78
|
}
|
|
80
|
-
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\
|
|
79
|
+
wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
|
|
81
80
|
if initial != wantInitial {
|
|
82
81
|
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
83
82
|
}
|
|
@@ -85,7 +84,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
|
|
|
85
84
|
if err != nil {
|
|
86
85
|
t.Fatal(err)
|
|
87
86
|
}
|
|
88
|
-
if want := "feedback PAY-234\n\
|
|
87
|
+
if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
|
|
89
88
|
t.Fatalf("feedback prompt = %q, want %q", feedback, want)
|
|
90
89
|
}
|
|
91
90
|
}
|
|
@@ -206,6 +205,62 @@ func TestSetupRepoAddsPluginPropertyToJSONCWithComments(t *testing.T) {
|
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
func TestSetupRepoCreatesOpenCodeTUIConfig(t *testing.T) {
|
|
209
|
+
dir := t.TempDir()
|
|
210
|
+
if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
|
|
211
|
+
t.Fatalf("SetupRepo: %v", err)
|
|
212
|
+
}
|
|
213
|
+
path := filepath.Join(dir, ".opencode", "tui.json")
|
|
214
|
+
data, err := os.ReadFile(path)
|
|
215
|
+
if err != nil {
|
|
216
|
+
t.Fatal(err)
|
|
217
|
+
}
|
|
218
|
+
var cfg struct {
|
|
219
|
+
Schema string `json:"$schema"`
|
|
220
|
+
Plugin []string `json:"plugin"`
|
|
221
|
+
}
|
|
222
|
+
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
223
|
+
t.Fatalf("created TUI config is invalid JSON: %v\n%s", err, data)
|
|
224
|
+
}
|
|
225
|
+
if cfg.Schema != "https://opencode.ai/tui.json" {
|
|
226
|
+
t.Fatalf("$schema = %q", cfg.Schema)
|
|
227
|
+
}
|
|
228
|
+
if !reflect.DeepEqual(cfg.Plugin, []string{configuredPlugin}) {
|
|
229
|
+
t.Fatalf("plugin = %v", cfg.Plugin)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
func TestSetupRepoUpdatesExistingOpenCodeTUIJSONC(t *testing.T) {
|
|
234
|
+
dir := t.TempDir()
|
|
235
|
+
configDir := filepath.Join(dir, ".opencode")
|
|
236
|
+
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
|
237
|
+
t.Fatal(err)
|
|
238
|
+
}
|
|
239
|
+
path := filepath.Join(configDir, "tui.jsonc")
|
|
240
|
+
original := `{
|
|
241
|
+
// keep the local theme
|
|
242
|
+
"theme": "catppuccin"
|
|
243
|
+
}
|
|
244
|
+
`
|
|
245
|
+
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
|
|
246
|
+
t.Fatal(err)
|
|
247
|
+
}
|
|
248
|
+
if err := opencode.New().SetupRepo(t.Context(), dir); err != nil {
|
|
249
|
+
t.Fatalf("SetupRepo: %v", err)
|
|
250
|
+
}
|
|
251
|
+
data, err := os.ReadFile(path)
|
|
252
|
+
if err != nil {
|
|
253
|
+
t.Fatal(err)
|
|
254
|
+
}
|
|
255
|
+
text := string(data)
|
|
256
|
+
if !strings.Contains(text, "// keep the local theme") || !strings.Contains(text, configuredPlugin) {
|
|
257
|
+
t.Fatalf("TUI config was not preserved and updated:\n%s", text)
|
|
258
|
+
}
|
|
259
|
+
if _, err := os.Stat(filepath.Join(configDir, "tui.json")); !os.IsNotExist(err) {
|
|
260
|
+
t.Fatalf("unexpected tui.json created: %v", err)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
209
264
|
func setupTwice(t *testing.T, dir string) {
|
|
210
265
|
t.Helper()
|
|
211
266
|
h := opencode.New()
|
|
@@ -10,7 +10,7 @@ import (
|
|
|
10
10
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
11
11
|
)
|
|
12
12
|
|
|
13
|
-
const relayFlowPlugin = "relay-flow-plugin@0.2.
|
|
13
|
+
const relayFlowPlugin = "relay-flow-plugin@0.2.3-alpha"
|
|
14
14
|
|
|
15
15
|
type jsoncToken struct {
|
|
16
16
|
kind byte
|
|
@@ -47,12 +47,34 @@ func setupRepo(repoPath string) error {
|
|
|
47
47
|
if err != nil {
|
|
48
48
|
return err
|
|
49
49
|
}
|
|
50
|
+
if err := ensurePluginConfig(path, mode, ""); err != nil {
|
|
51
|
+
return err
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tuiPath, tuiMode, err := openCodeTUIConfigPath(repoPath)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return err
|
|
57
|
+
}
|
|
58
|
+
if err := os.MkdirAll(filepath.Dir(tuiPath), 0o755); err != nil {
|
|
59
|
+
return fmt.Errorf("opencode: create TUI config directory: %w", err)
|
|
60
|
+
}
|
|
61
|
+
if err := ensurePluginConfig(tuiPath, tuiMode, "https://opencode.ai/tui.json"); err != nil {
|
|
62
|
+
return fmt.Errorf("opencode: setup TUI config: %w", err)
|
|
63
|
+
}
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func ensurePluginConfig(path string, mode os.FileMode, schema string) error {
|
|
50
68
|
data, err := os.ReadFile(path)
|
|
51
69
|
if err != nil {
|
|
52
70
|
if !os.IsNotExist(err) {
|
|
53
71
|
return fmt.Errorf("opencode: read %s: %w", path, err)
|
|
54
72
|
}
|
|
55
|
-
|
|
73
|
+
if schema == "" {
|
|
74
|
+
data = []byte("{\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
|
|
75
|
+
} else {
|
|
76
|
+
data = []byte("{\n \"$schema\": \"" + schema + "\",\n \"plugin\": [\"" + relayFlowPlugin + "\"]\n}\n")
|
|
77
|
+
}
|
|
56
78
|
} else {
|
|
57
79
|
data, err = updateOpenCodeConfig(data)
|
|
58
80
|
if err != nil {
|
|
@@ -86,6 +108,24 @@ func openCodeConfigPath(repoPath string) (string, os.FileMode, error) {
|
|
|
86
108
|
return jsonPath, 0o644, nil
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
func openCodeTUIConfigPath(repoPath string) (string, os.FileMode, error) {
|
|
112
|
+
dir := filepath.Join(repoPath, ".opencode")
|
|
113
|
+
for _, name := range []string{"tui.json", "tui.jsonc"} {
|
|
114
|
+
path := filepath.Join(dir, name)
|
|
115
|
+
info, err := os.Stat(path)
|
|
116
|
+
if err == nil {
|
|
117
|
+
if !info.Mode().IsRegular() {
|
|
118
|
+
return "", 0, fmt.Errorf("opencode: TUI config %s is not a regular file", path)
|
|
119
|
+
}
|
|
120
|
+
return path, info.Mode().Perm(), nil
|
|
121
|
+
}
|
|
122
|
+
if !os.IsNotExist(err) {
|
|
123
|
+
return "", 0, fmt.Errorf("opencode: inspect TUI config %s: %w", path, err)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return filepath.Join(dir, "tui.json"), 0o644, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
func updateOpenCodeConfig(data []byte) ([]byte, error) {
|
|
90
130
|
tokens, err := tokenizeJSONC(data)
|
|
91
131
|
if err != nil {
|
|
@@ -6,12 +6,18 @@ import (
|
|
|
6
6
|
"crypto/rand"
|
|
7
7
|
"encoding/hex"
|
|
8
8
|
"net/url"
|
|
9
|
+
"strconv"
|
|
9
10
|
"strings"
|
|
10
11
|
)
|
|
11
12
|
|
|
12
|
-
// RunID identifies one durable
|
|
13
|
+
// RunID identifies one durable execution attempt. Opaque to consumers.
|
|
13
14
|
type RunID string
|
|
14
15
|
|
|
16
|
+
// AttemptID identifies an execution generation for one logical
|
|
17
|
+
// repo/workflow/ticket run. Attempt numbers are durably allocated by the Run
|
|
18
|
+
// Manager and are intentionally simple numeric values.
|
|
19
|
+
type AttemptID uint64
|
|
20
|
+
|
|
15
21
|
// NodeVisitID identifies one entry into a workflow node. Opaque to consumers.
|
|
16
22
|
type NodeVisitID string
|
|
17
23
|
|
|
@@ -25,6 +31,27 @@ func NewRunID(repo, workflow, ticket string) RunID {
|
|
|
25
31
|
}, "/"))
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
// NewAttemptRunID derives an execution ID that is fenced from the stable
|
|
35
|
+
// logical run ID. Attempt 1 retains the original deterministic ID for the
|
|
36
|
+
// first execution; explicit restarts use numeric attempt suffixes.
|
|
37
|
+
func NewAttemptRunID(logical RunID, attempt AttemptID) RunID {
|
|
38
|
+
if attempt <= 1 {
|
|
39
|
+
return logical
|
|
40
|
+
}
|
|
41
|
+
return RunID(string(logical) + "~attempt~" + strconv.FormatUint(uint64(attempt), 10))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// LogicalRunID returns the stable logical ID embedded in an execution ID.
|
|
45
|
+
// First attempts are already logical IDs; explicit attempts use the
|
|
46
|
+
// `~attempt~<number>` suffix generated by NewAttemptRunID.
|
|
47
|
+
func LogicalRunID(execution RunID) RunID {
|
|
48
|
+
const marker = "~attempt~"
|
|
49
|
+
if index := strings.LastIndex(string(execution), marker); index >= 0 {
|
|
50
|
+
return RunID(string(execution)[:index])
|
|
51
|
+
}
|
|
52
|
+
return execution
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
// NewNodeVisitID returns a fresh random node-visit ID. Generation happens
|
|
29
56
|
// once per node entry as a durable replay-safe side effect; this function
|
|
30
57
|
// only produces the random value.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package identity_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"testing"
|
|
5
|
+
|
|
6
|
+
"github.com/rajpopat27/relay-flow/internal/identity"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestAttemptRunIDUsesNumericAttemptSuffix(t *testing.T) {
|
|
10
|
+
logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
11
|
+
|
|
12
|
+
if got := identity.NewAttemptRunID(logical, 1); got != logical {
|
|
13
|
+
t.Fatalf("initial attempt ID = %q, want logical ID %q", got, logical)
|
|
14
|
+
}
|
|
15
|
+
if got := identity.NewAttemptRunID(logical, 2); got != logical+"~attempt~2" {
|
|
16
|
+
t.Fatalf("restart attempt ID = %q, want numeric suffix", got)
|
|
17
|
+
}
|
|
18
|
+
if got := identity.NewAttemptRunID(logical, 3); got == identity.NewAttemptRunID(logical, 2) {
|
|
19
|
+
t.Fatalf("attempt IDs are not fenced: attempt 2 and 3 both use %q", got)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestAttemptIDsAreNumeric(t *testing.T) {
|
|
24
|
+
var first identity.AttemptID = 1
|
|
25
|
+
var second identity.AttemptID = 2
|
|
26
|
+
if first != 1 || second != 2 {
|
|
27
|
+
t.Fatalf("attempt IDs are not numeric: %d, %d", first, second)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestLogicalRunIDStripsAttemptSuffix(t *testing.T) {
|
|
32
|
+
logical := identity.NewRunID("payments", "basicFlow", "PAY-101")
|
|
33
|
+
execution := identity.NewAttemptRunID(logical, 4)
|
|
34
|
+
if got := identity.LogicalRunID(execution); got != logical {
|
|
35
|
+
t.Fatalf("logical ID = %q, want %q", got, logical)
|
|
36
|
+
}
|
|
37
|
+
if got := identity.LogicalRunID(logical); got != logical {
|
|
38
|
+
t.Fatalf("first-attempt logical ID = %q, want %q", got, logical)
|
|
39
|
+
}
|
|
40
|
+
}
|