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 +5 -5
- package/cmd/relay-flow/commands_test.go +70 -0
- package/cmd/relay-flow/main.go +9 -1
- package/internal/execution/goworkflows/engine.go +1 -1
- package/internal/execution/goworkflows/engine_test.go +35 -0
- package/internal/execution/temporal/operations.go +1 -1
- package/internal/execution/temporal/operations_test.go +75 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/run/run.go +10 -0
- package/internal/server/api_test.go +31 -0
- package/internal/server/client.go +11 -1
- package/internal/server/fixture_test.go +4 -0
- package/internal/server/server.go +2 -0
- package/internal/workflow/report.go +4 -4
- package/internal/workflow/report_test.go +4 -4
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
package/cmd/relay-flow/main.go
CHANGED
|
@@ -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
|
-
|
|
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{
|
|
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{
|
|
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.
|
|
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")
|
package/internal/run/run.go
CHANGED
|
@@ -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
|
|
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("
|
|
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.
|
|
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.
|
|
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
|
|