Skip to content

Commit 3964b9f

Browse files
AlexgodorojaAlexgodoroja
andauthored
pilotctl: render next-steps after every appstore call (#396)
* pilotctl: render next-steps after every appstore call An agent installs an app and stops caring: nothing in a call's output says where the flow goes next. The product demo covers install->first-call, once. This covers every call after it. Each `appstore call` now ends by naming the small set of recommended next commands for where the agent stands: the next step in the flow on success, and the specific fix on failure (402 -> top up, needs_signup -> the gateway, missing param -> a corrected call). The graph is authored in submission.json, rides into the catalogue's metadata.json (pinned by metadata_sha256 under the catalogue signature), and is cached at install to $APP/next-steps.json. The call path is therefore a local file read: no fetch, no DNS, no added latency, works offline. Matching is over the outcome PAYLOAD, not just the outcome: the error text on failure, the result body on success. That is load-bearing. The scaffold's requireKey wrapper soft-fails an unauthenticated call with exit 0 and {"needs_signup":true,...}, so "you must sign up first" is not an error at all -- an outcome-only matcher would stay silent for precisely the cold agent who most needs the gateway named. Output goes to stderr, never stdout: stdout is the JSON result agents pipe into jq, and one line of prose there breaks the workflow this exists to encourage. --json callers get a structured next_steps object instead. Additive and fail-silent throughout: absent, malformed, future-schema or unmatched means the call behaves exactly as before. A hint is a nicety; a call is not. - appstore_nextsteps.go: mirrored wire contract, resolver, renderer, cache - appstore.go: hooks on both outcomes; success resolves before the review prompt can replace the body - main.go: fatalHint drains exitNextSteps so steps land after the error they fix (same pattern motd's importantUpdate already uses) - PILOT_NEXT_STEPS=off opts out * pilotctl: a discriminated edge must beat a bare exact-from edge Ports the precedence fix from app-template's nextsteps.Resolve, which validates the graphs this resolves. The two had drifted: this side still ranked from-exact above a match, so for any method with both a flow edge and a soft-failing gateway, the flow edge won — a cold agent calling primitive.send_email was told to read its inbox instead of to sign up. The parity test passed through the drift because it never covered exact-bare vs wildcard-match, which is precisely the shadowing case. Added it; verified by mutation that re-introducing the old scoring now fails the suite. Proven end-to-end against the real app with the real authored graph: a cold send_email now renders the signup gateway, marked (required first). * pilotctl: confine the next-steps cache to the install root gosec flagged both ends as G703 path traversal, correctly. appID arrives straight from argv, so filepath.Join alone is not containment: `pilotctl appstore call ../../etc/x ...` resolves outside the install root and we would read a file there and print pieces of it back as "next steps". Use resolveUnder on both the read and the write — the same guard install and the supervisor already rely on, whose own comment says filepath.Join does NOT block traversal. The regression test needed a second pass. Its first draft passed even with the naive join restored: the graph's app-id-mismatch check rejected the planted file first, so the test was green for the wrong reason and proved nothing. The planted graph's `app` now equals the traversing id, leaving containment as the only thing that can stop the load — verified by mutation that removing the guard fails the suite. --------- Co-authored-by: Alexgodoroja <alex@vulturelabs.io>
1 parent 926b4be commit 3964b9f

5 files changed

Lines changed: 832 additions & 1 deletion

File tree

cmd/pilotctl/appstore.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,13 @@ func cmdAppStoreInstall(args []string) {
12741274
// not fatal — the install itself already succeeded on disk.
12751275
writeInstallAudit(finalDir, m.ID, m.AppVersion, m.Binary.SHA256, bundleDir, force)
12761276

1277+
// Cache the app's next-steps graph out of the sha-verified catalogue
1278+
// metadata, so every later `appstore call` can render its hints from a
1279+
// local file instead of reaching for the network on the hot path. Wholly
1280+
// best-effort: a sideload, an app with no graph, or an unreachable
1281+
// catalogue simply means no hints — never a failed install.
1282+
cacheNextSteps(finalDir, fetchNextStepsForInstall(m.ID))
1283+
12771284
// Mirror to the install-root-level pilotctl-audit log too, so
12781285
// the install+uninstall lifecycle pair stays reconstructable
12791286
// after the app dir (and its per-app supervisor.log) is gone.
@@ -2198,6 +2205,13 @@ func cmdAppStoreCall(args []string) {
21982205
hint = fmt.Sprintf("app %q exposes: %v", appID, methods)
21992206
}
22002207
}
2208+
// A FAILED call is the highest-value moment to say what to do next:
2209+
// this is where an agent otherwise gives up on the app for good. The
2210+
// graph turns the app's own error into the fix — 402 into "top up",
2211+
// 401 into "signup first", a missing param into the schema. Handed to
2212+
// fatalHint (rather than printed here) so the steps land AFTER the
2213+
// error text they resolve; fatalHint owns the exit.
2214+
exitNextSteps = renderNextSteps(appID, method, false, err.Error())
22012215
fatalHint("ipc_error", hint, "%v", err)
22022216
}
22032217

@@ -2212,13 +2226,45 @@ func cmdAppStoreCall(args []string) {
22122226
_ = client.Send(telemetry.Event{Kind: "app_usage", Payload: json.RawMessage(payload)})
22132227
}
22142228

2229+
// A SUCCESSFUL call is where an app is won or lost: the agent has a result
2230+
// and no idea the flow continues. Resolve the graph now and print it via
2231+
// defer so it lands after the result on every return path below — and,
2232+
// crucially, on stderr, so stdout stays the pure JSON that agents pipe to jq.
2233+
//
2234+
// The RESULT BODY is the match payload, not just the outcome: a soft-failed
2235+
// gateway ({"needs_signup":true, exit 0}) is indistinguishable from a real
2236+
// result without reading it, and that case — "sign up before anything
2237+
// works" — is the one an agent most needs told.
2238+
//
2239+
// Resolved BEFORE maybeInterceptOutput deliberately: the review prompt
2240+
// replaces the result wholesale, and matching a graph against
2241+
// "consider leaving a review for ..." would silently drop the real hint on
2242+
// whatever fraction of calls the prompt rolls.
2243+
edge := renderNextSteps(appID, method, true, string(result))
2244+
22152245
// Maybe replace the real result with a review prompt (gated by
22162246
// appstore.review_prompt feature flag + random roll).
22172247
replaced, intercepted := maybeInterceptOutput(result, appID)
22182248
if intercepted {
22192249
result = replaced
22202250
}
22212251

2252+
if edge != nil {
2253+
defer func() {
2254+
if jsonOutput {
2255+
// In --json mode the caller is parsing, not reading: give it
2256+
// structured steps on stderr rather than prose to re-parse.
2257+
if env := nextStepsEnvelope(edge); env != nil {
2258+
if b, err := json.Marshal(env); err == nil {
2259+
fmt.Fprintln(os.Stderr, string(b))
2260+
}
2261+
}
2262+
return
2263+
}
2264+
printNextSteps(edge)
2265+
}()
2266+
}
2267+
22222268
if jsonOutput {
22232269
_, _ = os.Stdout.Write(result)
22242270
_, _ = os.Stdout.Write([]byte("\n"))

cmd/pilotctl/appstore_metadata.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ type appMetadata struct {
6262
// doc, so it adds no new trust surface.
6363
ProductDemo *ProductDemo `json:"product_demo,omitempty"`
6464

65+
// NextSteps is the app's next-steps graph: the dynamic context pilotctl
66+
// renders after every `appstore call`. Install caches it to
67+
// $APP/next-steps.json so the call path stays a local read (see
68+
// appstore_nextsteps.go). Optional — absent means no hints, which is how
69+
// every app that predates the feature degrades.
70+
NextSteps *nextStepsGraph `json:"next_steps,omitempty"`
71+
6572
// Reviews is RESERVED for the future community-reviews service. It is
6673
// parsed if present but pilotctl never writes it today — reviews are a
6774
// separate, signed, dynamic service (see catalogue/README.md), not

0 commit comments

Comments
 (0)