Skip to content

Commit bb8b912

Browse files
authored
Merge pull request #84 from LAA-Software-Engineering/feat/cli-state-command
feat(cli): agentctl state list/show (deployment store)
2 parents 31b668b + 652d9ae commit bb8b912

6 files changed

Lines changed: 551 additions & 2 deletions

File tree

internal/cli/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
// The inspect command (section 10.2) uses the same preparation pipeline and prints one effective
1010
// normalized resource envelope (Kind/name) as JSON, YAML, or indented JSON for table output.
1111
//
12+
// The state command (section 10.2, §14.1) lists or shows rows from the SQLite deployment store
13+
// (applied_resources, applied_projects) read-only via [state.DeploymentStore].
14+
//
1215
// The plan command compares that prepared graph to the SQLite deployment store (default
1316
// .agentic/state.db, or project.spec.state.dsn / --state) and prints a diff plus risk delta
1417
// via [plan.ComputePlan] and [plan.FormatPlan].

internal/cli/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewRootCmd() *cobra.Command {
2222
root := &cobra.Command{
2323
Use: "agentctl",
2424
Short: "Declarative control plane for agent systems",
25-
Long: "agentctl validates, inspects, plans, diffs, applies, and runs declarative agent systems defined as YAML.",
25+
Long: "agentctl validates, inspects, plans, diffs, applies, runs, and reads deployment state for declarative agent systems defined as YAML.",
2626
SilenceErrors: true,
2727
Run: func(cmd *cobra.Command, args []string) {
2828
_ = cmd.Help()
@@ -39,6 +39,7 @@ func NewRootCmd() *cobra.Command {
3939
root.AddCommand(newPlanCmd())
4040
root.AddCommand(newDiffCmd())
4141
root.AddCommand(newApplyCmd())
42+
root.AddCommand(newStateCmd())
4243
root.AddCommand(newRunCmd())
4344
root.AddCommand(newLogsCmd())
4445
return root

internal/cli/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestRootHelp_listsGlobalFlags(t *testing.T) {
1919
t.Fatal(err)
2020
}
2121
out := buf.String()
22-
for _, sub := range []string{"diff", "inspect"} {
22+
for _, sub := range []string{"diff", "inspect", "state"} {
2323
if !strings.Contains(out, sub) {
2424
t.Fatalf("help should mention %q subcommand:\n%s", sub, out)
2525
}

internal/cli/state.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"text/tabwriter"
12+
"time"
13+
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
16+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
// maxStateJSONSnippet is the max runes shown for normalized_spec_json in table output (issue #72).
21+
const maxStateJSONSnippet = 120
22+
23+
func newStateCmd() *cobra.Command {
24+
cmd := &cobra.Command{
25+
Use: "state",
26+
Short: "Inspect SQLite deployment state (applied resources)",
27+
Long: `Read-only commands for rows in the deployment store (design doc §10.2, §14.1).
28+
29+
Uses the same --project / --state resolution as plan and apply. Rows are scoped to the
30+
environment from -e / --env when set, otherwise "local".
31+
32+
This command does not modify state.`,
33+
}
34+
cmd.AddCommand(newStateListCmd())
35+
cmd.AddCommand(newStateShowCmd())
36+
return cmd
37+
}
38+
39+
func newStateListCmd() *cobra.Command {
40+
return &cobra.Command{
41+
Use: "list",
42+
Short: "List applied resources for the selected environment",
43+
SilenceUsage: true,
44+
Long: `Print every row in applied_resources for the current environment, plus the
45+
applied_projects row for this project when present.
46+
47+
Exit codes (§11.2): 0 success, 1 SQLite failure, 2 validation failure (invalid project).`,
48+
RunE: runStateList,
49+
}
50+
}
51+
52+
func newStateShowCmd() *cobra.Command {
53+
return &cobra.Command{
54+
Use: "show Kind/name",
55+
Short: "Show one applied resource row",
56+
SilenceUsage: true,
57+
Long: `Print kind, name, environment, spec hash, applied time, and normalized spec JSON
58+
for one applied_resources row. Kind/name parsing matches other commands (case-insensitive kind).
59+
60+
For large JSON, table output truncates with "..."; use -o json or -o yaml for the full value.
61+
62+
Exit codes (§11.2): 0 success, 1 SQLite failure, 2 validation or unknown resource.`,
63+
RunE: func(cmd *cobra.Command, args []string) error {
64+
if len(args) != 1 {
65+
return NewExitError(ExitValidationError, fmt.Errorf("state: show requires exactly one Kind/name argument"))
66+
}
67+
return runStateShow(cmd, args)
68+
},
69+
}
70+
}
71+
72+
func runStateList(cmd *cobra.Command, args []string) error {
73+
_ = args
74+
ctx := context.Background()
75+
g := Globals()
76+
77+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
78+
if err != nil {
79+
return NewExitError(ExitValidationError, err)
80+
}
81+
env := planEnvironment(g)
82+
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
83+
if err != nil {
84+
return fmt.Errorf("state: resolve state path: %w", err)
85+
}
86+
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
87+
return fmt.Errorf("state: create state directory: %w", err)
88+
}
89+
90+
st, err := sqlite.Open(ctx, dsn)
91+
if err != nil {
92+
return fmt.Errorf("state: open sqlite %q: %w", dsn, err)
93+
}
94+
defer func() { _ = st.Close() }()
95+
96+
rows, err := st.ListAppliedResourcesByEnv(ctx, env)
97+
if err != nil {
98+
return fmt.Errorf("state: list applied resources: %w", err)
99+
}
100+
101+
var proj *state.AppliedProject
102+
pname := strings.TrimSpace(graph.Meta.Name)
103+
if pname != "" {
104+
p, err := st.GetAppliedProject(ctx, env, pname)
105+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
106+
return fmt.Errorf("state: get applied project: %w", err)
107+
}
108+
if err == nil {
109+
proj = p
110+
}
111+
}
112+
113+
return writeStateListOutput(cmd, g, env, dsn, proj, rows)
114+
}
115+
116+
func runStateShow(cmd *cobra.Command, args []string) error {
117+
ctx := context.Background()
118+
g := Globals()
119+
120+
id, err := ParseResourceRef(args[0])
121+
if err != nil {
122+
return NewExitError(ExitValidationError, fmt.Errorf("state: %w", err))
123+
}
124+
125+
graph, root, err := prepareProjectGraph(g.ProjectRoot, g)
126+
if err != nil {
127+
return NewExitError(ExitValidationError, err)
128+
}
129+
env := planEnvironment(g)
130+
dsn, err := resolveStateSQLitePath(root, graph, g.StatePath)
131+
if err != nil {
132+
return fmt.Errorf("state: resolve state path: %w", err)
133+
}
134+
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
135+
return fmt.Errorf("state: create state directory: %w", err)
136+
}
137+
138+
st, err := sqlite.Open(ctx, dsn)
139+
if err != nil {
140+
return fmt.Errorf("state: open sqlite %q: %w", dsn, err)
141+
}
142+
defer func() { _ = st.Close() }()
143+
144+
row, err := st.GetAppliedResource(ctx, env, id)
145+
if err != nil {
146+
if errors.Is(err, sql.ErrNoRows) {
147+
return NewExitError(ExitValidationError, fmt.Errorf("state: no applied resource %s for environment %q", id.String(), env))
148+
}
149+
return fmt.Errorf("state: get applied resource: %w", err)
150+
}
151+
152+
return writeStateShowOutput(cmd, g, dsn, env, row)
153+
}
154+
155+
func writeStateListOutput(cmd *cobra.Command, g *Global, env, dsn string, proj *state.AppliedProject, rows []state.AppliedResource) error {
156+
out := cmd.OutOrStdout()
157+
switch g.Output {
158+
case render.FormatJSON:
159+
payload := map[string]any{
160+
"environment": env,
161+
"statePath": dsn,
162+
"resources": stateListResourcesJSON(rows),
163+
"appliedProject": nil,
164+
}
165+
if proj != nil {
166+
payload["appliedProject"] = appliedProjectJSON(proj)
167+
}
168+
return render.WriteJSON(out, payload)
169+
case render.FormatYAML:
170+
m := map[string]any{
171+
"environment": env,
172+
"statePath": dsn,
173+
"resources": stateListResourcesYAML(rows),
174+
}
175+
if proj != nil {
176+
m["appliedProject"] = appliedProjectJSON(proj)
177+
} else {
178+
m["appliedProject"] = nil
179+
}
180+
return render.WriteYAML(out, m)
181+
default:
182+
if _, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\n", env, dsn); err != nil {
183+
return err
184+
}
185+
if proj != nil {
186+
if _, err := fmt.Fprintf(out, "Applied project: %s version=%s appliedAt=%s\n\n",
187+
proj.ProjectName, proj.Version, proj.AppliedAt.UTC().Format(time.RFC3339)); err != nil {
188+
return err
189+
}
190+
}
191+
if len(rows) == 0 {
192+
_, err := fmt.Fprintf(out, "No applied resources for environment %q.\n", env)
193+
return err
194+
}
195+
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
196+
_, _ = fmt.Fprintln(tw, "KIND\tNAME\tSPEC_HASH\tAPPLIED_AT")
197+
for _, r := range rows {
198+
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", r.Kind, r.Name, r.SpecHash, r.AppliedAt.UTC().Format(time.RFC3339))
199+
}
200+
return tw.Flush()
201+
}
202+
}
203+
204+
func stateListResourcesJSON(rows []state.AppliedResource) []map[string]any {
205+
out := make([]map[string]any, 0, len(rows))
206+
for _, r := range rows {
207+
out = append(out, appliedResourceJSON(r))
208+
}
209+
return out
210+
}
211+
212+
func stateListResourcesYAML(rows []state.AppliedResource) []map[string]any {
213+
return stateListResourcesJSON(rows)
214+
}
215+
216+
func appliedProjectJSON(p *state.AppliedProject) map[string]any {
217+
if p == nil {
218+
return nil
219+
}
220+
return map[string]any{
221+
"projectName": p.ProjectName,
222+
"env": p.Env,
223+
"version": p.Version,
224+
"appliedAt": p.AppliedAt.UTC().Format(time.RFC3339Nano),
225+
}
226+
}
227+
228+
func appliedResourceJSON(r state.AppliedResource) map[string]any {
229+
return map[string]any{
230+
"kind": r.Kind,
231+
"name": r.Name,
232+
"env": r.Env,
233+
"specHash": r.SpecHash,
234+
"appliedAt": r.AppliedAt.UTC().Format(time.RFC3339Nano),
235+
"normalizedSpecJson": r.NormalizedSpecJSON,
236+
}
237+
}
238+
239+
func writeStateShowOutput(cmd *cobra.Command, g *Global, dsn, env string, row *state.AppliedResource) error {
240+
if row == nil {
241+
return fmt.Errorf("state: nil row")
242+
}
243+
out := cmd.OutOrStdout()
244+
switch g.Output {
245+
case render.FormatJSON:
246+
return render.WriteJSON(out, map[string]any{
247+
"environment": env,
248+
"statePath": dsn,
249+
"resource": appliedResourceJSON(*row),
250+
})
251+
case render.FormatYAML:
252+
return render.WriteYAML(out, map[string]any{
253+
"environment": env,
254+
"statePath": dsn,
255+
"resource": appliedResourceJSON(*row),
256+
})
257+
default:
258+
if _, err := fmt.Fprintf(out, "Environment: %s\nState: %s\n\n", env, dsn); err != nil {
259+
return err
260+
}
261+
if _, err := fmt.Fprintf(out, "Kind: %s\nName: %s\nEnv: %s\nSpec hash: %s\nApplied at: %s\n\n",
262+
row.Kind, row.Name, row.Env, row.SpecHash, row.AppliedAt.UTC().Format(time.RFC3339)); err != nil {
263+
return err
264+
}
265+
if _, err := fmt.Fprint(out, "Normalized spec (truncated in table output; use -o json or yaml for full JSON):\n"); err != nil {
266+
return err
267+
}
268+
_, err := fmt.Fprint(out, clipString(row.NormalizedSpecJSON, maxStateJSONSnippet)+"\n")
269+
return err
270+
}
271+
}
272+
273+
func clipString(s string, maxRunes int) string {
274+
s = strings.TrimSpace(s)
275+
if maxRunes <= 0 || s == "" {
276+
return s
277+
}
278+
r := []rune(s)
279+
if len(r) <= maxRunes {
280+
return s
281+
}
282+
return string(r[:maxRunes]) + "..."
283+
}

0 commit comments

Comments
 (0)