relay-flow 0.2.0-alpha → 0.2.2-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 +171 -26
- package/cmd/relay-flow/beads_composition_test.go +451 -0
- package/cmd/relay-flow/commands_test.go +593 -15
- package/cmd/relay-flow/main.go +356 -119
- package/cmd/relay-flow/scenario_test.go +246 -35
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/beads-workflow.yaml +74 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/go.mod +2 -1
- package/go.sum +2 -0
- package/internal/config/config.go +13 -2
- package/internal/config/merge_test.go +18 -0
- package/internal/execution/goworkflows/activities.go +136 -87
- package/internal/execution/goworkflows/end_feedback_test.go +124 -0
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +100 -22
- package/internal/execution/goworkflows/fakes_test.go +63 -25
- package/internal/execution/goworkflows/interpreter.go +45 -30
- package/internal/execution/goworkflows/mailbox_test.go +85 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +72 -23
- package/internal/execution/goworkflows/recovery_test.go +7 -7
- package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +15 -0
- package/internal/harness/factory.go +25 -3
- package/internal/harness/harness.go +30 -4
- package/internal/harness/opencode/opencode.go +125 -10
- package/internal/harness/opencode/opencode_test.go +183 -0
- package/internal/harness/opencode/repo_setup.go +361 -0
- package/internal/harness/plugin_selection_test.go +5 -5
- package/internal/paths/paths.go +18 -16
- package/internal/recover/recover.go +11 -6
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service.go +10 -0
- package/internal/repo/service_test.go +55 -6
- 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.go +6 -4
- 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/beads/bdcli/bdcli.go +323 -0
- package/internal/task/beads/bdcli/bdcli_test.go +297 -0
- package/internal/task/beads/bdcli/testdata/array.json +1 -0
- package/internal/task/beads/bdcli/testdata/children.json +1 -0
- package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
- package/internal/task/beads/bdcli/testdata/commented.json +1 -0
- package/internal/task/beads/bdcli/testdata/comments.json +1 -0
- package/internal/task/beads/bdcli/testdata/created.json +1 -0
- package/internal/task/beads/bdcli/testdata/object.json +1 -0
- package/internal/task/beads/bdcli/testdata/ready.json +1 -0
- package/internal/task/beads/bdcli/testdata/show.json +1 -0
- package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
- package/internal/task/beads/bdcli/testdata/updated.json +1 -0
- package/internal/task/beads/beads.go +840 -0
- package/internal/task/beads/beads_test.go +609 -0
- package/internal/task/beads/comments_test.go +242 -0
- package/internal/task/beads/config_compatibility_test.go +163 -0
- package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
- package/internal/task/beads/repo_composition_test.go +232 -0
- package/internal/task/beads/runtime_config_test.go +81 -0
- package/internal/task/beads/status_compatibility_test.go +233 -0
- package/internal/task/beads/status_test.go +257 -0
- package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
- package/internal/task/beads/validation_test.go +110 -0
- package/internal/task/contract_test.go +12 -0
- package/internal/task/factory.go +52 -3
- package/internal/task/jira/auth.go +209 -0
- package/internal/task/jira/auth_test.go +160 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +96 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +263 -90
- package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +165 -0
- package/internal/task/jira/rest/adf_test.go +60 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/templates_test.go +118 -0
- package/internal/task/jira/transition_defaults_test.go +22 -18
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/task/task.go +32 -0
- package/internal/workflow/report_test.go +45 -0
- 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 → beads/bdcli/testdata/empty.json} +0 -0
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"database/sql"
|
|
6
6
|
"errors"
|
|
7
7
|
"path/filepath"
|
|
8
|
+
"reflect"
|
|
8
9
|
"testing"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -189,7 +190,7 @@ func TestNodeRuntimeSessionRegistrationKeepsOldSessionBoundToOldVisit(t *testing
|
|
|
189
190
|
|
|
190
191
|
func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
191
192
|
ctx := context.Background()
|
|
192
|
-
fr := &runtimeTestRunner{
|
|
193
|
+
fr := &runtimeTestRunner{}
|
|
193
194
|
fh := &runtimeTestHarness{}
|
|
194
195
|
db := openProjectionDB(t, filepath.Join(t.TempDir(), "state.db"))
|
|
195
196
|
defer db.Close()
|
|
@@ -219,11 +220,20 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
|
|
|
219
220
|
if err != nil {
|
|
220
221
|
t.Fatal(err)
|
|
221
222
|
}
|
|
222
|
-
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "" {
|
|
223
|
+
if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
|
|
223
224
|
t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
|
|
224
225
|
}
|
|
225
|
-
if fr.findCalls !=
|
|
226
|
-
t.Fatalf("
|
|
226
|
+
if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
|
|
227
|
+
t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
|
|
228
|
+
}
|
|
229
|
+
if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
|
|
230
|
+
t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
|
|
231
|
+
}
|
|
232
|
+
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
|
|
233
|
+
t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
|
|
234
|
+
}
|
|
235
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
|
|
236
|
+
t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
|
|
227
237
|
}
|
|
228
238
|
for _, prompt := range fh.prompts {
|
|
229
239
|
if prompt != "work" {
|
|
@@ -248,15 +258,19 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
|
|
|
248
258
|
t.Fatal(err)
|
|
249
259
|
}
|
|
250
260
|
fh := &runtimeTestHarness{}
|
|
251
|
-
|
|
261
|
+
fr := &runtimeTestRunner{}
|
|
262
|
+
a := &Activities{Runner: fr, Harness: fh, Runs: p}
|
|
252
263
|
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"}
|
|
264
|
+
spec := harness.LaunchSpec{RunID: id, NodeVisitID: "visit-first", Node: "implement", NodeType: workflow.NodeHITL, Agent: "build", Prompt: "standard prompt", NudgePrompt: "custom instructions"}
|
|
254
265
|
if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
|
|
255
266
|
t.Fatal(err)
|
|
256
267
|
}
|
|
257
|
-
if len(fh.prompts) != 1 || fh.prompts[0] != "
|
|
268
|
+
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
|
|
258
269
|
t.Fatalf("initial prompt = %q", fh.prompts)
|
|
259
270
|
}
|
|
271
|
+
if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
|
|
272
|
+
t.Fatalf("HITL workspace statuses = %v, want in-review", fr.statuses)
|
|
273
|
+
}
|
|
260
274
|
}
|
|
261
275
|
|
|
262
276
|
func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
@@ -288,16 +302,22 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
|
|
|
288
302
|
if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
|
|
289
303
|
t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
|
|
290
304
|
}
|
|
291
|
-
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "
|
|
305
|
+
if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
|
|
292
306
|
t.Fatalf("live revisit prompt = %q", fr.sentTexts)
|
|
293
307
|
}
|
|
294
308
|
if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
|
|
295
309
|
t.Fatalf("revisit replacement prompt = %q", fh.prompts)
|
|
296
310
|
}
|
|
297
311
|
rt, _ := p.getNodeRuntime(ctx, id, "implement")
|
|
298
|
-
if rt.TerminalID == "live-old" || rt.SessionID != "" {
|
|
312
|
+
if rt.TerminalID == "live-old" || rt.SessionID != "session-old" {
|
|
299
313
|
t.Fatalf("send failure did not replace IDs: %+v", rt)
|
|
300
314
|
}
|
|
315
|
+
if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
|
|
316
|
+
t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
|
|
317
|
+
}
|
|
318
|
+
if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
|
|
319
|
+
t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
|
|
320
|
+
}
|
|
301
321
|
}
|
|
302
322
|
|
|
303
323
|
func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
@@ -325,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
|
|
|
325
345
|
if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
|
|
326
346
|
t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
|
|
327
347
|
}
|
|
348
|
+
fh := a.Harness.(*runtimeTestHarness)
|
|
349
|
+
if len(fh.rendered) != 0 || fh.buildCalls != 0 {
|
|
350
|
+
t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
|
|
351
|
+
}
|
|
328
352
|
}
|
|
329
353
|
|
|
330
354
|
func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
|
|
@@ -412,14 +436,16 @@ func TestEngineRuntimePolicyDefaultsKeepBoth(t *testing.T) {
|
|
|
412
436
|
}
|
|
413
437
|
|
|
414
438
|
type runtimeTestRunner struct {
|
|
415
|
-
createErr error
|
|
416
439
|
findCalls int
|
|
440
|
+
findIDs []string
|
|
417
441
|
createCalls int
|
|
418
442
|
live bool
|
|
443
|
+
liveID string
|
|
419
444
|
sendErr error
|
|
420
445
|
sentTexts []string
|
|
421
446
|
closeCalls int
|
|
422
447
|
closedIDs []string
|
|
448
|
+
statuses []string
|
|
423
449
|
}
|
|
424
450
|
|
|
425
451
|
func (*runtimeTestRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
|
|
@@ -429,8 +455,17 @@ func (*runtimeTestRunner) ValidateRepo(context.Context, string, string) error {
|
|
|
429
455
|
func (*runtimeTestRunner) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
|
|
430
456
|
return runner.Environment{ID: "env"}, nil
|
|
431
457
|
}
|
|
432
|
-
func (r *runtimeTestRunner)
|
|
433
|
-
|
|
458
|
+
func (r *runtimeTestRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
|
|
459
|
+
r.statuses = append(r.statuses, status)
|
|
460
|
+
return nil
|
|
461
|
+
}
|
|
462
|
+
func (r *runtimeTestRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
|
|
463
|
+
r.findCalls++
|
|
464
|
+
r.findIDs = append(r.findIDs, terminal.ID)
|
|
465
|
+
if !r.live || (r.liveID != "" && terminal.ID != r.liveID) {
|
|
466
|
+
return runner.Terminal{}, false, nil
|
|
467
|
+
}
|
|
468
|
+
return terminal, true, nil
|
|
434
469
|
}
|
|
435
470
|
func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, text string) error {
|
|
436
471
|
r.sentTexts = append(r.sentTexts, text)
|
|
@@ -438,16 +473,9 @@ func (r *runtimeTestRunner) SendTerminal(_ context.Context, _ runner.Terminal, t
|
|
|
438
473
|
}
|
|
439
474
|
func (r *runtimeTestRunner) CreateTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
|
|
440
475
|
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
|
|
476
|
+
r.live = true
|
|
477
|
+
r.liveID = "fresh-term"
|
|
478
|
+
return runner.Terminal{ID: r.liveID}, nil
|
|
451
479
|
}
|
|
452
480
|
func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
|
|
453
481
|
r.closeCalls++
|
|
@@ -455,7 +483,12 @@ func (r *runtimeTestRunner) CloseTerminal(_ context.Context, terminal runner.Ter
|
|
|
455
483
|
r.live = false
|
|
456
484
|
return nil
|
|
457
485
|
}
|
|
458
|
-
func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
486
|
+
func (r *runtimeTestRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
|
|
487
|
+
if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
|
|
488
|
+
return runner.Terminal{}, err
|
|
489
|
+
} else if ok {
|
|
490
|
+
return terminal, nil
|
|
491
|
+
}
|
|
459
492
|
return r.CreateTerminal(ctx, env, title, command)
|
|
460
493
|
}
|
|
461
494
|
func (*runtimeTestRunner) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
|
|
@@ -464,8 +497,23 @@ func (*runtimeTestRunner) CleanupRun(context.Context, runner.RunSpec) error
|
|
|
464
497
|
type runtimeTestHarness struct {
|
|
465
498
|
buildCalls int
|
|
466
499
|
prompts []string
|
|
500
|
+
resumeIDs []string
|
|
501
|
+
rendered []harness.PromptKind
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
|
|
505
|
+
h.rendered = append(h.rendered, kind)
|
|
506
|
+
prompt := "work"
|
|
507
|
+
if kind == harness.PromptFeedback {
|
|
508
|
+
prompt = "feedback"
|
|
509
|
+
}
|
|
510
|
+
if nudge != "" {
|
|
511
|
+
prompt += "\n\n" + nudge
|
|
512
|
+
}
|
|
513
|
+
return prompt, nil
|
|
467
514
|
}
|
|
468
515
|
|
|
516
|
+
func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
|
|
469
517
|
func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
|
|
470
518
|
func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
|
|
471
519
|
return harness.Session{}, false, nil
|
|
@@ -473,6 +521,7 @@ func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness
|
|
|
473
521
|
func (h *runtimeTestHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
474
522
|
h.buildCalls++
|
|
475
523
|
h.prompts = append(h.prompts, spec.Prompt)
|
|
524
|
+
h.resumeIDs = append(h.resumeIDs, spec.ResumeID)
|
|
476
525
|
return runner.Command{Executable: "opencode", Args: []string{spec.ResumeID}}, nil
|
|
477
526
|
}
|
|
478
527
|
|
|
@@ -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 {
|
|
@@ -759,8 +759,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
|
|
|
759
759
|
return err
|
|
760
760
|
}
|
|
761
761
|
rm := &run.RunManager{Executor: engine, Runs: engine}
|
|
762
|
-
specsFor := func(w *workflow.Workflow
|
|
763
|
-
return goworkflows.
|
|
762
|
+
specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
|
|
763
|
+
return goworkflows.RenderMailboxSpecs(system, work, w)
|
|
764
764
|
}
|
|
765
765
|
return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
|
|
766
766
|
}
|
|
@@ -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 {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package goworkflows
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
|
|
12
|
+
b, err := os.ReadFile("../../../testdata/report-contract.json")
|
|
13
|
+
if err != nil {
|
|
14
|
+
t.Fatal(err)
|
|
15
|
+
}
|
|
16
|
+
var fixtures map[string]struct {
|
|
17
|
+
Envelope run.ReportRequest `json:"envelope"`
|
|
18
|
+
SummaryReport string `json:"summaryReport"`
|
|
19
|
+
FeedbackReport string `json:"feedbackReport"`
|
|
20
|
+
}
|
|
21
|
+
if err := json.Unmarshal(b, &fixtures); err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
fixture := fixtures["work"]
|
|
25
|
+
if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
|
|
26
|
+
t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
|
|
27
|
+
}
|
|
28
|
+
if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
|
|
29
|
+
t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -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},
|
|
@@ -30,6 +32,8 @@ func newFakeHarness() *fakeHarness {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
|
|
36
|
+
|
|
33
37
|
func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
|
|
34
38
|
if !f.agents[agent] {
|
|
35
39
|
return errUnknownAgent
|
|
@@ -42,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
|
|
|
42
46
|
return s, ok, nil
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
|
|
50
|
+
prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
|
|
51
|
+
if nudge != "" {
|
|
52
|
+
prompt += "\n\n" + nudge
|
|
53
|
+
}
|
|
54
|
+
return prompt, nil
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
|
|
46
58
|
// The fake mirrors the required env contract; the real opencode harness
|
|
47
59
|
// builds the executable/args. NEXT_STEPS_JSON carries the legal targets
|
|
@@ -141,6 +153,9 @@ func TestBuildCommandEnvContract(t *testing.T) {
|
|
|
141
153
|
if cmd.Env["RELAY_FLOW_TICKET"] != "PAY-101" || cmd.Env["RELAY_FLOW_NODE"] != "coding" {
|
|
142
154
|
t.Fatalf("ticket/node env wrong: %v", cmd.Env)
|
|
143
155
|
}
|
|
156
|
+
if _, ok := cmd.Env["RELAY_FLOW_NODE_VISIT_ID"]; ok {
|
|
157
|
+
t.Fatalf("internal node visit ID leaked into harness env: %v", cmd.Env)
|
|
158
|
+
}
|
|
144
159
|
|
|
145
160
|
// NEXT_STEPS_JSON must decode to the legal targets and their when
|
|
146
161
|
// explanations, not just exist.
|
|
@@ -9,8 +9,12 @@ import (
|
|
|
9
9
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
// Factory constructs a Harness from root harness config
|
|
13
|
-
|
|
12
|
+
// Factory constructs a Harness from root harness config and supplies the
|
|
13
|
+
// selected harness's init defaults without coupling core to an adapter.
|
|
14
|
+
type Factory struct {
|
|
15
|
+
DefaultConfig func() config.RawValues
|
|
16
|
+
New func(config.RawValues) (Harness, error)
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
var (
|
|
16
20
|
registryMu sync.RWMutex
|
|
@@ -35,7 +39,25 @@ func New(name string, cfg config.RawValues) (Harness, error) {
|
|
|
35
39
|
if !ok {
|
|
36
40
|
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
37
41
|
}
|
|
38
|
-
return f(cfg)
|
|
42
|
+
return f.New(config.Merge(defaultConfig(f), cfg))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Defaults returns a fresh copy of the named harness's root config defaults.
|
|
46
|
+
func Defaults(name string) (config.RawValues, error) {
|
|
47
|
+
registryMu.RLock()
|
|
48
|
+
f, ok := registry[name]
|
|
49
|
+
registryMu.RUnlock()
|
|
50
|
+
if !ok {
|
|
51
|
+
return nil, fmt.Errorf("harness: unknown plugin %q (registered: %s)", name, strings.Join(Names(), ", "))
|
|
52
|
+
}
|
|
53
|
+
return config.Merge(defaultConfig(f)), nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func defaultConfig(f Factory) config.RawValues {
|
|
57
|
+
if f.DefaultConfig == nil {
|
|
58
|
+
return nil
|
|
59
|
+
}
|
|
60
|
+
return f.DefaultConfig()
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
// ValidateName returns an error listing registered names when name is not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Package harness defines the harness contract:
|
|
2
|
-
// discovery, resume syntax, and launch
|
|
3
|
-
// executes the returned command; the harness
|
|
4
|
-
// state.
|
|
1
|
+
// Package harness defines the harness contract: repository setup, prompt
|
|
2
|
+
// rendering, agent validation, session discovery, resume syntax, and launch
|
|
3
|
+
// command construction. The runner executes the returned command; the harness
|
|
4
|
+
// never manipulates runner state.
|
|
5
5
|
package harness
|
|
6
6
|
|
|
7
7
|
import (
|
|
@@ -17,6 +17,29 @@ type Session struct {
|
|
|
17
17
|
Title string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
type PromptKind string
|
|
21
|
+
|
|
22
|
+
const (
|
|
23
|
+
PromptInitial PromptKind = "initial"
|
|
24
|
+
PromptFeedback PromptKind = "feedback"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// PromptData is the task-system-neutral data core supplies to the selected
|
|
28
|
+
// harness. Harness templates, including HITL instructions, are rendered only
|
|
29
|
+
// by the harness.
|
|
30
|
+
type PromptData struct {
|
|
31
|
+
TaskSystem string
|
|
32
|
+
Ticket string
|
|
33
|
+
Workflow string
|
|
34
|
+
Repo string
|
|
35
|
+
Node string
|
|
36
|
+
NodeType workflow.NodeType
|
|
37
|
+
Agent string
|
|
38
|
+
NodeDescription string
|
|
39
|
+
NextSteps string
|
|
40
|
+
Mailbox string
|
|
41
|
+
}
|
|
42
|
+
|
|
20
43
|
type LaunchSpec struct {
|
|
21
44
|
RunID identity.RunID
|
|
22
45
|
NodeVisitID identity.NodeVisitID
|
|
@@ -30,12 +53,15 @@ type LaunchSpec struct {
|
|
|
30
53
|
Title string
|
|
31
54
|
Prompt string
|
|
32
55
|
NudgePrompt string
|
|
56
|
+
PromptData PromptData
|
|
33
57
|
NextSteps []workflow.Route
|
|
34
58
|
ResumeID string
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
type Harness interface {
|
|
62
|
+
SetupRepo(ctx context.Context, repoPath string) error
|
|
38
63
|
ValidateAgent(ctx context.Context, repoPath, agent string) error
|
|
39
64
|
FindSession(ctx context.Context, repoPath, title string) (Session, bool, error)
|
|
65
|
+
RenderPrompt(kind PromptKind, data PromptData, nudgeTemplate string) (string, error)
|
|
40
66
|
BuildCommand(spec LaunchSpec) (runner.Command, error)
|
|
41
67
|
}
|
|
@@ -15,36 +15,94 @@ import (
|
|
|
15
15
|
"os"
|
|
16
16
|
"os/exec"
|
|
17
17
|
"path/filepath"
|
|
18
|
+
"regexp"
|
|
18
19
|
"strings"
|
|
19
20
|
|
|
20
21
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
21
22
|
"github.com/rajpopat27/relay-flow/internal/harness"
|
|
22
23
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
24
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
23
25
|
)
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const (
|
|
28
|
+
defaultInitialPrompt = `Task system: {{taskSystem}}
|
|
29
|
+
Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
|
|
30
|
+
|
|
31
|
+
Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
|
|
32
|
+
defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
|
|
33
|
+
defaultHITLPrompt = `Before submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer.`
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
|
|
37
|
+
|
|
38
|
+
var knownPromptVars = map[string]bool{
|
|
39
|
+
"taskSystem": true, "ticket": true, "workflow": true, "repo": true,
|
|
40
|
+
"node": true, "nodeType": true, "agent": true, "nodeDescription": true,
|
|
41
|
+
"nextSteps": true, "mailbox": true,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Config is the adapter-owned root harnessConfig.
|
|
45
|
+
type Config struct {
|
|
46
|
+
Initial string `yaml:"initial"`
|
|
47
|
+
Feedback string `yaml:"feedback"`
|
|
48
|
+
HITL string `yaml:"hitl"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// DefaultConfig is written by relay-flow init and also fills omitted values
|
|
52
|
+
// when older or hand-written config is loaded.
|
|
53
|
+
func DefaultConfig() config.RawValues {
|
|
54
|
+
return config.RawValues{
|
|
55
|
+
"initial": defaultInitialPrompt,
|
|
56
|
+
"feedback": defaultFeedbackPrompt,
|
|
57
|
+
"hitl": defaultHITLPrompt,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
28
60
|
|
|
29
61
|
func init() {
|
|
30
|
-
harness.Register("opencode",
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
harness.Register("opencode", harness.Factory{
|
|
63
|
+
DefaultConfig: DefaultConfig,
|
|
64
|
+
New: func(raw config.RawValues) (harness.Harness, error) {
|
|
65
|
+
var cfg Config
|
|
66
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
67
|
+
return nil, fmt.Errorf("opencode harnessConfig: %w", err)
|
|
68
|
+
}
|
|
69
|
+
for name, tmpl := range map[string]string{
|
|
70
|
+
"initial": cfg.Initial,
|
|
71
|
+
"feedback": cfg.Feedback,
|
|
72
|
+
"hitl": cfg.HITL,
|
|
73
|
+
} {
|
|
74
|
+
if err := validateTemplate(tmpl); err != nil {
|
|
75
|
+
return nil, fmt.Errorf("opencode harnessConfig templates.%s: %w", name, err)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return New(cfg), nil
|
|
79
|
+
},
|
|
36
80
|
})
|
|
37
81
|
}
|
|
38
82
|
|
|
39
83
|
// Harness implements harness.Harness for OpenCode. It is safe for
|
|
40
84
|
// concurrent use; the seams below are only swapped by tests before use.
|
|
41
85
|
type Harness struct {
|
|
86
|
+
templates Config
|
|
42
87
|
// listAgents is the test seam; nil → real `opencode agent list`.
|
|
43
88
|
listAgents func(ctx context.Context) ([]string, error)
|
|
44
89
|
}
|
|
45
90
|
|
|
46
91
|
// New returns the production Harness.
|
|
47
|
-
func New() *Harness {
|
|
92
|
+
func New(cfg ...Config) *Harness {
|
|
93
|
+
if len(cfg) > 0 {
|
|
94
|
+
return &Harness{templates: cfg[0]}
|
|
95
|
+
}
|
|
96
|
+
return &Harness{templates: Config{
|
|
97
|
+
Initial: defaultInitialPrompt, Feedback: defaultFeedbackPrompt, HITL: defaultHITLPrompt,
|
|
98
|
+
}}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SetupRepo ensures the relay-flow runtime plugin is configured for OpenCode
|
|
102
|
+
// in the registered repository.
|
|
103
|
+
func (h *Harness) SetupRepo(_ context.Context, repoPath string) error {
|
|
104
|
+
return setupRepo(repoPath)
|
|
105
|
+
}
|
|
48
106
|
|
|
49
107
|
// ValidateAgent reports whether name is a known OpenCode agent for the
|
|
50
108
|
// repo, per `opencode agent list` (agent names are the unindented first
|
|
@@ -75,6 +133,26 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
|
|
|
75
133
|
return harness.Session{}, false, nil
|
|
76
134
|
}
|
|
77
135
|
|
|
136
|
+
// RenderPrompt renders the selected session prompt, appends HITL approval
|
|
137
|
+
// instructions for HITL nodes, then renders and appends the node's nudge
|
|
138
|
+
// template.
|
|
139
|
+
func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
|
|
140
|
+
var tmpl string
|
|
141
|
+
switch kind {
|
|
142
|
+
case harness.PromptInitial:
|
|
143
|
+
tmpl = h.templates.Initial
|
|
144
|
+
case harness.PromptFeedback:
|
|
145
|
+
tmpl = h.templates.Feedback
|
|
146
|
+
default:
|
|
147
|
+
return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
|
|
148
|
+
}
|
|
149
|
+
prompt := renderTemplate(tmpl, data)
|
|
150
|
+
if data.NodeType == workflow.NodeHITL {
|
|
151
|
+
prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
|
|
152
|
+
}
|
|
153
|
+
return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
|
|
154
|
+
}
|
|
155
|
+
|
|
78
156
|
// BuildCommand returns the structured opencode invocation with the
|
|
79
157
|
// required RELAY_FLOW_* env. spec.ResumeID non-empty resumes the prior
|
|
80
158
|
// session (`opencode --session <id>`); empty is a fresh launch. The
|
|
@@ -164,3 +242,40 @@ func listAgents(ctx context.Context) ([]string, error) {
|
|
|
164
242
|
}
|
|
165
243
|
return names, nil
|
|
166
244
|
}
|
|
245
|
+
|
|
246
|
+
func validateTemplate(tmpl string) error {
|
|
247
|
+
for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
|
|
248
|
+
if !knownPromptVars[match[1]] {
|
|
249
|
+
return fmt.Errorf("unknown template variable {{%s}}", match[1])
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return nil
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func renderTemplate(tmpl string, data harness.PromptData) string {
|
|
256
|
+
values := map[string]string{
|
|
257
|
+
"taskSystem": data.TaskSystem,
|
|
258
|
+
"ticket": data.Ticket,
|
|
259
|
+
"workflow": data.Workflow,
|
|
260
|
+
"repo": data.Repo,
|
|
261
|
+
"node": data.Node,
|
|
262
|
+
"nodeType": string(data.NodeType),
|
|
263
|
+
"agent": data.Agent,
|
|
264
|
+
"nodeDescription": data.NodeDescription,
|
|
265
|
+
"nextSteps": data.NextSteps,
|
|
266
|
+
"mailbox": data.Mailbox,
|
|
267
|
+
}
|
|
268
|
+
return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
|
|
269
|
+
return values[promptVarPattern.FindStringSubmatch(match)[1]]
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func appendPrompt(prompt, extra string) string {
|
|
274
|
+
if extra == "" {
|
|
275
|
+
return prompt
|
|
276
|
+
}
|
|
277
|
+
if prompt == "" {
|
|
278
|
+
return extra
|
|
279
|
+
}
|
|
280
|
+
return prompt + "\n\n" + extra
|
|
281
|
+
}
|