Skip to content

Commit e036ac1

Browse files
authored
Merge pull request #136 from LAA-Software-Engineering/feat/inspect-web-ui
feat(cli,inspect): read-only web inspector for runs, traces, and state (#109)
2 parents 8ceb8e2 + 78e5fd5 commit e036ac1

40 files changed

Lines changed: 2395 additions & 180 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
99
### Added
1010

1111
- **Optional OpenTelemetry trace export** (issue #108): `Project.spec.telemetry` (`enabled`, `serviceName`, `endpoint` with `env:` tokens, `consoleExport`) emits WayFind-aligned `gen_ai.*` spans (`agent.run`, `model.chat`, `tool.exec`, `approval`) alongside SQLite traces. Disabled by default; init failures log a warning and never fail runs. See [`docs/OTEL.md`](docs/OTEL.md) for a Jaeger quick start.
12+
- **`agentctl inspect --web`** — read-only local inspector (default `http://127.0.0.1:8787`) over SQLite state: runs, trace timeline, run steps, applied deployment resources, and checkpoints ([#109](https://github.com/LAA-Software-Engineering/agentic-control-plane/issues/109)).
1213
- **Run checkpointing and resume** (issue #105): SQLite `run_checkpoints` table stores per-run execution snapshots after each completed step. `agentctl run --resume <run-id>` rehydrates interpolation context and continues from the next step without replaying earlier steps. Interrupted runs exit cleanly (status `interrupted`, exit code 0) and cascade with trace retention pruning. Checkpoints are written before step rows are marked succeeded to avoid replay on crash; runs pin `workflow_spec_hash` and `environment_name` for safe resume.
1314
- **Built-in policy presets** (issue #104): `strict`, `permissive`, and `shell_safe`. Select via `Project.spec.defaults.policy`, by referencing a preset name on agents/workflows, or with `Policy.spec.preset` (local rules layer on top). Presets expand during [NormalizeProjectGraph]; `strict`/`permissive` materialize approval flags, while `shell_safe` sets `ResolvedPreset` and relies on runtime token classification plus tool safety metadata for plan risk.
1415
- **`shell_safe` token classification** for native `command.run` / `run` / `exec` / `shell` operations: read-only first tokens (`ls`, `cat`, …) run unattended when the command contains no shell metacharacters (`;|&$`, newlines, `` ` ``, `$(…)`); risky tokens, unknown tokens, and side-effecting non-shell tools require `--approve`. **Heuristic only — not a sandbox.**

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,11 @@ agentctl plan --project my-agent-system
105105
agentctl apply --project my-agent-system --auto-approve
106106
agentctl run workflow/hello --project my-agent-system
107107
agentctl logs --project my-agent-system --workflow hello
108+
agentctl inspect --web --project my-agent-system # read-only local UI on http://127.0.0.1:8787
108109
```
109110

111+
`inspect --web` binds to **localhost only** and opens the state DB read-only. Avoid running it while `agentctl run` is writing the same SQLite file (you may see `database is locked` without WAL); use it when runs are idle or on a copy of the DB.
112+
110113
### Example `project.yaml`
111114

112115
The project root is a **`Project`** resource: `apiVersion`, `kind`, `metadata.name`, and **`spec.imports`** listing other YAML files (policies, tools, workflows). After **`agentctl init my-agent-system`**, `my-agent-system/project.yaml` looks like this:
@@ -220,7 +223,7 @@ GO_UPDATE_GOLDEN=1 go test ./internal/cli/... -run TestGolden_
220223
Recent landings already cover much of “hardening”: **plan/apply optimistic concurrency** (exit **3** when deployment state drifts), **MCP** over **streamable HTTP** as well as stdio, **trace retention** (`spec.traces.retentionDays`), **`defaults.runtime`** / **`spec.runtime`** (MVP `local`), and clearer **defaults vs environment overlay** documentation. What is still open for near-term polish:
221224

222225
- More **`diff` / drift** UX where the design doc calls for it (beyond today’s resource-level diff)
223-
- Richer **`inspect`** output and **`logs`** filtering (see sections **10.2** and **17.3** in `docs/DESIGN_DOC.md`)
226+
- Richer **`logs`** filtering (see sections **10.2** and **17.3** in `docs/DESIGN_DOC.md`); **`inspect --web`** covers read-only run/state browsing ([#109](https://github.com/LAA-Software-Engineering/agentic-control-plane/issues/109))
224227
- **`agentctl test`**-style workflow fixtures (**stretch** per design doc)
225228

226229
### Post-MVP (from design doc section 19)

internal/cli/doc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//
1212
// The inspect command (section 10.2) uses the same preparation pipeline and prints one effective
1313
// normalized resource envelope (Kind/name) as JSON, YAML, or indented JSON for table output.
14+
// With --web it serves a read-only local inspector via [inspect] over SQLite state (issue #109).
1415
//
1516
// The state command (section 10.2, §14.1) lists or shows rows from the SQLite deployment store
1617
// (applied_resources, applied_projects) read-only via [state.DeploymentStore].

internal/cli/inspect.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,49 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/inspect"
89
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
910
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
1011
"github.com/spf13/cobra"
1112
)
1213

1314
func newInspectCmd() *cobra.Command {
14-
return &cobra.Command{
15-
Use: "inspect Kind/name",
16-
Short: "Print the effective normalized resource (after defaults and env overlay)",
15+
var web bool
16+
var port int
17+
var traceUI string
18+
19+
cmd := &cobra.Command{
20+
Use: "inspect [Kind/name]",
21+
Short: "Print an effective resource or start the read-only web inspector",
1722
Long: `Load the project the same way as validate, plan, and run (defaults, optional -e / --env
18-
overlay via Environment resources, then validation), then print one resource.
23+
overlay via Environment resources, then validation).
1924
20-
Argument must be Kind/name (e.g. Agent/reviewer, workflow/demo). Kind is matched case-insensitively.
25+
Without --web, prints one resource Kind/name (e.g. Agent/reviewer, workflow/demo).
26+
Kind is matched case-insensitively. Output is the full resource envelope (design doc §6.1).
2127
22-
Output is the full resource envelope: apiVersion, kind, metadata, and spec (design doc §6.1).
28+
With --web, starts a local read-only HTTP server over the SQLite state database (runs,
29+
trace events, applied deployment resources, checkpoints). Binds to 127.0.0.1 by default.
2330
2431
Exit code 2 for validation failure, unknown resource, or bad Kind/name (§11.2).`,
2532
Example: ` agentctl inspect Workflow/pr-review
2633
agentctl inspect Agent/reviewer -o yaml
27-
agentctl inspect Policy/default -e staging -o json`,
34+
agentctl inspect --web
35+
agentctl inspect --web --port 8787 --state .agentic/state.db`,
2836
SilenceUsage: true,
29-
RunE: runInspect,
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
if web {
39+
if len(args) != 0 {
40+
return NewExitError(ExitValidationError, fmt.Errorf("inspect: --web does not accept Kind/name arguments"))
41+
}
42+
return runInspectWeb(cmd, port, traceUI)
43+
}
44+
return runInspect(cmd, args)
45+
},
3046
}
47+
cmd.Flags().BoolVar(&web, "web", false, "start read-only local web inspector over SQLite state")
48+
cmd.Flags().IntVar(&port, "port", inspect.DefaultPort, "TCP port when using --web (localhost only)")
49+
cmd.Flags().StringVar(&traceUI, "trace-ui", "", "optional base URL for OTel trace deep links (issue #108)")
50+
return cmd
3151
}
3252

3353
func environmentLabel(g *Global) string {

internal/cli/inspect_web.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"strings"
9+
"syscall"
10+
"time"
11+
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/inspect"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func runInspectWeb(cmd *cobra.Command, port int, traceUIBase string) error {
18+
if err := inspect.ValidateInspectPort(port); err != nil {
19+
return NewExitError(ExitValidationError, err)
20+
}
21+
g := Globals()
22+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
23+
if err != nil {
24+
return NewExitError(ExitValidationError, err)
25+
}
26+
traceBase, err := inspect.ValidateTraceUIBaseURL(traceUIBase)
27+
if err != nil {
28+
return NewExitError(ExitValidationError, err)
29+
}
30+
31+
env := planEnvironment(g)
32+
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
33+
if err != nil {
34+
return fmt.Errorf("inspect: resolve state path: %w", err)
35+
}
36+
if _, err := os.Stat(dsn); err != nil {
37+
if os.IsNotExist(err) {
38+
return NewExitErrorf(ExitValidationError, "inspect: state database %q does not exist (run plan/apply or a workflow first)", dsn)
39+
}
40+
return fmt.Errorf("inspect: stat state %q: %w", dsn, err)
41+
}
42+
43+
ctx := context.Background()
44+
st, err := sqlite.OpenReadOnly(ctx, dsn)
45+
if err != nil {
46+
return fmt.Errorf("inspect: open sqlite read-only %q: %w", dsn, err)
47+
}
48+
defer func() { _ = st.Close() }()
49+
50+
cfg := inspect.Config{
51+
Port: port,
52+
StatePath: dsn,
53+
Env: env,
54+
ProjectName: strings.TrimSpace(graph.Meta.Name),
55+
TraceUIBaseURL: traceBase,
56+
}
57+
srv, err := inspect.NewServer(st, cfg)
58+
if err != nil {
59+
return fmt.Errorf("inspect: %w", err)
60+
}
61+
62+
runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
63+
defer stop()
64+
65+
errCh := make(chan error, 1)
66+
go func() { errCh <- srv.ListenAndServe(runCtx) }()
67+
68+
deadline := time.Now().Add(3 * time.Second)
69+
for !srv.ListenReady() {
70+
if time.Now().After(deadline) {
71+
return fmt.Errorf("inspect: server did not start listening")
72+
}
73+
time.Sleep(10 * time.Millisecond)
74+
}
75+
76+
addr := srv.BoundAddr()
77+
url := "http://" + addr + "/"
78+
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Inspector listening on %s (read-only)\nOpen %s\nPress Ctrl+C to stop.\n", addr, url); err != nil {
79+
return err
80+
}
81+
82+
if err := <-errCh; err != nil && runCtx.Err() == nil {
83+
return fmt.Errorf("inspect: server: %w", err)
84+
}
85+
return nil
86+
}

internal/cli/inspect_web_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/inspect"
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
16+
)
17+
18+
func TestInspect_web_flagsAndAPI(t *testing.T) {
19+
ctx := context.Background()
20+
db := filepath.Join(t.TempDir(), "web.db")
21+
root := runProjRoot(t)
22+
23+
// Create state via a workflow run.
24+
ResetGlobalsForTest()
25+
runCmd := NewRootCmd()
26+
runCmd.SetOut(io.Discard)
27+
runCmd.SetErr(io.Discard)
28+
runCmd.SetArgs([]string{
29+
"run", "workflow/demo",
30+
"--project", root,
31+
"-e", "staging",
32+
"--state", db,
33+
"--input", "topic=inspect-web",
34+
})
35+
if err := runCmd.Execute(); err != nil {
36+
t.Fatal(err)
37+
}
38+
39+
st, err := sqlite.OpenReadOnly(ctx, db)
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
t.Cleanup(func() { _ = st.Close() })
44+
45+
runs, err := st.ListRecentRuns(ctx, 5)
46+
if err != nil || len(runs) == 0 {
47+
t.Fatalf("runs=%v err=%v", runs, err)
48+
}
49+
50+
srv, err := inspect.NewServer(st, inspect.Config{
51+
StatePath: db,
52+
Env: "staging",
53+
Port: 0, // not used when served via httptest pattern below
54+
})
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
ts := httptest.NewServer(srv.Handler())
60+
t.Cleanup(ts.Close)
61+
res, err := http.Get(ts.URL + "/api/runs")
62+
if err != nil {
63+
t.Fatal(err)
64+
}
65+
defer res.Body.Close()
66+
if res.StatusCode != http.StatusOK {
67+
t.Fatalf("status=%d", res.StatusCode)
68+
}
69+
b, _ := io.ReadAll(res.Body)
70+
if !strings.Contains(string(b), runs[0].RunID) {
71+
t.Fatalf("body missing run id: %s", b)
72+
}
73+
}
74+
75+
func TestInspect_web_requiresNoArgs(t *testing.T) {
76+
db := filepath.Join(t.TempDir(), "missing.db")
77+
root := runProjRoot(t)
78+
79+
ResetGlobalsForTest()
80+
cmd := NewRootCmd()
81+
cmd.SetOut(io.Discard)
82+
cmd.SetErr(io.Discard)
83+
cmd.SetArgs([]string{"inspect", "--web", "--project", root, "--state", db, "Workflow/demo"})
84+
err := cmd.Execute()
85+
if err == nil {
86+
t.Fatal("expected error")
87+
}
88+
if ExitCodeOf(err) != ExitValidationError {
89+
t.Fatalf("exit=%d err=%v", ExitCodeOf(err), err)
90+
}
91+
}
92+
93+
func TestInspect_web_invalidTraceUI_exit2(t *testing.T) {
94+
root := runProjRoot(t)
95+
db := filepath.Join(t.TempDir(), "ui.db")
96+
97+
ResetGlobalsForTest()
98+
runCmd := NewRootCmd()
99+
runCmd.SetOut(io.Discard)
100+
runCmd.SetErr(io.Discard)
101+
runCmd.SetArgs([]string{"run", "workflow/demo", "--project", root, "--state", db, "--input", "topic=trace-ui-test"})
102+
if err := runCmd.Execute(); err != nil {
103+
t.Fatal(err)
104+
}
105+
106+
ResetGlobalsForTest()
107+
cmd := NewRootCmd()
108+
cmd.SetOut(io.Discard)
109+
cmd.SetErr(io.Discard)
110+
cmd.SetArgs([]string{"inspect", "--web", "--project", root, "--state", db, "--trace-ui", "javascript:alert(1)"})
111+
err := cmd.Execute()
112+
if err == nil {
113+
t.Fatal("expected error")
114+
}
115+
if ExitCodeOf(err) != ExitValidationError {
116+
t.Fatalf("exit=%d err=%v", ExitCodeOf(err), err)
117+
}
118+
}
119+
120+
func TestInspect_web_invalidPort_exit2(t *testing.T) {
121+
root := runProjRoot(t)
122+
db := filepath.Join(t.TempDir(), "port.db")
123+
124+
ResetGlobalsForTest()
125+
cmd := NewRootCmd()
126+
cmd.SetOut(io.Discard)
127+
cmd.SetErr(io.Discard)
128+
cmd.SetArgs([]string{"inspect", "--web", "--project", root, "--state", db, "--port", "99999"})
129+
err := cmd.Execute()
130+
if err == nil {
131+
t.Fatal("expected error")
132+
}
133+
if ExitCodeOf(err) != ExitValidationError {
134+
t.Fatalf("exit=%d err=%v", ExitCodeOf(err), err)
135+
}
136+
}
137+
138+
func TestInspect_web_missingDB_exit2(t *testing.T) {
139+
db := filepath.Join(t.TempDir(), "no.db")
140+
root := runProjRoot(t)
141+
142+
ResetGlobalsForTest()
143+
cmd := NewRootCmd()
144+
cmd.SetOut(io.Discard)
145+
cmd.SetErr(io.Discard)
146+
cmd.SetArgs([]string{"inspect", "--web", "--project", root, "--state", db})
147+
err := cmd.Execute()
148+
if err == nil {
149+
t.Fatal("expected error")
150+
}
151+
if ExitCodeOf(err) != ExitValidationError {
152+
t.Fatalf("exit=%d err=%v", ExitCodeOf(err), err)
153+
}
154+
}
155+
156+
func TestInspect_web_readOnlyOpen(t *testing.T) {
157+
ctx := context.Background()
158+
db := filepath.Join(t.TempDir(), "ro-cli.db")
159+
st, err := sqlite.Open(ctx, db)
160+
if err != nil {
161+
t.Fatal(err)
162+
}
163+
if err := st.StartRun(ctx, state.Run{
164+
RunID: "r", WorkflowName: "w", Env: "local", Status: state.RunStatusRunning,
165+
StartedAt: time.Now().UTC(), InputJSON: `{}`,
166+
}); err != nil {
167+
t.Fatal(err)
168+
}
169+
_ = st.Close()
170+
171+
ro, err := sqlite.OpenReadOnly(ctx, db)
172+
if err != nil {
173+
t.Fatal(err)
174+
}
175+
_ = ro.Close()
176+
}

0 commit comments

Comments
 (0)