Skip to content

Commit 9583790

Browse files
fix(cli): classify engine conflict errors with correct exit code
Recognize *specgraph.Error in classifyError so a lock conflict surfaces as CONFLICT (exit 2) instead of a generic INTERNAL_ERROR. Add end-to-end tests proving CLI commands and dual servers coexist on the same directory. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 088a169 commit 9583790

2 files changed

Lines changed: 101 additions & 2 deletions

File tree

internal/cli/output.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"strings"
78

89
"github.com/spf13/cobra"
910
"github.com/tyeongkim/spec-graph/internal/jsoncontract"
1011
"github.com/tyeongkim/spec-graph/internal/model"
12+
"github.com/tyeongkim/spec-graph/pkg/specgraph"
1113
)
1214

1315
// exitError carries a process exit code; a nil err means stdout already holds
@@ -96,7 +98,18 @@ func classifyError(err error) (errorClass, bool) {
9698
return errorClass{"RELATION_NOT_FOUND", 1}, true
9799
case errors.As(err, &changesetNotFound):
98100
return errorClass{"CHANGESET_NOT_FOUND", 1}, true
99-
default:
100-
return errorClass{"INTERNAL_ERROR", 1}, false
101101
}
102+
103+
var sgErr *specgraph.Error
104+
if errors.As(err, &sgErr) {
105+
return errorClass{specgraphCode(sgErr.Code), sgErr.ExitCode()}, true
106+
}
107+
108+
return errorClass{"INTERNAL_ERROR", 1}, false
109+
}
110+
111+
// specgraphCode maps a specgraph.ErrorCode to the uppercase JSON error code
112+
// string used in CLI output.
113+
func specgraphCode(code specgraph.ErrorCode) string {
114+
return strings.ToUpper(string(code))
102115
}

internal/cli/serve_coexist_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cli_test
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"os/exec"
7+
"testing"
8+
"time"
9+
10+
"github.com/tyeongkim/spec-graph/internal/jsoncontract"
11+
)
12+
13+
// startServe launches `spec-graph serve` as a long-running subprocess with an
14+
// open stdin pipe, so the server stays alive (and, under the old lifetime-lock
15+
// model, would hold the project lock) until the returned stop func is called.
16+
func startServe(t *testing.T, dir, dbFile string) (stdin io.WriteCloser, stop func()) {
17+
t.Helper()
18+
19+
cmd := exec.Command(binaryPath, "--db", dbFile, "serve")
20+
cmd.Dir = dir
21+
22+
in, err := cmd.StdinPipe()
23+
if err != nil {
24+
t.Fatalf("serve stdin pipe: %v", err)
25+
}
26+
if err := cmd.Start(); err != nil {
27+
t.Fatalf("start serve: %v", err)
28+
}
29+
30+
// Give the server a moment to open the engine and begin serving.
31+
time.Sleep(300 * time.Millisecond)
32+
33+
stop = func() {
34+
_ = in.Close()
35+
_ = cmd.Wait()
36+
}
37+
return in, stop
38+
}
39+
40+
func TestE2E_CLICoexistsWithRunningServer(t *testing.T) {
41+
dbFile := initTestProject(t)
42+
dir := t.TempDir()
43+
44+
if r := runCLI(t, dir, "init", "--db", dbFile); r.exitCode != 0 {
45+
t.Fatalf("init: exit=%d stderr=%s", r.exitCode, r.stderr)
46+
}
47+
48+
_, stop := startServe(t, dir, dbFile)
49+
defer stop()
50+
51+
r := runCLI(t, dir, "--db", dbFile, "entity", "add", "--type", "requirement", "--id", "REQ-001", "--title", "Concurrent with server")
52+
if r.exitCode != 0 {
53+
t.Fatalf("entity add while server running: exit=%d stderr=%s", r.exitCode, r.stderr)
54+
}
55+
56+
r = runCLI(t, dir, "--db", dbFile, "entity", "list")
57+
if r.exitCode != 0 {
58+
t.Fatalf("entity list while server running: exit=%d stderr=%s", r.exitCode, r.stderr)
59+
}
60+
var list jsoncontract.EntityListResponse
61+
if err := json.Unmarshal([]byte(r.stdout), &list); err != nil {
62+
t.Fatalf("entity list unmarshal: %v\nraw: %s", err, r.stdout)
63+
}
64+
if list.Count != 1 {
65+
t.Errorf("entity list count=%d while server running; want 1", list.Count)
66+
}
67+
}
68+
69+
func TestE2E_DualServersCoexist(t *testing.T) {
70+
dbFile := initTestProject(t)
71+
dir := t.TempDir()
72+
73+
if r := runCLI(t, dir, "init", "--db", dbFile); r.exitCode != 0 {
74+
t.Fatalf("init: exit=%d stderr=%s", r.exitCode, r.stderr)
75+
}
76+
77+
_, stop1 := startServe(t, dir, dbFile)
78+
defer stop1()
79+
_, stop2 := startServe(t, dir, dbFile)
80+
defer stop2()
81+
82+
r := runCLI(t, dir, "--db", dbFile, "entity", "add", "--type", "requirement", "--id", "REQ-001", "--title", "Two servers up")
83+
if r.exitCode != 0 {
84+
t.Fatalf("entity add with two servers running: exit=%d stderr=%s", r.exitCode, r.stderr)
85+
}
86+
}

0 commit comments

Comments
 (0)