api

package
v0.26.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package api is a thin HTTP client for Ronja's /api/v2 surface.

It is deliberately hand-rolled rather than generated from the OpenAPI spec: the CLI touches a handful of endpoints, and a generated client would drag in a code-generation step and a dependency for no benefit at this size.

Index

Constants

View Source
const (
	ErrAuthorizationPending = "authorization_pending"
	ErrSlowDown             = "slow_down"
	ErrExpiredToken         = "expired_token"
	ErrAccessDenied         = "access_denied"
	ErrInvalidGrant         = "invalid_grant"
)

The device-authorization flow. Mirrors backend/api/v2/authentication/ api_cli_auth.go — the poll error codes are a wire contract, so they are matched by exact string.

View Source
const (
	DefaultMinPollInterval = 5 * time.Second
	DefaultPollBackoff     = 2 * time.Second
	DefaultMaxPollInterval = 20 * time.Second
	// DefaultMaxPollFailureWindow bounds an UNBROKEN RUN of transient poll
	// failures (a dropped connection, a 429, a 5xx from a pod restarting
	// mid-deploy) by ELAPSED TIME rather than by a count.
	//
	// A count was the wrong unit: it silently means "count x interval", so the
	// real tolerance moved whenever the cadence did. Five retries at the 5s
	// floor is ~25 seconds, which is less than an ordinary rolling deploy takes
	// — a login would die on a perfectly healthy instance mid-release. Elapsed
	// time says what is actually meant, and stays honest under backoff.
	//
	// Generous is nearly free here: a login has ~10 minutes of life, and the
	// flow deadline bounds total wall time independently of this. What this
	// still rules out is the case it exists for — a permanently broken instance
	// hidden behind a silent loop for the full ten minutes.
	DefaultMaxPollFailureWindow = 90 * time.Second
	// DefaultRateLimitBackoff is added to the interval on a 429, which is the
	// global rate limiter answering. It is the one transient failure where
	// retrying at the same cadence is actively wrong: the server is saying
	// there is too much traffic, and an unchanged cadence is a promise to keep
	// producing exactly as much.
	DefaultRateLimitBackoff = 5 * time.Second
)

Default device-flow poll timing. The server advertises its own interval and enforces it, so these are a floor, a backoff step and a ceiling rather than the primary cadence.

View Source
const (
	// MigrationApplied — the DDL ran and a ledger row was written.
	MigrationApplied = "applied"
	// MigrationSkipped — already in the ledger with identical SQL.
	MigrationSkipped = "skipped"
	// MigrationPending — not in the ledger; a real apply would apply it.
	MigrationPending = "pending"
	// MigrationDrifted — in the ledger under DIFFERENT SQL. A real apply
	// refuses the whole set.
	MigrationDrifted = "drifted"
)

Migration outcomes, as reported per migration. The first two are what a real apply returns; the last two only ever come back from a dry run.

View Source
const (
	LifecycleLive     = "live"
	LifecycleDraft    = "draft"
	LifecycleVersion  = "version"
	LifecycleArchived = "archived"
	LifecycleProposed = "proposed"
)

Workflow lifecycle values, mirrored from rdb.WorkflowLifecycle (backend/ronja/rdb/table_workflow.go). The CLI branches on these by exact string, so they are a wire contract in the same sense the device-flow poll codes are.

View Source
const (
	RunStatusRunning = "running"
	RunStatusDone    = "done"
	RunStatusError   = "error"
)

Workflow run statuses, mirrored from rdb.WorkflowRunStatus (backend/ronja/rdb/table_workflow.go:78). The enum is exactly these three, and the poll loop's terminal condition is "not running", so a status added server-side would end the poll rather than hang it.

View Source
const (
	RunHealthDone     = "done"
	RunHealthDegraded = "degraded"
	RunHealthFailed   = "failed"
)

Derived run health, mirrored from workflow.RunHealth (backend/api/v2/workflow/run_steps.go). Never stored: the server computes it on read from the run status plus the step rows. Degraded is the one worth knowing about — a run that finished but had a best-effort step fail, which a bare "done" would hide.

View Source
const DefaultDatabaseSQLTimeout = slowRequestTimeout

DefaultDatabaseSQLTimeout bounds one `db sql` call when the caller names no deadline.

The slow bound, matching Query: a statement against a real Postgres database is real work, and an aggregate over a large table legitimately outlasts a read timeout. It remains only a DEFAULT — --timeout moves it, and a value past the client's own ceiling is honoured (see httpFor).

View Source
const DefaultMigrationTimeout = 20 * time.Minute

DefaultMigrationTimeout bounds one migration set.

Deliberately far longer than a query default. The server runs the whole set in ONE transaction holding a per-database advisory lock, under a 20-minute ceiling of its own; a client that gave up sooner would abandon a migration that is still committing and leave the operator with no idea whether it landed. Better to wait for the answer than to guess at it.

View Source
const DefaultQueryTimeout = slowRequestTimeout

DefaultQueryTimeout bounds one query when the caller names no deadline.

The slow bound rather than the read bound, for the usual reason — a query is real work server-side. It is only a DEFAULT, though: a large query legitimately routes to Batch compute and takes minutes, so `ronja query --timeout` can move it, and Query honours a value past the client's own ceiling. See httpFor.

View Source
const DefaultRawTimeout = slowRequestTimeout

DefaultRawTimeout bounds one raw call when the caller names no deadline.

The slow bound rather than the read bound: an arbitrary POST can be anything, including the endpoints that legitimately do real work server-side, and killing one of those at 30s reports a healthy instance as a broken one.

It is only a DEFAULT, for the same reason DefaultQueryTimeout is: `ronja api` can be pointed at an endpoint that runs for minutes, and a bound nobody can move is a bound that eventually kills a healthy request. `--timeout` moves it, including past the client's own ceiling — see httpFor.

View Source
const QueryFormatCSVMeta = "csv_meta"

QueryFormatCSVMeta asks for the envelope the CLI is built around: a CSV string plus the row count, the truncation flag and the failure field.

The CLI always sends it. The endpoint has other formats, and picking between them would be a flag nobody can answer — CSV is the one shape that is both human-legible at a terminal and directly consumable by every data tool, and the "meta" half is what makes truncation and a failed query distinguishable from an empty result.

View Source
const SeverityError = "error"

SeverityError is mirrored from rworkflow's SeverityError. The CLI branches on it by exact string — an error finding is what makes `wf push` refuse — so it is a wire contract.

The server's finding CODES (invalid_path, unresolved_ref, secret_dropped, …) are deliberately not mirrored as constants. The CLI never branches on a code: it prints Message and Path, so an unknown code is displayed as faithfully as a known one, and a mirrored list would only be one more thing to keep in step with backend/resource/rworkflow/validate_files.go.

View Source
const UserAgent = "ronja-cli"

UserAgent identifies the CLI in server logs.

Variables

View Source
var (
	ErrDeviceDenied  = errors.New("the request was declined in the browser")
	ErrDeviceExpired = errors.New("the code expired before it was approved")
)

ErrDeviceDenied and ErrDeviceExpired are the terminal outcomes a caller is expected to explain to a human, as opposed to genuine failures.

Functions

func CodeOf

func CodeOf(err error) string

CodeOf extracts the server's machine-readable error code, or "" if the error was not an HTTP error.

func DescribeLifecycle

func DescribeLifecycle(lifecycle string) string

DescribeLifecycle renders a lifecycle value for a human, including the ones the CLI refuses to work with.

func IsTimeout

func IsTimeout(err error) bool

IsTimeout reports a request that died on a DEADLINE rather than on an answer.

The distinction is what makes a write recoverable: a 4xx means the server considered the request and refused it, while a timeout means we stopped listening — the write may well have committed, and the caller has to go and look rather than assume either way.

Both forms are checked because they arrive differently: a per-request context deadline surfaces as context.DeadlineExceeded, while the http.Client's own Timeout surfaces as a *url.Error that merely reports Timeout() — and which of the two fired is not something a caller should have to reason about.

func SameAccess

func SameAccess(a, b DataAppAccess) bool

SameAccess compares two access sets for sync purposes, order-insensitively.

func StatusOf

func StatusOf(err error) int

StatusOf extracts the HTTP status, or 0 if the error was not an HTTP error.

Types

type Client

type Client struct {
	BaseURL string
	Token   string
	HTTP    *http.Client

	// MinPollInterval floors the cadence PollDevice uses, so a server that
	// advertises nothing (or zero) still gets polled politely.
	MinPollInterval time.Duration
	// PollBackoff is added to the interval each time the server answers
	// slow_down.
	PollBackoff time.Duration
	// MaxPollInterval caps that backoff, so a run of slow_down answers cannot
	// stretch the cadence past the point where the flow expires unpolled.
	MaxPollInterval time.Duration
	// MaxPollFailureWindow bounds an unbroken run of transient failures by
	// elapsed time before PollDevice gives up. See isTransient.
	MaxPollFailureWindow time.Duration
	// RateLimitBackoff is added to the interval on a 429.
	RateLimitBackoff time.Duration
}

func New

func New(baseURL, token string) *Client

func (*Client) ApplyMigrations

func (c *Client) ApplyMigrations(ctx context.Context, databaseID string, in MigrationSetInput, timeout time.Duration) (*MigrationSetResult, error)

ApplyMigrations applies (or, with DryRun, plans) an ordered migration set.

func (*Client) CheckoutDataApp

func (c *Client) CheckoutDataApp(ctx context.Context, id string, patch DataAppPatch) (*DataApp, error)

CheckoutDataApp forks a live data app into the caller's own draft, optionally overlaying a metadata + allowlist patch in the same call.

Idempotent on the draft: a caller who already has one gets it back. The overlay is what makes a first push one request instead of two, and the server re-checks every bound id in it against the caller's access.

func (*Client) CheckoutWorkflow

func (c *Client) CheckoutWorkflow(ctx context.Context, id string) (*Workflow, error)

CheckoutWorkflow gets-or-creates the caller's own draft of a live workflow. Idempotent: calling it twice returns the same draft.

func (*Client) CommitDataAppDraft

func (c *Client) CommitDataAppDraft(ctx context.Context, draftID string) (*DataAppCommitOutcome, error)

CommitDataAppDraft applies a draft onto its parent — or, for a never-published app the caller may not publish itself, submits it for an admin's approval. Takes the DRAFT id. Read the returned outcome before reporting a publish.

func (*Client) CommitWorkflowDraft

func (c *Client) CommitWorkflowDraft(ctx context.Context, draftID string) error

CommitWorkflowDraft applies a draft onto its parent. Takes the DRAFT id.

func (*Client) CreateDataApp

func (c *Client) CreateDataApp(ctx context.Context, in CreateDataAppInput) (*DataApp, error)

CreateDataApp creates a data app inside a feature. The row it returns is LIVE and empty — see CreateDataAppInput.

func (*Client) CreateWorkflow

func (c *Client) CreateWorkflow(ctx context.Context, in CreateWorkflowInput) (*Workflow, error)

CreateWorkflow creates a workflow inside a feature. The row it returns is a parentless DRAFT — hidden until published — which is why the first push needs no separate checkout.

func (*Client) DatabaseSQL

func (c *Client) DatabaseSQL(ctx context.Context, databaseID string, in DatabaseSQLInput, timeout time.Duration) (*DatabaseSQLResult, error)

DatabaseSQL runs one statement against a managed database.

func (*Client) DeleteDataApp

func (c *Client) DeleteDataApp(ctx context.Context, id string) error

DeleteDataApp marks a data app for deletion. Unlike DiscardDataAppDraft this removes the APP, so it is only ever reached through an explicit opt-in.

The one case the CLI needs it for is a parentless draft — an app created by a first push and then abandoned, typically because that push failed to compile. Discard cannot express it (there is no parent for the row to fall back to), and without this the only remaining answer is the web UI, which is no answer at all for a caller that has nothing but a terminal.

func (*Client) DeleteDataAppFile

func (c *Client) DeleteDataAppFile(ctx context.Context, id, path string) (*DataAppFileDeleteResponse, error)

DeleteDataAppFile removes one file. The server refuses to delete the entrypoint; its message is passed through rather than second-guessed.

func (*Client) DeleteWorkflow

func (c *Client) DeleteWorkflow(ctx context.Context, id string) error

DeleteWorkflow soft-deletes a workflow — the row survives a 30-day TTL before the janitor purges it. Unlike DiscardWorkflowDraft this removes the WORKFLOW, so it is only ever reached through an explicit opt-in.

The one case the CLI needs it for is a parentless draft — a workflow created by a first push and then abandoned. Discard cannot express it (there is no parent for the row to fall back to), and without this the only remaining answer is the web UI, which is no answer at all for a caller that has nothing but a terminal.

func (*Client) DeleteWorkflowFile

func (c *Client) DeleteWorkflowFile(ctx context.Context, id, path string) (*FileDeleteResponse, error)

DeleteWorkflowFile removes one file from a draft. The server refuses to delete the entrypoint; its message is passed through rather than second- guessed, because the remedy (change the entrypoint first) is its to state.

func (*Client) DiscardDataAppDraft

func (c *Client) DiscardDataAppDraft(ctx context.Context, draftID string) error

DiscardDataAppDraft deletes a draft, throwing its uncommitted changes away. The parent live app is untouched.

func (*Client) DiscardWorkflowDraft

func (c *Client) DiscardWorkflowDraft(ctx context.Context, draftID string) error

DiscardWorkflowDraft deletes a draft, throwing its uncommitted changes away. The parent live workflow is untouched.

func (*Client) Do

func (c *Client) Do(ctx context.Context, method, path string, body, out any) error

Do issues a request against /api/v2 and decodes a JSON response into out. Pass a nil body to send none, and a nil out to discard the response.

func (*Client) DoRaw

func (c *Client) DoRaw(ctx context.Context, method, path string, header http.Header, body []byte, timeout time.Duration) (*RawResponse, error)

DoRaw issues an arbitrary request against a path relative to the instance root and returns the response without reading it.

header is applied verbatim; the defaults below fill in only what it does not already carry, so a caller can override any of them — including Authorization, which is how someone tests a different credential without re-logging-in.

timeout bounds the whole call, the response body INCLUDED — the deadline rides on Body.Close, not on this function's return. Zero or less means no client-side deadline at all, which the command surfaces as `--timeout 0`.

func (*Client) GetDataApp

func (c *Client) GetDataApp(ctx context.Context, id string) (*DataApp, error)

GetDataApp reads a data app's metadata. 404s for an id the caller cannot reach — the read gate does not distinguish absent from invisible.

func (*Client) GetDataAppDraft

func (c *Client) GetDataAppDraft(ctx context.Context, id string) (*DataApp, error)

GetDataAppDraft reads the CALLER'S OWN open draft of a data app, returning (nil, nil) when they have none.

The endpoint answers 200 with a JSON `null` body rather than 404 in that case, so decoding into a **DataApp is not a stylistic choice: decoding into a struct would turn "no draft" into a zero-value DataApp with an empty id, and every caller would then act on a draft that does not exist.

func (*Client) GetDataAppFile

func (c *Client) GetDataAppFile(ctx context.Context, id, path string) (*DataAppFile, error)

GetDataAppFile reads one file back by path, answering 404 when the row holds none.

It lives on the write side because it exists for the write side: a PUT that died on a TIMEOUT may still have committed, and the only way to find out which is to ask. Note that it is subject to the same silent draft resolution as ListDataAppFiles — pass the id you mean.

func (*Client) GetFeature

func (c *Client) GetFeature(ctx context.Context, id string) (*Feature, error)

GetFeature reads a feature's metadata. 404s for an id the caller cannot reach.

func (*Client) GetTable

func (c *Client) GetTable(ctx context.Context, id string) (*Table, error)

GetTable reads a table's identity so an id can be shown with its name.

Best-effort by contract: the caller is expected to fall back to printing the bare id. A token scoped away from the data surface, or a table the caller cannot read, fails here — and neither is a reason to refuse to tell someone which tables a run would overwrite.

func (*Client) GetText

func (c *Client) GetText(ctx context.Context, path string) (string, error)

GetText fetches a path relative to the instance ROOT (not /api/v2) and returns it verbatim. The agent-readable docs surface — /llms.txt, /docs/api/endpoints.md, /docs/api/skills.md — lives there and is served unauthenticated, so this deliberately does not require a token.

func (*Client) GetWorkflow

func (c *Client) GetWorkflow(ctx context.Context, id string) (*Workflow, error)

GetWorkflow reads a workflow's metadata. 404s for an id the caller cannot reach — the read gate does not distinguish absent from invisible.

func (*Client) GetWorkflowDraft

func (c *Client) GetWorkflowDraft(ctx context.Context, id string) (*Workflow, error)

GetWorkflowDraft reads the CALLER'S OWN open draft of a workflow, returning (nil, nil) when they have none.

The endpoint answers 200 with a JSON `null` body rather than 404 in that case (see the route's own description in backend/api/v2/workflow/handler.go), so decoding into a **Workflow is not a stylistic choice: decoding into a struct would turn "no draft" into a zero-value Workflow with an empty id, and every caller would then act on a draft that does not exist.

func (*Client) GetWorkflowFile

func (c *Client) GetWorkflowFile(ctx context.Context, id, path string) (*WorkflowFile, error)

GetWorkflowFile reads one file back by path, answering 404 when the row holds none.

It lives on the write side because it exists for the write side: a PUT that died on a TIMEOUT may still have committed, and the only way to find out which is to ask. Nothing else reads a single file — the rest of the CLI wants the whole set — so this is a repair tool, not a read API.

func (*Client) GetWorkflowRun

func (c *Client) GetWorkflowRun(ctx context.Context, runID string) (*RunResponse, error)

GetWorkflowRun reads one run: the row, its steps and its derived health.

Note the route is /workflow/run/:runID — keyed by the RUN, not nested under the workflow — so polling needs nothing but the id the run started with.

func (*Client) ListDataAppFiles

func (c *Client) ListDataAppFiles(ctx context.Context, id string) ([]DataAppFile, error)

ListDataAppFiles reads every file of ONE data-app row.

⚠️ The id you pass is NOT necessarily the row you get. Unlike the workflow equivalent, GET /dataapp/:id/files SILENTLY RESOLVES TO THE CALLER'S OWN OPEN DRAFT when one exists (api/v2/dataapp/handler.go), and nothing in the response says which row answered — the files carry a dataAppID, but a caller that did not think to look will never notice.

That matters because a baseline is a claim about a specific row. Recording draft bytes under the live app's identity makes `status` compare the wrong two things: it reports drift that is not there, or worse hides drift that is, and a push then acts on the difference. So every caller in this CLI resolves the draft explicitly with GetDataAppDraft and passes the id it actually means — never a live id in the hope that the server picks the same row it would have.

func (*Client) ListWorkflowFiles

func (c *Client) ListWorkflowFiles(ctx context.Context, id string) ([]WorkflowFile, error)

ListWorkflowFiles reads every file of ONE workflow row — note that a draft has its own complete file set, so the id passed here decides whether you get the live code or the draft's.

The route returns a bare JSON array ([]*rdb.WorkflowFile), not an envelope.

func (*Client) Me

func (c *Client) Me(ctx context.Context) (*Me, error)

Me identifies the caller. It is the CLI's login verification: the endpoint returns the user, and their tenant and role when one is selected.

Note that /api/v2/authentication is admin-scoped, so a SCOPED token cannot call it — which is why `login` mints a full-access Personal Access Token. A PAT still never exceeds its user's live role.

func (*Client) PollDevice

func (c *Client) PollDevice(ctx context.Context, start *DeviceStart) (*DeviceToken, error)

PollDevice waits for a human to approve the flow, then returns the minted token. It blocks until approval, denial, expiry, or ctx cancellation.

The server's advertised interval is the starting cadence; a slow_down response backs it off, because the server enforces the throttle and an over-eager client would otherwise never make progress.

TRANSIENT failures are tolerated rather than fatal, and the tolerance is measured in ELAPSED TIME (MaxPollFailureWindow), not in retries. A login has a ~10 minute budget during which a human is walking to a browser, and in that window a rolling deploy, a 429 from the global rate limiter or a dropped connection are all entirely ordinary. Aborting on the first one would burn a flow the user then has to restart for no reason — and a retry COUNT quietly made the real tolerance a function of the cadence, so it came out shorter than an ordinary deploy takes. The RFC-defined outcomes (access_denied, expired_token, invalid_grant) are still terminal — those are the server's considered answer, not a blip. The deadline remains the backstop so this always terminates.

func (*Client) PublishWorkflowDraft

func (c *Client) PublishWorkflowDraft(ctx context.Context, draftID string) error

PublishWorkflowDraft takes a PARENTLESS draft live. Takes the draft's own id, which for a parentless draft is also the workflow's stable identity.

func (*Client) PutDataAppFile

func (c *Client) PutDataAppFile(ctx context.Context, id, path, content string) (*DataAppFileSaveResponse, error)

PutDataAppFile creates or replaces one file, returning the saved row plus the recompile's diagnostics when it failed. See DataAppFileSaveResponse.

SLOW: every write recompiles the whole bundle with esbuild and uploads a fresh bundle to S3, so this is seconds of real work rather than a row write.

func (*Client) PutWorkflowFile

func (c *Client) PutWorkflowFile(ctx context.Context, id, path, content string) (*FileSaveResponse, error)

PutWorkflowFile creates or replaces one file.

This is the hard-validating path: unreachable `{{ ref }}` / `{{ write }}` / `{{ agent }}` / `{{ codex }}` markers roll the write back with a 400, and the file keeps its previous content. Secrets are the soft tier and come back as Warnings instead.

The other SLOW call: every save re-derives the workflow's binding columns in the same transaction, so a file write is a row write with marker resolution attached — not something to hold to a read's deadline.

func (*Client) Query

func (c *Client) Query(ctx context.Context, in QueryInput, timeout time.Duration) (*QueryResult, error)

Query runs one SQL statement and returns the csv_meta envelope.

timeout bounds the request. Zero or less means no client-side deadline at all, which the command surfaces as `--timeout 0` — a query that has been routed to Batch compute can legitimately outlast any number anyone would think to type, and being told to go and look for a result that is still coming is not an improvement on waiting for it.

func (*Client) RequestDataAppReview

func (c *Client) RequestDataAppReview(ctx context.Context, draftID string) error

RequestDataAppReview submits the caller's own edit draft for admin review.

Unlike the workflow equivalent this lives on the data-app handler itself rather than the governance one, so the path has no /draft/ segment.

func (*Client) RequestWorkflowReview

func (c *Client) RequestWorkflowReview(ctx context.Context, draftID string) error

RequestWorkflowReview submits the caller's own edit draft for admin review.

NOTE the different prefix: this route lives on the GOVERNANCE handler, whose group is scoped `admin` rather than the core workflow handler's `automation`. The CLI's full-access PAT covers both; a scoped token would half-work, which is why the failure is reported as-is rather than swallowed.

func (*Client) RunWorkflow

func (c *Client) RunWorkflow(ctx context.Context, workflowID string, parameterValues map[string]any) (*WorkflowRun, error)

RunWorkflow starts a run and returns IMMEDIATELY with the created run row at status "running" — execution is asynchronous, and GetWorkflowRun is how you learn what happened.

Two refusals arrive here as 400s with the server's own message: an approval gate (which no HTTP caller can satisfy — only an agent session produces an approval) and the per-tenant credit kill-stop. Both are passed through rather than paraphrased.

parameterValues is always sent, as {} when there are none. NOT `omitempty`: that omits the key, which is the null map it is meant to avoid — the server decodes a missing key to a nil map exactly as it does an explicit null.

func (*Client) StartDevice

func (c *Client) StartDevice(ctx context.Context, clientName string) (*DeviceStart, error)

StartDevice opens a device-authorization flow. No authentication required.

func (*Client) UpdateDataApp

func (c *Client) UpdateDataApp(ctx context.Context, id string, patch DataAppPatch) (*DataApp, error)

UpdateDataApp patches a data app's metadata and allowlists.

Returns the row that RECEIVED the edit, which is not necessarily the one addressed: patching a live app auto-forks a draft, exactly as a file write does. Callers that care which row they changed must read the returned id.

func (*Client) UpdateWorkflow

func (c *Client) UpdateWorkflow(ctx context.Context, id string, patch WorkflowPatch) error

UpdateWorkflow patches a workflow's metadata.

The endpoint answers with nothing, and it DROPS the save-time warnings the file path returns (the server's own comment says to plumb a new endpoint rather than overload this one) — so a caller that needs the row's current state re-reads it afterwards.

func (*Client) ValidateDataApp

func (c *Client) ValidateDataApp(ctx context.Context, draftID string) (*DataApp, error)

ValidateDataApp recompiles a draft and stamps it validated, which CommitDataAppDraft refuses without. Takes the DRAFT id.

This is the authoritative answer to "does this compile": it always recompiles from the row's current files, so a draft left un-validated by a failed file write is re-checked here rather than trusted.

SLOW for the same reason PutDataAppFile is.

func (*Client) ValidateWorkflowFiles

func (c *Client) ValidateWorkflowFiles(ctx context.Context, in ValidateInput) (*ValidateResult, error)

ValidateWorkflowFiles dry-runs a candidate workflow. Findings are data: a candidate full of errors still comes back as a successful call, and only a malformed request (no featureID, a feature the caller cannot read) is an error here.

One of the two SLOW calls: the whole folder goes up in one body and every marker in it is resolved against the caller's access, so this is doing real work rather than reading a row.

type CompileDiagnostic

type CompileDiagnostic struct {
	Message string `json:"message"`
	Line    int    `json:"line"`
	Column  int    `json:"column"`
	File    string `json:"file,omitempty"`
}

CompileDiagnostic is one author-readable build error, mirroring dataappbundle.Diagnostic.

type CompileError

type CompileError struct {
	Message     string              `json:"message"`
	Diagnostics []CompileDiagnostic `json:"diagnostics"`
}

CompileError is a failed bundle compile, mirroring the api/v2/dataapp CompileDiagnostics response type.

type CreateDataAppInput

type CreateDataAppInput struct {
	FeatureID   string `json:"featureID"`
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`

	DataAppAccess
}

CreateDataAppInput is the body of POST /dataapp, mirroring the subset of rdataapp.CreateInput the CLI writes.

What this call PRODUCES matches the workflow equivalent as of migration 000495: a hidden PARENTLESS DRAFT, visible only to its drafter, which the first commit promotes to live IN PLACE — so the id this returns is the app's id forever and is safe to record in ronja.json before any publish. (Before 000495 the row was born LIVE and empty, serving the pending page to the whole feature until someone published it.)

The allowlists are sent at creation because a data app with none can query nothing, and the alternative (create, then patch) leaves a window where the app exists in a state its author never asked for.

type CreateWorkflowInput

type CreateWorkflowInput struct {
	FeatureID  string `json:"featureID"`
	Title      string `json:"title"`
	Entrypoint string `json:"entrypoint"`
	// Parameters is the declared parameter set, when the folder manages one.
	// omitempty because a folder that does not manage parameters must create a
	// workflow with none rather than assert an empty declaration.
	Parameters []WorkflowParameter `json:"parameters,omitempty"`
}

CreateWorkflowInput is the body of POST /workflow.

The server's input type is the whole rdb.Workflow, and this deliberately is not: everything else on that row is either derived (the binding columns are stamped from the code at file save), stamped by the server (drafter attribution is scrubbed from HTTP input on purpose), or not the CLI's to set. Sending three fields makes it obvious that a first push creates an EMPTY workflow whose bindings then come from its files.

type DataApp

type DataApp struct {
	ID        string `json:"id"`
	FeatureID string `json:"featureID"`

	Name        string `json:"name"`
	Description string `json:"description"`
	// Entrypoint names the file the bundle compiles from. Reported, never
	// pushed: rdataapp.Patch has no entrypoint field, so a data app's is fixed
	// at "App.tsx" from the moment the row is created.
	Entrypoint string `json:"entrypoint"`
	// BundleFileKey is the compiled bundle in S3, empty on an app that has never
	// compiled. Read as a FACT ABOUT THE PAST only — a failed compile leaves the
	// previous key in place, so a non-empty value never means "the current source
	// compiles". ValidatedAt is the field that answers that.
	BundleFileKey string `json:"bundleFileKey"`
	// ValidatedAt is cleared by every file mutation and re-stamped only by a
	// clean compile. It is the single gate POST :id/commit enforces, so it is
	// also what `ronja app publish` checks before it tries.
	ValidatedAt *time.Time `json:"validatedAt"`

	DataAppAccess

	// ParentDataAppID is empty on a live row AND on a parentless draft (an app
	// created by POST /dataapp and not yet published, since migration 000495),
	// and set on an edit shadow or a committed version snapshot. So it — not
	// the lifecycle, which reads "draft" for both — is what tells a never-
	// published app apart from somebody's open edits to a live one.
	ParentDataAppID string     `json:"parentDataAppID"`
	CommittedAt     *time.Time `json:"committedAt"`

	// Lifecycle is trigger-computed server-side: live|draft|version|archived|proposed.
	Lifecycle string `json:"lifecycle"`
	// SubmittedForReviewAt is set once a drafter asks an admin to commit their
	// draft; null on every other row, and on a draft they have not submitted.
	SubmittedForReviewAt *time.Time `json:"submittedForReviewAt"`
	DrafterUserID        string     `json:"drafterUserID"`

	CreatedBy string    `json:"createdBy"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

DataApp is a HAND-MIRROR of the subset of rdb.DataApp the CLI reads.

Same rule and same hazard as Workflow in workflow.go: a json tag that does not exist on the wire decodes to the zero value SILENTLY, so a typo here shows up as an empty column of output — or as an allowlist the CLI believes is empty and then pushes as empty, revoking the app's access to its own tables. Every tag below is copied from backend/ronja/rdb/table_data_app.go; keep them in step with that file, and see dataapp_test.go for the fixture that checks them.

The lifecycle vocabulary is shared with workflows (rdb.DataAppLifecycle carries the same five values as rdb.WorkflowLifecycle), so the Lifecycle* constants and DescribeLifecycle are reused rather than duplicated.

Mirroring decisions specific to this row:

  • The server's optional.V[T] marshals as the bare value or JSON `null`, and unmarshalling `null` into a non-pointer is a documented no-op — so optional STRINGS are plain strings ("" is exactly what "unset" means for an id) while optional TIMESTAMPS are pointers, because "has this been validated at all" is the question the whole publish path turns on.
  • The allowlist slices carry NO omitempty server-side (they are plain []string columns), so they arrive as [] rather than absent. Normalize still runs, because a nil from a hand-written fixture or an older instance would otherwise range differently from an empty one.

func (*DataApp) IdentityID

func (d *DataApp) IdentityID() string

IdentityID is the STABLE id of the data app this row belongs to: the parent for a draft or a version snapshot, the row itself for a live app.

This is what a manifest binding records. A draft's own id is per-user and disappears on commit, so a committed file naming one would point at a row that stops existing the moment its author publishes.

func (*DataApp) IsValidated

func (d *DataApp) IsValidated() bool

IsValidated reports whether the row's current source has compiled cleanly since its last file edit — the gate POST :id/commit enforces.

func (*DataApp) Normalize

func (d *DataApp) Normalize()

Normalize replaces nil allowlists with empty ones.

type DataAppAccess

type DataAppAccess struct {
	AllowedTableIDs    []string `json:"allowedTableIDs"`
	AllowedSecretIDs   []string `json:"allowedSecretIDs"`
	AllowedAgentIDs    []string `json:"allowedAgentIDs"`
	AllowedWorkflowIDs []string `json:"allowedWorkflowIDs"`
	AllowedCodexIDs    []string `json:"allowedCodexIDs"`
	AllowedMetricIDs   []string `json:"allowedMetricIDs"`
	// Capabilities are the coarse switches (query_ronja, run_agent, …) the
	// minted script-token carries alongside the id allowlists.
	Capabilities []string `json:"capabilities"`
}

DataAppAccess is a data app's capability surface: what its bundle may reach.

One type serving three roles on purpose — it is embedded in the row, it is the manifest's `access` block, and it is what the patch sends. A second struct would only add a conversion that silently drops whatever field was added to one side and not the other, and this is the type where a dropped field means a REVOKED capability rather than a cosmetic gap.

Its json tags are therefore part of the ronja.json FILE FORMAT as well as the wire, and renaming one is a breaking change to committed manifests.

These are grants, not descriptions: an id here is a capability the app's viewers inherit, which is why the server re-checks every one of them against the pusher's own access (governance.ValidateDataAppScope) rather than trusting the file.

func (DataAppAccess) IsEmpty

func (a DataAppAccess) IsEmpty() bool

IsEmpty reports an access set that grants nothing.

func (DataAppAccess) Normalized

func (a DataAppAccess) Normalized() DataAppAccess

Normalized returns a copy with nil lists replaced by empty ones and every list sorted and de-duplicated.

Sorting is what makes SameAccess a comparison of MEANING rather than of spelling. An allowlist is a set — the server stores it as one and the token mint reads it as one — so a manifest listing two tables in the other order is the same grant, and treating it as drift would make `app status` report a change forever and `app push` send a patch that changes nothing.

type DataAppCommitOutcome

type DataAppCommitOutcome struct {
	DataAppID    string `json:"dataAppID"`
	FirstPublish bool   `json:"firstPublish"`
	Proposed     bool   `json:"proposed"`
}

DataAppCommitOutcome is the body of POST /dataapp/:id/commit, mirroring rdataapp.CommitOutcome.

It exists because the route has TWO successful endings and they are not the same event: a commit either publishes, or — for a non-admin publishing a brand-new app into a shared feature — raises a proposal an admin must approve. `proposed` is the only thing on the wire that tells them apart, so a caller that ignores this body necessarily reports one of them wrongly.

type DataAppFile

type DataAppFile struct {
	ID        string    `json:"id"`
	DataAppID string    `json:"dataAppID"`
	Path      string    `json:"path"`
	Content   string    `json:"content"`
	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

DataAppFile mirrors rdb.DataAppFile — one source file of one data-app row. There is no content hash on the wire, so the CLI hashes Content itself.

type DataAppFileDeleteResponse

type DataAppFileDeleteResponse struct {
	DataAppID    string        `json:"dataAppID"`
	CompileError *CompileError `json:"compileError,omitempty"`
}

DataAppFileDeleteResponse is the body of DELETE :id/files/*path. DataAppID is the row the delete landed on — deleting from a live app auto-forks a draft.

type DataAppFileSaveResponse

type DataAppFileSaveResponse struct {
	DataAppFile
	CompileError *CompileError `json:"compileError,omitempty"`
}

DataAppFileSaveResponse is the body of PUT :id/files/*path.

The compile failure arrives IN a 200 rather than as an error, and that is the server being accurate rather than lenient: the file write COMMITS before the bundle is recompiled, so the save really did happen. Pushing a multi-file app walks through states that do not compile by construction — App.tsx importing a module that is one request away — so a client that treated this as a failure could not push more than one file.

What it does NOT mean is that the app is fine. validated_at was cleared by the write and only a clean compile re-stamps it, so ValidateDataApp is the verdict and CommitDataAppDraft refuses until it passes.

type DataAppPatch

type DataAppPatch struct {
	Name *string `json:"name,omitempty"`

	AllowedTableIDs    *[]string `json:"allowedTableIDs,omitempty"`
	AllowedSecretIDs   *[]string `json:"allowedSecretIDs,omitempty"`
	AllowedAgentIDs    *[]string `json:"allowedAgentIDs,omitempty"`
	AllowedWorkflowIDs *[]string `json:"allowedWorkflowIDs,omitempty"`
	AllowedCodexIDs    *[]string `json:"allowedCodexIDs,omitempty"`
	AllowedMetricIDs   *[]string `json:"allowedMetricIDs,omitempty"`
	Capabilities       *[]string `json:"capabilities,omitempty"`
}

DataAppPatch is the body of PUT /dataapp/:id, mirroring the subset of rdataapp.Patch the CLI writes.

Every field is a POINTER because every field of the server's Patch is an optional.V: an absent key means "unchanged", while an explicit value — including an empty list — REPLACES what is there. Both are things a folder legitimately means ("I do not manage the allowlists" and "this app is allowed nothing"), and a plain slice with omitempty collapses them into the first, making it impossible to remove the last table from an app.

There is deliberately no Entrypoint: rdataapp.Patch has none, because a data app's entrypoint is stamped at create and never moves.

func (DataAppPatch) Empty

func (p DataAppPatch) Empty() bool

Empty reports a patch that would change nothing, so the caller can skip the round trip rather than send `{}`.

func (*DataAppPatch) SetAccess

func (p *DataAppPatch) SetAccess(access DataAppAccess)

SetAccess fills in every allowlist field from a declared set.

All seven or none: the allowlists are one decision, and patching a subset would let a folder that manages access leave a list it happens not to use pointing at whatever the row had.

type DatabaseSQLInput

type DatabaseSQLInput struct {
	SQL string `json:"sql"`
	// Params are positional bound parameters for $1, $2 … placeholders, carried
	// as RAW JSON rather than []any.
	//
	// That is load-bearing, not fussiness. Decoding a parameter list into []any
	// turns every number into a float64, which silently loses precision above
	// 2^53 — 9007199254740993 becomes ...992, and 1234567890123456789 becomes
	// ...800. Re-encoding cannot recover what the decode threw away, so the wrong
	// number would reach the database with no error at any layer. Passing the
	// user's bytes through untouched means a bigint id survives the trip.
	//
	// Sent only when non-empty: the field is optional server-side, and an
	// explicit null adds nothing.
	Params  []json.RawMessage `json:"params,omitempty"`
	MaxRows int               `json:"maxRows,omitempty"`
}

DatabaseSQLInput is the POST :id/sql body.

type DatabaseSQLResult

type DatabaseSQLResult struct {
	DatabaseID string `json:"databaseID"`
	// Result is the CSV, header row included. Empty for a statement with no
	// result set — a plain INSERT, say — which is not the same as no rows.
	Result   string `json:"result"`
	RowCount int    `json:"rowCount"`
	// Truncated reports that the row cap cut the result off, so Result is a
	// prefix rather than the answer.
	Truncated  bool  `json:"truncated"`
	DurationMs int64 `json:"durationMs"`
}

DatabaseSQLResult is the POST :id/sql response.

No omitempty anywhere: this struct is emitted verbatim by `ronja db sql --json`, and a field that vanishes when it is zero makes the payload a different shape depending on the answer.

type DeviceStart

type DeviceStart struct {
	DeviceCode              string `json:"deviceCode"`
	UserCode                string `json:"userCode"`
	VerificationURI         string `json:"verificationUri"`
	VerificationURIComplete string `json:"verificationUriComplete"`
	ExpiresIn               int    `json:"expiresIn"`
	Interval                int    `json:"interval"`
}

type DeviceToken

type DeviceToken struct {
	Token     string `json:"token"`
	TokenID   string `json:"tokenID"`
	TokenName string `json:"tokenName"`
	// UserID and TenantID are unused by the CLI today. They are kept because
	// this struct is a faithful mirror of the server's response, and a mirror
	// that quietly omits fields is one nobody can check against the source.
	UserID   string `json:"userID"`
	TenantID string `json:"tenantID"`
	// ExpiresAt is RFC 3339. CLI-minted tokens are time-bounded, and a
	// credential that stops working with no warning is a bad surprise.
	ExpiresAt string `json:"expiresAt"`
}

type Error

type Error struct {
	Status int
	Code   string
	Body   string
}

Error is a non-2xx response. Code carries the machine-readable `error` field when the server sent one — the CLI branches on it for the device-flow poll, where "authorization_pending" is an expected, non-fatal answer.

func (*Error) Error

func (e *Error) Error() string

type Feature

type Feature struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// Scope is private|workspace|organization. Compare against
	// scopeOrganization; an unknown value is treated as not-shared by callers,
	// which fails toward attempting the commit and letting the server decide.
	Scope string `json:"scope"`
}

Feature is a HAND-MIRROR of the sliver of rdb.Feature the CLI reads: what a feature is called, and how private it is.

Scope is the only field anything branches on, and it decides whether a commit is admin-only. Workflows get it for free — the server stamps featureScope onto every workflow row at read time — but rdb.DataApp carries no such column, so `ronja app publish` has to ask. That asymmetry is the entire reason this type exists; it is not a general feature client and should not grow into one, since discovery belongs on plain HTTP.

Tags copied from backend/ronja/rdb/table_feature.go.

type FileDeleteResponse

type FileDeleteResponse struct {
	Warnings []string `json:"warnings,omitempty"`
}

FileDeleteResponse mirrors the handler's FileDeleteResponse. Deleting the last file that referenced a secret drops that binding, which is exactly the kind of change nobody asked for that a warning exists to surface.

type FileSaveResponse

type FileSaveResponse struct {
	WorkflowFile
	Warnings []string `json:"warnings,omitempty"`
}

FileSaveResponse mirrors the workflow handler's FileSaveResponse: the saved file at the response root, plus soft save-time notices.

Warnings is the SOFT tier of the save path — an unreachable secret is filtered out of the binding rather than refused — and it is the only place those notices exist, so the CLI passes them through verbatim rather than paraphrasing.

type Me

type Me struct {
	User struct {
		ID    string `json:"id"`
		Email string `json:"email"`
		// Serialized from optional.V[string], so these are `null` rather than
		// absent when unset; encoding/json leaves the zero value in place.
		FirstName string `json:"first_name"`
		LastName  string `json:"last_name"`
	} `json:"user"`
	Tenant *struct {
		ID   string `json:"id"`
		Name string `json:"name"`
	} `json:"tenant,omitempty"`
	Role *struct {
		Name           string `json:"name"`
		PrivilegeLevel int    `json:"privilegeLevel"`
	} `json:"role,omitempty"`
	IsRonjaStaff bool `json:"isRonjaStaff"`
}

Me mirrors the /authentication/me response (MeResponse in backend/api/v2/authentication/api_login.go). Tenant and Role are absent for a user who has not selected an organization.

This is a HAND-MIRROR of server types, so the field names have to come from the structs that are actually serialized, not from what a CLI would like to receive:

  • User is a *rdb.User — backend/ronja/rdb/table_user.go is the source of truth. It has first_name / last_name and NO "name" field.
  • Role is a *rdb.Role — backend/ronja/rdb/table_roles.go — which is keyed on name and has NO id column.

Keeping this in step with those files is load-bearing (see CLAUDE.md): a field that does not exist on the wire decodes to the zero value silently, so the mistake shows up as a blank line of output rather than an error.

type Migration

type Migration struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`
	// SQL is sent VERBATIM. The server's ledger hash covers exactly these bytes,
	// so trimming or normalising here — a trailing newline is the obvious
	// temptation — would make a file hash differently from the way it was
	// applied, and every applied migration would then report as drifted.
	SQL string `json:"sql"`
}

Migration is one named migration in a set.

type MigrationResult

type MigrationResult struct {
	Name    string `json:"name"`
	Outcome string `json:"outcome"`
	Seq     int    `json:"seq"`
}

MigrationResult is one migration's verdict.

type MigrationSetInput

type MigrationSetInput struct {
	Migrations []Migration `json:"migrations"`
	DryRun     bool        `json:"dryRun,omitempty"`
}

MigrationSetInput is the POST :id/migrations body.

type MigrationSetResult

type MigrationSetResult struct {
	DatabaseID string            `json:"databaseID"`
	DryRun     bool              `json:"dryRun"`
	Results    []MigrationResult `json:"results"`
}

MigrationSetResult is the POST :id/migrations response.

func (*MigrationSetResult) Drifted

func (r *MigrationSetResult) Drifted() []MigrationResult

Drifted returns the migrations the server reported as drifted.

Only a DRY RUN can produce these: a real apply refuses the whole set and returns an error instead, which is why `db migrate push` never needs to inspect this and `db migrate status` always does.

func (*MigrationSetResult) Pending

func (r *MigrationSetResult) Pending() []MigrationResult

Pending returns the migrations a real apply would apply.

type QueryInput

type QueryInput struct {
	SQL     string `json:"sql"`
	MaxRows int    `json:"maxRows,omitempty"`
	Format  string `json:"format"`
}

QueryInput is the request body.

MaxRows omitted means the server's own default cap; the server also clamps anything above its ceiling, so an over-large value is not an error the CLI has to pre-empt.

type QueryResult

type QueryResult struct {
	// Result is the CSV, header row included.
	Result   string `json:"result"`
	RowCount int    `json:"rowCount"`
	// Truncated reports that the row cap cut the result off, so what is in
	// Result is a prefix rather than the answer.
	Truncated bool `json:"truncated"`
	// Error is the query's own failure — a syntax error, an unresolvable
	// {{ ref }}, a permission refusal. Non-empty means the query did not run.
	Error        string `json:"error"`
	SQL          string `json:"sql"`
	InstanceKind string `json:"instanceKind"`
}

QueryResult is the csv_meta envelope.

The trap worth stating loudly: a FAILED query comes back as HTTP 200 with Error set. Nothing in the transport layer will flag it, so every caller has to check Error before trusting Result — a query that fails and one that returns no rows are otherwise indistinguishable.

No omitempty anywhere: this struct is emitted verbatim by `ronja query --json`, and a field that vanishes when it is zero makes the payload a different shape depending on the answer.

func (*QueryResult) Failed

func (q *QueryResult) Failed() bool

Failed reports a query the server accepted and could not run.

type RawResponse

type RawResponse struct {
	Status int
	Header http.Header
	Body   io.ReadCloser
}

RawResponse is one undecoded HTTP response. The caller owns Body and MUST close it — closing is also what releases the request's deadline.

Header was added back when a caller finally needed it: `ronja api --retry` honours Retry-After, and a retry that ignores the interval the server just named is the one behaviour a rate limiter is entitled to treat as abuse. Still deliberately narrow — everything else about the response is the body.

type RunResponse

type RunResponse struct {
	WorkflowRun
	// Steps is never null: a legacy or step-less run hydrates as [].
	Steps []StepDTO `json:"steps"`
	// Health is done|degraded|failed — see the constants above.
	Health string `json:"health"`
}

RunResponse mirrors workflow.RunResponse — the GET /workflow/run/:runID wire shape.

The server embeds *rdb.WorkflowRun, so every run field sits at the JSON ROOT alongside steps and health rather than under a "run" key. The embedded (untagged) WorkflowRun below reproduces exactly that flattening — do not give it a json tag, or the CLI stops decoding every field of the run it polls.

func (*RunResponse) Running

func (r *RunResponse) Running() bool

Running reports a run still in flight — the poll loop's continue condition.

Written as "== running" rather than "not done and not error" on purpose. The enum is exactly three values today, so the two spellings agree; they differ only on a status added server-side later, and there the failure modes are not symmetric. Ending the poll on an unrecognised status reports it (a caller sees "status: cancelled" and knows more than the CLI does), while treating it as still-running hangs until --timeout on a run that already finished.

type StepDTO

type StepDTO struct {
	ID           string  `json:"id"`
	ParentStepID *string `json:"parentStepId"`
	Seq          int     `json:"seq"`
	Name         string  `json:"name"`
	// Status is a run-span status; "failed" is the one the health derivation
	// keys on.
	Status      string  `json:"status"`
	StartedAt   string  `json:"startedAt"`
	EndedAt     *string `json:"endedAt"`
	DurationMs  *int64  `json:"durationMs"`
	AICallCount int64   `json:"aiCallCount"`
	QueryCount  int64   `json:"queryCount"`
	TokensUsed  int64   `json:"tokensUsed"`
	Error       *string `json:"error"`
	Logs        *string `json:"logs"`
}

StepDTO mirrors workflow.StepDTO — one observable step of a run, projected from the container-step run spans.

ParentStepID keeps the server's parentStepId spelling (a frontend back-compat name for what is now parent_span_id). TokensUsed is 0 in production today.

type Table

type Table struct {
	ID   string  `json:"id"`
	Name *string `json:"name"`
}

Table is a minimal mirror of rdb.ModelV2: an id and a name, which is all the CLI wants — a table id in a refusal message is correct but unreadable.

Name is optional server-side, hence the pointer: an unnamed table is a real state (a freshly created one), not a decode failure.

type ValidateBindings

type ValidateBindings struct {
	InputTableIDs  []string `json:"inputTableIDs"`
	OutputTableIDs []string `json:"outputTableIDs"`
	SecretIDs      []string `json:"secretIDs"`
	QuerySecretIDs []string `json:"querySecretIDs"`
	AgentIDs       []string `json:"agentIDs"`
	CodexIDs       []string `json:"codexIDs"`
}

ValidateBindings mirrors rworkflow.ValidateBindings — what the candidate WOULD bind to if saved as-is. Every slice is always present on the wire (no omitempty server-side), so unlike Workflow's binding columns these need no normalization.

type ValidateFile

type ValidateFile struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

ValidateFile is one candidate file, mirroring rworkflow.ValidateFile.

type ValidateFinding

type ValidateFinding struct {
	Severity string `json:"severity"`
	Code     string `json:"code"`
	Message  string `json:"message"`
	Path     string `json:"path"`
	// Marker is the canonical marker form for the offending binding, when the
	// finding is about one. A hint for locating the problem, not necessarily
	// the literal text in the file.
	Marker string `json:"marker,omitempty"`
}

ValidateFinding mirrors rworkflow.ValidateFinding: one problem, attributed to the source that caused it.

Path is a file path, or a synthetic source ("parameters[0].optionsQuery", "pipPackages", "inputTableIDs") when the problem does not belong to a file — so a client grouping by Path gets a sensible bucket either way.

func (ValidateFinding) IsError

func (f ValidateFinding) IsError() bool

IsError reports whether a finding would block a save.

type ValidateInput

type ValidateInput struct {
	FeatureID   string              `json:"featureID"`
	Entrypoint  string              `json:"entrypoint"`
	Parameters  []WorkflowParameter `json:"parameters,omitempty"`
	Files       []ValidateFile      `json:"files"`
	PipPackages []string            `json:"pipPackages,omitempty"`
}

ValidateInput mirrors rworkflow.ValidateFilesInput — a whole candidate workflow, with no workflow ID anywhere in it. That absence is the point: the endpoint persists nothing and needs nothing to exist, which is what makes "iterate an existing script to green before the first push" possible.

Parameters and PipPackages are omitempty because the CLI does not persist either in the manifest today: a workflow's declared parameters live server-side on the draft, and sending an empty list would read as "this candidate declares no parameters" rather than "I am not telling you about them". The server treats an absent list as none, which is the honest answer for a folder that has no record of them.

type ValidateResult

type ValidateResult struct {
	Findings []ValidateFinding `json:"findings"`
	Resolved ValidateBindings  `json:"resolved"`
}

ValidateResult mirrors rworkflow.ValidateResult.

func (*ValidateResult) Errors

func (r *ValidateResult) Errors() []ValidateFinding

Errors returns the blocking findings, in the order the server reported them.

func (*ValidateResult) OK

func (r *ValidateResult) OK() bool

OK reports whether the candidate would save cleanly. Warnings do not make it false — a save succeeds through them, and so does a push.

type Workflow

type Workflow struct {
	ID          string `json:"id"`
	WorkspaceID string `json:"workspaceID"`
	FeatureID   string `json:"featureID"`

	Title       string `json:"title"`
	Description string `json:"description"`
	Entrypoint  string `json:"entrypoint"`
	// Kind is the output-channel discriminator: unspecified|function|report|pipeline.
	Kind string `json:"kind"`

	Parameters []WorkflowParameter `json:"parameters"`
	Variables  map[string]any      `json:"variables"`

	UseDedicatedCompute bool `json:"useDedicatedCompute"`
	// ApprovalGate is null on a workflow with no gate. `wf test` (Phase 4)
	// preflights Enabled: a gated workflow cannot be run over HTTP at all,
	// because only an agent session can produce an approval.
	ApprovalGate *WorkflowApprovalGate `json:"approvalGate"`

	// ParentWorkflowID is empty for a live row AND for a parentless draft (a
	// brand-new workflow nobody has published yet). Non-empty on an edit draft
	// and on a committed version snapshot.
	ParentWorkflowID string     `json:"parentWorkflowID"`
	CommittedAt      *time.Time `json:"committedAt"`
	Hidden           bool       `json:"hidden"`

	SecretIDs         []string `json:"secretIDs"`
	ExplicitSecretIDs []string `json:"explicitSecretIDs"`
	InputTableIDs     []string `json:"inputTableIDs"`
	OutputTableIDs    []string `json:"outputTableIDs"`
	AgentIDs          []string `json:"agentIDs"`
	CodexIDs          []string `json:"codexIDs"`
	QuerySecretIDs    []string `json:"querySecretIDs"`
	PipPackages       []string `json:"pipPackages"`

	// Lifecycle is trigger-computed server-side: live|draft|version|archived|proposed.
	Lifecycle string `json:"lifecycle"`
	// SubmittedForReviewAt is set once a drafter asks an admin to commit their
	// draft; null on every other row, and on a draft they have not submitted.
	SubmittedForReviewAt *time.Time `json:"submittedForReviewAt"`
	DrafterUserID        string     `json:"drafterUserID"`
	// FeatureScope is the joined feature's scope (private|workspace|
	// organization), stamped at read time — the field to branch on for privacy,
	// not the legacy scope/private columns.
	FeatureScope string `json:"featureScope"`

	CreatedAt time.Time `json:"createdAt"`
	UpdatedAt time.Time `json:"updatedAt"`
}

Workflow is a HAND-MIRROR of the subset of rdb.Workflow the CLI reads.

Same rule as Me in auth.go, and the same hazard: a json tag that does not exist on the wire decodes to the zero value SILENTLY, so a typo here shows up as an empty column of output or — worse — as "no approval gate" on a workflow that has one. Every tag below is copied from backend/ronja/rdb/table_workflow.go; keep them in step with that file.

Two mirroring decisions worth knowing about:

  • The server's optional.V[T] marshals as the bare value or JSON `null`. Unmarshalling `null` into a non-pointer is a documented no-op in encoding/json, so optional STRINGS are plain strings here ("" is exactly what "unset" means for an id) while optional TIMESTAMPS are pointers, because "is there a submitted-for-review stamp at all" is a question the CLI genuinely asks.
  • The ID slices carry `omitempty` server-side, so an empty binding set is ABSENT from the JSON rather than `[]`. Normalize() replaces the resulting nils with empty slices so callers can range and compare without nil-checking every field.

This is a SUBSET, not the whole row: rdb.Workflow carries roughly twenty more columns (schedule and run bookkeeping, logic summaries, governance stamps) that no CLI command reads. Adding a field here is cheap and adding one that nothing consumes is dead weight nobody keeps in step — so the rule is "everything the CLI reads, plus what makes a read field legible", and the fixture test in workflow_test.go is what checks those tags against the wire.

func (*Workflow) IdentityID

func (w *Workflow) IdentityID() string

IdentityID is the STABLE id of the workflow this row belongs to: the parent for an edit draft or a version snapshot, the row itself for a live workflow or a parentless draft.

This is what a manifest binding records. A draft's own id is per-user and disappears on commit, so a committed file naming one would point at a row that stops existing the moment its author publishes.

func (*Workflow) IsGated

func (w *Workflow) IsGated() bool

IsGated reports whether runs of this workflow require a human approval.

func (*Workflow) Normalize

func (w *Workflow) Normalize()

Normalize replaces nil binding slices with empty ones.

The server omits them when empty, so "no output tables" and "field absent" arrive identically — and code that compares or ranges over them should not have to care which.

type WorkflowApprovalGate

type WorkflowApprovalGate struct {
	Enabled bool `json:"enabled"`
}

WorkflowApprovalGate is a SUBSET of rdb.WorkflowApprovalGate: what the CLI acts on, which is whether the gate is switched on at all.

The rest of the server's shape — the approvers, the author's message, the context parameter names, and the whole email/Slack delivery tree — is deliberately NOT mirrored. A gate the CLI cannot run is a gate the CLI has nothing to say about, and a mirrored field nobody reads is a field nobody keeps in step with backend/ronja/rdb/table_workflow.go. Add them back when a command genuinely needs them (a `wf status` that names the approvers and says where the request goes is the obvious candidate), not before.

type WorkflowFile

type WorkflowFile struct {
	ID         string    `json:"id"`
	WorkflowID string    `json:"workflowID"`
	Path       string    `json:"path"`
	Content    string    `json:"content"`
	CreatedAt  time.Time `json:"createdAt"`
	UpdatedAt  time.Time `json:"updatedAt"`
}

WorkflowFile mirrors rdb.WorkflowFile — one source file of one workflow row. There is no content hash on the wire, so the CLI hashes Content itself.

type WorkflowParameter

type WorkflowParameter struct {
	Name         string   `json:"name"`
	Label        string   `json:"label"`
	Description  string   `json:"description,omitempty"`
	Type         string   `json:"type"` // "string" | "number" | "date" | "select"
	DefaultValue any      `json:"defaultValue,omitempty"`
	Required     bool     `json:"required"`
	Options      []string `json:"options,omitempty"`
	OptionsQuery string   `json:"optionsQuery,omitempty"`
}

WorkflowParameter mirrors rdb.WorkflowParameter. OptionsQuery is SQL carrying `{{ ref('tableID') }}` markers, which is why validation attributes findings from it to a synthetic parameters[N].optionsQuery path.

type WorkflowPatch

type WorkflowPatch struct {
	Title      string               `json:"title,omitempty"`
	Entrypoint string               `json:"entrypoint,omitempty"`
	Parameters *[]WorkflowParameter `json:"parameters,omitempty"`
}

WorkflowPatch is the body of PUT /workflow/:id, mirroring the subset of rworkflow.Patch the CLI writes.

Two fields, both omitempty: every field of the server's Patch is an optional.V, so an absent key means "unchanged" while an explicit empty string would CLEAR the value. A folder whose manifest has lost its title must not be able to blank the workflow's.

Entrypoint is here because the manifest owns it: without this the row keeps running whatever file it was created with, and an entrypoint RENAME is impossible — the server refuses to delete the file the row still names. Ordering matters and belongs to the caller: rworkflow's Update refuses an entrypoint the workflow has no file for, so the new file must be written BEFORE this patch, and the old one deleted after it. Parameters is a POINTER for the reason the string fields are omitempty: the server's Patch.Parameters is an optional slice, so an absent key leaves the declaration alone while an explicit [] CLEARS it. Both are things a folder legitimately means — "I don't manage parameters" and "I declare none" — and a plain slice with omitempty collapses them into the first, making it impossible to remove the last parameter from a workflow.

func (WorkflowPatch) Empty

func (p WorkflowPatch) Empty() bool

Empty reports a patch that would change nothing, so the caller can skip the round trip rather than send `{}`.

type WorkflowRun

type WorkflowRun struct {
	ID              string         `json:"id"`
	WorkflowID      string         `json:"workflowID"`
	ParameterValues map[string]any `json:"parameterValues"`
	// Status is running|done|error — see the constants above.
	Status       string                   `json:"status"`
	Outputs      []WorkflowRunOutput      `json:"outputs"`
	TableOutputs []WorkflowRunTableOutput `json:"tableOutputs"`
	// Logs is the run's FULL captured output, on the row itself. There is no
	// streaming endpoint, which is why the CLI tails this at the end rather
	// than following it live.
	Logs        string     `json:"logs"`
	Error       *string    `json:"error"`
	ExecutedBy  string     `json:"executedBy"`
	ExecutedAt  time.Time  `json:"executedAt"`
	CompletedAt *time.Time `json:"completedAt"`

	// ExecRunID / TraceID link the run to the generic execution telemetry and
	// to the cross-primitive trace. Both nullable; the CLI carries them so a
	// caller can follow either one into the web UI.
	ExecRunID *string `json:"execRunID"`
	TraceID   *string `json:"traceID"`
	// TriggeringRunKind / TriggeringRunID name the run that triggered this one.
	// Always absent for a CLI-started run — it is a top-level execution — and
	// mirrored anyway so the shape is the server's, not a CLI-shaped subset.
	TriggeringRunKind *string `json:"triggeringRunKind"`
	TriggeringRunID   *string `json:"triggeringRunID"`
	// ProcessingStartedAt is stamped by the Python harness the moment user code
	// begins, so the gap from ExecutedAt is queue + container start. Absent when
	// the container never reported in.
	ProcessingStartedAt *time.Time `json:"processingStartedAt"`

	// Kind is the workflow's output channel, denormalized at run-create time:
	// unspecified|function|report|pipeline.
	Kind string `json:"kind"`
	// Body is the inline return value of a function-kind run, capped server-side
	// at 8 KB.
	Body *string `json:"body"`

	// Telemetry counters, hydrated from the joined exec_run. Zero for legacy
	// runs and for runs that made no AI call or query.
	TokensUsed  int64 `json:"tokensUsed"`
	AICallCount int64 `json:"aiCallCount"`
	QueryCount  int64 `json:"queryCount"`
}

WorkflowRun mirrors rdb.WorkflowRun — one execution of one workflow row.

Mirrored in FULL rather than as a subset (unlike Workflow), because this struct is what `--json` prints: the CLI reads maybe six of these fields, but dropping the rest would quietly narrow the machine-readable output of a command whose whole purpose is to report what a run did.

The optional.V columns are pointers wherever "absent" is a real answer (a run still going has no completedAt; a successful one has no error). TenantID is deliberately not mirrored: it is the server's bookkeeping, and nothing at the command line has a use for it.

type WorkflowRunOutput

type WorkflowRunOutput struct {
	FileKey string `json:"fileKey"`
	// Format is html|csv|xlsx|json|pdf|docx|xml.
	Format string `json:"format"`
	FileID string `json:"fileID,omitempty"`
	// Name is the author-supplied filename, empty for unnamed outputs.
	Name string `json:"name,omitempty"`
}

WorkflowRunOutput mirrors rdb.WorkflowRunOutput — one file a run produced.

type WorkflowRunTableOutput

type WorkflowRunTableOutput struct {
	ModelID     string `json:"modelID"`
	LogicalName string `json:"logicalName"`
	DisplayName string `json:"displayName"`
	// WriteMode is replace|append.
	WriteMode string `json:"writeMode"`
	FileCount int    `json:"fileCount"`
}

WorkflowRunTableOutput mirrors rdb.WorkflowRunTableOutput — one TABLE a run wrote to. This is the thing `--write-live` exists for: WriteMode "replace" means the run replaced the table's contents.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL