relay-flow 0.3.11-alpha → 0.3.13-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 CHANGED
@@ -28,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
28
28
  **OpenCode**
29
29
 
30
30
  ```sh
31
- opencode plugin relay-flow-plugin@0.3.11-alpha
31
+ opencode plugin relay-flow-plugin@0.3.13-alpha
32
32
  ```
33
33
 
34
34
  **Pi**
35
35
 
36
36
  ```sh
37
- pi install npm:relay-flow-plugin@0.3.11-alpha
37
+ pi install npm:relay-flow-plugin@0.3.13-alpha
38
38
  ```
39
39
 
40
40
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -164,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
164
164
  ```json
165
165
  {
166
166
  "$schema": "https://opencode.ai/config.json",
167
- "plugin": ["relay-flow-plugin@0.3.11-alpha"]
167
+ "plugin": ["relay-flow-plugin@0.3.13-alpha"]
168
168
  }
169
169
  ```
170
170
 
@@ -173,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
173
173
  ```json
174
174
  {
175
175
  "$schema": "https://opencode.ai/tui.json",
176
- "plugin": ["relay-flow-plugin@0.3.11-alpha"]
176
+ "plugin": ["relay-flow-plugin@0.3.13-alpha"]
177
177
  }
178
178
  ```
179
179
 
@@ -189,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
189
189
  settings before starting a Pi harness session:
190
190
 
191
191
  ```sh
192
- pi install npm:relay-flow-plugin@0.3.11-alpha
192
+ pi install npm:relay-flow-plugin@0.3.13-alpha
193
193
  ```
194
194
 
195
195
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "context"
6
6
  "database/sql"
7
+ "encoding/json"
7
8
  "errors"
8
9
  "fmt"
9
10
  "io"
@@ -277,6 +278,75 @@ func TestReportAckMatrix(t *testing.T) {
277
278
  }
278
279
  }
279
280
 
281
+ func TestReportValidationErrorIsStructuredOnStderr(t *testing.T) {
282
+ const message = "NEXT STEP is end, so FEEDBACK must be exactly None."
283
+ const valid = `{"runId":"run-1","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"done","commits":"None","notCompleted":"None","issuesDiscovered":"None","verification":"None","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
284
+ home := t.TempDir()
285
+ serveAck(t, home, runsvc.ReportAck{}, &runsvc.InvalidReportError{Reason: message})
286
+ reader, writer, err := os.Pipe()
287
+ if err != nil {
288
+ t.Fatal(err)
289
+ }
290
+ original := os.Stderr
291
+ os.Stderr = writer
292
+ code := cli(t, home, valid, "report")
293
+ os.Stderr = original
294
+ _ = writer.Close()
295
+ defer reader.Close()
296
+ output, err := io.ReadAll(reader)
297
+ if err != nil {
298
+ t.Fatal(err)
299
+ }
300
+ var response struct {
301
+ Error struct {
302
+ Code string `json:"code"`
303
+ Message string `json:"message"`
304
+ } `json:"error"`
305
+ }
306
+ if err := json.Unmarshal(output, &response); err != nil || code != exitFail || response.Error.Code != "invalidReport" || response.Error.Message != message {
307
+ t.Fatalf("CLI validation response: exit=%d stderr=%q parsed=%+v err=%v", code, output, response, err)
308
+ }
309
+ }
310
+
311
+ func TestReportOtherAPIErrorsPreserveCodeForRetryClassification(t *testing.T) {
312
+ const report = `{"runId":"run-1","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end"}}`
313
+ for _, tc := range []struct {
314
+ name, code string
315
+ err error
316
+ }{
317
+ {"invalid request", "invalid", fmt.Errorf("%w: malformed report", server.ErrInvalid)},
318
+ {"server failure", "internalError", errors.New("temporary backend failure")},
319
+ } {
320
+ t.Run(tc.name, func(t *testing.T) {
321
+ home := t.TempDir()
322
+ serveAck(t, home, runsvc.ReportAck{}, tc.err)
323
+ reader, writer, err := os.Pipe()
324
+ if err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ original := os.Stderr
328
+ os.Stderr = writer
329
+ code := cli(t, home, report, "report")
330
+ os.Stderr = original
331
+ _ = writer.Close()
332
+ defer reader.Close()
333
+ output, err := io.ReadAll(reader)
334
+ if err != nil {
335
+ t.Fatal(err)
336
+ }
337
+ var response struct {
338
+ Error struct {
339
+ Code string `json:"code"`
340
+ Message string `json:"message"`
341
+ } `json:"error"`
342
+ }
343
+ if err := json.Unmarshal(output, &response); err != nil || code != exitFail || response.Error.Code != tc.code || response.Error.Message != tc.err.Error() {
344
+ t.Fatalf("CLI response: exit=%d stderr=%q parsed=%+v err=%v", code, output, response, err)
345
+ }
346
+ })
347
+ }
348
+ }
349
+
280
350
  func TestReportUnreachableServerExits1(t *testing.T) {
281
351
  valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
282
352
  if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
@@ -9,6 +9,7 @@ import (
9
9
  "bufio"
10
10
  "context"
11
11
  "encoding/json"
12
+ "errors"
12
13
  "flag"
13
14
  "fmt"
14
15
  "io"
@@ -1146,7 +1147,14 @@ func cmdReport(c *server.Client, stdin io.Reader) int {
1146
1147
  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1147
1148
  defer cancel()
1148
1149
  if _, err := c.SubmitReport(ctx, req); err != nil {
1149
- fmt.Fprintln(os.Stderr, err)
1150
+ var apiErr *server.APIError
1151
+ if errors.As(err, &apiErr) {
1152
+ _ = json.NewEncoder(os.Stderr).Encode(map[string]any{
1153
+ "error": map[string]string{"code": apiErr.Code, "message": apiErr.Message},
1154
+ })
1155
+ } else {
1156
+ fmt.Fprintln(os.Stderr, err)
1157
+ }
1150
1158
  return exitFail
1151
1159
  }
1152
1160
  return exitOK
@@ -394,7 +394,7 @@ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.R
394
394
  }
395
395
  if err := wf.ValidateReport(req.Node, req.Report); err != nil {
396
396
  slog.Info("report validation failed", append(attrs, "reason", err.Error())...)
397
- return run.ReportAck{Accepted: false}, err
397
+ return run.ReportAck{}, &run.InvalidReportError{Reason: err.Error()}
398
398
  }
399
399
  signal := reportSignal{
400
400
  ReportID: req.ReportID, Node: req.Node,
@@ -3,6 +3,7 @@ package goworkflows_test
3
3
  import (
4
4
  "context"
5
5
  "database/sql"
6
+ "errors"
6
7
  "path/filepath"
7
8
  "strings"
8
9
  "testing"
@@ -294,6 +295,40 @@ func TestSerialGraphOneNodeAtATime(t *testing.T) {
294
295
  })
295
296
  }
296
297
 
298
+ func TestInvalidReportIsPermanentAndDoesNotSignal(t *testing.T) {
299
+ log := newEventLog()
300
+ engine := newEngine(t, goworkflows.Dependencies{
301
+ Repos: repoRegistryWith("payments", newFakeTaskSystem(log)),
302
+ Runner: newFakeRunner(log), Harness: newFakeHarness(log),
303
+ })
304
+ rid, err := startRun(engine, linearWorkflow(false))
305
+ if err != nil {
306
+ t.Fatal(err)
307
+ }
308
+ waitFor(t, 10*time.Second, func() bool {
309
+ r, err := engine.GetRun(context.Background(), rid)
310
+ return err == nil && r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
311
+ })
312
+ before, _ := engine.GetRun(context.Background(), rid)
313
+ invalid := successReport("end")
314
+ invalid.Feedback.RequiredActions = "needs work"
315
+ ack, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", invalid))
316
+ if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted {
317
+ t.Fatalf("invalid report: ack=%+v err=%v", ack, err)
318
+ }
319
+ if want := `NEXT STEP is end, so FEEDBACK must be exactly None.`; err.Error() != want {
320
+ t.Fatalf("validation message = %q, want %q", err, want)
321
+ }
322
+ after, err := engine.GetRun(context.Background(), rid)
323
+ if err != nil || after.CurrentNodeVisitID != before.CurrentNodeVisitID {
324
+ t.Fatalf("invalid report advanced visit: before=%+v after=%+v err=%v", before, after, err)
325
+ }
326
+ ack, err = engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end")))
327
+ if err != nil || !ack.Accepted {
328
+ t.Fatalf("corrected report: ack=%+v err=%v", ack, err)
329
+ }
330
+ }
331
+
297
332
  func TestRevisitCreatesNewVisit(t *testing.T) {
298
333
  log := newEventLog()
299
334
  sys := newFakeTaskSystem(log)
@@ -210,7 +210,7 @@ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.R
210
210
  return run.ReportAck{}, err
211
211
  }
212
212
  if err := wf.ValidateReport(req.Node, req.Report); err != nil {
213
- return run.ReportAck{Accepted: false}, err
213
+ return run.ReportAck{}, &run.InvalidReportError{Reason: err.Error()}
214
214
  }
215
215
  if err := c.SignalWorkflow(ctx, string(req.RunID), "", reportSignalName, reportSignal{
216
216
  ReportID: req.ReportID, Node: req.Node, NodeVisitID: state.CurrentNodeVisitID, Report: req.Report,
@@ -3,6 +3,7 @@ package temporal
3
3
  import (
4
4
  "context"
5
5
  "database/sql"
6
+ "errors"
6
7
  "testing"
7
8
  "time"
8
9
 
@@ -11,10 +12,84 @@ import (
11
12
  "github.com/rajpopat27/relay-flow/internal/task"
12
13
  "github.com/rajpopat27/relay-flow/internal/workflow"
13
14
  commonpb "go.temporal.io/api/common/v1"
15
+ enumspb "go.temporal.io/api/enums/v1"
16
+ historypb "go.temporal.io/api/history/v1"
14
17
  workflowpb "go.temporal.io/api/workflow/v1"
18
+ "go.temporal.io/sdk/client"
19
+ "go.temporal.io/sdk/converter"
15
20
  _ "modernc.org/sqlite"
16
21
  )
17
22
 
23
+ type reportValidationClient struct {
24
+ client.Client
25
+ start run.Start
26
+ signaled bool
27
+ }
28
+
29
+ func (c *reportValidationClient) QueryWorkflow(context.Context, string, string, string, ...interface{}) (converter.EncodedValue, error) {
30
+ return reportValidationValue{}, nil
31
+ }
32
+
33
+ type reportValidationValue struct{}
34
+
35
+ func (reportValidationValue) HasValue() bool { return true }
36
+ func (reportValidationValue) Get(dest interface{}) error {
37
+ *(dest.(*ReportStateSnapshot)) = ReportStateSnapshot{
38
+ State: run.StateWaiting, CurrentNode: "coding", CurrentNodeVisitID: "visit-1",
39
+ }
40
+ return nil
41
+ }
42
+
43
+ func (c *reportValidationClient) GetWorkflowHistory(context.Context, string, string, bool, enumspb.HistoryEventFilterType) client.HistoryEventIterator {
44
+ payload, _ := converter.GetDefaultDataConverter().ToPayloads(c.start)
45
+ return &reportValidationHistory{event: &historypb.HistoryEvent{
46
+ EventType: enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED,
47
+ Attributes: &historypb.HistoryEvent_WorkflowExecutionStartedEventAttributes{
48
+ WorkflowExecutionStartedEventAttributes: &historypb.WorkflowExecutionStartedEventAttributes{Input: payload},
49
+ },
50
+ }}
51
+ }
52
+
53
+ func (c *reportValidationClient) SignalWorkflow(context.Context, string, string, string, interface{}) error {
54
+ c.signaled = true
55
+ return nil
56
+ }
57
+
58
+ type reportValidationHistory struct {
59
+ event *historypb.HistoryEvent
60
+ read bool
61
+ }
62
+
63
+ func (h *reportValidationHistory) HasNext() bool { return !h.read }
64
+ func (h *reportValidationHistory) Next() (*historypb.HistoryEvent, error) {
65
+ h.read = true
66
+ return h.event, nil
67
+ }
68
+
69
+ func TestTemporalSubmitReportRejectsInvalidReportBeforeSignal(t *testing.T) {
70
+ client := &reportValidationClient{start: run.Start{Workflow: workflow.Workflow{
71
+ Name: "test", Nodes: map[string]workflow.Node{
72
+ "coding": {Type: workflow.NodeAgent, OnSuccess: []workflow.Route{{Target: "end"}}},
73
+ "end": {},
74
+ },
75
+ }}}
76
+ engine := &Engine{client: client}
77
+ report := workflow.Report{
78
+ Status: workflow.OutcomeSuccess, NextStep: "end",
79
+ Summary: workflow.Summary{Completed: "done", Commits: "None", NotCompleted: "None", IssuesDiscovered: "None", Verification: "None", Notes: "None"},
80
+ Feedback: workflow.Feedback{ReasonForNextStep: "None", RequiredActions: "needs work", RelevantContext: "None", ExpectedResult: "None"},
81
+ }
82
+ ack, err := engine.SubmitReport(context.Background(), run.ReportRequest{
83
+ RunID: "run-1", Node: "coding", ReportID: "message-1", Report: report,
84
+ })
85
+ if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted || client.signaled {
86
+ t.Fatalf("invalid Temporal report: ack=%+v err=%v signaled=%v", ack, err, client.signaled)
87
+ }
88
+ if want := `NEXT STEP is end, so FEEDBACK must be exactly None.`; err.Error() != want {
89
+ t.Fatalf("validation message = %q, want %q", err, want)
90
+ }
91
+ }
92
+
18
93
  func TestApplyTemporalProjectionStateRebuildsCanceledTimingIdempotently(t *testing.T) {
19
94
  started := time.Now().UTC().Add(-time.Minute)
20
95
  finished := started.Add(30 * time.Second)
@@ -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.3.11-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.13-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -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.3.11-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.13-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -120,6 +120,16 @@ type ReportAck struct {
120
120
  Duplicate bool `json:"duplicate"`
121
121
  }
122
122
 
123
+ // ErrInvalidReport identifies permanent semantic report-validation failures.
124
+ var ErrInvalidReport = errors.New("invalid report")
125
+
126
+ // InvalidReportError keeps the validation message intact while allowing
127
+ // callers to classify it with errors.Is(err, ErrInvalidReport).
128
+ type InvalidReportError struct{ Reason string }
129
+
130
+ func (e *InvalidReportError) Error() string { return e.Reason }
131
+ func (e *InvalidReportError) Is(target error) bool { return target == ErrInvalidReport }
132
+
123
133
  // ErrRestartConflict means the ticket cannot accept an explicit restart in
124
134
  // its current durable state. Server handlers map it to HTTP 409.
125
135
  var ErrRestartConflict = errors.New("restart conflict")
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "context"
6
6
  "encoding/json"
7
+ "errors"
7
8
  "fmt"
8
9
  "io"
9
10
  "net/http"
@@ -150,6 +151,36 @@ func TestReportEndpointAcceptsJSON(t *testing.T) {
150
151
  }
151
152
  }
152
153
 
154
+ func TestReportValidationErrorPreservesCodeAndMessageThroughClient(t *testing.T) {
155
+ const message = "NEXT STEP is end, so FEEDBACK must be exactly None."
156
+ fake := &fakeServices{reportErr: &run.InvalidReportError{Reason: message}}
157
+ c, cleanup := startHandler(t, fake)
158
+ defer cleanup()
159
+ body := []byte(`{"runId":"run-1","node":"coding","reportId":"message-1","report":{"status":"success","nextStep":"end"}}`)
160
+ status, env := do(t, c, http.MethodPost, "http://relay/reports", body)
161
+ if status != http.StatusBadRequest || env.Error == nil || env.Error.Code != "invalidReport" || env.Error.Message != message {
162
+ t.Fatalf("invalid report response: status=%d env=%+v", status, env)
163
+ }
164
+
165
+ dir := t.TempDir()
166
+ _, cleanupSocket := startHandlerOnSocket(t, dir, fake)
167
+ defer cleanupSocket()
168
+ client := server.NewClient(filepath.Join(dir, "server.sock"))
169
+ _, err := client.SubmitReport(context.Background(), run.ReportRequest{
170
+ RunID: "run-1", Node: "coding", ReportID: "message-1",
171
+ Report: workflow.Report{Status: workflow.OutcomeSuccess, NextStep: "end"},
172
+ })
173
+ var apiErr *server.APIError
174
+ if !errors.As(err, &apiErr) || apiErr.Code != "invalidReport" || apiErr.Message != message || apiErr.StatusCode != http.StatusBadRequest {
175
+ t.Fatalf("Go client lost structured validation error: %v", err)
176
+ }
177
+ fake.reportErr = errors.New("temporary backend failure")
178
+ status, env = do(t, c, http.MethodPost, "http://relay/reports", body)
179
+ if status != http.StatusInternalServerError || env.Error == nil || env.Error.Code != "internalError" {
180
+ t.Fatalf("temporary failure misclassified: status=%d env=%+v", status, env)
181
+ }
182
+ }
183
+
153
184
  func TestProcessedReportIDBypassesReportBodyValidation(t *testing.T) {
154
185
  runID := "payments/basicFlow/PAY-101"
155
186
  reportID := "session-1:message-1"
@@ -38,6 +38,16 @@ func (c *Client) httpClient() *http.Client {
38
38
  }
39
39
  }
40
40
 
41
+ // APIError retains the server's machine-readable code and agent-readable
42
+ // message separately so callers need not parse formatted error text.
43
+ type APIError struct {
44
+ Code string
45
+ Message string
46
+ StatusCode int
47
+ }
48
+
49
+ func (e *APIError) Error() string { return fmt.Sprintf("server %s: %s", e.Code, e.Message) }
50
+
41
51
  // call performs one HTTP request against the socket server and decodes
42
52
  // the standard envelope. Non-2xx responses return an error carrying the
43
53
  // server-provided code and message.
@@ -77,7 +87,7 @@ func (c *Client) call(ctx context.Context, method, path string, body []byte, out
77
87
  }
78
88
  if !env.OK {
79
89
  if env.Error != nil {
80
- return fmt.Errorf("server %s: %s", env.Error.Code, env.Error.Message)
90
+ return &APIError{Code: env.Error.Code, Message: env.Error.Message, StatusCode: resp.StatusCode}
81
91
  }
82
92
  return fmt.Errorf("server returned not-ok (HTTP %d)", resp.StatusCode)
83
93
  }
@@ -36,6 +36,7 @@ type fakeServices struct {
36
36
  runtimeAck run.NodeRuntimeRegistrationAck
37
37
  processedReports map[string]bool
38
38
  submittedReports int
39
+ reportErr error
39
40
  restartRun run.Run
40
41
  restartErr error
41
42
  restarts []string
@@ -125,6 +126,9 @@ func (f *fakeServices) SubmitReport(ctx context.Context, _ run.ReportRequest) (r
125
126
  case <-ctx.Done():
126
127
  }
127
128
  }
129
+ if f.reportErr != nil {
130
+ return run.ReportAck{}, f.reportErr
131
+ }
128
132
  return run.ReportAck{Accepted: true}, nil
129
133
  }
130
134
 
@@ -130,6 +130,8 @@ func writeEnv(w http.ResponseWriter, status int, env envelope) {
130
130
  // anything else is an unexpected 500.
131
131
  func mapErr(w http.ResponseWriter, err error) {
132
132
  switch {
133
+ case errors.Is(err, run.ErrInvalidReport):
134
+ writeErr(w, http.StatusBadRequest, "invalidReport", err.Error())
133
135
  case errors.Is(err, run.ErrRestartConflict):
134
136
  writeErr(w, http.StatusConflict, "conflict", err.Error())
135
137
  case errors.Is(err, run.ErrNotFound):
@@ -50,9 +50,6 @@ func (w *Workflow) ValidateReport(node string, report Report) error {
50
50
  if report.Status != OutcomeSuccess && report.Status != OutcomeFailure {
51
51
  return fmt.Errorf("report status %q must be %q or %q", report.Status, OutcomeSuccess, OutcomeFailure)
52
52
  }
53
- if report.Status == OutcomeFailure && report.NextStep == EndNode {
54
- return fmt.Errorf("failure reports cannot select %q", EndNode)
55
- }
56
53
  for _, field := range []struct{ name, value string }{
57
54
  {"summary.completed", report.Summary.Completed},
58
55
  {"summary.commits", report.Summary.Commits},
@@ -74,6 +71,9 @@ func (w *Workflow) ValidateReport(node string, report Report) error {
74
71
  if err != nil {
75
72
  return err
76
73
  }
74
+ if report.Status == OutcomeFailure && report.NextStep == EndNode {
75
+ return fmt.Errorf("STATUS is failure, so NEXT STEP cannot be end; valid targets: %s", strings.Join(sortedTargets(routes), ", "))
76
+ }
77
77
  legal := false
78
78
  for _, r := range routes {
79
79
  if r.Target == report.NextStep {
@@ -88,7 +88,7 @@ func (w *Workflow) ValidateReport(node string, report Report) error {
88
88
  if report.NextStep == EndNode {
89
89
  f := report.Feedback
90
90
  if f.ReasonForNextStep != None || f.RequiredActions != None || f.RelevantContext != None || f.ExpectedResult != None {
91
- return fmt.Errorf("report selects %q: every feedback field must be %q because end has no mailbox", EndNode, None)
91
+ return fmt.Errorf("NEXT STEP is end, so FEEDBACK must be exactly None.")
92
92
  }
93
93
  }
94
94
  return nil
@@ -208,8 +208,8 @@ func TestValidateReportNextStepMustMatchStatusRoute(t *testing.T) {
208
208
  Summary: fullSummary(),
209
209
  Feedback: fullFeedback(),
210
210
  }
211
- if err := wf.ValidateReport("coding", r); err == nil {
212
- t.Fatal("failure report selecting a success-only target accepted")
211
+ if err := wf.ValidateReport("coding", r); err == nil || !strings.Contains(err.Error(), "valid targets: coding") {
212
+ t.Fatalf("failure report selecting a success-only target: %v, want configured failure route", err)
213
213
  }
214
214
  })
215
215
 
@@ -265,8 +265,8 @@ func TestValidateReportEndRequiresNoneFeedback(t *testing.T) {
265
265
  Summary: fullSummary(),
266
266
  Feedback: fullFeedback(),
267
267
  }
268
- if err := wf.ValidateReport("coding", r); err == nil {
269
- t.Fatal("end report carrying feedback accepted; end has no mailbox")
268
+ if err := wf.ValidateReport("coding", r); err == nil || err.Error() != "NEXT STEP is end, so FEEDBACK must be exactly None." {
269
+ t.Fatalf("end report error = %v, want actionable four-field correction", err)
270
270
  }
271
271
  })
272
272
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.3.11-alpha",
3
+ "version": "0.3.13-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",