api

package
v0.28.6 Latest Latest
Warning

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

Go to latest
Published: Sep 3, 2026 License: Apache-2.0 Imports: 15 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 (
	TriggerKindCron    = "cron"
	TriggerKindEmail   = "email"
	TriggerKindWebhook = "webhook"
	TriggerKindTable   = "table"
	TriggerKindEvent   = "event"
	TriggerKindMailbox = "mailbox"
)

Automation trigger kinds, mirrored from the TriggerKind* consts in backend/resource/rscheduledjob. The CLI branches on these by exact string, so they are a wire contract in the same sense the workflow lifecycles are.

View Source
const (
	ActionKindAgent      = "agent"
	ActionKindWorkflow   = "workflow"
	ActionKindSavedAgent = "saved_agent"
)

Automation action kinds, mirrored from the ActionKind* consts in the same package. `agent` runs the inline prompt; `workflow` runs a workflow; `saved_agent` runs a saved Agent, optionally with a per-automation seed.

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 (
	SearchKindTable    = "table"
	SearchKindSecret   = "secret"
	SearchKindAgent    = "agent"
	SearchKindMailbox  = "mailbox"
	SearchKindWorkflow = "workflow"
	// SearchKindNote covers BOTH note sub-kinds. The endpoint reports a note's
	// skill/knowledge split in `subKind`, which this mirror does not carry and
	// `bind` does not ask about: an automation's reference set declares
	// kind="note" for either.
	SearchKindNote = "note"
)

The kinds a hit can carry, mirrored from search.Hit's own list.

Only the six `bind` can resolve are named. The full set the endpoint can return is `feature`, `table`, `workflow`, `note`, `dataapp`, `agent`, `scheduledjob`, `secret`, `mcpserver` and `mailbox` — and the omission that matters is on the other side: there is NO `codex` hit, because the endpoint does not fan out to codexes at all. A caller cannot tell that from an empty result, so it has to know.

View Source
const (
	TableKindDerived = "derived"
	TableKindMetric  = "metric"
)

Table kinds, mirrored from rdb.ModelKind (backend/ronja/rdb/table_model_v2.go). Only the two the pipeline loop reasons about are named: `derived` is what it manages, and `metric` is the other DRAFTABLE kind — it runs the identical checkout/sync/commit flow and is out of scope by choice, not by capability, so a message about it must never claim otherwise. The remaining kinds (foundation, integration, manual_integration, dynamic) are reported by their raw string; naming them here would be four constants nothing branches on.

View Source
const (
	TableStatusPending     = "pending"
	TableStatusInvalidated = "invalidated"
	TableStatusBuilding    = "building"
	TableStatusReady       = "ready"
	TableStatusBuildFailed = "build_failed"
)

Table build statuses, mirrored from rdb.ModelStatus.

The CLI reads BuildVerdict, not Status — see WaitForTableBuild. These exist only for the fallback that derives a verdict from an older server's response, which is the one place the raw status is still branched on.

View Source
const (
	ShadowStatusDraft     = "draft"
	ShadowStatusCommitted = "committed"
)

Draft lifecycle, mirrored from rdb.ShadowStatus. A row with no shadow status is a plain live table.

View Source
const (
	// BuildVerdictOK — the last run succeeded.
	BuildVerdictOK = "ok"
	// BuildVerdictOKPartial — queryable, but the build skipped corrupt source
	// files, so the data is incomplete.
	BuildVerdictOKPartial = "ok_partial"
	// BuildVerdictFailedStale — the last run FAILED and the table is still
	// serving the PREVIOUS build's data. `status` reads `ready` here.
	BuildVerdictFailedStale = "failed_stale"
	// BuildVerdictFailed — the run failed with no earlier data to fall back on.
	BuildVerdictFailed = "failed"
	// BuildVerdictBuilding — still running; keep polling.
	BuildVerdictBuilding = "building"
	// BuildVerdictPending — queued and not started. A sync of a table with no
	// `code` parks here forever, so a `pending` that never advances is a
	// failure, not progress.
	BuildVerdictPending = "pending"
	// BuildVerdictInvalidated — an upstream table changed; this one's data is
	// stale pending a rebuild.
	BuildVerdictInvalidated = "invalidated"
)

Build verdicts, mirrored from the buildVerdict* consts in backend/api/v2/feature/tableview.go.

This is the field to branch on after a sync, and the reason it exists: a live table whose re-sync FAILS stays `ready` and goes on serving the previous build's data, so `status` alone reports a failure as success. The CLI branches on these by exact string, which makes them a wire contract in the same sense the device-flow poll codes are.

View Source
const (
	DefaultTableBuildPollInterval    = 2500 * time.Millisecond
	DefaultMaxTableBuildPollInterval = 10 * time.Second
	DefaultTableBuildTimeout         = 15 * time.Minute
	// DefaultMaxTableBuildFailureWindow is the same 90 seconds the device flow
	// tolerates, and for the same reason: a rolling deploy takes longer than any
	// small number of polls, and the overall Timeout is the independent backstop.
	DefaultMaxTableBuildFailureWindow = 90 * time.Second
)

The default poll schedule.

The interval matches `wf test`'s (the web builder polls at 3s; 2.5s is the same order), and for the same reason it holds that cadence for the first few polls before backing off: most syncs of a small table finish in seconds, and backing off immediately would make the common case worse to protect the rare one.

The timeout is generous because a derived build over a large partitioned input legitimately takes minutes, and giving up early on a healthy build reports a working instance as a broken one.

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"
	RunStatusWaiting  = "waiting"
	RunStatusResuming = "resuming"
)

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

The last two belong to durable waits and only ever appear on a runtime-2 run that suspended:

  • waiting is PARKED: the burst finished cleanly, its outputs are persisted, and the server knows what has to happen before the run resumes. Nothing failed and nothing is executing, so it is neither a success nor a failure — it is a run that is not finished yet.
  • resuming is the wake race's single-winner latch, between "a wake decided to resume this run" and "the resume run exists". Terminal for THIS row: the parked run never executes again, its successor carries the lineage.

Both are real persisted statuses, so a poll can land on either — briefly on resuming, indefinitely on waiting — which is why the CLI has to name them rather than let them fall through to "a status this version does not know" and report a parked run as a failure.

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

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. Waiting is the parked run's health, and it is a SEPARATE value rather than a flavour of done for the reason the status is: a parked run reported as healthy tells the reader a stalled pipeline succeeded.

View Source
const AutomationListLimit = 200

AutomationListLimit is what ListAutomations asks for: the server's own maximum.

The server SILENTLY falls back to 100 for any value outside 1..200, so asking for more is not an error — it is just not honoured, and the caller would then be reasoning about a page it did not request.

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 FindingSecretDropped = "secret_dropped"

FindingSecretDropped is the code the server reports for a binding its save will FILTER OUT rather than refuse: a `{{ secret }}` marker naming a secret the author cannot reach. The workflow saves, the binding is silently dropped, and every run that touches the marker fails.

Mirrored because `wf push`'s EXIT CODE turns on it, which nothing else here does — see droppedBindings. Branching on the code and not the prose is the whole point: the codes are a declared wire contract ("may be added to but never renamed"), and a CLI matching on the sentence would start passing green the day somebody reworded it.

View Source
const KitIndexPath = "/docs/api/kit.md"

KitIndexPath is the index listing every kit file with a one-line description.

View Source
const MaxSearchLimit = 40

MaxSearchLimit mirrors maxTotalCap in the same file. The server clamps silently, so asking for more is not an error; it is just not honoured.

View Source
const MinSearchTermLen = 2

MinSearchTermLen mirrors minQueryLen in backend/api/v2/search/handler.go: a term shorter than this returns an empty result with no search performed, and with no error to say so.

Mirrored rather than left to the server because the two answers are identical on the wire and mean opposite things — "nothing here is called that" and "nobody looked" — and a caller reporting the first when the second happened sends its reader hunting for a row that is sitting right there.

View Source
const PartialDataCorruptErrorType = "partial_data_corrupt"

PartialDataCorruptErrorType is the rjerr type string (rjerr.ERR_PARTIAL_DATA_CORRUPT) that marks the NON-FATAL "the build completed but skipped corrupt source files" notice. Anything else on a `ready` row is a real failure. Used only by the verdict fallback below.

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 RunInFlightCode = "run_in_flight"

RunInFlightCode is the wire discriminator on the 409 every run surface answers when a resource's `skip` concurrency policy refuses a start, mirrored from gt.RunInFlightCode (backend/lib/api/gt/error.go).

View Source
const SearchScoreExact = 3

Search relevance buckets, mirrored from the `ORDER BY CASE` every resource's SearchForUser computes (backend/resource/rmodelv2/search.go and its siblings): 3 exact, 2 prefix, 1 substring, on the row's NAME.

Only the exact bucket has a constant, because it is the only one anything here may act on. A prefix or substring hit is a guess, and a wrong bind deploys a folder against the wrong row while reporting success.

⚠️ A hit can carry this score without its title matching at all. The endpoint merges a TAG leg (backend/api/v2/search/tags.go) that stamps the matching TAG's score onto the resource it is attached to, so a table tagged `orders` arrives as an exact hit whatever it is called. A caller that means "exactly this name" has to check the name.

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, …) are deliberately not mirrored WHOLESALE. The CLI 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.

ONE is mirrored, and only because a decision hangs on it — see FindingSecretDropped.

View Source
const StatusConflict = http.StatusConflict

StatusConflict is the status both optimistic-concurrency refusals answer with — a file whose stored content is not what the write asserted, and a commit whose parent has moved since the draft was seeded.

An alias for net/http's constant rather than a second spelling of 409: named because the CLI branches on it in two commands and the branch is load-bearing — a 409 must never be routed into `wf publish`'s needs-review fallback, which is keyed on 400 — but the VALUE has one definition.

View Source
const TableEngineDuckDB = "duckdb"

TableEngineDuckDB is the only engine value, mirrored from rdb.ModelEngineDuckDB. It means `code` is pure DuckDB SQL with `{{ ref('table-…') }}` markers, which is exactly what a pipeline folder's .sql files are — so it is sent explicitly at create rather than left to the server's default. The default only applies when `code` is present, and stating it costs one field and removes a conditional nobody would remember.

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 KitDocsPath added in v0.27.0

func KitDocsPath(kitPath string) string

KitDocsPath is the docs route serving one kit file, by its kit-relative path ("components/ui/button.tsx").

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.

func Unanswered added in v0.28.6

func Unanswered(err error) bool

Unanswered reports that the instance never delivered a verdict — as opposed to delivering one the caller does not like.

The distinction is who a message may blame. A 400 is the server having considered the request and refused it, so the refusal is about the request; a connection that never landed, a 429, or any 5xx is the instance not answering, and a CLI that renders those as "your files are wrong" attributes an outage to the author.

A decode failure and a build-request failure are deliberately NOT included: both mean an answer arrived (or that we never got as far as asking), and neither is an outage.

Neither is a CANCELLED context, and that one is not a technicality. root.go installs signal.NotifyContext, so Ctrl-C during a request surfaces here as a *url.Error wrapping context.Canceled — which the url.Error arm below would otherwise read as an outage, and a caller that warns-and-continues on an outage would then treat "the reader stopped this" as permission to carry on and write files.

A DEADLINE is excluded for the same reason in reverse: the request stopped at OUR end, not the instance's, and IsTimeout is the separate question a caller asks about a write that may have landed. BOTH deadline shapes are excluded, which is why the test is IsTimeout rather than a bare errors.Is(context.DeadlineExceeded): the http.Client's own Timeout does not produce that sentinel at all, only a *url.Error that reports Timeout() — so checking the sentinel alone excluded the per-request deadline and let the client ceiling through the url.Error arm below as an outage, which is the opposite verdict on the same event.

Separate from retryableStatus and the device flow's isTransient, which answer "should I try again" — a different question with a deliberately different membership.

Types

type Agent added in v0.28.5

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

Agent is the sliver of a saved Agent the edge verifier reads.

type Automation added in v0.28.5

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

	Name        string `json:"name"`
	Description string `json:"description"`
	// Prompt is the inline-agent action's instructions. Empty on the workflow
	// and saved_agent kinds, which carry a sentinel server-side.
	Prompt string `json:"prompt"`

	// TriggerKind is IMMUTABLE. UpdateAutomationInput carries no field for it,
	// so AutomationPatch has none either — a folder that changed it is refused
	// by the CLI with the reason rather than having the field silently ignored,
	// and the remedy is the web app: DELETE is a 30-day soft delete the CLI
	// cannot empty (purge is human-only), so "delete and push again" is not an
	// executable answer.
	TriggerKind string `json:"triggerKind"`

	CronExpr string `json:"cronExpr"`
	Timezone string `json:"timezone"`
	// ReportingTimezone is the zone the run's queries bucket dates in, ORTHOGONAL
	// to Timezone above (trigger-WHEN vs report-IN) and accepted on every trigger
	// kind. "" means the row declares none and falls back to the organization
	// default — not the same as the literal "UTC" a reset writes.
	ReportingTimezone string `json:"reportingTimezone"`

	Enabled bool `json:"enabled"`
	// DisabledReason is why a disabled row is disabled: "" (nobody said), "user"
	// (a human paused it), "mailbox_disconnected", or one of the
	// marked-for-deletion family.
	//
	// ⚠️ Load-bearing for the re-enable gate, and asymmetric:
	// CallerMayClearDisabledReason admits "", "user" and
	// "mailbox_disconnected" and REFUSES the deletion family with an input
	// error. So a folder declaring enabled:true against a paused row is
	// ACCEPTED — silently reversing an incident response — while the same
	// folder against a row in a workspace pending deletion fails every push
	// with a message about something else entirely.
	DisabledReason string `json:"disabledReason"`

	EmailAllowedFromDomains []string `json:"emailAllowedFromDomains"`
	// ⚠️ THE ROW'S SPELLING. The create/update bodies read this field as
	// `emailAllowedFromAddresses`; see this file's header.
	EmailAllowedFromAddrs []string `json:"emailAllowedFromAddrs"`
	// ⚠️ THE ROW'S SPELLING, likewise: the bodies read `watchedTableIds`.
	WatchedTableIDs []string `json:"watchedTableIDs"`

	EventName     string                  `json:"eventName"`
	MailboxID     string                  `json:"mailboxID"`
	MailboxFilter AutomationMailboxFilter `json:"mailboxFilter"`

	RateLimitPerMinute *int `json:"rateLimitPerMinute"`
	RateLimitPerDay    *int `json:"rateLimitPerDay"`

	ReferenceBased     bool     `json:"referenceBased"`
	ApprovedTools      []string `json:"approvedTools"`
	ApprovedToolGroups []string `json:"approvedToolGroups"`
	Model              string   `json:"model"`

	// Private is the create-time visibility flag, READ ONLY here on purpose.
	// The update route refuses a value that CHANGES it (a matching echo is
	// accepted and does nothing), so AutomationPatch carries no such field —
	// a client that never sends one cannot get that refusal wrong.
	Private bool `json:"private"`
	// FeatureScope is the joined feature's scope (private|workspace|
	// organization), stamped at read time — the field to branch on for privacy
	// rather than Private above.
	FeatureScope string `json:"featureScope"`

	CreatedAt time.Time `json:"createdAt"`
	// UpdatedAt is the row's last write, and it is this loop's ONLY drift
	// anchor. See Stamp for how it is spelled, and
	// wfdir.LockAutomation.UpdatedAt for why there is nothing else to anchor on
	// and what is refused with it.
	UpdatedAt time.Time `json:"updatedAt"`

	// Action is what runs when the trigger fires. It carries the reference set
	// too (inside Config), so a route that returns the action returns the
	// references — there is no second call to make.
	Action *AutomationAction `json:"action"`

	// EmailAddress / WebhookURL are the resolved per-automation receiver for an
	// email or webhook trigger, computed by the handler rather than stored.
	// Empty for the other kinds, and empty when the deployment has no ingest
	// domain or base URL configured — which is the ordinary state of a dev box,
	// so absence is never an error.
	EmailAddress string `json:"emailAddress"`
	WebhookURL   string `json:"webhookURL"`
	// WebhookHasSigningSecret reports whether HMAC verification is on. Only the
	// BOOLEAN is readable afterwards.
	WebhookHasSigningSecret bool `json:"webhookHasSigningSecret"`
	// WebhookSigningSecret is the real HMAC key, revealed ONCE in a create or
	// rotate response and never again — the get and list paths never set it. A
	// caller that does not store it here cannot get it back, and clearing a
	// secret is a human-only route, so a folder declares signing on or off at
	// create and can never re-assert it.
	WebhookSigningSecret string `json:"webhookSigningSecret"`

	// Warnings are NON-BLOCKING advisories about a write that SUCCEEDED — today
	// the two mailbox-trigger ones. A property of the WRITE, never of the row,
	// so they are absent on get and list and must be reported at the moment they
	// arrive or not at all.
	Warnings []string `json:"warnings"`
}

Automation is a HAND-MIRROR of the subset of rscheduledjob.ScheduledJobWithActionResponse the CLI reads — the DTO create, update and GET :jobID all return, which is what lets a caller read a row and write it back without a second shape to learn.

The DTO embeds rdb.ScheduledJob, so the row's own fields sit at the top level of the JSON object and are flattened here. Mirroring decisions, the same ones Workflow's doc records:

  • 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 the optional STRINGS are plain strings ("" is exactly what "unset" means for a workspace or a mailbox) and the optional INTS are pointers, because "inherit the organization default" and "an explicit 0" are different answers a folder may mean.
  • This is a SUBSET. Left out on purpose: nextRunAt / lastRunAt and the run health columns (state a file must not own), emailAddressToken and webhookToken (server-minted capabilities), the soft-delete and move bookkeeping, and the audit stamps. A mirrored field nobody reads is a field nobody keeps in step.

func (*Automation) Normalize added in v0.28.5

func (a *Automation) Normalize()

Normalize replaces nil slices with empty ones.

The server omits several of these when empty, so "no watched tables" and "field absent" arrive identically — and code that compares a folder's declaration against the row should not have to care which.

func (*Automation) Stamp added in v0.28.5

func (a *Automation) Stamp() string

Stamp renders UpdatedAt as the drift anchor a folder's lock file records.

A method rather than a caller-side Format for two reasons. The anchor is compared against itself across runs, so every writer has to spell it identically or the guard reports drift on a row nobody touched. And a ZERO time answers "" — the value that DISARMS the guard — rather than "0001-01-01T00:00:00Z", which is a perfectly well-formed anchor claiming agreement with a moment two thousand years ago and would never match again.

type AutomationAction added in v0.28.5

type AutomationAction struct {
	ID             string                 `json:"id"`
	ScheduledJobID string                 `json:"scheduledJobID"`
	Ordinal        int                    `json:"ordinal"`
	Kind           string                 `json:"kind"`
	Config         AutomationActionConfig `json:"config"`
}

AutomationAction mirrors rscheduledjob.AutomationActionResponse.

⚠️ This is what GET :jobID gained on this branch. Before it the route returned the bare row and the only route carrying an action was LIST, so reading back the automation a folder had just written meant listing up to 200 rows for the whole organization and filtering client-side — and past that, silently not finding rows that exist. For a sync loop "I cannot find the row" is not a benign miss: it reads as gone, and the next push creates a second one.

type AutomationActionConfig added in v0.28.5

type AutomationActionConfig struct {
	// Agent fields.
	Prompt             string                `json:"prompt,omitempty"`
	Model              *string               `json:"model,omitempty"`
	ReferenceBased     bool                  `json:"referenceBased,omitempty"`
	References         []AutomationReference `json:"references,omitempty"`
	ApprovedTools      []string              `json:"approvedTools,omitempty"`
	ApprovedToolGroups []string              `json:"approvedToolGroups,omitempty"`

	// Workflow fields.
	WorkflowID      string         `json:"workflowID,omitempty"`
	ParameterValues map[string]any `json:"parameterValues,omitempty"`

	// AgentID is the saved Agent a `saved_agent` action runs.
	AgentID string `json:"agentID,omitempty"`
}

AutomationActionConfig mirrors rscheduledjob.AutomationActionConfigResponse — the camelCase WIRE shape, not the snake_case storage one. Only the fields the kind in use populates are set.

type AutomationActionConfigInput added in v0.28.5

type AutomationActionConfigInput struct {
	WorkflowID      string         `json:"workflowID,omitempty"`
	ParameterValues map[string]any `json:"parameterValues,omitempty"`
	AgentID         string         `json:"agentID,omitempty"`
	// Prompt is the per-automation seed message for a saved_agent action, not
	// the inline agent's instructions — those are the body's top-level `prompt`.
	Prompt string `json:"prompt,omitempty"`
}

AutomationActionConfigInput is action.config as the server READS it.

type AutomationActionInput added in v0.28.5

type AutomationActionInput struct {
	Kind   string                      `json:"kind"`
	Config AutomationActionConfigInput `json:"config"`
}

AutomationActionInput is the `action` object on the create and update bodies, mirroring workspace.actionInput / actionConfigInput.

A separate type from AutomationAction because it is a different shape: the wire config a client SENDS carries only the workflow and saved-agent fields (an inline agent action is built from the top-level fields for back-compat, so an agent action's config is ignored), and none of the response's ids.

type AutomationMailboxFilter added in v0.28.5

type AutomationMailboxFilter struct {
	FromDomains       []string `json:"fromDomains,omitempty"`
	FromAddrs         []string `json:"fromAddrs,omitempty"`
	SubjectContains   []string `json:"subjectContains,omitempty"`
	RequireAttachment bool     `json:"requireAttachment,omitempty"`
	Label             string   `json:"label,omitempty"`
}

AutomationMailboxFilter is the optional match filter on a mailbox trigger, mirrored from workspace.mailboxFilterInput — the same shape the row's `mailboxFilter` jsonb emits, so it serves both directions.

Semantics: AND across fields, OR within a list, case-folded, and the two sender lists are ORed with each other. An absent or all-empty filter fires on every new message, which is a supported state.

Label is carried even though the server REFUSES any non-empty value, for the server's own stated reason: Ronja has no per-message label projection, so a stored label could only ever evaluate to "never matches" — an automation that looks healthy and cannot fire. A type that dropped the field would turn a declared label into a filter BROADER than the author asked for, silently. Carrying it is what makes the refusal reachable.

type AutomationPatch added in v0.28.5

type AutomationPatch struct {
	Name              *string `json:"name,omitempty"`
	Description       *string `json:"description,omitempty"`
	Prompt            *string `json:"prompt,omitempty"`
	CronExpr          *string `json:"cronExpr,omitempty"`
	Timezone          *string `json:"timezone,omitempty"`
	ReportingTimezone *string `json:"reportingTimezone,omitempty"`

	Enabled *bool   `json:"enabled,omitempty"`
	Model   *string `json:"model,omitempty"`

	References         *[]AutomationReference `json:"references,omitempty"`
	ApprovedTools      *[]string              `json:"approvedTools,omitempty"`
	ApprovedToolGroups *[]string              `json:"approvedToolGroups,omitempty"`

	EmailAllowedFromDomains   *[]string `json:"emailAllowedFromDomains,omitempty"`
	EmailAllowedFromAddresses *[]string `json:"emailAllowedFromAddresses,omitempty"`
	WatchedTableIds           *[]string `json:"watchedTableIds,omitempty"`

	MailboxID     *string                  `json:"mailboxID,omitempty"`
	MailboxFilter *AutomationMailboxFilter `json:"mailboxFilter,omitempty"`

	RateLimitPerMinute *int `json:"rateLimitPerMinute,omitempty"`
	RateLimitPerDay    *int `json:"rateLimitPerDay,omitempty"`

	Action *AutomationActionInput `json:"action,omitempty"`
}

AutomationPatch is the body of PUT /automations/:jobID, mirroring the subset of workspace.UpdateAutomationInput the CLI writes.

Every field is a POINTER because every field of the server's body 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, and a plain slice with omitempty collapses them into the first, making it impossible to clear the last watched table.

⚠️ A POINTER TO A NIL SLICE IS NOT AN EMPTY LIST. `&([]string)(nil)` marshals as `null`, and an explicit null reads on the server as absent — "unchanged" — which is the opposite of what a caller writing `&refs` after refs came back empty means. Clearing a list takes a pointer to an EMPTY, NON-NIL slice, so anything building one of these fields from a value that may be nil has to normalise it first. The types cannot enforce that; this sentence is the only thing standing between a folder that deletes its last reference and a push that silently keeps it.

Two fields are absent on purpose, and both absences are the safety property:

  • `private` — the server accepts an echoed value and refuses a changed one. A client that never sends it cannot get that wrong, and the field has no place in a committed file (see CreateAutomationInput).
  • `triggerKind` — the server's body has none, so a folder that changed it must be REFUSED with the reason rather than have the field quietly dropped. Having no field here is what makes the silent version unrepresentable.

func (AutomationPatch) Empty added in v0.28.5

func (p AutomationPatch) Empty() bool

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

⚠️ Worth more here than on DataAppPatch: `Update` and the reference / action writes are SEPARATE server-side calls, so every needless PUT is another chance to half-apply — the new schedule with the old references, indistinguishable after the fact from somebody editing references in the web app.

type AutomationReference added in v0.28.5

type AutomationReference struct {
	ResourceID string `json:"resourceID"`
	Kind       string `json:"kind"`
}

AutomationReference is one resource a reference-based run may reach, mirrored from rscheduledjob.AutomationActionRefResponse — which is also the shape the create/update bodies take (workspace.referenceInput), so this one type serves both directions.

Kind is the server's reference vocabulary and is NOT the CLI's dependency vocabulary: a table is `model` here, where markers.KindTable is "table". The full set is model, workflow, note, secret, dataapp, mcpserver, feature, mailbox (rscheduledjob.ValidateReferenceKind).

type BodySource added in v0.28.6

type BodySource interface {
	// Len is the exact number of bytes Open will yield.
	Len() int64
	// Open returns a fresh reader over the whole body. The caller closes it.
	Open() (io.ReadCloser, error)
}

BodySource is a request body that can be produced more than once.

Two callers need that. --retry and --wait-until re-issue the request, each attempt calling DoRaw again and therefore Open again; and net/http itself replays a body across a 307/308 redirect through GetBody. A plain io.Reader satisfies neither — it is drained by the first attempt, and the second would send a zero-byte body, which a server accepts.

Len is what makes a body streamable rather than buffered: it becomes Content-Length, so the bytes can come off disk as they are written to the socket instead of being held whole. A source that cannot state its size in advance (a pipe, a character device) has no business implementing this — the commands buffer those and hand back a BytesBody.

type BusyError added in v0.27.0

type BusyError struct {
	Message     string
	Calibration string
	// RetryAfter is the parsed Retry-After header, or 0 when the server sent
	// none (or something unparseable). The caller supplies its own default.
	RetryAfter time.Duration
	// Body is the 429 verbatim, so a --json caller can be shown what the server
	// actually said rather than this struct's reading of it.
	Body []byte
}

BusyError is a 429 from the preview endpoint, surfaced as its own type rather than as a plain *Error with StatusOf(err) == 429.

Two reasons it earns a type. It is not a failure of the REQUEST — the app was never rendered, so nothing at all is known about it, and a caller must not report it as "your app is broken" or change any code in response. And it is the one answer that names its own remedy: the Retry-After the server sent, which a retry that ignored it would be a rate limiter's definition of abuse.

func (*BusyError) Error added in v0.27.0

func (e *BusyError) Error() string

type BytesBody added in v0.28.6

type BytesBody []byte

BytesBody is a body already held in memory.

func (BytesBody) Len added in v0.28.6

func (b BytesBody) Len() int64

func (BytesBody) Open added in v0.28.6

func (b BytesBody) Open() (io.ReadCloser, error)

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

	// TableBuild is the schedule WaitForTableBuild polls on. Grouped rather than
	// flattened alongside the device-flow knobs above, because "MaxInterval"
	// belonging to one loop or the other is not something a reader should have
	// to infer from a comment.
	TableBuild TableBuildPoll
}

func New

func New(baseURL, token string) *Client

func (*Client) ApplyMigrations

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

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

env picks which copy the set lands on; see DatabaseSQL.

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) CheckoutTable added in v0.27.0

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

CheckoutTable gets-or-creates the CALLER'S OWN draft of a table and returns it. The returned row has a NEW id, and every subsequent call in the edit loop takes that id rather than the original's.

NOT idempotent, unlike CheckoutWorkflow: a second checkout while a draft is already open is an error. GetTableDraft is how an existing draft is picked back up, which is why the push path always tries that first.

The draft copies the original's config and its data references, so it is queryable before anything changes — but a draft that has been checked out and never SYNCED has no partitions of its own, so sample rows are only ever read after a successful sync.

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) CommitTableDraft added in v0.27.0

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

CommitTableDraft publishes a draft onto the table it was forked from. Takes the DRAFT's id — this is the one call in the loop where the wrong id silently targets the wrong row.

Two consequences worth holding on to:

  • It OVERWRITES the parent's fields from the draft, and a base-version CAS decides whether it may: the draft records the committed version it forked from, and a commit onto a table that has moved since is refused with a 409. Nothing is written when it is — the CAS runs inside the commit's own transaction, under the parent's row lock — so the draft is intact.
  • It CASCADES. The server emits a materialized event and dependent tables are invalidated and rebuilt asynchronously — which is why the loop needs no `run` verb, and why a publish is worth saying out loud.

Refused on a shared feature for a non-admin, and possibly for an admin committing their own draft under a self-approval policy. Decide which branch to take from the feature's scope and the caller's role BEFORE calling, rather than from the rejection prose. confirmHeadVersionID is empty for an ordinary commit, which sends no body at all — the shape this route took before the CAS existed. Supply it only after a 409 has named the version being overwritten, and only on a deliberate override (`pipeline publish --overwrite-remote`). It must equal the head AT COMMIT TIME, not merely be non-empty, so a caller who learns the head and then deliberates while a third version lands gets another 409 — which is why nothing here retries.

func (*Client) CommitWorkflowDraft

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

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

confirmHeadVersionID is empty for an ordinary commit, which sends no body at all — the shape this route took before the CAS existed. Supply it only after a 409 has named the version being overwritten, and only on a deliberate override (`wf publish --overwrite-remote`).

func (*Client) CreateAutomation added in v0.28.5

func (c *Client) CreateAutomation(ctx context.Context, in CreateAutomationInput) (*Automation, error)

CreateAutomation creates one automation and returns the row it made.

The response carries the action and, for a webhook trigger that asked for one, the HMAC signing secret in its ONLY appearance anywhere — a caller that discards this response cannot get it back.

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) CreateTable added in v0.27.0

func (c *Client) CreateTable(ctx context.Context, in CreateTableInput) (*Table, error)

CreateTable creates a derived table inside a feature.

The row it returns is LIVE, not a draft — unlike a workflow or a data app, whose create endpoints mint a parentless draft that publish promotes in place. A table created here is visible to the feature immediately, with no data until something syncs it. (Parentless table drafts are a known follow-up; until then a caller that creates a table has created something colleagues can see.)

Admin-only server-side, even though editing a draft is not: create is the one step of the loop a non-admin cannot perform.

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) DataAppHeadVersionID added in v0.28.5

func (c *Client) DataAppHeadVersionID(ctx context.Context, parentID string) (string, error)

DataAppHeadVersionID resolves the drift anchor for a live data app: its most recently committed version, or — when it has never been versioned — the app's OWN id.

The parent-id fallback matches HeadVersionID's, and here it is a CONVENTION of this client rather than a mirror of a server-side resolver: rdataapp has no commit CAS to have one. What it does mirror is the ORDER — rdataapp's versionHeadOrder is total — so element 0 is an identity two readers agree on and not merely whichever tied row the plan produced. The value is compared for equality against a value this same function produced, and it is a real id rather than "", so "never versioned" stays distinguishable from "nothing recorded", which is the difference between an anchor that vouches and one that refuses.

func (*Client) DatabaseSQL

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

DatabaseSQL runs one statement against a managed database.

env picks which copy: EnvironmentProd (the zero value's meaning too) runs against the database itself, EnvironmentDev against its dev copy.

func (*Client) DeleteAutomation added in v0.28.5

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

DeleteAutomation soft-deletes one automation, recording the reason on the audit entry.

⚠️ SOFT, and the softness is a caller's problem rather than a detail. The row stops firing and leaves normal listings, but sits in a 30-day trash this CLI cannot empty — purge is a human-only route — and it is AUTO-PAUSED there, so a restore brings back a disabled row. Deleting is therefore not a way to recreate an automation differently: a caller that "deletes and pushes again" to change something unpatchable leaves one trashed row per attempt.

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, baseSha256 *string) (*DataAppFileDeleteResponse, error)

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

baseSha256 asserts what is being deleted, and nil sends NO BODY AT ALL rather than `{}` — the route took none before preconditions existed, and an unconditional delete has no reason to start sending one.

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, baseSha256 *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.

baseSha256 asserts what is being deleted, and nil sends NO BODY AT ALL rather than `{}` — the route took none before preconditions existed, and an unconditional delete has no reason to start sending one.

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) DiscardTableDraft added in v0.27.0

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

DiscardTableDraft deletes a draft, throwing its uncommitted changes away. The parent table is untouched. Takes the DRAFT's id, and is not reversible.

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 BodySource, 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) DownloadFileByID added in v0.27.0

func (c *Client) DownloadFileByID(ctx context.Context, fileID string) (io.ReadCloser, error)

DownloadFileByID streams a file's bytes, given its id. The caller owns the returned reader and MUST close it — closing is also what releases the request's deadline.

Two calls, because the download route is keyed on the storage KEY rather than on the file id: the row first, then the bytes.

⚠️ The key is escaped WHOLE, slashes included, into one path segment — the route is `/download/:key`, not a wildcard, and gin unescapes the parameter on the other side. Joining the key's own slashes into the path would produce a URL that matches no route at all.

func (*Client) DownloadFrame added in v0.27.0

func (c *Client) DownloadFrame(ctx context.Context, shot PreviewScreenshot) (io.ReadCloser, error)

DownloadFrame streams one preview frame's bytes.

It prefers the KEY the preview already handed over — one call, on the same scope the preview itself needs — and falls back to resolving the id only when the instance predates the key (which costs an extra call AND a route a scoped token cannot reach; see PreviewScreenshot.FileKey).

func (*Client) GetAgent added in v0.28.5

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

GetAgent reads one saved Agent. Scope: agents:read.

⚠️ Its two failure shapes DISAGREE about which status they use, and the handler says so: an Agent that exists but is not yours answers 404, an id matching nothing at all answers 400. Both mean "not available to you", which is exactly why the verifier treats the pair identically.

func (*Client) GetAutomation added in v0.28.5

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

GetAutomation reads one automation's full configuration, action and reference set included.

404s for an id the caller cannot reach: the read gate does not distinguish absent from invisible, so a caller must not report "deleted" on a 404 — it means "not yours to see", which for a shared folder is a colleague's private feature as often as it is a missing row.

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. A feature the caller cannot reach is refused with the sentence "feature not found" — whether it does not exist, is in another organization, or is someone else's private one, which the server deliberately does not tell apart.

⚠️ Do NOT depend on the STATUS to tell those cases apart. Today a lookup miss happens to reach the wire as 400 and an unreadable-but-present feature as 404, but that split is an artefact of which helper each arm was built from rather than a contract: it is recorded as a known-open item in the backend's access model and is expected to be unified in a later pass. Older instances also answer "no rows" or a bare "not found" here. So the CLI's own matcher (commands.explainFeatureUnreachable) accepts EITHER status for this sentence and branches on the message, which is what makes it survive that pass without a client change. Anything new reading this error should do the same.

Two callers, and the second is a READ FOR EXISTENCE rather than for a field: commands.confirmFeatureIn calls it so `init` can refuse a --feature it cannot reach before writing a manifest that names it. That is still not a general feature client — nothing here enumerates, and discovery stays on plain HTTP.

func (*Client) GetFile added in v0.27.0

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

GetFile reads a file row. 404s for an id the caller cannot reach.

func (*Client) GetKitFile added in v0.27.0

func (c *Client) GetKitFile(ctx context.Context, kitPath string) (string, error)

GetKitFile returns the verbatim source of one component-kit file.

Unauthenticated (GetText attaches no credential), because the docs surface is and because the kit is identical for every tenant — it is code shipped in the binary, not customer data.

A 404 is translated rather than passed on: the instance answers an unknown kit path with the generic /docs/api breadcrumb, which reads as "wrong host" to someone who mistyped a filename. Any kit paths the breadcrumb's near-miss suggestions name are carried into the message, since a one-letter typo is by far the likeliest way to get here.

func (*Client) GetNote added in v0.28.5

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

GetNote reads one note. Scope: analytics:read — the note group declares it for every route, so this is NOT the `notes:read` a reader would guess from the path, and a token scoped by guesswork is refused before the row is looked at.

The id may carry either prefix: a note rides `note-` and the legacy `skill-`, which is why markers.IsResourceID answers for both and why this takes the string as it was written rather than normalising it.

Failure shapes collapse the way GetAgent's do — a note that exists but is not visible answers 404, an id matching nothing answers 400 — and the verifier treats the pair identically for the reason this file's opening note gives.

func (*Client) GetSecret added in v0.28.5

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

GetSecret reads one secret's metadata. Scope: secrets:read.

func (*Client) GetTable

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

GetTable reads one table row. 404s for an id the caller cannot reach — the read gate does not distinguish absent from invisible.

This is the only endpoint that serves BuildVerdict, LastBuildError and the full `code`, which is why the pipeline loop's status command pays for it per file instead of reading everything off the list.

func (*Client) GetTableDraft added in v0.27.0

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

GetTableDraft reads the CALLER'S OWN open draft of a table, 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 **Table is not a stylistic choice: decoding into a struct would turn "no draft" into a zero-value Table with an empty id, and every caller would then act on a draft that does not exist. Same shape, and same reasoning, as GetWorkflowDraft.

`id` is the LIVE table's id; the row that comes back has its own, and that is the id the rest of the edit loop takes.

func (*Client) GetTableDraftReview added in v0.27.0

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

GetTableDraftReview reads the diff payload for one DRAFT id. A row that is not an open draft is a 400 (the server's message says so; it is passed through).

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) GetWorkflowRunLineageHead added in v0.28.5

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

GetWorkflowRunLineageHead reads the run currently carrying this run's resume LINEAGE: the named run itself unless it parked and was resumed, in which case it is the successor that took the work over.

This is the route a follow polls, and it is polled with the ORIGINAL run id every time — the server re-resolves the head on every call, so each park/resume hop is picked up without the client tracking ids. That matters because a parked run never finishes under its own id: the wake latch leaves it at `resuming` forever by design and a successor run carries the work, so a follow that polled the named run would watch a lineage that has already finished sit at `resuming` until it gave up.

The response is the same RunResponse the plain route answers with, and for a run that never parked it is byte-identical to it. When it DID park, the ID SHIFT is the signal: the returned `id` names the head, `resumeOfRunID` names the lineage root, and status, error, body, outputs and logs all read off the head row.

Two error shapes, and the difference is the whole reason the CLI can degrade on an old instance: a run id that matches nothing answers 400 (`no rows`), exactly as the sibling run routes do, so a 404 from this path is never a missing RUN. It means one of two other things — the route is not there (an instance predating it, answering the router's plain-text breadcrumb, which is why callers key the fallback on the STATUS and never on the body: that body is not JSON at all), or the caller has no read access to the run's workflow, which the gate refuses as a 404 so the path cannot be used to probe run ids. The plain GetWorkflowRun disambiguates, because it answers if and only if the caller has that access: it answering while this 404s is the route being absent, and both 404ing is a run the caller may not read.

func (*Client) HeadVersionID added in v0.26.9

func (c *Client) HeadVersionID(ctx context.Context, parentID string) (string, error)

HeadVersionID resolves the CAS anchor for a live workflow: the id of its most recently committed version, or — when it has never been versioned — the workflow's OWN id.

The parent-id fallback is not a guess. It mirrors rworkflow.resolveHeadVersionID exactly: an unversioned workflow's drafts are seeded from the live row itself, so the live row IS the anchor, and sending anything else as confirmHeadVersionID would be refused.

Deliberately NOT parsed out of the 409 message. The HTTP error body is the flat app-wide `{"error": "<prose>"}` shape with no structured payload, and a client that regexes an id out of prose starts silently overwriting the wrong version the day somebody rewords it.

func (*Client) ListAutomations added in v0.28.5

func (c *Client) ListAutomations(ctx context.Context, featureID string) ([]*Automation, error)

ListAutomations reads every automation belonging to one feature.

featureID is REQUIRED, and the requirement is the point. The route without it lists everything the caller can see across the organization, which for a folder bound to one feature is both far more than it asked for and — past the page cap — silently less than it needs. `featureID` was added to this route on this branch precisely so a folder can ask its own question; a feature's automations fit one page, and Total is how the caller learns when they do not.

A feature the caller cannot read is a 404, not a 403, matching the sibling trash route: a forbidden would confirm the feature exists.

Returns ErrAutomationListTruncated rather than a short list when Total exceeds the rows returned. Read that type before making it non-fatal.

func (*Client) ListDataAppFiles

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

ListDataAppFiles reads every file of ONE data-app row — exactly the row whose id is passed. A draft is its own address: the server never substitutes the caller's open draft for a live id (it once did, silently, which is why every caller in this CLI resolves the draft explicitly with GetDataAppDraft and passes the id it actually means). A baseline is a claim about a specific row, so naming the row is what keeps `status` comparing the right two things.

func (*Client) ListDataAppVersions added in v0.28.5

func (c *Client) ListDataAppVersions(ctx context.Context, parentID string) ([]DataApp, error)

ListDataAppVersions reads an app's committed version snapshots, newest first.

The server orders them (committed_at DESC, id DESC) — the same TOTAL order rworkflow uses, and for the same reason. committed_at alone is not total: rdataapp.commitDraftInTx stamps it from one `now` taken before the parent row lock, so two commits the lock serialized can share a timestamp, and two reads of an untiebroken list could then disagree about which is the head. The drift guard reads a differing head as "somebody published" and refuses — safe, but a deploy refused for a reason no message can explain. The id tiebreak removes the case.

Like ListWorkflowVersions this is a LIST route: the rows carry no `url` and should be treated as identity and timing only.

func (*Client) ListFeatureTables added in v0.27.0

func (c *Client) ListFeatureTables(ctx context.Context, featureID string) ([]*TableListItem, error)

ListFeatureTables reads every table belonging to one feature.

Shadow rows are excluded by the endpoint's own default, so this is live rows only — which is what a clone wants. Filtering to `derived` is left to the CALLER rather than done here: the skipped kinds have to be REPORTED ("not cloned (kinds: dynamic, integration)"), and a client that dropped them silently could not say what it skipped.

⚠️ Pagination is offset-based, so a table created or deleted mid-walk can shift a row across a page boundary and be seen twice or not at all. Nothing here compensates: the fix would be a stable cursor the API does not offer, and pretending otherwise (deduplicating, retrying) would hide the skew rather than remove it. A clone that missed a table reports one fewer file; it does not corrupt anything.

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) ListWorkflowRuns added in v0.27.0

func (c *Client) ListWorkflowRuns(ctx context.Context, workflowID string, limit int) ([]WorkflowRun, error)

ListWorkflowRuns reads a workflow's most recent runs, newest first.

The ordering is REQUESTED, not assumed: rworkflow.QueryRuns applies no default ORDER BY, so a limited query without one returns whatever rows Postgres happened to reach first — which for a "find the last failed run" caller is not a sort problem but a wrong answer. `executed_at desc` is validated server-side against the table's columns.

Runs belong to a workflow ROW, and a draft is its own row, so this is asked of the draft `wf test` runs rather than of the live workflow. That matches the server's own resume rule: the run being resumed must belong to the same workflow the resume is posted to.

func (*Client) ListWorkflowVersions added in v0.26.9

func (c *Client) ListWorkflowVersions(ctx context.Context, parentID string) ([]Workflow, error)

ListWorkflowVersions reads a workflow's committed version history, newest first — the server orders it (committed_at DESC, id DESC), which is the SAME total order rworkflow.resolveHeadVersionID resolves the head by, so element 0 is the head rather than merely a recent version.

It lives on the write side, like GetWorkflowFile, because it exists for the write side: the only caller is the commit CAS override, which needs the id of the version it is about to overwrite. The route answers a bare JSON array of rows and stamps no `url` on them (it is a LIST route), so treat these as identity and timing only.

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) PreviewDataApp added in v0.27.0

func (c *Client) PreviewDataApp(ctx context.Context, appID string, in PreviewRequest) (*PreviewOutcome, error)

PreviewDataApp renders a data app headlessly and returns what was seen.

A busy harness comes back as *BusyError, NOT as a *PreviewOutcome with a flag: the two are different events (an observation versus the absence of one), and a single return value would let a caller read a busy answer as an app with no errors. Everything else non-2xx is an ordinary *Error.

func (*Client) Promote added in v0.28.5

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

Promote moves the migrations a database's dev copy has and the database does not onto the database itself.

databaseID is the PRODUCTION database, never the copy — the copy has no id the CLI ever sees. There is no environment parameter for the same reason the server refuses a dev session here: this call names production explicitly, and redirecting it would promote the copy into itself and report success.

The tail is applied through the same server-side path ApplyMigrations uses, so the response is a MigrationSetResult with the same four outcomes.

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, baseSha256 *string) (*DataAppFileSaveResponse, error)

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

baseSha256 is the optional compare-and-swap precondition described on putDataAppFileInput. A failed precondition is a 409 raised INSIDE the write's transaction, before the file row is touched — so unlike most failures on this route the file certainly kept its previous content, which is why the push loop treats a 409 as definite rather than reconciling it.

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, baseSha256 *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.

baseSha256 is the optional compare-and-swap precondition described on putWorkflowFileInput. A failed precondition is a 409, and the write is rolled back — the file certainly keeps its previous content, exactly like a 400.

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) (*DataAppCommitOutcome, 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.

It answers the same DataAppCommitOutcome shape as the commit route (with `proposed: false`), so the audience is available on BOTH endings of "take my draft live".

An older server answers this route with an EMPTY body, which is io.EOF to the decoder. That is not a failure — the review WAS submitted; only the narration is missing — so it degrades to a zero-value outcome rather than reporting a successful submission as an error.

io.ErrUnexpectedEOF is deliberately NOT tolerated, and the distinction is the whole point: io.EOF means the server sent NO body (nothing to narrate), while ErrUnexpectedEOF means it sent a body that was CUT OFF mid-way — a truncated response we have no business reading as "fine, just quiet". Widening the check to any EOF-ish error would swallow exactly the connection failures a publish path must surface.

func (*Client) RequestTableReview added in v0.27.0

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

RequestTableReview submits the caller's own draft for admin review. Takes the DRAFT's id, and is idempotent.

NOTE the different prefix, exactly as with RequestWorkflowReview: this route lives on the GOVERNANCE handler at /api/v2/table/draft/:id/request-review, whose group is scoped `admin` rather than the table loop's `data`. 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.

This is the only exit from the loop for a non-admin in a SHARED feature, where commit is refused.

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) ResumeWorkflowRun added in v0.27.0

func (c *Client) ResumeWorkflowRun(ctx context.Context, workflowID, resumeOfRunID string) (*WorkflowRun, error)

ResumeWorkflowRun starts a run that RESUMES a failed one, replaying the lineage's journaled step results instead of re-executing the work they record. Like RunWorkflow it returns immediately at status "running".

It sends resumeOfRunID and NOTHING ELSE, which is the contract rather than a simplification. A resume inherits the target run's parameter values verbatim — the server copies them across — and refuses any parameterValues the caller supplies, because changed parameters would silently invalidate every step result computed under the old ones. So this deliberately does not share RunWorkflow's body struct: there, an empty map is sent to avoid a null; here, a parameterValues key is a thing to be unable to send at all.

The server owns the rest of the rules (the target must be a failed run of THIS workflow, its lineage must have no run already running and none that has succeeded, and a gated workflow cannot be resumed) and its refusals are passed through verbatim.

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) Search added in v0.28.5

func (c *Client) Search(ctx context.Context, term string, limit int) ([]SearchHit, error)

Search runs the global find-anything search and returns the merged hits, best first.

Access-filtered per credential by construction — every kind is searched through its own read-access rules — so an empty result means "nothing you can see is called that", never "no such row". The one asymmetry worth knowing about a SCOPED token: `secret` hits are absent unless the token also holds `secrets:read`, since that is what reading secret metadata costs everywhere else.

A limit of zero or less takes the server's default (which is also MaxSearchLimit).

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) SyncTable added in v0.27.0

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

SyncTable triggers a DETERMINISTIC materialization: the server executes the stored `code` exactly as written, with no AI and no code rewriting.

This is the golden path, and the distinction from POST /:id/build matters enough to state: `/build` is AI-ASSISTED and may REWRITE the stored SQL, which for a folder-synced table would silently replace the file's own content. The CLI never calls it.

Two further properties, both of which shape the loop around this call:

  • It syncs EXACTLY the row named. It does not mint a draft (`/build` does), so the id passed here decides whether the live table or the draft is materialized.
  • The 200 means ACCEPTED, not started successfully. Every failure — down to syncing a table with no code at all — is reported on the ROW, never in this response. WaitForTableBuild is the other half of this call.

A sync does NOT cascade: dependent tables are not rebuilt. Commit is what cascades.

func (*Client) UpdateAutomation added in v0.28.5

func (c *Client) UpdateAutomation(ctx context.Context, id string, patch AutomationPatch) (*Automation, error)

UpdateAutomation patches one automation and returns the row as it now stands.

⚠️ NOT TRANSACTIONAL server-side: the row update and the reference / action writes are separate calls, so a failure at the second leaves the new schedule with the OLD references — a state indistinguishable, after the fact, from somebody editing references in the web app. Nothing here can fix that; a caller must report a failure at the moment it happens, while it is still attributable, rather than retrying quietly.

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) UpdateTableCode added in v0.27.0

func (c *Client) UpdateTableCode(ctx context.Context, id, code string, inputModels []string, baseCodeSha256 string) error

UpdateTableCode writes a table's SQL and its declared inputs.

`id` is the row being written, which in the edit loop is always the DRAFT's id. Writing the LIVE row instead is silently lost work: committing a draft overwrites the parent's fields from the draft, so a PUT on the canonical vanishes the moment anyone commits.

The code sent should be the CANONICAL id-ref form (see tablerefs). A positional ref written here is re-resolved server-side against whatever inputModels this same call declares, which is not necessarily the list it was numbered against.

The endpoint answers with nothing, so a caller that needs the row's current state re-reads it.

baseCodeSha256 is empty for an unconditional write, which sends no such field at all — the shape this route took before the precondition existed. Supply it only as the fingerprint of the CODE THIS CLIENT LAST SENT to this same row; anything else (the disk form of an aliased file, a hash taken from a different row) is refused with a 409 that describes a conflict nobody caused.

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.

func (*Client) WaitForTableBuild added in v0.27.0

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

WaitForTableBuild polls a table until its build reaches a verdict.

EVERY command polls through this one function, so the `ready`-with-an-error trap is decoded in exactly one place. Pass the id of the row that was actually synced — in the pipeline loop that is always the DRAFT, which is also what keeps the draft-aware LastBuildError describing the row being waited on.

A `failed` or `failed_stale` verdict comes back as a RESULT, not an error. Errors are reserved for losing contact with the instance, a cancelled context, and the deadline.

The deadline case names the stuck-`pending` cause specifically, because it is the one failure with no error row to read: syncing a table with no `code` parks it at `pending` forever, and the server reports that nowhere else.

type CommitDraftInput added in v0.26.9

type CommitDraftInput struct {
	ConfirmHeadVersionID string `json:"confirmHeadVersionID,omitempty"`
}

CommitDraftInput is the OPTIONAL body of POST /workflow/:id/commit, mirroring rworkflow.CommitDraftInput (backend/resource/rworkflow/model.go).

An empty body is the normal case and means "no override": the commit then requires the parent to still be at the version this draft was seeded from, and is refused with a 409 when it is not.

ConfirmHeadVersionID acknowledges the specific committed version this commit will OVERWRITE. It must equal the head AT COMMIT TIME, not merely be non-empty — a caller who learns the head, deliberates, and commits while a third version lands gets another 409. That re-refusal is the feature, which is why nothing here retries.

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 CreateAutomationInput added in v0.28.5

type CreateAutomationInput struct {
	FeatureID string `json:"featureID"`

	Name        string `json:"name,omitempty"`
	Description string `json:"description,omitempty"`
	Prompt      string `json:"prompt,omitempty"`

	// TriggerKind defaults to cron server-side when absent.
	TriggerKind string `json:"triggerKind,omitempty"`

	CronExpr          string `json:"cronExpr,omitempty"`
	Timezone          string `json:"timezone,omitempty"`
	ReportingTimezone string `json:"reportingTimezone,omitempty"`

	EmailAllowedFromDomains   []string `json:"emailAllowedFromDomains,omitempty"`
	EmailAllowedFromAddresses []string `json:"emailAllowedFromAddresses,omitempty"`
	WatchedTableIds           []string `json:"watchedTableIds,omitempty"`

	EventName     string                   `json:"eventName,omitempty"`
	MailboxID     string                   `json:"mailboxID,omitempty"`
	MailboxFilter *AutomationMailboxFilter `json:"mailboxFilter,omitempty"`

	// WebhookSigningSecret asks the server to mint an HMAC key, returned ONCE in
	// the response. Create-only: there is no way to re-assert it later, and
	// clearing one is a human-only route.
	WebhookSigningSecret *bool `json:"webhookSigningSecret,omitempty"`

	RateLimitPerMinute *int `json:"rateLimitPerMinute,omitempty"`
	RateLimitPerDay    *int `json:"rateLimitPerDay,omitempty"`

	// Enabled is a POINTER because absent is a legitimate third state: a folder
	// that does not declare `enabled` is not managing it, which is what stops a
	// hard-coded true from reversing a human pause on some later push.
	Enabled *bool `json:"enabled,omitempty"`

	Model              string                `json:"model,omitempty"`
	ReferenceBased     *bool                 `json:"referenceBased,omitempty"`
	References         []AutomationReference `json:"references,omitempty"`
	ApprovedTools      []string              `json:"approvedTools,omitempty"`
	ApprovedToolGroups []string              `json:"approvedToolGroups,omitempty"`

	Action *AutomationActionInput `json:"action,omitempty"`
}

CreateAutomationInput is the body of POST /automations, mirroring the subset of workspace.CreateAutomationInput the CLI writes.

It uses the INPUT vocabulary for the two mismatched fields (emailAllowedFromAddresses, watchedTableIds) and sends exactly one spelling of each — sending both is a 400.

⚠️ There is deliberately NO `private`. Visibility is create-only server-side and it stays out of the file schema entirely: a shared folder pushing private:true would make the row invisible to the next colleague who pushes, whose status then reports it gone and whose push creates a duplicate.

The trigger-conditional fields are omitempty because the server REJECTS a field belonging to a different trigger kind rather than ignoring it, so a body must carry only what the chosen kind uses.

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 CreateTableInput added in v0.27.0

type CreateTableInput struct {
	Name      string `json:"name"`
	FeatureID string `json:"featureID"`
	// Kind is always TableKindDerived from this loop. A pipeline folder is a
	// folder of SQL; a kind that is not built from SQL has nothing a .sql file
	// could mean.
	Kind        string   `json:"kind"`
	Engine      string   `json:"engine"`
	Code        string   `json:"code"`
	InputModels []string `json:"inputModels"`
}

CreateTableInput is the body of POST /feature/model, mirroring the subset of feature.CreateTableInput the CLI writes.

Six fields, where the server's request type has fifteen. Everything else is either build state, draft lifecycle, metric recipe or verification — none of it the CLI's to set, and the server's own type comment explains why the fat body was narrowed in the first place.

InputModels is sent even though the server would derive it from the refs in `code`. That is the same rule as on the update path and for the same reason — see UpdateTableCode — plus one specific to create: the derivation and the access check run off different inputs, and sending the list makes the lineage this folder DECLARES the lineage the row gets, rather than whatever a regex found.

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"`
	// RuntimeVersion is 1, 2 or 3. Stamped at CREATE here, and afterwards
	// raisable one way (1 -> 2, 1 -> 3, 2 -> 3) through WorkflowPatch — never
	// lowered.
	//
	// omitempty, so a folder that declares no runtime sends no key and the SERVER
	// chooses. Its default is no longer 1, so the answer is read back off the
	// created row rather than assumed — see resolvePushTarget.
	RuntimeVersion int `json:"runtimeVersion,omitempty"`
	// ReportingTimezone is the declared execution calendar, when the folder
	// manages one. A plain string with omitempty rather than the patch's pointer,
	// because the three-state distinction does not exist here: the CREATE
	// endpoint REFUSES an explicit empty string (reportingtz.ParseDeclared
	// rejects it) and stamps the organization's default when the key is absent,
	// which is exactly what an unmanaged folder wants. A folder declaring the
	// reset value sends the literal "UTC" instead — see effectiveDeclaredZone.
	ReportingTimezone string `json:"reportingTimezone,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"`
	// BaseVersionID is what the DRAFT was forked from — and ⚠️ it does NOT mean
	// what the identically-named field on Workflow means, which is why
	// `app clone` resolves its anchor through dataAppCloneAnchor rather than
	// through cloneAnchor.
	//
	// Two writers, two meanings:
	//
	//   - An ordinary checkout (rdataapp.buildDraftFromParent) stamps
	//     optional.Value(parent.ID) — the LIVE APP'S OWN ID — for every draft,
	//     whether or not the app has committed versions. rworkflow stamps the
	//     head VERSION row's id in the same place. So on a versioned app this
	//     value is not a version at all, and comparing it with what
	//     DataAppHeadVersionID answers can only ever fail.
	//   - RestoreFromVersion stamps the real version id being restored, which is
	//     the workflow meaning.
	//
	// The two are told apart by the value: equal to the app's identity id means
	// the parent-id sentinel, anything else is a genuine version snapshot.
	//
	// Null on every other row.
	BaseVersionID string `json:"baseVersionID"`

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

	// URL is the absolute frontend /apps/<id> page, stamped by the SERVER
	// (api/v2/dataapp.DataAppView) on the single-row routes. Never built here —
	// see the same field on Workflow for why, and for why an empty value is
	// normal, silent, and must not be filled in with a guess.
	URL string `json:"url,omitempty"`
}

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 the minted script-token carries
	// alongside the id allowlists. Only four names ever belong here (ai,
	// query_external, write_external, upload_file) — everything else is
	// derived server-side from the allowlists. The list lives in
	// commands.declarableCapabilities, which `app push` refuses against;
	// `ronja app init --help` describes each one.
	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 DataAppAudience added in v0.28.2

type DataAppAudience struct {
	// Reach is "author" (a private feature — an audience of one) or
	// "organization" (everyone in the org can open it).
	Reach string `json:"reach"`
	// FeatureName is the feature that DECIDES the reach; an app has no scope of
	// its own.
	FeatureName string `json:"featureName"`
	// Reads / Calls / Writes are the app's bindings, labelled. Writes is
	// non-empty only when the app can write to a managed database — every
	// viewer gets it, since an app cannot tell viewers apart.
	Reads  []string `json:"reads"`
	Calls  []string `json:"calls"`
	Writes []string `json:"writes"`
	// Sentence is the line to print.
	Sentence string `json:"sentence"`
}

DataAppAudience mirrors rdataapp.AudienceSummary.

The SENTENCE is composed server-side and printed verbatim. Composing one here would be a second statement of "who can do what", and two statements of one grant drift — the CLI would eventually describe an app's powers differently from the app's own publish response and from what Ronja tells the user in chat.

type DataAppCommitOutcome

type DataAppCommitOutcome struct {
	DataAppID    string `json:"dataAppID"`
	FirstPublish bool   `json:"firstPublish"`
	Proposed     bool   `json:"proposed"`
	// Audience is who ends up able to open the app and what they can do — the
	// question publish never answered, and the reason two customers shipped a
	// system of record to an audience of one, or handed a whole organization a
	// managed database's write role without being told.
	Audience *DataAppAudience `json:"audience,omitempty"`
}

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"`
	// Environment is which copy actually answered: "prod", or "dev" when the
	// request was redirected to the database's dev copy.
	Environment string `json:"environment"`
	// ParentDatabaseID is the production database the caller named, and the
	// server sets it ONLY when the request was redirected. So it is the honest
	// signal that a redirect happened, where Environment is the label for it.
	//
	// It carries no omitempty here even though the server's does: this struct is
	// emitted verbatim by `ronja db sql --json`, and a field that vanishes when
	// it is empty makes the payload a different shape depending on the answer.
	ParentDatabaseID string `json:"parentDatabaseID"`
}

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 Environment added in v0.28.5

type Environment string

Environment names WHICH COPY of a managed database a request runs against.

A managed database can have a dev copy — a schema-identical sandbox with its own ledger — and the server resolves it. The client never learns the copy's id and never sends one: it names the PRODUCTION database and says which environment it means. That asymmetry is the whole safety property, so do not "simplify" this into a second database id the caller passes around.

const (
	// EnvironmentProd is the database itself, and the default everywhere.
	EnvironmentProd Environment = "prod"
	// EnvironmentDev is the database's dev copy, resolved server-side.
	EnvironmentDev Environment = "dev"
)

func ParseEnvironment added in v0.28.5

func ParseEnvironment(raw string) (Environment, error)

ParseEnvironment validates a caller-typed environment name.

Validated HERE rather than left to the server so a typo fails locally, before a migration set or a statement is sent: the server answers a bad value with a 400, which is correct but arrives as a message about a query parameter nobody typed. Trimmed and case-folded because the server does the same, and a value this command accepts must not become one the server rejects.

type ErrAutomationListTruncated added in v0.28.5

type ErrAutomationListTruncated struct {
	FeatureID string
	// Total is what the server says exists; Returned is what one page carried.
	Total    uint64
	Returned int
	// Unfiltered records that the page came back holding rows from OTHER
	// features, which only an instance predating the `featureID` parameter does
	// — so Total is the organization's count and not this feature's. Without it
	// the message tells the reader to split a feature that may hold three
	// automations, and no --force gets them past it.
	Unfiltered bool
}

ErrAutomationListTruncated is what ListAutomations returns when the feature holds more automations than one page carries.

A REFUSAL rather than a short list, and rather than paging. model.QueryOptions.Token encodes an OFFSET, so walking pages while rows are created or deleted shifts a row across a boundary and skips it — which for a sync loop is not a benign miss: a row it cannot see reads as deleted, and the next push creates a duplicate beside the live one. That is the exact false-green this loop exists to remove, and paging would re-introduce it through the mechanism meant to fix it. An honest "could not tell" beats a paginated wrong answer.

func (*ErrAutomationListTruncated) Error added in v0.28.5

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 File added in v0.27.0

type File struct {
	ID          string `json:"id"`
	Name        string `json:"name"`
	FileKey     string `json:"fileKey"`
	ContentType string `json:"contentType"`
	ContentSize int64  `json:"contentSize"`
}

File is a HAND-MIRROR of the sliver of rdb.File a preview frame needs.

Narrow on purpose, and for the same reason Feature is: this is not a general file client, and discovery belongs on plain HTTP. It exists because a preview hands back FILE IDS and the bytes live behind a second call keyed on FileKey — which is the only field here anything branches on.

Tags copied from backend/ronja/rdb/table_file.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"`
	// Environment is which copy the set was applied to, or planned against:
	// "prod", or "dev" when the request was redirected to the dev copy.
	Environment string `json:"environment"`
	// ParentDatabaseID is the production database the caller named, set only on
	// a redirect. See DatabaseSQLResult.ParentDatabaseID.
	ParentDatabaseID string `json:"parentDatabaseID"`
	// Notes are advisory and never block. Only promote emits them today: a
	// promoted migration that writes ROWS replays its SQL against production's
	// data rather than the dev copy's, which only the operator can judge.
	Notes []string `json:"notes"`
}

MigrationSetResult is the POST :id/migrations response, and — identically — the POST :id/promote one.

Reused for promote rather than mirrored a second time, because it IS the same shape: promote applies a set of migrations through the same server-side path, so it returns the same verdicts. Sharing the type is what lets both commands print through one reporter, which is the only way the two stay describable in the same words.

No omitempty anywhere, for the reason DatabaseSQLResult gives: `--json` emits this verbatim.

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 Note added in v0.28.5

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

Note is the sliver of a note the edge verifier reads.

The BODY is deliberately not decoded. The verifier branches on the error and nothing else, and a note's content is the largest thing on this surface — a skill note is a document — so decoding it would move a megabyte per edge to answer a question the status line already has.

type PreviewBusy added in v0.27.0

type PreviewBusy struct {
	Busy        bool   `json:"busy"`
	Message     string `json:"message"`
	Calibration string `json:"calibration"`
}

PreviewBusy is the 429 body: the shared render pool (or this caller's own per-minute budget) was spent, so NOTHING about the app was observed.

type PreviewConsoleError added in v0.27.0

type PreviewConsoleError struct {
	Message string `json:"message"`
}

PreviewConsoleError is a deduped console.error call. Message carries the same <untrusted_app_output> envelope PreviewRuntimeError.Message does, and for a blunter reason: a console.error commonly prints a whole tenant row.

type PreviewHarnessRequest added in v0.27.0

type PreviewHarnessRequest struct {
	URL    string `json:"url"`
	Method string `json:"method"`
	Status int    `json:"status"`
	Ms     int64  `json:"ms"`
}

PreviewHarnessRequest is one service-API request as the BROWSER saw it — the fallback view for a bundle with no op ledger.

type PreviewNetworkFailure added in v0.27.0

type PreviewNetworkFailure struct {
	URL        string `json:"url"`
	Status     int    `json:"status"`
	StatusText string `json:"statusText"`
}

PreviewNetworkFailure is a 4xx/5xx or transport failure, after platform-CDN noise has been filtered out server-side.

type PreviewOp added in v0.27.0

type PreviewOp struct {
	Path      string `json:"path"`
	StartedAt int64  `json:"startedAt"`
	Ms        int64  `json:"ms"`
	Rows      *int64 `json:"rows,omitempty"`
	OK        bool   `json:"ok"`
}

PreviewOp is one entry of the SDK op ledger — a query, agent run or file read the app performed, timed from inside the page.

Rows is a POINTER because nil ("this operation has no row count to report") and 0 ("it returned nothing") are different facts, and a preview exists partly to tell a blank page apart from an empty result.

type PreviewOutcome added in v0.27.0

type PreviewOutcome struct {
	Result *PreviewResult
	Raw    []byte
}

PreviewOutcome pairs one decoded observation with the bytes it arrived as.

The raw bytes are not a convenience. This file is a hand-mirror taken at one moment, and an instance newer than the CLI sends fields it does not know about; a report written by re-marshalling PreviewResult would silently drop exactly those, which is the drift the mirror convention exists to bound. So `ronja app test` writes report.json from Raw and reads Result only to summarise — the file is always at least as complete as the server's answer.

type PreviewRequest added in v0.27.0

type PreviewRequest struct {
	// Route is an in-app hash fragment ("#/orders"). The server adds a missing
	// '#' and refuses a control character.
	Route string `json:"route,omitempty"`
	// ViewportPreset is "desktop" (the default) or "mobile". The server resolves
	// anything else to desktop rather than failing, which is exactly why the CLI
	// refuses an unknown value locally: a typo that silently renders the wrong
	// size answers a question nobody asked.
	ViewportPreset string `json:"viewportPreset,omitempty"`
	// TimeoutMs is the render budget, clamped server-side to the deployed
	// harness's ceiling and never refused. Zero means "the server's default".
	TimeoutMs int `json:"timeoutMs,omitempty"`
	// Steps is an optional interaction plan run after the app renders.
	//
	// ⚠️ USR_ADMIN only, and it EXECUTES FOR REAL with viewer authority: a click
	// runs the app's own code, so it can dispatch workflows and agents, upload
	// files and write data. Nothing is mocked and nothing is rolled back.
	Steps []PreviewStep `json:"steps,omitempty"`
}

PreviewRequest is the body of POST :id/preview. Every field is optional; an empty body renders the app's default route at desktop size.

type PreviewResult added in v0.27.0

type PreviewResult struct {
	Rendered   bool  `json:"rendered"`
	DurationMs int64 `json:"durationMs"`

	// Viewport and Route are what the harness CONFIRMED it applied — never a
	// restatement of what this CLI asked for. Both are ABSENT when the harness
	// did not echo the settings it used (which is every deployed harness that
	// predates the echo) and when nothing was rendered at all, so an empty value
	// means "we cannot tell", not "desktop" and not "the default route".
	//
	// ⚠️ Printing the REQUEST here instead would be the one genuinely misleading
	// thing this report could say: a caller checking a mobile layout would read
	// its own argument back and conclude the mobile render was observed.
	Viewport string `json:"viewport,omitempty"`
	Route    string `json:"route,omitempty"`

	// Settle says WHY the harness stopped waiting, and is the first thing worth
	// reading: "deadline" means the frame shows a page that was still working.
	Settle *PreviewSettle        `json:"settle,omitempty"`
	Ops    []PreviewOp           `json:"ops"`
	Errors []PreviewRuntimeError `json:"errors"`

	ConsoleErrors   []PreviewConsoleError   `json:"consoleErrors"`
	NetworkFailures []PreviewNetworkFailure `json:"networkFailures,omitempty"`

	// AppOutputNote is the server's static one-liner about app-authored output:
	// that every `message` on Errors and ConsoleErrors is text the APP produced
	// and arrives wrapped in <untrusted_app_output>, to be read as evidence about
	// the render rather than as an instruction.
	//
	// Present ONLY when there is such output to describe — an observation with no
	// errors carries no note, because a note attached to nothing would read as a
	// claim that some other string here is untrusted.
	AppOutputNote string `json:"appOutputNote,omitempty"`

	// HarnessRequests is the outside-in view of the app's service-API traffic,
	// carried ONLY when the op ledger is absent (a bundle compiled before the
	// ledger shipped). It cannot report row counts.
	HarnessRequests []PreviewHarnessRequest `json:"harnessRequests,omitempty"`

	// CompileDiagnostics is populated INSTEAD of a render when the bundle did not
	// build. Still an observation: the call is a 200 and Errors is empty, which
	// is why a caller gating on Errors alone would call a broken build clean.
	CompileDiagnostics []CompileDiagnostic `json:"compileDiagnostics,omitempty"`

	// Steps is one entry per interaction the VALIDATOR reported, in plan order.
	// Absent when no plan was sent — and also when a validator predating steps
	// support ignored one, which is what StepsNote exists to say out loud.
	Steps     []PreviewStepResult `json:"steps,omitempty"`
	StepsNote string              `json:"stepsNote,omitempty"`

	ScreenshotFileIDs []string `json:"screenshotFileIDs,omitempty"`
	// SelectedScreenshotFileID names the ONE representative frame — the frame the
	// in-product agent is handed as an image. Selection is by PHASE preference,
	// not by position, so "the last id" is not it.
	SelectedScreenshotFileID string   `json:"selectedScreenshotFileID,omitempty"`
	StepScreenshotFileIDs    []string `json:"stepScreenshotFileIDs,omitempty"`

	Hint        string `json:"hint,omitempty"`
	Calibration string `json:"calibration"`

	// DataAppID is the row that was ACTUALLY rendered: the caller's own open
	// draft when they have one, which is not necessarily the id in the path.
	DataAppID string `json:"dataAppID"`
	// Screenshots is the persisted filmstrip in capture order. Empty when no
	// frame was captured or the instance has no bucket wired.
	Screenshots []PreviewScreenshot `json:"screenshots"`
}

PreviewResult is one render's observation — api/v2/dataapp.PreviewResult flattened, since the server embeds tools.PreviewObservation into it.

Ops / Errors / ConsoleErrors carry no omitempty server-side, so "no errors" arrives as an explicit `[]` rather than an absent key. Mirrored the same way so a re-marshal of this struct says the same thing the server did.

type PreviewRuntimeError added in v0.27.0

type PreviewRuntimeError struct {
	// Message arrives WRAPPED in <untrusted_app_output> — see AppOutputNote. The
	// wrap is the server's, and the raw bytes keep it: report.json is written
	// from PreviewOutcome.Raw, so what a reader opens is exactly what the server
	// framed. Only the terminal summary unwraps it, and only for display.
	//
	// Stack / RawStack / Source / Line / Column are NOT wrapped. They are Ronja's
	// reading of the bundle's own source map rather than a string the app chose.
	Message  string `json:"message"`
	Stack    string `json:"stack,omitempty"`
	RawStack string `json:"rawStack,omitempty"`
	Source   string `json:"source,omitempty"`
	Line     int    `json:"line,omitempty"`
	Column   int    `json:"column,omitempty"`
}

PreviewRuntimeError is one uncaught error or unhandled rejection. Source / Line / Column are the first source-map-resolved frame inside the author's own code, so a reader has "App.tsx:270:28" without parsing a stack.

type PreviewScreenshot added in v0.27.0

type PreviewScreenshot struct {
	FileID string `json:"fileID"`
	// FileKey is what the bytes are actually fetched by, and it is the field to
	// prefer: /api/v2/file/download/:key is `data`-scoped like the preview
	// itself, whereas resolving an id through /api/v2/feature/file/:id needs a
	// route whose group carries no scope at all — which a SCOPED token is
	// refused on. Empty only against an instance older than this field.
	FileKey string `json:"fileKey"`
	// Phase is the NEWEST phase these pixels were observed at: "first_paint" /
	// "settled" / "deadline", or "step-<n>" for a `screenshot` step's frame.
	Phase string `json:"phase"`
	// Selected marks the ONE representative frame, on the wire so the CLI, the
	// chat card and the model all cite the same picture.
	Selected bool `json:"selected"`
}

PreviewScreenshot is one persisted filmstrip frame.

An id and a storage key, not a presigned URL: the caller fetches the bytes with the credential it already has. See DownloadFrame.

type PreviewSettle added in v0.27.0

type PreviewSettle struct {
	// Signal is "sdk" (the app's own op ledger reported everything finished),
	// "heuristic" (network and DOM went quiet) or "deadline" (the budget ran
	// out, so the frame shows a page still working).
	Signal              string   `json:"signal"`
	PendingOpsAtCapture int      `json:"pendingOpsAtCapture"`
	DOMQuietMs          int64    `json:"domQuietMs"`
	InFlightAtCapture   int      `json:"inFlightAtCapture"`
	PendingHosts        []string `json:"pendingHosts,omitempty"`
	OpsStarted          int      `json:"opsStarted"`
	FirstRenderAt       int64    `json:"firstRenderAt"`
	LastSettledAt       int64    `json:"lastSettledAt"`
	ReportedReadyAt     int64    `json:"reportedReadyAt"`
	PageTimeOriginMs    int64    `json:"pageTimeOriginMs"`
}

PreviewSettle mirrors jsvalidator.SettleInfo.

⚠️ TWO CLOCKS. DOMQuietMs is the HARNESS's clock; FirstRenderAt / LastSettledAt / ReportedReadyAt are the PAGE's (performance.now()). They are not comparable without PageTimeOriginMs. The CLI reads only Signal, and anything that starts subtracting one from the other must read that note in backend/platform/jsvalidator/types.go first.

type PreviewStep added in v0.27.0

type PreviewStep struct {
	Action    string `json:"action"`
	Selector  string `json:"selector,omitempty"`
	Text      string `json:"text,omitempty"`
	Value     string `json:"value,omitempty"`
	TimeoutMs int    `json:"timeoutMs,omitempty"`
}

PreviewStep is one requested interaction, mirroring tools.PreviewStepRequest.

The grammar, and what each action needs:

"click"      — selector (or text)
"fill"       — selector + a NON-EMPTY value
"waitFor"    — selector (or text); waits for VISIBLE, not merely present
"screenshot" — nothing; captures a frame tagged phase "step-<n>"

Text is a SELECTOR shorthand (Playwright's text= engine), never a value alias on fill.

type PreviewStepResult added in v0.27.0

type PreviewStepResult struct {
	Index       int                `json:"index"`
	Action      string             `json:"action"`
	OK          bool               `json:"ok"`
	Skipped     string             `json:"skipped,omitempty"`
	Error       string             `json:"error,omitempty"`
	SettleAfter *PreviewStepSettle `json:"settleAfter,omitempty"`
	// DOMText is document.body.innerText after the step.
	//
	// ⚠️ UNTRUSTED. It is the APP's content, rendered from tenant rows and
	// whatever external systems wrote into them. It reaches report.json because
	// that is a file the caller chose to write; it is deliberately NOT printed in
	// the terminal summary, where it would be indistinguishable from the CLI's
	// own words.
	DOMText string `json:"domText,omitempty"`
}

PreviewStepResult is what happened at one interaction step.

⚠️ Read Skipped BEFORE OK. A skipped step NEVER RAN (the invocation's step window was spent), so its ok:false says nothing about the app; a step with ok:false and no skipped is one that ran and did not do what it asked.

type PreviewStepSettle added in v0.27.0

type PreviewStepSettle struct {
	Signal   string `json:"signal"`
	Ms       int64  `json:"ms"`
	InFlight int    `json:"inFlight"`
}

PreviewStepSettle is the abbreviated settle report after a mutating step.

type PromoteInput added in v0.28.5

type PromoteInput struct {
	DryRun bool `json:"dryRun,omitempty"`
}

PromoteInput is the POST :id/promote body.

It carries no migration list, and that is the design rather than an omission: what promotes is exactly the tail the dev copy's ledger has past production's, which the server computes from the two ledgers. There is nothing for a caller to choose, so there is nothing for a caller to get wrong.

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"`
	// ZoneUsed is the IANA reporting timezone the query's DuckDB session ran
	// at. The CLI carries no client timezone, so it is normally "UTC" — while
	// the same SQL in the browser runs at the signed-in user's zone. Any query
	// that buckets by day/week/month can therefore return different numbers on
	// the two surfaces, and this is the only field that says so.
	ZoneUsed string `json:"zoneUsed"`
}

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 RunInFlight added in v0.28.4

type RunInFlight struct {
	Code              string `json:"code"`
	BlockingRunID     string `json:"blockingRunId"`
	BlockingRunStatus string `json:"blockingRunStatus"`
	BlockingSince     string `json:"blockingSince"`
}

RunInFlight is the machine-readable half of that 409: the run already holding the workflow's concurrency slot.

It exists because the message cannot be relied on to carry it. The server builds this refusal with the blocking run in DETAILS precisely so a client does not have to parse a sentence — rjerr's client message unwraps to the bare sentinel and drops anything a wrapper added — so reading these fields is the only way to name the run that is in the way, and matching prose is the thing that breaks the day somebody rewords it.

BlockingRunID is empty when the server could not name the run: the partial unique index is the race-proof layer and works without a read, so the loser is sometimes anonymous. Callers must handle that rather than print an empty id.

func AsRunInFlight added in v0.28.4

func AsRunInFlight(err error) (*RunInFlight, bool)

AsRunInFlight reports whether an error is the concurrency-skip refusal, and what it said.

Gated on the CODE and not on the status: a 409 on this client also means a file precondition refusing (see workflow_write.go), and those two have nothing in common but a number. An unrecognised 409 comes back false, and the caller falls through to the server's own message.

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|waiting — see the constants above.
	Health string `json:"health"`
	// JournalEntries is how many steps this run's resume LINEAGE has journaled
	// — what a resume would replay instead of re-running. Derived on read, and
	// populated only for a FAILED run: 0 everywhere else, including on a
	// successful run that journaled plenty, because that run has nothing to
	// resume.
	//
	// It is NOT len(Steps). Steps are the observable timeline (run spans), a
	// different set from the journal, and a report that printed one as the other
	// would be a confident wrong number at the moment somebody decides whether
	// to trust a resume to skip work.
	JournalEntries int `json:"journalEntries"`
}

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, and durable waits are what turned that from a preference into the only correct spelling: a parked run is in flight in the SERVER's sense — it resumes on its own — but there is nothing here to wait for, because a park lasts until an agent answers or a timer fires, which can be days. "Not done and not error" would sit on it until --timeout.

The same shape covers a status added server-side later, where the failure modes are not symmetric either. 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 on a run that has already stopped executing.

type SearchHit added in v0.28.5

type SearchHit struct {
	// Kind is which resource this is — one of the SearchKind* constants below.
	Kind string `json:"kind"`
	// ID is the resource id, prefixed by kind ("table-…", "workflow-…"). This is
	// the value a bind records.
	ID string `json:"id"`
	// Title is the resource's own name, which is what a hit's Score was computed
	// against for every kind that names one.
	Title string `json:"title"`
	// Score is the relevance BUCKET, not a distance: see SearchScoreExact.
	Score float64 `json:"score"`
}

SearchHit is a HAND-MIRROR of the subset of search.Hit the CLI reads (backend/lib/search/hit.go).

Four fields of eleven, on table.go's rule: a mirrored field nobody reads is a field nobody keeps in step. The ones left out are left out for a reason — `featureID` and `updatedAt` describe where a row lives and when it moved, which says nothing about whether it is the row an alias means, and `verification` and `subKind` qualify hits `bind` never asks for.

type Secret added in v0.28.5

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

Secret is the sliver of a secret the edge verifier reads.

It carries NO credential material and never will: the read route serves the row's metadata, and the values live behind a separate surface this CLI has no business touching.

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"`
	// Replayed marks a step whose result came out of the resume lineage's
	// journal — the body never ran. Always present server-side, and false for
	// every step of a run that is not a resume. It is what separates a
	// durability skip from a continue_on_error skip, which share status
	// "skipped".
	Replayed bool `json:"replayed"`
}

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"`
	FeatureID string `json:"featureID"`

	Name string `json:"name"`
	// Kind is derived|foundation|integration|manual_integration|dynamic|metric.
	Kind string `json:"kind"`
	// Status is pending|invalidated|building|ready|build_failed. Read
	// BuildVerdict instead wherever you mean "did the build work" — see the
	// constants above.
	Status string `json:"status"`

	// Code is the table's SQL, in whichever ref form the row happens to store —
	// id form from an HTTP write, POSITIONAL form from any AI-assisted build.
	// Never hash or write this field raw; use CanonicalCode.
	Code        string   `json:"code"`
	InputModels []string `json:"inputModels"`

	// ParentModelID is empty on a live table and set on a draft (and on a
	// committed version snapshot). ShadowStatus says which.
	ParentModelID string `json:"parentModelID"`
	// ShadowStatus is draft|committed, empty on a live table.
	ShadowStatus string `json:"shadowStatus"`

	// BuildVerdict collapses `status` × "is there a failure on THIS ROW?" into
	// the one value to branch on after a sync.
	//
	// Populated ONLY by the single-row GET — never by the list endpoint, and
	// never on the create/checkout responses, which share the same DTO. ABSENT
	// means "not populated on this response", NOT "no verdict", so read it only
	// off a GetTable answer. WaitForTableBuild owns the fallback for an older
	// instance that does not send it at all.
	BuildVerdict string `json:"buildVerdict,omitempty"`

	// LastBuildError is the last recorded failure, absent when there is none.
	//
	// DRAFT-AWARE, deliberately, and that is not the same scope as BuildVerdict:
	// the server reads the table id PLUS the caller's own open draft, so on a
	// healthy live table you happen to hold a failed draft of, BuildVerdict is
	// `ok` while this describes the draft's failure. The pipeline loop always
	// polls the DRAFT's own id, where the two scopes coincide.
	LastBuildError *TableBuildError `json:"lastBuildError,omitempty"`

	UpdatedAt time.Time `json:"updatedAt"`

	// URL is the absolute frontend page for this row, stamped by the SERVER.
	// Never built here, and EMPTY IS NORMAL AND SILENT — see the identical field
	// on Workflow for why. Absent on the list routes and on rows the server
	// refuses to link (a committed version snapshot).
	URL string `json:"url,omitempty"`
}

Table is a HAND-MIRROR of the subset of feature.TableView the CLI reads (backend/api/v2/feature/tableview.go).

A SUBSET, and a small one: TableView carries some sixty fields — metric recipe columns, build artifacts, PII state, move and soft-delete bookkeeping — and a mirrored field nobody reads is a field nobody keeps in step. The rule is "everything the pipeline loop reads, plus what makes a read field legible".

Mirroring decisions, the same two as Workflow's:

  • 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 every optional STRING here is a plain string. "" is exactly what "unset" means for a name, a parent id or a shadow status.
  • InputModels carries no omitempty server-side, but a hand-written fixture or an older instance can still send null, so Normalize replaces the nil.

func (*Table) CanonicalCode added in v0.27.0

func (t *Table) CanonicalCode() (string, bool)

CanonicalCode is Code in ID ref form, plus whether any positional ref could not be resolved.

EVERY remote code read goes through this before it is hashed, written to disk or compared — that is what makes a local sha256 baseline comparable to a remote row at all. See package tablerefs for why the stored form is mixed and why positional is steady state rather than legacy.

func (*Table) IdentityID added in v0.27.0

func (t *Table) IdentityID() string

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

This is what a manifest binding records, for the reason Workflow.IdentityID exists: a draft's 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 (*Table) Normalize added in v0.27.0

func (t *Table) Normalize()

Normalize replaces a nil InputModels with an empty slice, so callers can range and compare without nil-checking.

type TableBuildError added in v0.27.0

type TableBuildError struct {
	// Message is the human-readable reason. For the commonest derived-table
	// failure — the SQL writer concluding the transformation is impossible with
	// the given inputs — this carries that full explanation, which is the single
	// most useful thing a failed push can print.
	Message string `json:"message"`
	// Type is the stable rjerr type string. PartialDataCorruptErrorType is the
	// non-fatal notice; anything else is an ordinary build failure.
	Type       string    `json:"type,omitempty"`
	OccurredAt time.Time `json:"occurredAt"`
}

TableBuildError mirrors feature.TableBuildError: the last failure recorded against a table's build. The rows are cleared at the start of every rebuild, so a present value always describes the LATEST build.

type TableBuildPoll added in v0.27.0

type TableBuildPoll struct {
	// Interval is the wait between the first polls.
	Interval time.Duration
	// MaxInterval caps the backoff.
	MaxInterval time.Duration
	// Timeout bounds the whole wait. A non-positive value is DEFAULTED like
	// every other field rather than read as "no deadline": there is no caller
	// that wants a build watched forever, and a partially-set struct meaning
	// exactly that is a hang nobody asked for.
	Timeout time.Duration
	// MaxFailureWindow bounds an UNBROKEN run of failed polls by ELAPSED TIME.
	// Unbroken rather than cumulative: a poll that succeeds proves the instance
	// is reachable, so the window starts over.
	//
	// Time rather than a count, on the device flow's precedent
	// (DefaultMaxPollFailureWindow): a count silently means "count x interval",
	// so the real tolerance moved whenever the cadence did — three polls at 2.5s
	// is ~5 seconds, which is far less than an ordinary rolling deploy takes, and
	// a build that is running perfectly well server-side was reported as a lost
	// instance.
	MaxFailureWindow time.Duration
	// RateLimitBackoff 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 it.
	RateLimitBackoff time.Duration
}

TableBuildPoll bounds WaitForTableBuild's cadence and patience.

A struct rather than five more flat fields on Client: the device flow already owns five poll knobs there, and a second flat set would leave every one of them needing its name to say which loop it belongs to.

type TableBuildResult added in v0.27.0

type TableBuildResult struct {
	// Verdict is one of the BuildVerdict* constants. Never empty: an instance
	// that does not send one has it derived (see fallbackVerdict).
	Verdict string
	// Table is the last row read. Never nil on a nil error.
	Table *Table
	// Derived records that the verdict was computed client-side because the
	// instance did not send one — worth knowing before quoting it as the
	// server's answer.
	Derived bool
}

TableBuildResult is how a build ENDED.

A failed build is DATA here, not an error: the caller renders the reason, leaves the draft in place to iterate on, and exits non-zero itself. An error return from WaitForTableBuild means something else — we stopped being able to ask.

func (*TableBuildResult) ErrorMessage added in v0.27.0

func (r *TableBuildResult) ErrorMessage() string

ErrorMessage is the reason the build failed, empty when there is none.

func (*TableBuildResult) OK added in v0.27.0

func (r *TableBuildResult) OK() bool

OK reports a build that produced usable data. `ok_partial` counts: the table IS queryable, and the incompleteness is a warning to print rather than a reason to refuse.

type TableCommitDraftInput added in v0.28.6

type TableCommitDraftInput struct {
	ConfirmHeadVersionID string `json:"confirmHeadVersionID,omitempty"`
}

TableCommitDraftInput is the (optional) body of POST /feature/model/:id/commit, mirroring rmodelv2.CommitDraftInput (backend/resource/rmodelv2/model.go). Named apart from the workflow package's CommitDraftInput because both live in this one package; the wire shape is the same because the server's two types are.

An empty body is the normal case and means "no override": the commit then requires the live table to still be at the version this draft forked from, and is refused with a 409 when it is not.

type TableDraftReview added in v0.27.0

type TableDraftReview struct {
	DraftID string `json:"draftID"`
	// TableID is the LIVE parent's id.
	TableID string `json:"tableID"`

	// FieldsDelta is empty when the column sets match. It is only meaningful
	// once the draft has been SYNCED: an unbuilt draft has no fields and reports
	// every live column as removed.
	FieldsDelta []TableFieldDelta `json:"fieldsDelta"`

	// InputModelsDelta is the lineage change, ABSENT when the declared inputs
	// agree. Reported separately from the SQL because it is the one change a
	// draft can make with no SQL diff at all — a positional ref resolves by
	// INDEX into this list, so repointing it moves what the same text reads.
	InputModelsDelta *TableInputModelsDelta `json:"inputModelsDelta,omitempty"`

	// DraftRowCount / LiveRowCount are materialized row counts, or -1 for "not
	// available" (never built, no field stats yet). -1 IS NOT ZERO, and printing
	// it as one would report a table that was never built as a table that came
	// back empty.
	DraftRowCount int64 `json:"draftRowCount"`
	LiveRowCount  int64 `json:"liveRowCount"`

	// BaseStale is true when the live table advanced after this draft forked.
	// Landing a stale draft would revert those changes, so the commit call
	// REFUSES it with a 409 rather than letting it through — this plus
	// InterveningVersions is the warning that comes first, and it says what the
	// commit is about to say anyway.
	BaseStale bool `json:"baseStale"`
	// InterveningVersions are the versions that landed in between, newest
	// first. Present only when BaseStale.
	InterveningVersions []TableInterveningVersion `json:"interveningVersions"`
	// HeadVersionID is the live table's CURRENT head committed version, and the
	// value to send as confirmHeadVersionID to overwrite it on purpose.
	//
	// EMPTY is a real state, not "unknown": a table that has never been
	// committed to has no version to name, and the server returns empty rather
	// than falling back to the table's own id the way the workflow surface does.
	// Returned ALWAYS, not only when BaseStale — but it is a point-in-time read,
	// so a commit that carries it can still be refused if somebody lands a
	// version in between. That re-refusal is the point of an override that
	// names a version rather than a bare --force.
	HeadVersionID string `json:"headVersionID"`

	// SubmittedForReview is whether request-review has already been called.
	// `pipeline publish` reads it on the review route it takes and reports
	// "already submitted" instead of filing a second request: the server accepts
	// one and the draft is unharmed, but telling somebody their draft was just
	// submitted when an admin has had it since yesterday reports progress that
	// did not happen.
	SubmittedForReview bool `json:"submittedForReview"`
}

TableDraftReview mirrors governance.TableDraftReview — the diff payload behind a confidence report: what landing this draft would actually change.

Computed entirely from state that already exists (nothing is built or executed), so it is cheap and safe to call in a loop.

⚠️ Note the SCOPE quirk, which is the one thing that can make this call fail on an otherwise working loop: the route lives at /api/v2/table/draft/:id/review in the ADMIN scope group, while the rest of the authoring loop (checkout / PUT / sync / commit) is `data`. A full-access PAT — the CLI default — is unaffected; a token scoped to `data` alone runs the whole loop except this.

type TableFieldDelta added in v0.27.0

type TableFieldDelta struct {
	Name      string `json:"name"`
	Change    string `json:"change"`
	DraftType string `json:"draftType,omitempty"`
	LiveType  string `json:"liveType,omitempty"`
}

TableFieldDelta mirrors governance.TableFieldDelta — one column-level entry in a draft's schema diff.

Change is "added" (only in the draft) or "removed" (only in live). The server compares column NAMES only: "retyped" is RESERVED and currently never emitted, and DraftType / LiveType are reserved with it — currently always absent. Mirrored anyway so the payload decodes if they start arriving; treat an empty one as "not reported" rather than as a type of "".

type TableInputModelsDelta added in v0.27.0

type TableInputModelsDelta struct {
	Added   []string `json:"added,omitempty"`
	Removed []string `json:"removed,omitempty"`
}

TableInputModelsDelta mirrors governance.TableInputModelsDelta — the LINEAGE change committing a draft would make: the table ids its declared `input_models` gains and loses against the live row's.

Mirrored where the payload's nameDelta and descriptionDelta are NOT, and the rule is the one this file's header states: a mirrored field nobody reads is a field nobody keeps in step. Lineage is the one of the three this loop DECLARES — `inputModels` is derived from the refs in the .sql file and sent on every write — so a delta here is a consequence of the file just pushed and belongs in the confidence report. A name is not: it is stamped once at create and never pushed again.

A SET comparison server-side, so a pure REORDER of the same ids is reported as nothing. That gap is invisible to this loop by construction: the CLI sends a sorted list derived from id-form refs and refuses positional ones outright, which is what makes an order change meaningless here.

type TableInterveningVersion added in v0.27.0

type TableInterveningVersion struct {
	VersionID   string    `json:"versionID"`
	Name        string    `json:"name"`
	CommittedAt time.Time `json:"committedAt"`
}

TableInterveningVersion mirrors governance.TableInterveningVersion — one committed version of the live table that landed AFTER this draft forked.

type TableListItem added in v0.27.0

type TableListItem struct {
	ID        string `json:"id"`
	FeatureID string `json:"featureID"`
	Name      string `json:"name"`
	Kind      string `json:"kind"`
	Status    string `json:"status"`
	// ParentModelID / ShadowStatus are the draft markers. The list EXCLUDES
	// shadow rows by default (includeShadows), so in practice both are empty
	// here — they are mirrored so a caller can assert that rather than assume it.
	ParentModelID string `json:"parentModelID"`
	ShadowStatus  string `json:"shadowStatus"`
	// HasError is the list-only "something is recorded against this row" flag.
	// It is a HINT: it says a failure exists, never what it was, and never
	// whether the table is nonetheless serving good data. Read the single-row
	// GET's buildVerdict for the answer.
	HasError  bool      `json:"hasError"`
	UpdatedAt time.Time `json:"updatedAt"`
}

TableListItem is a HAND-MIRROR of the subset of rmodelv2.ModelV2QueryItem the CLI reads (backend/resource/rmodelv2/model.go).

A separate type from Table rather than a reuse, because the LIST projection is a genuinely different shape and pretending otherwise would be a lie in the one direction that costs: it carries NO `code` and NO `buildVerdict`, so a caller handed a Table with both empty could not tell "this table has no SQL" from "this endpoint does not serve SQL". HasError is the list's own cheap health signal and exists on no other surface.

type UpdateTableInput added in v0.27.0

type UpdateTableInput struct {
	Code        string   `json:"code"`
	InputModels []string `json:"inputModels"`
	// BaseCodeSha256 is the sha256 of the `code` the caller believes the row
	// currently holds, lowercase hex over the raw bytes — wfdir.HashString,
	// which rmodelv2.CodeSHA256 is held byte-identical to.
	//
	// ⚠️ It is a fingerprint of the WIRE form, because that is what the row
	// stores: the id-ref SQL this client SENT, not the aliased SQL on disk.
	BaseCodeSha256 string `json:"baseCodeSha256,omitempty"`
}

UpdateTableInput is the body of PUT /feature/model/:id, mirroring the two fields of rmodelv2.Patch the CLI writes.

BOTH are always sent, together, on every write. This is the single most load-bearing decision in the client, so it is stated here rather than left to a caller to remember:

  • Server-side derivation (resolveDerivedRefs) fires ONLY when the effective declared list is empty, and a checked-out draft INHERITS its parent's non-empty input_models. So a PUT that omitted the field would 400 the moment an edit added a new upstream ref — the commonest pipeline edit there is.
  • Sending the exact list also REMOVES stale entries. Left behind they are phantom lineage edges that survive in the graph and trigger GetDependents-driven cascade rebuilds of a table that no longer reads from them.

No omitempty on either: an empty `inputModels` is a real declaration ("this table reads from nothing"), and omitempty would collapse it into "derive it for me", which is a different instruction.

BaseCodeSha256 is the opposite case, and it MUST carry omitempty — the one place on this type where the tag is load-bearing in the other direction. The server reads three states (rmodelv2.checkCodePrecondition): ABSENT is "write unconditionally", a digest is compare-and-swap, and "" is a 400 rather than "no check", deliberately, so that a caller who thought they were asserting something is told they were not. An `omitempty`-less tag would send `""` on every unconditional write and turn the ordinary push into a 400.

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"`
	// RuntimeVersion is 1, 2 or 3 — the runtime the candidate is checked against.
	//
	// ⚠️ 0 is NOT the create default here. This endpoint reads it as "run no
	// runtime-scoped rules", which is what every caller written before runtimes
	// existed is asking for — deliberately unlike CreateWorkflowInput, where an
	// absent key means "give me the current default". So a rehearsal of a CREATE
	// has to NAME the runtime the create will produce, or it rehearses a
	// different save. wfdir.Manifest.RuntimeForValidate is where that is decided.
	RuntimeVersion int `json:"runtimeVersion,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.

RuntimeVersion is omitempty for a third reason: the server reads an absent value as 1, so a v1 candidate's request bytes stay exactly what they were before durable workflows existed.

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"`
	// RuntimeVersion is the semantics generation the code is written against: 1
	// (the standard runtime) or 2 (durable — steps are journaled, so a failed
	// run can be resumed). Stamped at create and afterwards raisable ONE WAY,
	// 1 -> 2, through WorkflowPatch; the server refuses 2 -> 1 outright.
	//
	// ZERO MEANS THE INSTANCE DID NOT SAY, not "runtime 1". The column is NOT
	// NULL server-side, so an instance that has it always sends a value; a 0
	// here is an instance predating durable workflows, and the caller falls back
	// to what the folder's manifest declares rather than concluding v1.
	RuntimeVersion int `json:"runtimeVersion"`

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

	// ReportingTimezone is the IANA calendar the workflow's DuckDB session runs
	// at on EVERY run (migration 000499) — what date_trunc and date casts bucket
	// against, so it decides which day or month a timestamp near midnight lands
	// in. A plain string for the reason the other optional strings are: the
	// server's optional.V marshals `null` when unset, and unmarshalling null into
	// a string is a documented no-op.
	//
	// "" therefore means the row declares NO zone, and falls back to the caller's
	// context zone at run time. That is a PERMANENT state, not a migration
	// backlog: 000499 backfills nothing, and a workflow created while the
	// organization has no default declares nothing either. It is NOT the same as
	// the literal "UTC" a reset writes, and the CLI keeps the two apart.
	ReportingTimezone string `json:"reportingTimezone"`

	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"`
	// BaseVersionID is the committed version a DRAFT was forked from — the
	// server's own record of what this draft's content descends from
	// (rdb.Workflow.BaseVersionID, stamped at checkout and cleared on commit).
	//
	// Null on every row that is not an open draft, and null on a draft of a
	// workflow that has never been versioned, so an empty value means "no
	// anchor" rather than "no such version". It is read ONLY by `wf clone`, to
	// record the right head pointer when the files came from a draft rather
	// than from live: the current head may be NEWER than the draft's base, and
	// a folder that recorded the newer one would claim to have seen a version
	// whose changes it does not contain.
	BaseVersionID string `json:"baseVersionID"`
	// 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"`

	// URL is the absolute frontend page for this row, stamped by the SERVER
	// (api/v2/workflow.WorkflowView) on the single-row routes. Never built here:
	// the instance URL a profile records is the API origin, and the deploy
	// template puts the backend on api.* and the frontend on app.*, so a link
	// assembled from it points at a host that serves no pages. In local dev the
	// two differ even more sharply.
	//
	// EMPTY IS NORMAL AND SILENT. The field is omitempty server-side and an
	// instance with no configured frontend origin emits none at all (see
	// backend/lib/deeplink), which is the ordinary state of a dev box. A caller
	// with no url prints no link — it never falls back to deriving one, and it
	// never treats absence as an error.
	//
	// Absent on the LIST routes (GET /workflow/query, GET :id/versions) and on
	// rows the server refuses to link, so read it off the response you have
	// rather than assuming every Workflow value carries one.
	URL string `json:"url,omitempty"`
}

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"`
	ReportingTimezone *string              `json:"reportingTimezone,omitempty"`
	RuntimeVersion    int                  `json:"runtimeVersion,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. ReportingTimezone is a POINTER for the same reason Parameters is one, and the server's rule is what forces it: an absent key leaves the declared execution zone alone, while an explicit "" RESETS it to the literal "UTC" (rworkflow.resolveDeclaredZonePatch). Both are things a folder legitimately means — "I don't manage the zone" and "I declare UTC" — and a plain string with omitempty collapses them into the first, making the reset unexpressible. RuntimeVersion is a plain int with omitempty, and the third state the two pointers carry has nothing to describe here: the field moves ONE WAY (1 -> 2, the Durable runtime), so "the folder does not manage it" and "the folder declares the runtime the row already has" both mean "send nothing". The server refuses 2 -> 1 outright; the CLI refuses it earlier, before it has written anything.

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|waiting|resuming — 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"`

	// ResumeOfRunID marks this run as a RESUME of an earlier failed one: the
	// lineage's journaled step results were replayed instead of re-executed.
	// Absent for an ordinary run, which is every run of a v1 workflow.
	//
	// It always names the LINEAGE ROOT, never the run the caller asked to
	// resume — the server normalizes it — so a chain R1 → R2 → R3 has both R2
	// and R3 pointing at R1. Nothing in the CLI has to unwind that: a resume is
	// requested against the failed run you have, and the server does the rest.
	ResumeOfRunID *string `json:"resumeOfRunID"`

	// Kind is the workflow's output channel, denormalized at run-create time:
	// unspecified|function|report|pipeline.
	Kind string `json:"kind"`
	// RuntimeVersion is the workflow's runtime, denormalized at run-create time
	// — the semantics this run and every resume of its lineage execute under,
	// which is deliberately not re-read off the workflow row. 0 on an instance
	// that predates durable workflows.
	RuntimeVersion int `json:"runtimeVersion"`
	// 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