Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/commands/c1i.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,16 @@ request was sent.
| 3 | not authenticated, or API `401`/`403` |
| 4 | API `404` (not found) |
| 5 | API `429` (rate limited) |
| 6 | C1 failed: API `5xx`, a `200` with a body that isn't JSON, or a redirect loop (`RedirectLoopError`) |
| 6 | C1 failed: API `5xx`, a `200` with a body that isn't JSON, a redirect loop (`RedirectLoopError`), or a stuck pagination cursor (`automations`/`functions` lists, `api --paginate`) |
| 7 | MCP tool call completed but the tool itself reported `isError: true` |
| 8 | a system beyond C1, or the MCP protocol layer, failed (includes an unreachable gateway) |

Typed internally and classified in one place, `cmd/errors.go`: from the client,
`APIError`, `AuthError`, `PathError`, `RedirectError`, `RedirectLoopError`; from
the gateway, `mcpgateway.TransportError` (unreachable); plus package-`cmd`
wrappers `usageError`, `toolExecutionError`, `nonJSONResponseError`,
`upstreamError`, and `helperLeftError`.
`stuckPaginationCursorError` (a guarded list's cursor stopped
advancing), `upstreamError`, and `helperLeftError`.

## Documentation

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ credentials fails instead of using the working directory. With a relative

### Fixed

- **`automations list`, `automations executions list`, `functions list`,
`functions commits`, `functions usage`, and `api --paginate` could loop
forever**, re-printing the same rows: on a large enough result set,
`/api/v1/automations`, `/api/v1/automation_executions`,
`/api/v1/functions`, and `/api/v1/functions/{id}/commits` ignore
`page_size`/`page_token` and return the first page under a fresh
`nextPageToken` every time. These commands now stop when the cursor stops
advancing (the same token twice, or two pages in a row of rows already
seen), keep the rows already printed, warn on stderr that results are
INCOMPLETE, and exit `6`.

- **`--debug` and `--max-retries` did nothing on `docs openapi`,
`docs endpoints` and `docs endpoint`.** The spec fetch now uses the shared
transport, so it traces, refuses a redirect to a different path or host, and
Expand Down
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,11 @@ tracked root file outside its allowlist. Stage explicit paths rather than `-A`.
and `client.RedirectLoopError` (a same-path chain that never settles in 5
hops); `internal/mcpgateway` adds `TransportError`
for an unreachable gateway. `cmd/errors.go` maps them — plus its own
`usageError`, `toolExecutionError`, `nonJSONResponseError`, and
`upstreamError` wrappers, for failures with no natural client-level type,
and `helperLeftError` (a login's undeleted temporary credential, always 1) —
to exit codes:
`usageError`, `toolExecutionError`, `nonJSONResponseError`,
`stuckPaginationCursorError` (a guarded list's cursor stopped advancing),
and `upstreamError` wrappers, for failures with no natural
client-level type, and `helperLeftError` (a login's undeleted temporary
credential, always 1) — to exit codes:

| Code | Meaning |
|------|---------|
Expand All @@ -241,7 +242,7 @@ tracked root file outside its allowlist. Stage explicit paths rather than `-A`.
| `3` | auth (`401`/`403`) |
| `4` | not-found (`404`) |
| `5` | rate-limited (`429`) |
| `6` | C1 failed (API `5xx`, a redirect loop, or a `200` with a non-JSON body) |
| `6` | C1 failed (API `5xx`, a redirect loop, a `200` with a non-JSON body, or a stuck pagination cursor on a guarded list) |
| `7` | tool-execution error (`mcp gateway call` result has `isError: true`) |
| `8` | a system beyond C1 or the protocol layer failed (an unreachable gateway, an upstream connector failure, or a protocol-level JSON-RPC error) |

Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,12 @@ c1i functions usage <function-id> [--page-size N] [--page-token TOKEN] [--limit

`functions source` auto-resolves the function's published commit (falling back to its head/latest draft) and base64-decodes the source files. Without `--out-dir`, each file is printed to stdout with a `// ===== <name> =====` delimiter; with `--out-dir`, files are written to disk. Fetched source is developer-authored code that commonly inlines credentials, so files are written `0600` and the directory is created — or, if it already exists, tightened, never widened — to at most `0700` (owner-only; group access to the directory would buy nothing since the files inside are already unreadable to group/other, and a filename alone can be informative). Any setuid/setgid/sticky bit on the directory is stripped outright, since none of them mean anything once there is no group or other access left. A tightened pre-existing directory prints a warning to stderr naming its old mode. `functions usage` scans every automation and emits one row per step that calls the given function — useful before deleting a draft.

`functions list`, `functions commits`, and `functions usage` (which reads
`/api/v1/automations`) stop if the server's pagination cursor stops
advancing — a repeated `nextPageToken`, or two pages in a row holding only
rows already seen, which these routes can do on large result sets. The command keeps the rows already printed, warns on stderr that
results are INCOMPLETE and names the route, and exits `6`.

### Automations

```sh
Expand All @@ -317,6 +323,9 @@ c1i automations executions list [--state done|error|pending|...] [--template-id

Each `automations list` row includes `function_ids` (every distinct function the automation invokes), so `--calls-function` can answer "which automations call function X?". `executions list --state` accepts the short forms (`done`, `error`, `pending`, ...) or the full `AUTOMATION_EXECUTION_STATE_*` enum; state and template filtering are applied client-side, so pair a narrow filter with `--limit` to bound the work.

`automations list` and `automations executions list` carry the same
stuck-cursor guard described under Functions above.

### Policies

```sh
Expand Down Expand Up @@ -843,7 +852,7 @@ file, or `-` for stdin) — the two are mutually exclusive. GET and DELETE refus
a body by default (a body on either is more likely a mistake than intent); pass
`--allow-delete-body` to lift that for DELETE specifically, for the handful of
C1 endpoints that require one. `--query key=value` and
`--header key=value` are both repeatable. When `--paginate` is used, each page's first array-valued field is unwrapped and each item is emitted as a single line of NDJSON — the same format used by list commands. This covers both the canonical `list` key and typed keys like `automationExecutions`; use `--list-key <field>` to force a specific field. If the server returns the same `nextPageToken` twice in a row, `c1i` aborts with an error rather than looping forever. Without `--paginate`, the full JSON response is pretty-printed — and if that response carries a non-empty `nextPageToken`, `c1i` warns on stderr that the result is partial and names `--paginate`, since a truncated page is otherwise indistinguishable from a complete one at exit 0. stdout is unchanged either way.
`--header key=value` are both repeatable. When `--paginate` is used, each page's first array-valued field is unwrapped and each item is emitted as a single line of NDJSON — the same format used by list commands. This covers both the canonical `list` key and typed keys like `automationExecutions`; use `--list-key <field>` to force a specific field. If the cursor stops advancing — the same `nextPageToken` sent twice, or two pages in a row holding only items already seen (compared by item `id`, else the item's canonical JSON) — `c1i` stops instead of looping, keeps the rows already printed (a single such page is skipped, not re-emitted; id-less items that legitimately repeat across pages can trip this), warns on stderr that results are INCOMPLETE and names the route, and exits `6`. Without `--paginate`, the full JSON response is pretty-printed — and if that response carries a non-empty `nextPageToken`, `c1i` warns on stderr that the result is partial and names `--paginate`, since a truncated page is otherwise indistinguishable from a complete one at exit 0. stdout is unchanged either way.

### API Discovery & Documentation

Expand Down Expand Up @@ -1010,7 +1019,7 @@ branch on without parsing text:
| `3` | not authenticated, or API returned `401`/`403` |
| `4` | API returned `404` (not found) |
| `5` | API returned `429` (rate limited — back off and retry) |
| `6` | C1 failed: the API returned `5xx`, a redirect chain never settled, or it answered `200` with a body that isn't JSON |
| `6` | C1 failed: the API returned `5xx`, a redirect chain never settled, it answered `200` with a body that isn't JSON, or a guarded list command's pagination cursor stopped advancing |
| `7` | `mcp gateway call` completed, but the tool itself reported an error (`isError: true` in its result) |
| `8` | a system beyond C1, or the MCP protocol layer, failed — an upstream connector was unreachable or errored, the gateway itself was unreachable (DNS failure, refused connection), or the gateway returned a protocol-level JSON-RPC error |

Expand Down
2 changes: 1 addition & 1 deletion cmd/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ APP_ID=$(c1i apps create --display-name "Example" | jq -r '.app.id')
| 3 | not authenticated, or API `401`/`403` | re-authenticate (`c1i auth login`); a scoped credential needs a role that covers the call |
| 4 | API `404` | stop — the resource/path doesn't exist |
| 5 | API `429` | back off and retry |
| 6 | C1 failed: API `5xx`, a `200` with a body that isn't JSON, or a redirect chain that never settles | retryable, not your fault |
| 6 | C1 failed: API `5xx`, a `200` with a body that isn't JSON, a redirect chain that never settles, or a stuck pagination cursor (`automations`/`functions` lists, `api --paginate`) | retryable, except a stuck cursor, which persists: the printed rows are real but incomplete |
| 7 | `mcp gateway call` succeeded but the tool reported `isError: true` | inspect the printed result, not just the code |
| 8 | a system beyond C1, or the protocol layer, failed — including a gateway that is unreachable (DNS failure, refused connection) | retrying the same call usually repeats it. For a connector failure, check the server with `mcp servers get <connector-id> --app-id <id>` and `mcp servers test-connection`; the upstream may be unreachable or its credentials expired. For a protocol error, it's a version mismatch or a c1i bug — report it, don't work around it |

Expand Down
51 changes: 43 additions & 8 deletions cmd/api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -167,10 +168,14 @@ an intended non-GET REST write.`,
}

enc := newEmitter(cmd)
guard := newPaginationCursorGuard()
pageToken := ""
prevToken := ""
route := fmt.Sprintf("%s %s", method, path)

for !limitReached(enc.Written(), limit) {
if guard.tokenAlreadyUsed(pageToken) {
return warnStuckCursor(cmd, route)
}
reqPath := path
var bodyBytes []byte
switch method {
Expand Down Expand Up @@ -236,7 +241,7 @@ an intended non-GET REST write.`,
// Deliberately says "partial" rather than "the first page":
// --query page_token=X makes this page N. And --paginate is
// offered, not promised — on an endpoint whose cursor never
// advances it aborts (see the prevToken guard below), which is
// advances it aborts (see paginationCursorGuard), which is
// still a clear failure rather than this silent one.
if tok := nextPageTokenOf(data); tok != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
Expand All @@ -250,6 +255,18 @@ an intended non-GET REST write.`,
return fmt.Errorf("failed to parse response: %w", err)
}

ids := make([]string, len(items))
for i, item := range items {
ids[i] = itemIdentity(item)
}
emit, stuck := guard.checkPage(ids)
if stuck {
return warnStuckCursor(cmd, route)
}
if !emit {
items = nil
}

for _, item := range items {
// Encode the raw item, not a decoded any: decoding to any turns
// JSON numbers into float64 and corrupts large integer IDs. The
Expand All @@ -266,12 +283,7 @@ an intended non-GET REST write.`,
if nextToken == "" {
break
}
// Guard against servers that return the same token forever.
// Without this, c1i would loop indefinitely emitting no progress.
if nextToken == prevToken {
return fmt.Errorf("API returned the same nextPageToken twice in a row for %s; the endpoint may not support pagination via the cursor c1i is sending. Drop --paginate and call the endpoint directly, or report this with the path", path)
}
prevToken = nextToken
guard.markTokenUsed(pageToken)
pageToken = nextToken
}

Expand Down Expand Up @@ -414,3 +426,26 @@ func extractListAndToken(data []byte, listKey string) ([]json.RawMessage, string

return nil, nextPageToken, nil
}

// itemIdentity identifies a list item for the stuck-cursor guard: its string
// "id" when present, else its canonical JSON (re-marshaling sorts keys;
// UseNumber keeps large integers distinct).
func itemIdentity(item json.RawMessage) string {
var withID struct {
ID string `json:"id"`
}
if err := json.Unmarshal(item, &withID); err == nil && withID.ID != "" {
return withID.ID
}
dec := json.NewDecoder(bytes.NewReader(item))
dec.UseNumber()
var decoded any
if err := dec.Decode(&decoded); err != nil {
return string(item)
}
canon, err := json.Marshal(decoded)
if err != nil {
return string(item)
}
return string(canon)
}
Loading
Loading