relay-flow 0.2.0-alpha → 0.2.1-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 +18 -6
- package/cmd/relay-flow/commands_test.go +519 -15
- package/cmd/relay-flow/main.go +331 -119
- package/cmd/relay-flow/scenario_test.go +209 -34
- package/cmd/relay-flow/serve.go +1 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/internal/execution/goworkflows/activities.go +65 -65
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +73 -13
- package/internal/execution/goworkflows/fakes_test.go +13 -21
- package/internal/execution/goworkflows/interpreter.go +16 -8
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +45 -21
- package/internal/execution/goworkflows/recovery_test.go +5 -5
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +5 -0
- package/internal/paths/paths.go +18 -16
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service_test.go +4 -4
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +2 -0
- package/internal/task/factory.go +16 -0
- package/internal/task/jira/auth.go +183 -0
- package/internal/task/jira/auth_test.go +107 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +36 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +92 -61
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +128 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/transition_defaults_test.go +18 -16
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- package/internal/task/jira/acli/testdata/search_success.json +0 -1
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -32,9 +32,10 @@ import (
|
|
|
32
32
|
|
|
33
33
|
// Dependencies carries the replaceable boundaries used by activities.
|
|
34
34
|
type Dependencies struct {
|
|
35
|
-
Repos
|
|
36
|
-
Runner
|
|
37
|
-
Harness
|
|
35
|
+
Repos *repo.Registry
|
|
36
|
+
Runner runner.Runner
|
|
37
|
+
Harness harness.Harness
|
|
38
|
+
TaskSystem string
|
|
38
39
|
|
|
39
40
|
// RetentionDays bounds completed/canceled run retention; zero uses the
|
|
40
41
|
// machine default of 30 days.
|
|
@@ -91,6 +92,23 @@ func InitDatabase(path string) error {
|
|
|
91
92
|
return nil
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// HasNonterminalRuns inspects an existing database without migrating or
|
|
96
|
+
// otherwise modifying it. It is used by init --force before config changes.
|
|
97
|
+
func HasNonterminalRuns(path string) (bool, error) {
|
|
98
|
+
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro", path))
|
|
99
|
+
if err != nil {
|
|
100
|
+
return false, fmt.Errorf("open %s: %w", path, err)
|
|
101
|
+
}
|
|
102
|
+
defer db.Close()
|
|
103
|
+
var active bool
|
|
104
|
+
if err := db.QueryRow(`SELECT EXISTS(
|
|
105
|
+
SELECT 1 FROM relay_runs WHERE state NOT IN ('completed', 'canceled')
|
|
106
|
+
)`).Scan(&active); err != nil {
|
|
107
|
+
return false, fmt.Errorf("inspect %s: %w", path, err)
|
|
108
|
+
}
|
|
109
|
+
return active, nil
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
// New opens the SQLite database at path (created with mode 0600 when
|
|
95
113
|
// missing), migrates the relay_runs projection, and constructs the engine.
|
|
96
114
|
// A corrupt database file fails here.
|
|
@@ -130,10 +148,11 @@ func New(path string, deps Dependencies) (*Engine, error) {
|
|
|
130
148
|
return nil, fmt.Errorf("migrate relay_runs: %w", err)
|
|
131
149
|
}
|
|
132
150
|
activities := &Activities{
|
|
133
|
-
Repos:
|
|
134
|
-
Runner:
|
|
135
|
-
Harness:
|
|
136
|
-
|
|
151
|
+
Repos: deps.Repos,
|
|
152
|
+
Runner: deps.Runner,
|
|
153
|
+
Harness: deps.Harness,
|
|
154
|
+
TaskSystem: deps.TaskSystem,
|
|
155
|
+
Runs: proj,
|
|
137
156
|
}
|
|
138
157
|
retention := 30 * 24 * time.Hour
|
|
139
158
|
if deps.RetentionDays > 0 {
|
|
@@ -204,6 +223,7 @@ func (e *Engine) registerActivities() error {
|
|
|
204
223
|
a.ValidateAgents,
|
|
205
224
|
a.ApplyTaskConfig,
|
|
206
225
|
a.EnsureEnvironment,
|
|
226
|
+
a.SetEnvironmentStatus,
|
|
207
227
|
a.LoadNodeRuntime,
|
|
208
228
|
a.EnsureNodeRuntime,
|
|
209
229
|
a.CloseTerminals,
|
|
@@ -291,6 +311,19 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
|
291
311
|
if err != nil {
|
|
292
312
|
return false, err
|
|
293
313
|
}
|
|
314
|
+
if r.State != run.StateCompleted && r.State != run.StateCanceled && r.State != run.StateCanceling {
|
|
315
|
+
if _, err := e.instance(ctx, start.ID); errors.Is(err, sql.ErrNoRows) {
|
|
316
|
+
start.Runtime = e.runtime
|
|
317
|
+
if _, err := e.client.CreateWorkflowInstance(ctx,
|
|
318
|
+
client.WorkflowInstanceOptions{InstanceID: string(start.ID)},
|
|
319
|
+
e.activities.TicketWorkflow, start); err != nil && !errors.Is(err, backend.ErrInstanceAlreadyExists) {
|
|
320
|
+
return false, fmt.Errorf("create missing workflow instance %s: %w", start.ID, err)
|
|
321
|
+
}
|
|
322
|
+
return true, nil
|
|
323
|
+
} else if err != nil {
|
|
324
|
+
return false, err
|
|
325
|
+
}
|
|
326
|
+
}
|
|
294
327
|
// Existing run: reconcile only an active run at a work node.
|
|
295
328
|
if r.State == run.StateCompleted || r.State == run.StateCanceled || r.State == run.StateCanceling {
|
|
296
329
|
return false, nil
|
|
@@ -304,7 +337,7 @@ func (e *Engine) EnsureRun(ctx context.Context, start run.Start) (bool, error) {
|
|
|
304
337
|
}
|
|
305
338
|
ok := false
|
|
306
339
|
if runtime.TerminalID != "" {
|
|
307
|
-
_, ok, _ = e.activities.Runner.
|
|
340
|
+
_, ok, _ = e.activities.Runner.FindTerminal(ctx, runner.Terminal{
|
|
308
341
|
ID: runtime.TerminalID, Title: r.Ticket.Key + ":" + r.CurrentNode,
|
|
309
342
|
})
|
|
310
343
|
}
|
|
@@ -2,6 +2,7 @@ package goworkflows_test
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
+
"database/sql"
|
|
5
6
|
"path/filepath"
|
|
6
7
|
"strings"
|
|
7
8
|
"testing"
|
|
@@ -39,23 +40,25 @@ func linearWorkflow(cleanup bool) workflow.Workflow {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
func
|
|
43
|
+
func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
|
|
43
44
|
wf := linearWorkflow(false)
|
|
44
45
|
node := wf.Nodes["coding"]
|
|
45
46
|
node.Type = workflow.NodeHITL
|
|
46
47
|
description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
|
|
47
48
|
for _, want := range []string{
|
|
49
|
+
"Parent ticket: PAY-101",
|
|
48
50
|
"Do not make code changes",
|
|
49
51
|
"until the human is satisfied with the review",
|
|
50
|
-
"OpenCode's Question tool",
|
|
51
|
-
"Approve and Reject",
|
|
52
|
-
"If approved, output the report verbatim",
|
|
53
|
-
"If rejected, return to step 1",
|
|
54
52
|
} {
|
|
55
53
|
if !strings.Contains(description, want) {
|
|
56
54
|
t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
|
|
57
55
|
}
|
|
58
56
|
}
|
|
57
|
+
for _, unwanted := range []string{"Jira", "OpenCode", "Question tool"} {
|
|
58
|
+
if strings.Contains(description, unwanted) {
|
|
59
|
+
t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
59
62
|
|
|
60
63
|
node.Type = workflow.NodeAgent
|
|
61
64
|
agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
|
|
@@ -63,14 +66,18 @@ func TestMailboxDescriptionRequiresQuestionForHITL(t *testing.T) {
|
|
|
63
66
|
t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
for _, taskSystem := range []string{"jira", "linear"} {
|
|
70
|
+
prompt := goworkflows.BuildLaunchSpecPrompt(taskSystem, "PAY-101", "PAY-234")
|
|
71
|
+
want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback."
|
|
72
|
+
if prompt != want {
|
|
73
|
+
t.Fatalf("BuildLaunchSpecPrompt(%s) = %q, want %q", taskSystem, prompt, want)
|
|
74
|
+
}
|
|
75
|
+
if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
|
|
76
|
+
t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
|
|
77
|
+
}
|
|
78
|
+
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
79
|
+
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
70
80
|
}
|
|
71
|
-
}
|
|
72
|
-
if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
|
|
73
|
-
t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
@@ -104,6 +111,50 @@ func newEngine(t *testing.T, deps goworkflows.Dependencies) *goworkflows.Engine
|
|
|
104
111
|
return e
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
func TestEnsureRunCreatesMissingWorkflowForExistingProjection(t *testing.T) {
|
|
115
|
+
log := newEventLog()
|
|
116
|
+
sys := newFakeTaskSystem(log)
|
|
117
|
+
path := filepath.Join(t.TempDir(), "state.db")
|
|
118
|
+
deps := goworkflows.Dependencies{
|
|
119
|
+
Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log),
|
|
120
|
+
}
|
|
121
|
+
engine, err := goworkflows.New(path, deps)
|
|
122
|
+
if err != nil {
|
|
123
|
+
t.Fatal(err)
|
|
124
|
+
}
|
|
125
|
+
wf := linearWorkflow(false)
|
|
126
|
+
rid := identity.NewRunID("payments", wf.Name, "PAY-101")
|
|
127
|
+
db, err := sql.Open("sqlite", path)
|
|
128
|
+
if err != nil {
|
|
129
|
+
t.Fatal(err)
|
|
130
|
+
}
|
|
131
|
+
if _, err := db.Exec(`INSERT INTO relay_runs
|
|
132
|
+
(id, repo, workflow, ticket_id, ticket_key, state, started_at, updated_at)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, rid, "payments", wf.Name, "1", "PAY-101", run.StateStarting, time.Now(), time.Now()); err != nil {
|
|
134
|
+
t.Fatal(err)
|
|
135
|
+
}
|
|
136
|
+
db.Close()
|
|
137
|
+
if err := engine.Start(context.Background()); err != nil {
|
|
138
|
+
t.Fatal(err)
|
|
139
|
+
}
|
|
140
|
+
t.Cleanup(func() { _ = engine.Shutdown(context.Background()) })
|
|
141
|
+
|
|
142
|
+
created, err := engine.EnsureRun(context.Background(), run.Start{
|
|
143
|
+
ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
|
|
144
|
+
Ticket: task.TicketRef{ID: "1", Key: "PAY-101"},
|
|
145
|
+
})
|
|
146
|
+
if err != nil {
|
|
147
|
+
t.Fatal(err)
|
|
148
|
+
}
|
|
149
|
+
if !created {
|
|
150
|
+
t.Fatal("EnsureRun did not create the missing workflow instance")
|
|
151
|
+
}
|
|
152
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
153
|
+
r, err := engine.GetRun(context.Background(), rid)
|
|
154
|
+
return err == nil && r.CurrentNode == "coding"
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
107
158
|
func successReport(next string) workflow.Report {
|
|
108
159
|
none := "None"
|
|
109
160
|
return workflow.Report{
|
|
@@ -233,6 +284,9 @@ func TestRevisitCreatesNewVisit(t *testing.T) {
|
|
|
233
284
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
234
285
|
return r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
|
|
235
286
|
})
|
|
287
|
+
if indexOf(log.all(), "environmentStatus:in-progress") < 0 {
|
|
288
|
+
t.Fatalf("agent node did not set workspace status in-progress; events=%v", log.all())
|
|
289
|
+
}
|
|
236
290
|
|
|
237
291
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
238
292
|
first := r.CurrentNodeVisitID
|
|
@@ -329,6 +383,9 @@ func TestEndCleanupDisabledKeepsRetainedRunner(t *testing.T) {
|
|
|
329
383
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
330
384
|
return r.State == run.StateCompleted
|
|
331
385
|
})
|
|
386
|
+
if indexOf(log.all(), "environmentStatus:completed") < 0 {
|
|
387
|
+
t.Fatalf("end did not set workspace status completed; events=%v", log.all())
|
|
388
|
+
}
|
|
332
389
|
if len(fr.cleaned) != 0 || fr.liveTerminals() != 1 {
|
|
333
390
|
t.Fatalf("cleanup disabled: CleanupRun calls=%v live terminals=%d, want 0 and 1", fr.cleaned, fr.liveTerminals())
|
|
334
391
|
}
|
|
@@ -378,6 +435,10 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
378
435
|
r, _ := engine.GetRun(context.Background(), rid)
|
|
379
436
|
return r.CurrentNode == "review"
|
|
380
437
|
})
|
|
438
|
+
events := log.all()
|
|
439
|
+
if statusIdx, terminalIdx := indexOf(events, "environmentStatus:in-review"), indexOf(events, "ensureTerminal:PAY-101:review"); statusIdx < 0 || terminalIdx < 0 || statusIdx >= terminalIdx {
|
|
440
|
+
t.Fatalf("HITL status was not set before terminal start; events=%v", events)
|
|
441
|
+
}
|
|
381
442
|
|
|
382
443
|
// Exact cross-primitive order, observed through the fake-adapter and fake-
|
|
383
444
|
// runner call logs (the settled observation seam):
|
|
@@ -388,7 +449,6 @@ func TestTransitionOrdering(t *testing.T) {
|
|
|
388
449
|
// (recovery_test.go): a crash after acceptance, with comment injection
|
|
389
450
|
// failing, restarts on the same db and resumes the PERSISTED selected route
|
|
390
451
|
// without re-asking the agent or re-running effects.
|
|
391
|
-
events := log.all()
|
|
392
452
|
idx := map[string]int{}
|
|
393
453
|
for _, want := range []string{
|
|
394
454
|
"comment:PAY-101-coding", // summary to current mailbox
|
|
@@ -337,21 +337,17 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (
|
|
|
337
337
|
return e, nil
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
func (f *fakeRunner)
|
|
341
|
-
f.
|
|
342
|
-
|
|
343
|
-
f.log.add("findTerminal:" + title)
|
|
344
|
-
ft, ok := f.terminals[env.ID+"/"+title]
|
|
345
|
-
if !ok || !ft.live {
|
|
346
|
-
return runner.Terminal{}, false, nil
|
|
347
|
-
}
|
|
348
|
-
return ft.term, true, nil
|
|
340
|
+
func (f *fakeRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
|
|
341
|
+
f.log.add("environmentStatus:" + status)
|
|
342
|
+
return nil
|
|
349
343
|
}
|
|
350
344
|
|
|
351
|
-
func (f *fakeRunner)
|
|
345
|
+
func (f *fakeRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
352
346
|
f.mu.Lock()
|
|
353
347
|
defer f.mu.Unlock()
|
|
354
|
-
|
|
348
|
+
if terminal.ID != "" {
|
|
349
|
+
f.log.add("findTerminalID:" + terminal.ID)
|
|
350
|
+
}
|
|
355
351
|
for _, ft := range f.terminals {
|
|
356
352
|
if ft.term.ID == terminal.ID && ft.live {
|
|
357
353
|
return ft.term, true, nil
|
|
@@ -381,17 +377,13 @@ func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment,
|
|
|
381
377
|
return t, nil
|
|
382
378
|
}
|
|
383
379
|
|
|
384
|
-
func (f *fakeRunner) EnsureTerminal(
|
|
385
|
-
f.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
return ft.term, nil
|
|
380
|
+
func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
381
|
+
if terminal, ok, err := f.FindTerminal(ctx, stored); err != nil {
|
|
382
|
+
return runner.Terminal{}, err
|
|
383
|
+
} else if ok {
|
|
384
|
+
return terminal, nil
|
|
390
385
|
}
|
|
391
|
-
|
|
392
|
-
f.terminals[key] = &fakeTerminal{term: t, live: true, title: title}
|
|
393
|
-
f.log.add("ensureTerminal:" + title)
|
|
394
|
-
return t, nil
|
|
386
|
+
return f.CreateTerminal(ctx, env, title, command)
|
|
395
387
|
}
|
|
396
388
|
|
|
397
389
|
func (f *fakeRunner) CloseTerminal(_ context.Context, t runner.Terminal) error {
|
|
@@ -194,11 +194,12 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
194
194
|
// are also retained separately for live-terminal revisits.
|
|
195
195
|
nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
|
|
196
196
|
nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
TaskSystem: a.TaskSystem,
|
|
198
|
+
Ticket: start.Ticket.Key,
|
|
199
|
+
Workflow: wf.Name,
|
|
200
|
+
Repo: start.Repo,
|
|
201
|
+
Node: current,
|
|
202
|
+
NextSteps: nextStepsText(nextSteps),
|
|
202
203
|
})
|
|
203
204
|
if err != nil {
|
|
204
205
|
return err
|
|
@@ -214,7 +215,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
214
215
|
NodeType: node.Type,
|
|
215
216
|
Agent: node.Agent,
|
|
216
217
|
Title: title,
|
|
217
|
-
Prompt: BuildLaunchSpecPrompt(start.Ticket.Key, mb.Key),
|
|
218
|
+
Prompt: BuildLaunchSpecPrompt(a.TaskSystem, start.Ticket.Key, mb.Key),
|
|
218
219
|
NudgePrompt: nudge,
|
|
219
220
|
NextSteps: nextSteps,
|
|
220
221
|
}
|
|
@@ -360,6 +361,13 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
360
361
|
}); err != nil {
|
|
361
362
|
return err
|
|
362
363
|
}
|
|
364
|
+
if _, err := retryLoop(ctx, start.ID, a, work, "end",
|
|
365
|
+
func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
|
|
366
|
+
return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries,
|
|
367
|
+
a.SetEnvironmentStatus, work, start.RepoPath, runner.WorkspaceStatusCompleted)
|
|
368
|
+
}); err != nil {
|
|
369
|
+
return err
|
|
370
|
+
}
|
|
363
371
|
finalPolicy := work.Runtime
|
|
364
372
|
if wf.CleanupRunnerOnEnd {
|
|
365
373
|
finalPolicy.KeepTerminalsAlive = false
|
|
@@ -401,7 +409,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
|
|
|
401
409
|
// run-level activities like EnsureMailboxes / start/end ApplyTaskConfig).
|
|
402
410
|
// The log is wrapped in a replay-safe SideEffect so replays never re-emit
|
|
403
411
|
// it, and the error message is sanitized so info logs never embed argv
|
|
404
|
-
// payloads (
|
|
412
|
+
// payloads (Jira/Orca request bodies, commands, JQL, and prompt strings).
|
|
405
413
|
func retryLoop[T any](ctx goworkflow.Context, id run.ID, a *Activities, work run.Work, node string, schedule func(goworkflow.Context) goworkflow.Future[T]) (T, error) {
|
|
406
414
|
var zero T
|
|
407
415
|
attempt := 0
|
|
@@ -475,7 +483,7 @@ func logRetry(ctx goworkflow.Context, work run.Work, node string, f retry.Failur
|
|
|
475
483
|
}
|
|
476
484
|
|
|
477
485
|
// sanitizeRetryMessage strips each "[...]: " argv-listing span that adapter
|
|
478
|
-
// wrappers embed
|
|
486
|
+
// wrappers may embed argv/request context, so the info-level retry
|
|
479
487
|
// record never leaks --body/--command/JQL/prompt payloads. Keeps the
|
|
480
488
|
// surrounding wrap context and trailing exit status / stderr fragment that
|
|
481
489
|
// carry the failure reason. The original error returned to callers is
|
|
@@ -84,8 +84,8 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
84
84
|
if err != nil || !staleAck.Duplicate {
|
|
85
85
|
t.Fatalf("stale ack=%+v err=%v", staleAck, err)
|
|
86
86
|
}
|
|
87
|
-
if log.count("
|
|
88
|
-
t.Fatalf("normal path
|
|
87
|
+
if log.count("findTerminalID:") == 0 {
|
|
88
|
+
t.Fatalf("normal path did not check persisted terminal IDs: %v", log.all())
|
|
89
89
|
}
|
|
90
90
|
if log.count("findSession:") != 0 {
|
|
91
91
|
t.Fatalf("normal path used session discovery: %v", log.all())
|
|
@@ -93,7 +93,7 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
93
93
|
|
|
94
94
|
oldTerminal := secondRT.TerminalID
|
|
95
95
|
fr.killTerminals()
|
|
96
|
-
findTerminalBefore := log.count("
|
|
96
|
+
findTerminalBefore := log.count("findTerminalID:")
|
|
97
97
|
findSessionBefore := log.count("findSession:")
|
|
98
98
|
e2 := restartEngine(t, db, deps, e1)
|
|
99
99
|
waitFor(t, 10*time.Second, func() bool {
|
|
@@ -104,13 +104,19 @@ func TestPersistedRuntimeLoopAndRestart(t *testing.T) {
|
|
|
104
104
|
t.Fatal(err)
|
|
105
105
|
}
|
|
106
106
|
resumeEvent := "buildCommand:implement:" + string(second.CurrentNodeVisitID) + ":resume=session-implement"
|
|
107
|
-
waitFor(t, 10*time.Second, func() bool {
|
|
107
|
+
waitFor(t, 10*time.Second, func() bool {
|
|
108
|
+
runtime, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
|
|
109
|
+
return runtime.TerminalID != "" && runtime.TerminalID != oldTerminal
|
|
110
|
+
})
|
|
108
111
|
after, _ := e2.GetNodeRuntime(context.Background(), rid, "implement")
|
|
109
112
|
if after.TerminalID == oldTerminal || after.SessionID != "session-implement" || after.NodeVisitID != second.CurrentNodeVisitID {
|
|
110
113
|
t.Fatalf("restart did not resume/replace direct runtime: old=%+v after=%+v", secondRT, after)
|
|
111
114
|
}
|
|
112
|
-
if log.count("
|
|
113
|
-
t.Fatalf("restart
|
|
115
|
+
if log.count("findTerminalID:") == findTerminalBefore || log.count("findSession:") != findSessionBefore {
|
|
116
|
+
t.Fatalf("restart did not use only the persisted terminal/session IDs: %v", log.all())
|
|
117
|
+
}
|
|
118
|
+
if log.count(resumeEvent) < 1 {
|
|
119
|
+
t.Fatalf("restart did not pass persisted session ID to harness: %v", log.all())
|
|
114
120
|
}
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -189,7 +189,7 @@ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing
|
|
|
189
189
|
|
|
190
190
|
func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
191
191
|
ctx := context.Background()
|
|
192
|
-
fr := &runtimeTestRunner{
|
|
192
|
+
fr := &runtimeTestRunner{}
|
|
193
193
|
fh := &runtimeTestHarness{}
|
|
194
194
|
db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
|
|
195
195
|
defer db.Close()
|
|
@@ -219,11 +219,17 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
|
219
219
|
if err != nil {
|
|
220
220
|
t.Fatal(err)
|
|
221
221
|
}
|
|
222
|
-
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "" {
|
|
222
|
+
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
|
|
223
223
|
t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
|
|
224
224
|
}
|
|
225
|
-
if fr.findCalls !=
|
|
226
|
-
t.Fatalf("
|
|
225
|
+
if fr.findCalls != 1 || fh.buildCalls != 1 || fr.createCalls != 1 {
|
|
226
|
+
t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
|
|
227
|
+
}
|
|
228
|
+
if len(fr.findIDs) != 1 || fr.findIDs[0] != "dead-term" {
|
|
229
|
+
t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
|
|
230
|
+
}
|
|
231
|
+
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
|
|
232
|
+
t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
|
|
227
233
|
}
|
|
228
234
|
for _, prompt := range fh.prompts {
|
|
229
235
|
if prompt != "work" {
|
|
@@ -248,15 +254,19 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
|
|
|
248
254
|
t.Fatal(err)
|
|
249
255
|
}
|
|
250
256
|
fh := &runtimeTestHarness{}
|
|
251
|
-
|
|
257
|
+
fr := &runtimeTestRunner{}
|
|
258
|
+
a := &Activities{Runner: fr, Harness: fh, Runs: p}
|
|
252
259
|
nw := run.NodeWork{Work: run.Work{RunID: id}, Node: "implement", NodeVisitID: "visit-first"}
|
|
253
|
-
spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
|
|
260
|
+
spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
|
|
254
261
|
if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
|
|
255
262
|
t.Fatal(err)
|
|
256
263
|
}
|
|
257
264
|
if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
|
|
258
265
|
t.Fatalf("initial prompt = %q", fh.prompts)
|
|
259
266
|
}
|
|
267
|
+
if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
|
|
268
|
+
t.Fatalf("HITL workspace statuses = %v, want in-review", fr.statuses)
|
|
269
|
+
}
|
|
260
270
|
}
|
|
261
271
|
|
|
262
272
|
func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
@@ -295,9 +305,12 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
295
305
|
t.Fatalf("revisit replacement prompt = %q", fh.prompts)
|
|
296
306
|
}
|
|
297
307
|
rt, _ := p.getNodeRuntime(ctx, id, "implement")
|
|
298
|
-
if rt.TerminalID == "live-old" || rt.SessionID != "" {
|
|
308
|
+
if rt.TerminalID == "live-old" || rt.SessionID != "session-old" {
|
|
299
309
|
t.Fatalf("send failure did not replace IDs: %+v", rt)
|
|
300
310
|
}
|
|
311
|
+
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
|
|
312
|
+
t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
|
|
313
|
+
}
|
|
301
314
|
}
|
|
302
315
|
|
|
303
316
|
func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
@@ -412,14 +425,16 @@ func TestEngineRuntimePolicyDefaultsKeepBoth(t *testing.T) {
|
|
|
412
425
|
}
|
|
413
426
|
|
|
414
427
|
type runtimeTestRunner struct {
|
|
415
|
-
createErr error
|
|
416
428
|
findCalls int
|
|
429
|
+
findIDs []string
|
|
417
430
|
createCalls int
|
|
418
431
|
live bool
|
|
432
|
+
liveID string
|
|
419
433
|
sendErr error
|
|
420
434
|
sentTexts []string
|
|
421
435
|
closeCalls int
|
|
422
436
|
closedIDs []string
|
|
437
|
+
statuses []string
|
|
423
438
|
}
|
|
424
439
|
|
|
425
440
|
func (*runtimeTestRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
@@ -429,8 +444,17 @@ func (*runtimeTestRunner) ValidateRepo(context.Context, string, string) error {
|
|
|
429
444
|
func (*runtimeTestRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
430
445
|
return runner.Environment{ID: "env"}, nil
|
|
431
446
|
}
|
|
432
|
-
func (r *runtimeTestRunner)
|
|
433
|
-
|
|
447
|
+
func (r *runtimeTestRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
|
|
448
|
+
r.statuses = append(r.statuses, status)
|
|
449
|
+
return nil
|
|
450
|
+
}
|
|
451
|
+
func (r *runtimeTestRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
452
|
+
r.findCalls++
|
|
453
|
+
r.findIDs = append(r.findIDs, terminal.ID)
|
|
454
|
+
if !r.live || (r.liveID != "" && terminal.ID != r.liveID) {
|
|
455
|
+
return runner.Terminal{}, false, nil
|
|
456
|
+
}
|
|
457
|
+
return terminal, true, nil
|
|
434
458
|
}
|
|
435
459
|
func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, text string) error {
|
|
436
460
|
r.sentTexts = append(r.sentTexts, text)
|
|
@@ -438,16 +462,9 @@ func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, t
|
|
|
438
462
|
}
|
|
439
463
|
func (r *runtimeTestRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
|
|
440
464
|
r.createCalls++
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return runner.Terminal{}, err
|
|
445
|
-
}
|
|
446
|
-
return runner.Terminal{ID: "fresh-term"}, nil
|
|
447
|
-
}
|
|
448
|
-
func (r *runtimeTestRunner) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
|
|
449
|
-
r.findCalls++
|
|
450
|
-
return runner.Terminal{}, false, nil
|
|
465
|
+
r.live = true
|
|
466
|
+
r.liveID = "fresh-term"
|
|
467
|
+
return runner.Terminal{ID: r.liveID}, nil
|
|
451
468
|
}
|
|
452
469
|
func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
|
|
453
470
|
r.closeCalls++
|
|
@@ -455,7 +472,12 @@ func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Ter
|
|
|
455
472
|
r.live = false
|
|
456
473
|
return nil
|
|
457
474
|
}
|
|
458
|
-
func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
475
|
+
func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
476
|
+
if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
|
|
477
|
+
return runner.Terminal{}, err
|
|
478
|
+
} else if ok {
|
|
479
|
+
return terminal, nil
|
|
480
|
+
}
|
|
459
481
|
return r.CreateTerminal(ctx, env, title, command)
|
|
460
482
|
}
|
|
461
483
|
func (*runtimeTestRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
@@ -464,6 +486,7 @@ func (*runtimeTestRunner) CleanupRun(context.Context, runner.RunSpec) error
|
|
|
464
486
|
type runtimeTestHarness struct {
|
|
465
487
|
buildCalls int
|
|
466
488
|
prompts []string
|
|
489
|
+
resumeIDs []string
|
|
467
490
|
}
|
|
468
491
|
|
|
469
492
|
func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
@@ -473,6 +496,7 @@ func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness
|
|
|
473
496
|
func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
474
497
|
h.buildCalls++
|
|
475
498
|
h.prompts = append(h.prompts, spec.Prompt)
|
|
499
|
+
h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
|
|
476
500
|
return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
|
|
477
501
|
}
|
|
478
502
|
|
|
@@ -490,7 +490,7 @@ func TestTerminalReconcile(t *testing.T) {
|
|
|
490
490
|
terminalsBefore := fr.liveTerminals()
|
|
491
491
|
relaunchBefore := log.count("ensureTerminal:PAY-101:coding")
|
|
492
492
|
buildBefore := log.count("buildCommand:")
|
|
493
|
-
inspectBefore := log.count("
|
|
493
|
+
inspectBefore := log.count("findTerminalID:")
|
|
494
494
|
|
|
495
495
|
// Healthy terminal: EnsureRun checks the persisted direct handle and,
|
|
496
496
|
// finding it live, sends no reconcile and relaunches
|
|
@@ -502,7 +502,7 @@ func TestTerminalReconcile(t *testing.T) {
|
|
|
502
502
|
t.Fatal(err)
|
|
503
503
|
}
|
|
504
504
|
time.Sleep(300 * time.Millisecond)
|
|
505
|
-
if log.count("
|
|
505
|
+
if log.count("findTerminalID:") == inspectBefore {
|
|
506
506
|
t.Fatal("repeated EnsureRun never checked the persisted terminal handle")
|
|
507
507
|
}
|
|
508
508
|
if got := log.count("buildCommand:") - buildBefore; got != 0 {
|
|
@@ -792,7 +792,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
|
792
792
|
RepoName: "payments", RepoPath: "/srv/payments", TicketKey: "PAY-101",
|
|
793
793
|
}
|
|
794
794
|
env, _ := fr.EnsureEnvironment(context.Background(), survSpec)
|
|
795
|
-
_, _ = fr.EnsureTerminal(context.Background(), env, "PAY-101:coding", runner.Command{})
|
|
795
|
+
_, _ = fr.EnsureTerminal(context.Background(), env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
|
|
796
796
|
|
|
797
797
|
deps := goworkflows.Dependencies{Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh}
|
|
798
798
|
|
|
@@ -821,7 +821,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
|
821
821
|
if err != nil || preRuntime.TerminalID == "" {
|
|
822
822
|
t.Fatalf("pre-loss runtime = %+v, %v", preRuntime, err)
|
|
823
823
|
}
|
|
824
|
-
inspectBeforeRecover := log.count("
|
|
824
|
+
inspectBeforeRecover := log.count("findTerminalID:")
|
|
825
825
|
pc, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
826
826
|
_ = preEngine.Shutdown(pc)
|
|
827
827
|
pcancel()
|
|
@@ -871,7 +871,7 @@ func TestServeRecoverRebuildsFreshRuns(t *testing.T) {
|
|
|
871
871
|
t.Fatalf("%s recovery reused pre-loss terminal ID %q", key, rt.TerminalID)
|
|
872
872
|
}
|
|
873
873
|
}
|
|
874
|
-
if log.count("
|
|
874
|
+
if log.count("findTerminalID:") != inspectBeforeRecover {
|
|
875
875
|
t.Fatalf("recover used pre-loss direct terminal IDs: %v", log.all())
|
|
876
876
|
}
|
|
877
877
|
if log.count("closeTerminals:") == 0 {
|
|
@@ -6,7 +6,7 @@ import (
|
|
|
6
6
|
)
|
|
7
7
|
|
|
8
8
|
// 9.6: the info-level retry record must never embed argv payloads
|
|
9
|
-
// (
|
|
9
|
+
// (Jira/Orca request bodies or commands carrying prompts and RELAY_FLOW_*
|
|
10
10
|
// env, JQL). sanitizeRetryMessage strips each "[args...]: " span while
|
|
11
11
|
// preserving the surrounding wrap context and trailing stderr fragment.
|
|
12
12
|
func TestSanitizeRetryMessage(t *testing.T) {
|
|
@@ -19,25 +19,25 @@ func TestSanitizeRetryMessage(t *testing.T) {
|
|
|
19
19
|
mustContain []string
|
|
20
20
|
}{
|
|
21
21
|
{
|
|
22
|
-
name:
|
|
23
|
-
in:
|
|
24
|
-
mustNot:
|
|
22
|
+
name: "acli comment with body payload",
|
|
23
|
+
in: `acli [jira workitem comment create --key PAY-1 --body SECRET-BODY --json]: exit status 1: permission denied`,
|
|
24
|
+
mustNot: []string{"SECRET-BODY", "--body", "--key PAY-1"},
|
|
25
25
|
mustContain: []string{"acli", "exit status 1", "permission denied"},
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
|
-
name:
|
|
29
|
-
in:
|
|
30
|
-
mustNot:
|
|
28
|
+
name: "orca create with command payload",
|
|
29
|
+
in: `ensure run X: orca terminal create: orca [terminal create --worktree name:PAY-1 --title PAY-1:coding --command 'RELAY_FLOW_RUN_ID=r1 opencode --agent coder PROMPT-TEXT']: exit status 1: closed`,
|
|
30
|
+
mustNot: []string{"PROMPT-TEXT", "RELAY_FLOW_RUN_ID=r1", "--command", "--title PAY-1:coding"},
|
|
31
31
|
mustContain: []string{"ensure run X", "orca terminal create", "exit status 1", "closed"},
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
name:
|
|
35
|
-
in:
|
|
34
|
+
name: "no brackets passes through",
|
|
35
|
+
in: "plain failure",
|
|
36
36
|
mustContain: []string{"plain failure"},
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
|
-
name:
|
|
40
|
-
in:
|
|
39
|
+
name: "unterminated bracket passes through",
|
|
40
|
+
in: "weird [unterminated",
|
|
41
41
|
mustContain: []string{"weird"},
|
|
42
42
|
},
|
|
43
43
|
}
|
|
@@ -23,6 +23,8 @@ type fakeHarness struct {
|
|
|
23
23
|
session map[string]harness.Session // title -> session
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
var _ harness.Harness = (*fakeHarness)(nil)
|
|
27
|
+
|
|
26
28
|
func newFakeHarness() *fakeHarness {
|
|
27
29
|
return &fakeHarness{
|
|
28
30
|
agents: map[string]bool{"build": true},
|
|
@@ -141,6 +143,9 @@ func TestBuildCommandEnvContract(t *testing.T) {
|
|
|
141
143
|
if cmd.Env["RELAY_FLOW_TICKET"] != "PAY-101" || cmd.Env["RELAY_FLOW_NODE"] != "coding" {
|
|
142
144
|
t.Fatalf("ticket/node env wrong: %v", cmd.Env)
|
|
143
145
|
}
|
|
146
|
+
if _, ok := cmd.Env["RELAY_FLOW_NODE_VISIT_ID"]; ok {
|
|
147
|
+
t.Fatalf("internal node visit ID leaked into harness env: %v", cmd.Env)
|
|
148
|
+
}
|
|
144
149
|
|
|
145
150
|
// NEXT_STEPS_JSON must decode to the legal targets and their when
|
|
146
151
|
// explanations, not just exist.
|