Documentation
¶
Overview ¶
Package wfdir owns the on-disk shape of a synced folder — a workflow's or a data app's: what is committed to the customer's git (ronja.json), what is purely local sync bookkeeping (.ronja/state.json), and which local files are candidates for sync at all.
The two kinds share this whole model because they ARE the same shape: a feature-owned resource whose content is a set of source files under a per-user draft/commit lifecycle, with identical server-side path rules. What differs is carried by Kind, and by the two kind-specific manifest blocks (Parameters for a workflow, Access for a data app). The package name predates data apps and is left alone rather than churning every import.
The split is the whole design:
- ronja.json describes the RESOURCE — its title, its entrypoint, and which row it maps to on each instance. It is instance-plural on purpose (one repo pushing to staging and to production is the normal CI case, not an exotic one), and it belongs in version control.
- .ronja/state.json is this CHECKOUT's baseline: the hash of every file as the server last had it. Drafts are per-user, so a colleague cloning the repo must not inherit someone else's baseline — hence never committed, and hence clone/init drop a .ronja/.gitignore that excludes the whole directory rather than trusting anyone to add it.
Nothing here talks to the network, and the only non-stdlib import is internal/config — for NormalizeURL alone, because the manifest's per-instance keys have to fold exactly the way the credential store's do or the same instance ends up spelled two ways. That keeps the folder model unit-testable without a server.
Index ¶
- Constants
- Variables
- func CheckLocalPaths(paths []string, kind Kind) error
- func DirIsEmpty(dir string) (bool, error)
- func FileSlug(name, fallback string) string
- func Find[T any](items []T, keyOf func(T) InstanceKey, want InstanceKey) (int, error)
- func FindRoot(start string) (string, error)
- func Hash(body []byte) string
- func HashString(body string) string
- func ManifestPath(root string) string
- func Matches(a, b InstanceKey) bool
- func NotSyncable(path string, kind Kind) string
- func SaveManifest(root string, m *Manifest) error
- func SaveState(root string, s *State) error
- func Slug(title, fallback string) string
- func StatePath(root string) string
- func StructuralExclusion(path string, kind Kind) string
- func ValidatePath(path string) error
- func WriteFile(root, path, content string) error
- func WriteStateGitignore(root string) error
- type Binding
- type Diff
- type Enumeration
- type FileState
- type Instance
- type InstanceBaseline
- type InstanceKey
- type InstanceState
- type Kind
- type Manifest
- func (m *Manifest) Binding(key InstanceKey) (Binding, bool, error)
- func (m *Manifest) BindingEntry(key InstanceKey) (Instance, bool, error)
- func (m *Manifest) DeclaredAccess() api.DataAppAccess
- func (m *Manifest) DeclaredParameters() []api.WorkflowParameter
- func (m *Manifest) DeclaredReportingTimezone() string
- func (m *Manifest) IsDurable() bool
- func (m *Manifest) ManagesAccess() bool
- func (m *Manifest) ManagesParameters() bool
- func (m *Manifest) ManagesReportingTimezone() bool
- func (m *Manifest) RuntimeVersion() int
- func (m *Manifest) SetAccess(access api.DataAppAccess)
- func (m *Manifest) SetBinding(key InstanceKey, b Binding)
- func (m *Manifest) SetParameters(params []api.WorkflowParameter)
- func (m *Manifest) SetReportingTimezone(zone string)
- type Skipped
- type State
- type TableState
Constants ¶
const ( MaxPathLength = 256 MaxPathDepth = 8 )
The server's own limits, mirrored from backend/resource/rworkflow/ store_files.go (validatePath) — and identical in backend/resource/rdataapp/ store_files.go, whose validatePath is a deliberate copy of the same rules, so ONE grammar covers both kinds. They are duplicated rather than discovered because the point is to tell the caller a file will be rejected BEFORE a push spends a round-trip finding out — and to know, locally, which files in a folder are even candidates for sync.
Keeping them in step with the server is load-bearing; a rule that drifts looser here means a confusing mid-push 400, and one that drifts stricter means silently skipping a file the user expected to push.
const ( // ManifestName is the committed manifest at the folder root. ManifestName = "ronja.json" // StateDirName holds everything local-only. StateDirName = ".ronja" // StateFileName is the sync baseline inside StateDirName. StateFileName = "state.json" // GitignoreName is written inside StateDirName with "*" in it, so the // baseline cannot be committed by accident. GitignoreName = ".gitignore" )
The folder's fixed filenames.
const ( KindWorkflow = "workflow" KindDataApp = "dataapp" // KindPipeline is a folder of derived-table SQL files. Unlike the other two // it binds MANY resources — one table per .sql file — which is why Binding // carries a Tables map and why the single-id helpers refuse this kind rather // than answering with a workflow's field. KindPipeline = "pipeline" )
The manifest kinds this CLI understands. An unknown value is refused rather than ignored: the folder types are different resources with different semantics, and a command that pushed one's files into the other would be actively destructive.
const ( RuntimeDefault = 1 RuntimeDurable = 2 )
The workflow runtime versions a folder may declare.
RuntimeDefault is what a workflow gets when the create body says nothing, and its behaviour is frozen: v1 runs stay bit-identical. RuntimeDurable journals every @tools.step result, which is what makes a failed run resumable.
Two values, listed rather than range-checked, because the CLI has to refuse a third: the manifest is committed, and a folder declaring a runtime the instance has never heard of would create a workflow whose runtime nobody can name — discovered at the first run, not at the push.
const DefaultEntrypoint = "main.py"
DefaultEntrypoint matches the server's own default for a new workflow.
Retained as a package-level constant because it is what `wf init` writes and what a manifest missing an entrypoint falls back to; new code should prefer Kind.DefaultEntrypoint.
Variables ¶
var ( // WorkflowKind is a Python workflow folder. WorkflowKind = Kind{ Name: KindWorkflow, Label: "workflow", Command: "ronja wf", DefaultEntrypoint: "main.py", } // DataAppKind is a data-app folder. // // The entrypoint is the server's fixed default AND its only value: a data // app's entrypoint is stamped at create and rdataapp.Patch carries no field // to change it, so unlike a workflow's this is not a preference the manifest // can move. DataAppKind = Kind{ Name: KindDataApp, Label: "data app", Command: "ronja app", DefaultEntrypoint: "App.tsx", SkipDirs: []string{"node_modules", "dist", "build"}, } // PipelineKind is a folder of derived-table SQL files. // // No entrypoint: the other two kinds have ONE file the server runs, while a // pipeline folder is a SET of tables with no distinguished member — so the // concept has nothing to name here. Empty is carried deliberately rather // than defaulted to something harmless, because "" is what LoadManifest // falls back to and what SaveManifest round-trips. // // No SkipDirs, because SyncExt already covers what they would: a folder that // syncs `.sql` and nothing else has no reason to name node_modules/ or // venv/ — their contents are ruled out by extension, before anything is read // off disk. PipelineKind = Kind{ Name: KindPipeline, Label: "pipeline", Command: "ronja pipeline", SyncExt: ".sql", } )
var ErrAmbiguousInstance = fmt.Errorf("this folder is bound to several organizations")
ErrAmbiguousInstance is returned when the caller's organization is unknown and the folder is bound to several on one instance. Picking one would be a coin flip between two organizations' workflows.
var ErrMultiResourceBinding = errors.New("this folder kind binds many resources, not one — use Binding.Tables")
ErrMultiResourceBinding is returned by the single-id binding accessors for a kind whose folder binds many resources.
A sentinel rather than a formatted string because the refusal is a PROGRAMMING error, not a user-facing one: reaching here means a command asked a pipeline folder for "the" bound row, and the answer is that it has to use Tables.
var ErrNoManifest = errors.New("no ronja.json found")
ErrNoManifest is returned when a directory (or any of its parents) holds no ronja.json. Callers translate it into "run this inside a workflow folder".
Functions ¶
func CheckLocalPaths ¶
CheckLocalPaths reports whether a REMOTE file set can be laid out in a folder without losing a file, and is meant to run before the first byte is written.
It exists because the baseline is a promise: .ronja/state.json says "these paths are on disk with these hashes", and every later command believes it. A file the server has but the folder does not is read as a LOCAL DELETION — status shows a phantom deleted entry, and a push acts on it by deleting the file server-side. Losing the user's code to a folder that was wrong from the moment it was created is not a warning-level event.
Three ways a set cannot be laid out:
- A path WriteFile refuses (legacy rows, or a malicious one). Skipping it with a warning is what produced the phantom-deletion case above.
- A path Enumerate excludes by NAME (NotSyncable): a dot-file or dot-directory ANYWHERE in it, the folder's own ronja.json / .ronja/, one of this Kind's SkipDirs, or a file outside this Kind's SyncExt — a `.md` handed to a pipeline folder is written once and then invisible to every walk. The server's path grammar allows `.` in a segment, so `.env` and `.config/x.py` are legal workflow paths that clone would happily write — and `dist/app.js` is a legal data-app path — and that the walk would then never see again. Exactly the phantom-deletion shape above, just arrived at by naming rather than by refusal. This is why Kind is a parameter here and not only on Enumerate: the two must answer identically.
- Paths differing only by case. "Main.py" and "main.py" are two distinct rows server-side and ONE file on macOS or Windows, so the second write silently destroys the first — and the baseline then claims both exist. Checked unconditionally rather than by probing the filesystem, so a clone behaves identically everywhere and a folder cloned on Linux does not become unusable the moment a colleague pulls it on a Mac.
The remedy is not something the CLI can perform, so the error names the offending paths and points at the web builder, which can rename them.
func DirIsEmpty ¶
DirIsEmpty reports whether a directory has no entries. A missing directory counts as empty — the caller is about to create it.
func FileSlug ¶ added in v0.27.0
FileSlug turns a resource's NAME into a filename stem. It is Slug plus two rules: an underscore survives, and so does CASE.
The two are deliberately separate functions rather than one with a flag, because they name different things and only one of them round-trips. Slug names a clone DIRECTORY — a local choice, made once, that nothing compares against later. FileSlug names a file inside a folder that lives in the customer's git, and that file's stem is also the table's display name, so the name goes out and has to come back the same way. Folding `_` into `-` broke exactly that loop: an author writing orders_clean.sql got orders-clean.sql back from `pipeline clone`, which is a second file for one table and a phantom deletion the moment either is pushed.
Everything outside [a-z0-9_-] becomes a separator, and a literal `-` is treated as one too rather than as an allowed character — so runs collapse ("Sales / Revenue" and "sales---revenue" both give one dash) exactly as they do in Slug. Underscores are NOT collapsed: they are content here, and orders__clean is a filename somebody typed on purpose.
Only dashes are trimmed from the ends, not underscores. A leading `_` is a real naming convention for staging tables, and stripping it would be this same round-trip bug in a smaller font.
Case survives for that same round trip, and it is the last place the loop folded: a table named `Orders` came back as orders.sql, so the file git was already tracking as Orders.sql became a SECOND file for one table — visible only on a case-SENSITIVE filesystem, which is where CI runs. Collisions are still resolved case-INSENSITIVELY by uniqueSQLPath, because `Orders` and `orders` are one file on macOS and Windows.
func Find ¶
func Find[T any](items []T, keyOf func(T) InstanceKey, want InstanceKey) (int, error)
Find locates the entry matching want, returning its index or -1.
Two rules, and the second is the interesting one:
- A key that NAMES an organization matches only an entry with that exact organization. It never adopts an entry belonging to another, and never adopts an untagged one — a hand-written entry with no organization could belong to anybody.
- A key with an UNKNOWN organization (signed out) matches on URL alone when that is unambiguous, and errors when it is not. Without this a bound folder would report itself unbound to a signed-out `wf status`, which is a read-only command that used to work fine.
URLs are compared through config.NormalizeURL, because ronja.json is a committed file people edit by hand and the two spellings they reach for — a trailing slash and an uppercase hostname — are exactly what it folds away. A raw comparison misses those, and the folder then silently reports as unbound against the instance it is plainly bound to, which a first push would "fix" by creating a second workflow.
func FindRoot ¶
FindRoot walks up from start looking for a ronja.json, git-style, so the commands work from a subdirectory of the folder rather than only from its root. Returns ErrNoManifest when the filesystem root is reached.
func Hash ¶
Hash is the content fingerprint used everywhere in this package: sha256 over the raw bytes, hex-encoded.
The remote side has no hash of its own (workflow_files carries only content and timestamps), so the CLI hashes the `content` string it receives with exactly this function. Local and remote fingerprints are therefore comparable by construction — as long as both sides hash BYTES, which is why this takes a []byte and the string form converts rather than the other way round.
func HashString ¶
HashString fingerprints content that arrived as a string (i.e. from the API).
func ManifestPath ¶
ManifestPath and StatePath locate the two files inside a folder root.
func Matches ¶
func Matches(a, b InstanceKey) bool
Matches reports whether two keys name the same instance and organization, tolerating URL spelling the same way Find does. Used when writing, to replace an equivalent entry rather than accumulate a second one beside it.
func NotSyncable ¶ added in v0.27.0
NotSyncable reports why a FILE path is not one this kind syncs, or "" when it is. It is StructuralExclusion plus the kind's own content rule (SyncExt).
The one predicate both the walk and the layout check apply — Enumerate for every file it finds, CheckLocalPaths for every path it is about to write — and they must answer identically. A file one keeps and the other drops leaves a baseline claiming a path that no later walk returns: `status` invents a deletion and the next push acts on it by deleting the file server-side.
FILES only. A DIRECTORY is tested by StructuralExclusion alone: `staging/` has no extension, and pruning it here would hide every .sql file beneath it.
func SaveManifest ¶
SaveManifest writes the manifest back. It is committed to the customer's repo, so it is indented and newline-terminated — a file people read and diff, not an opaque blob.
func SaveState ¶
SaveState writes the baseline and (re)asserts the .gitignore beside it.
Writing the ignore file on every save rather than only at clone time is deliberate: the baseline records what one USER's draft looked like, and a second developer inheriting it from git would see phantom drift against a draft that is not theirs.
func Slug ¶
Slug turns a title into a directory name: lowercase, ASCII alphanumerics and dashes, no runs of dashes, no leading or trailing dash.
Used only to pick a default clone directory, so a title made entirely of characters this drops (an emoji, a non-Latin script) must still produce something — fallback — rather than an empty path.
func StructuralExclusion ¶
StructuralExclusion reports why a resource-relative path can never live in a synced folder of this kind, or "" when it can.
These are the rules Enumerate applies BY SHAPE, before the server's path grammar gets a look in — and the server's grammar accepts every one of them (`.` is an allowed segment character, so `.env` and `.config/x.py` are perfectly legal workflow file paths server-side; so is `dist/app.js`). That asymmetry is the whole reason this is a named, exported rule rather than an inline condition: anything that lays files out on disk and then trusts a baseline — clone, the post-publish baseline refresh — has to refuse a set containing one of these, because the file would be written, never enumerated again, and read as a LOCAL DELETION on the next status (and acted on by the next push).
Kind is a parameter for exactly that reason. A data app excludes node_modules/ and dist/ where a workflow does not, so "is this path syncable" has no kind-free answer — and answering it with one Kind here and another in Enumerate is the phantom-deletion bug above.
Keep in step with Enumerate.
func ValidatePath ¶
ValidatePath reports whether a relative path is one the server will accept: slash-separated, at most MaxPathDepth segments, at most MaxPathLength characters, each segment drawn from [A-Za-z0-9_.-] and non-empty, no leading or trailing slash, no traversal.
func WriteFile ¶
WriteFile writes one workflow file into the folder, creating parent directories as needed. The path is validated first: it arrives from the server, and a row carrying a traversal path (legacy data, a future bug) must not be able to write outside the folder.
func WriteStateGitignore ¶
WriteStateGitignore drops a "*" .gitignore inside .ronja/, so the directory excludes itself no matter what the surrounding repo's rules are.
Types ¶
type Binding ¶
type Binding struct {
WorkflowID string `json:"workflowID,omitempty"`
// DataAppID is the WorkflowID of a data-app folder. Two fields rather than
// one polymorphic "resourceID" because the manifest is a file people read: a
// key naming what it points at is worth more than one saved line, and the
// kind gate means only one of the two can ever be populated.
//
// Like WorkflowID, this may name a row that is still a parentless DRAFT:
// since migration 000495 POST /dataapp mints a draft, and publish promotes
// that same row in place, so the id is stable from first push onwards but
// only names a LIVE app once it has been published.
DataAppID string `json:"dataAppID,omitempty"`
FeatureID string `json:"featureID,omitempty"`
// Tables is a PIPELINE folder's binding: local relative path → the live
// `table-…` id that file builds. One folder, many resources — which is the
// one real divergence from the other two kinds and the reason the single-id
// helpers below refuse KindPipeline instead of quietly answering with
// WorkflowID.
//
// Keyed by the SLASH-separated relative path, exactly as Enumerate reports
// it, so the manifest and the baseline agree about what a file is called on
// every platform.
//
// omitempty: a workflow or data-app manifest must not grow an empty "tables"
// key, and a pipeline folder that has never pushed has nothing to record.
Tables map[string]string `json:"tables,omitempty"`
}
Binding is what one instance knows about this folder.
WorkflowID is ABSENT until the first push: `wf init` binds a feature (the container the workflow will be created in) but creates nothing server-side, so there is no id to record yet. FeatureID is what makes that first push possible, which is why it is recorded from the start.
WorkflowID is always the STABLE identity — the live row, or a parentless draft's own id — never a per-user edit draft's id. Draft ids come and go with every checkout/commit cycle; a manifest that recorded one would be a committed file pointing at a row that stops existing the moment its author publishes.
func (Binding) ResourceID ¶
ResourceID is the bound row for a kind, so callers that do not care which kind they are working with do not have to branch.
It REFUSES a multi-resource kind rather than falling through to WorkflowID. The fall-through was safe while there were exactly two kinds and the branch was `if dataapp { … } else { workflow }`; with a third it becomes a silent wrong answer — an empty string that reads as "never pushed here" for a folder that is bound to a dozen tables, which a push would then act on by creating all of them again.
func (Binding) WithResourceID ¶
WithResourceID returns a copy naming the bound row for a kind, refusing a multi-resource kind for the reason ResourceID does — and here the fall-through would have been worse than a wrong read: it would have WRITTEN a table id into a pipeline manifest's workflowID field, where nothing would ever look for it.
func (Binding) WithTable ¶ added in v0.27.0
WithTable returns a copy binding one relative path to one live table id.
It COPIES the map rather than writing through it. Binding is a value type but a map field is a reference, so mutating the caller's copy in place would reach into whatever Manifest.Instances entry it came from — a binding "recorded" before SaveManifest ran, and silently kept after a failure that should have left it unwritten.
type Diff ¶
type Diff struct {
Added []string `json:"added"`
Modified []string `json:"modified"`
Deleted []string `json:"deleted"`
// Unchanged is a count rather than a list: it is the boring majority, and
// nothing acts on the names.
Unchanged int `json:"unchanged"`
}
Diff is a comparison of two path → sha256 maps: what the left side has that the right side does not, and so on. Lists are sorted so output is stable.
func DiffHashes ¶
DiffHashes compares a current set of hashes against a baseline.
One function serves both comparisons the sync loop needs, because they are the same question asked twice:
- LOCAL vs baseline — what have I changed since the last sync (what push would send).
- REMOTE vs baseline — what has changed on the server since the last sync (drift: someone else's web-builder edit to the same per-user draft), which push must refuse to overwrite blindly.
type Enumeration ¶
type Enumeration struct {
// Files maps a syncable relative path to its sha256.
Files map[string]string
// Skipped lists everything the walk left out, sorted by path: path-invalid
// files, non-regular files, AND the structural exclusions (dot-files,
// dot-directories). A dot-DIRECTORY is reported once, by directory, rather
// than once per file inside it — .git would otherwise drown the report.
//
// The folder's own machinery (ronja.json, .ronja/) is the one thing NOT
// listed: it is the CLI's bookkeeping, not the user's mistake.
Skipped []Skipped
}
Enumeration is the syncable content of a folder plus what was left out.
func Enumerate ¶
func Enumerate(root string, kind Kind) (*Enumeration, error)
Enumerate walks a synced folder and hashes every file that could be pushed.
Three classes of exclusion, in order:
- The folder's own machinery: ronja.json and .ronja/. Never syncable, and the only exclusion not reported as Skipped.
- Structural exclusions: dotfiles and dot-directories anywhere in the tree — .git above all, which is enormous, full of paths the server would reject, and never source — plus this Kind's SkipDirs (node_modules/, dist/ and build/ for a data app). Ruled out by DIRECTORY so a repo's whole object store, or a whole dependency tree, is skipped rather than walked and discarded file by file (and reported once, by directory).
- Anything else the server's path rules reject, which is reported as Skipped so the caller can say so.
Every exclusion but the first is REPORTED. Silently dropping a file the server would have accepted is what turns a clone into a folder whose baseline lies: see StructuralExclusion and CheckLocalPaths.
Symlinks are not followed (WalkDir does not follow them), so a link pointing outside the folder cannot smuggle content into a push.
type FileState ¶
type FileState struct {
SHA256 string `json:"sha256"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
FileState is one file as the server last had it. UpdatedAt is diagnostics only — drift is decided on the hash, because workflow_files.updated_at moves for reasons that do not change content.
type Instance ¶
type Instance struct {
URL string `json:"url"`
TenantID string `json:"tenantID,omitempty"`
Binding
}
Instance is one binding: where it points, and what it points at.
TenantID is an organization identifier, not a secret. It is opaque, appears in ordinary API paths, and is useless without a credential — it is here for the same reason the URL is, so a committed folder says plainly which organization each binding belongs to.
func (Instance) Key ¶
func (i Instance) Key() InstanceKey
Key is the (instance, organization) this entry is bound to.
type InstanceBaseline ¶
type InstanceBaseline struct {
URL string `json:"url"`
TenantID string `json:"tenantID,omitempty"`
State *InstanceState `json:"state"`
}
InstanceBaseline is one binding's baseline. It mirrors Manifest.Instance so both files can share the lookup in Find.
func (InstanceBaseline) Key ¶
func (i InstanceBaseline) Key() InstanceKey
Key is the (instance, organization) this baseline belongs to.
type InstanceKey ¶
InstanceKey identifies what a folder is bound TO: one instance and one organization on it.
The organization is part of the key because workflow ids are scoped to one. Keyed by URL alone, a person who belongs to two organizations on the same instance has one entry for both — so a push made while signed in to the second sends the FIRST one's workflow id under the second's token. That does not fail safely: the id is simply not found there, and the push path treats not-found as "create it", quietly producing a duplicate workflow in the wrong organization.
It is deliberately NOT keyed by profile NAME. ronja.json is committed and shared, and a profile name is local to one machine — my "prod" is not yours.
TenantID may be empty, meaning "not known right now" — a signed-out `wf status` has no way to learn it. See Find for how that degrades.
func (InstanceKey) Known ¶
func (k InstanceKey) Known() bool
Known reports whether the key names an organization.
type InstanceState ¶
type InstanceState struct {
SourceID string `json:"sourceID"`
SourceLifecycle string `json:"sourceLifecycle,omitempty"`
BaselineUpdatedAt string `json:"baselineUpdatedAt,omitempty"`
Title string `json:"title,omitempty"`
Entrypoint string `json:"entrypoint,omitempty"`
Parameters *[]api.WorkflowParameter `json:"parameters,omitempty"`
ReportingTimezone *string `json:"reportingTimezone,omitempty"`
Access *api.DataAppAccess `json:"access,omitempty"`
Files map[string]FileState `json:"files"`
// Tables is a PIPELINE folder's per-file remote state: relative path →
// which row the baseline describes, and whether this checkout holds an open
// draft of it.
//
// Separate from Files rather than folded into FileState because the two
// answer different questions and are written at different moments: Files is
// "what were the bytes at the last sync" (the drift baseline), Tables is
// "which rows are these files, and where is my draft" (so push can resume
// its own draft without a round trip and status can say "draft staged"
// without one either).
//
// omitempty and three-state-safe: ABSENT means this folder does not track
// table state — the ordinary reading for every workflow and data-app
// baseline, and for a pipeline folder written before the key existed. It
// never means "no tables", which is what a present-but-empty map says.
Tables map[string]TableState `json:"tables,omitempty"`
}
InstanceState is what one instance's baseline records.
SourceID names the ROW the files came from, which is not the same as the manifest's WorkflowID: a clone prefers the caller's open draft when there is one, so the baseline may describe a draft row while the binding names the live workflow it belongs to. SourceLifecycle keeps that distinction legible without another round-trip.
Title and Entrypoint are the row's METADATA as the server last had it, and they are here for the same reason the file hashes are: without them a push sends the manifest's title and entrypoint blind, so a colleague renaming the workflow in the web builder is silently reverted by the next sync. They are omitempty because a state file written before they existed must keep parsing — and an absent one simply means no metadata guard, which is what a folder that has never recorded them deserves. Parameters is the same guard for the declared parameter set: a colleague adding one in the web builder must not be silently reverted by the next push. Pointer for the same reason it is one on the Manifest — a state file written before parameters were recorded has to be distinguishable from one that recorded "the row declares none", or every pre-existing folder would read as having lost a parameter set it never knew about. ReportingTimezone is the same guard for the declared execution calendar: a colleague changing the workflow's zone in the web builder must not be silently reverted by the next push. Pointer for the reason the others are — a state file written before the zone was recorded (nil) has to be distinguishable from one that recorded "the row declares none" (a pointer to ""), or every pre-existing folder would read as having lost a declaration it never knew about and the guard would fire on a difference nobody made. Access is the data-app counterpart of Parameters, and it is a pointer for the same reason: a state file written before allowlists were recorded must be distinguishable from one that recorded "the row allows nothing". Without that distinction every pre-existing folder would read as having lost an allowlist it never knew about, and the first push would report drift it cannot explain.
func (*InstanceState) Hashes ¶
func (i *InstanceState) Hashes() map[string]string
Hashes flattens a baseline into path → sha256 for diffing. A nil receiver yields an empty map, which is the right answer: no baseline means everything present locally is new.
func (*InstanceState) TableStateFor ¶ added in v0.27.0
func (i *InstanceState) TableStateFor(path string) TableState
TableStateFor returns one file's recorded table state, or the zero value when there is none — which is also what a nil receiver answers, since a folder that has never synced here has no state for any path. The zero value disarms both legs of the drift guard, which is the documented meaning of "no baseline".
type Kind ¶
type Kind struct {
// Name is the manifest's `kind` value, and the default directory name when a
// title slugifies to nothing.
Name string
// Label is how the kind is spoken about in messages: "workflow", "data app".
Label string
// Command is the CLI command group that drives folders of this kind —
// "ronja wf", "ronja app", "ronja pipeline".
//
// It lives ON the Kind rather than in a lookup beside it because it was
// previously spelled twice: once in this package (for LoadManifest's
// wrong-kind refusal, which names the command that WOULD work) and once in
// internal/commands (for "run this inside a folder created by …"). Both were
// binary if/else on KindDataApp, so a third kind would have been routed to
// `ronja wf` by BOTH of them — silently, in exactly the messages a lost
// caller reads. Declared here, a new kind cannot be added without answering
// the question.
Command string
// DefaultEntrypoint matches the server's own default for a new resource of
// this kind. EMPTY is a legitimate value (a pipeline folder has no
// entrypoint at all): LoadManifest falls back to it, so an empty default
// simply leaves Manifest.Entrypoint empty.
DefaultEntrypoint string
// SkipDirs are directory names this kind never syncs, on top of the dot-name
// rule every folder shares. Matched by SEGMENT at any depth.
//
// Empty for workflows: a Python folder has no conventional build output, and
// excluding a directory nobody has would only be a rule to trip over. A data
// app is a TSX folder, where node_modules/ and dist/ are ordinary — and where
// their absence from this list is expensive, because the server caps an app
// at 100 files and would reject the push late, by count, naming nothing
// useful.
SkipDirs []string
// SyncExt, when non-empty, is the ONE file extension a folder of this kind
// synchronises; every other file in it is ignored, silently, so the folder
// can be an ordinary repo directory.
//
// It lives on the Kind — beside SkipDirs and for the same reason — because
// this is a rule Enumerate and CheckLocalPaths must answer IDENTICALLY: a
// walk that drops a path the baseline keeps produces a baseline claiming a
// file no later walk will see, which status reports as a deletion and a push
// acts on. Threaded as one value, that mismatch is unrepresentable. See
// NotSyncable, which is where both sides read it.
SyncExt string
}
Kind is the resource a synced folder describes, plus the rules that differ between them.
A single VALUE rather than a handful of parameters, because these rules have to travel TOGETHER. If Enumerate decides a path is not syncable while CheckLocalPaths thinks it is, the folder ends up with a baseline claiming a file is on disk that the walk will never see again — `status` invents a deletion and the next push acts on it by deleting the file server-side. One Kind threaded through every entry point makes that mismatch unrepresentable, where "remember to pass the same exclusion list to both" would not.
func (Kind) Syncable ¶ added in v0.27.0
Syncable reports whether a FILE path holds content a folder of this kind sends. Case-insensitive, because `.SQL` is an ordinary thing to find in a repo that has been through Windows.
A kind with no SyncExt syncs everything the structural rules allow, which is how workflows and data apps behave.
type Manifest ¶
type Manifest struct {
Kind string `json:"kind"`
Title string `json:"title"`
// Entrypoint is the ONE file the server runs, and it is omitempty because a
// pipeline folder has no such file at all: a folder of .sql tables has no
// distinguished member, so `"entrypoint": ""` in a committed manifest would be
// a key inviting somebody to fill it in with something meaningless. Safe for
// the other two kinds because LoadManifest already falls back to
// Kind.DefaultEntrypoint, so an absent key reads exactly as an empty one did.
Entrypoint string `json:"entrypoint,omitempty"`
// Parameters are the workflow's declared parameters — the things `wf test
// --param` supplies and the script reads with tools.getVariable(name).
//
// A POINTER so the manifest can say three things rather than two, and the
// difference is load-bearing:
//
// absent (nil) — this folder does not manage parameters. Push never
// touches them. Every folder created before parameters
// were part of the manifest is this, which is why the
// distinction has to exist: treating absent as "declares
// none" would make the first push after upgrading CLEAR
// whatever the workflow had.
// [] (non-nil) — managed, and declares none. Push clears the row's.
// [...] — managed, and this is the set.
//
// `wf init` and `wf clone` both write the key, so a folder created by
// either manages its parameters from the start.
//
// The api type is reused rather than mirrored on purpose: a parameter is
// passed through to create/validate/patch verbatim, and a second struct
// would only add a conversion that silently drops any field added to one
// side. Its json tags are therefore part of the ronja.json FILE FORMAT, not
// just the wire — renaming one is a breaking change to committed manifests.
Parameters *[]api.WorkflowParameter `json:"parameters,omitempty"`
// Runtime is the workflow's runtime version: 1 (the default) or 2, the
// DURABLE runtime, whose @tools.step results are journaled so a failed run
// can be resumed instead of re-run from the top.
//
// A plain int with omitempty rather than the three-state pointer Parameters
// and Access carry, because the third state has nothing to describe. Runtime
// is STAMPED AT CREATE and there is no patch path for it server-side, so the
// only thing this field can do is ride the create body of a first push:
// "unmanaged" and "declares the default" both mean "send nothing, get v1".
// A pointer would publish a distinction no code could act on, and would make
// every ronja.json written before durable workflows existed differ on disk
// from one that means exactly the same thing.
//
// 0 is therefore absent, and absent is 1. `wf init` writes the key only for
// --runtime 2, so a v1 folder's manifest is byte-identical to what the CLI
// wrote before this existed.
Runtime int `json:"runtime,omitempty"`
// ReportingTimezone is the IANA calendar the workflow's DuckDB session runs
// at on every run — what date_trunc and date casts bucket against.
//
// It may live in a COMMITTED, shared file at all because a plain IANA name is
// instance-independent: "Europe/Stockholm" means the same calendar on
// localhost, staging and production, and on every profile. Nothing about it
// is an id, a credential or an organization's, which is what keeps it above
// Instances rather than inside a binding.
//
// A POINTER, with the same three states Parameters has and for the same
// back-compat reason:
//
// absent (nil) — this folder does not manage the zone. Push never touches
// it, which is what every folder created before this key
// existed needs: reading absent as "declares UTC" would make
// the first push after upgrading silently re-bucket the
// workflow's calendar onto UTC.
// "" (non-nil) — managed, declaring the RESET value. The API cannot express
// "no declaration" (an explicit "" patches the row to the
// literal "UTC"), so an empty string here means UTC, not
// "leave it alone" — that is what absent is for.
// "Europe/…" — managed, and this is the calendar. Push makes the row match.
//
// `wf clone` writes the key when the row DECLARES a zone; `wf init` leaves it
// absent so a fresh folder inherits the organization's default.
ReportingTimezone *string `json:"reportingTimezone,omitempty"`
// Access is the DATA-APP analogue of Parameters: the capability allowlists
// (tables, secrets, agents, workflows, codexes, metrics) and the capability
// set the app's minted script-token is scoped from.
//
// It is in the manifest — and Parameters is not enough — because the two
// kinds derive their bindings completely differently. A workflow's come from
// `{{ ref }}` / `{{ write }}` / `{{ secret }}` markers IN THE CODE, so the
// server can re-derive them from a file save. A data app's are explicit
// columns that POST :id/validate deliberately does NOT scan source for, so
// nothing on the server can recover them from the files: an app pushed
// without them compiles and then can query nothing. A folder that could not
// carry them would not be a complete description of its app.
//
// Pointer for exactly the reasons Parameters is one, and the three states
// mean the same things:
//
// absent (nil) — this folder does not manage the allowlists. Push never
// touches them, which is what a folder created before this
// existed (or one deliberately leaving them to the web UI)
// needs, since treating absent as "declares none" would
// REVOKE the app's access on the first push after upgrading.
// zero value — managed, and declares none.
// populated — managed, and this is the set.
//
// Note what this makes ronja.json: a file that declares privileges, in the
// customer's git. That is not a hole — governance.ValidateDataAppScope
// re-checks every id against the pusher's own access at checkout,
// ValidateAllowlistRefsLive re-checks at publish, and committing a shared
// app's draft is admin-gated — but it is why `app push` prints the allowlist
// change it is about to make rather than only noting that one exists.
Access *api.DataAppAccess `json:"access,omitempty"`
// Instances lists what this folder is bound to, one entry per instance and
// organization. No entry is not an error — it means "never pushed there
// yet", and the first push records the binding.
//
// A LIST rather than a map keyed by URL: one instance can carry several
// organizations, and each needs its own workflow id. A list also puts the
// organization on the page for whoever opens the committed file, instead of
// hiding it inside a composite key.
Instances []Instance `json:"instances"`
}
Manifest is ronja.json — the committed half of a workflow folder.
Everything above Instances describes the workflow independently of any instance, so it survives being cloned by someone signed in somewhere else. Instances holds the bindings, one per (instance, organization) this folder has been pushed to.
func LoadManifest ¶
LoadManifest reads and validates the manifest at a folder root, refusing one that describes a different KIND than the caller works with.
That refusal is load-bearing rather than tidy. A data-app folder and a workflow folder are indistinguishable by shape — both are source files plus a ronja.json — so without the check `ronja wf push` inside a data-app folder would create a workflow from TSX files, and `ronja app push` inside a workflow folder would overwrite an app's source with Python. Both destroy work while reporting success.
func (*Manifest) Binding ¶
func (m *Manifest) Binding(key InstanceKey) (Binding, bool, error)
Binding returns this folder's binding for one (instance, organization), and whether one exists. The bool matters: an absent entry is the ordinary "not pushed here yet" state, not a failure.
The error is ErrAmbiguousInstance, and only a caller with an UNKNOWN organization can get it — see Find. Every command that resolves a credential knows its organization, so in practice this reaches only the signed-out half of `wf status`.
func (*Manifest) BindingEntry ¶
func (m *Manifest) BindingEntry(key InstanceKey) (Instance, bool, error)
BindingEntry is Binding plus the entry's own key.
The key matters when the caller's organization was UNKNOWN: a matched entry names the organization it belongs to, which is a free answer to a question that would otherwise cost a round trip to /me. It is also the right answer — this folder is bound there, whatever the ambient credential thinks.
func (*Manifest) DeclaredAccess ¶
func (m *Manifest) DeclaredAccess() api.DataAppAccess
DeclaredAccess is the declared allowlist set, zero-valued when the folder does not manage it. For reading; use ManagesAccess to decide whether to WRITE.
func (*Manifest) DeclaredParameters ¶
func (m *Manifest) DeclaredParameters() []api.WorkflowParameter
DeclaredParameters is the declared set, empty when the folder does not manage them. For reading; use ManagesParameters to decide whether to WRITE.
func (*Manifest) DeclaredReportingTimezone ¶ added in v0.27.0
DeclaredReportingTimezone is the declared zone, empty when the folder does not manage it. For reading; use ManagesReportingTimezone to decide whether to WRITE — an empty answer means "declares the reset value" for a managed folder and "no opinion" for an unmanaged one, and only the pointer tells them apart.
Trimmed, because the server stores the trimmed form: a hand-edited " Europe/Stockholm " that compared unequal to what the row holds would patch on every push, forever.
func (*Manifest) IsDurable ¶ added in v0.27.0
IsDurable reports the runtime that journals EVERY step without the author writing a key — the one property anything outside the create path branches on. It is not the same question as "can this be resumed": a standard-runtime workflow whose steps carry explicit keys is resumable too, and the journal is what decides how much a resume skips.
func (*Manifest) ManagesAccess ¶
ManagesAccess reports whether the manifest is the source of truth for a data app's allowlists — i.e. whether push should sync them at all.
func (*Manifest) ManagesParameters ¶
ManagesParameters reports whether the manifest is the source of truth for the workflow's parameters — i.e. whether push should sync them at all.
func (*Manifest) ManagesReportingTimezone ¶ added in v0.27.0
ManagesReportingTimezone reports whether the manifest is the source of truth for the workflow's declared execution calendar — i.e. whether push should sync it at all.
func (*Manifest) RuntimeVersion ¶ added in v0.27.0
RuntimeVersion is the runtime this folder's workflow runs under, with the absent key resolved to the default. Read this rather than the field: an unstated runtime and a stated 1 are the same workflow, and only the create path cares which spelling the file used.
func (*Manifest) SetAccess ¶
func (m *Manifest) SetAccess(access api.DataAppAccess)
SetAccess marks the folder as managing the allowlists and records them, normalized so a manifest never round-trips `null` where the app declares an empty list.
func (*Manifest) SetBinding ¶
func (m *Manifest) SetBinding(key InstanceKey, b Binding)
SetBinding records (or updates) the binding for one (instance, organization), writing the canonical URL spelling and replacing any equivalent entry.
Without the replace, a push against a hand-edited "https://app.ronja.tech/" entry would leave the manifest carrying that one AND a canonical one, and which of the two answered a later Binding() would depend on nothing legible.
func (*Manifest) SetParameters ¶
func (m *Manifest) SetParameters(params []api.WorkflowParameter)
SetParameters marks the folder as managing parameters and records the set. A nil params still marks it managed, as an empty declaration — callers that mean "stop managing" clear the field directly.
func (*Manifest) SetReportingTimezone ¶ added in v0.27.0
SetReportingTimezone marks the folder as managing the declared zone and records it. An empty zone still marks it managed, as a declaration of the reset value — callers that mean "stop managing" clear the field directly.
type Skipped ¶
Skipped is a local file that will never be synced, and why.
Reported rather than silently dropped: a file the user believes is part of the workflow and which the CLI quietly ignores is the worst kind of surprise, and it is invisible in every other output.
type State ¶
type State struct {
Instances []InstanceBaseline `json:"instances"`
}
State is .ronja/state.json — the local sync baseline, never committed.
Keyed like the manifest, by (instance, organization), because the same folder can be bound to staging and production — or to two organizations on one instance — and each has its own idea of what "last synced" means.
func LoadState ¶
LoadState reads the baseline. A missing file is not an error — that is a folder someone cloned from git (whose .ronja was correctly never committed), or one that has never been pushed.
func (*State) Clear ¶
func (s *State) Clear(key InstanceKey)
Clear drops ONE binding's baseline, leaving every other instance's standing.
The whole-file alternative — writing a fresh empty State — is a bug, not a shortcut: a folder bound to localhost, staging and production keeps one baseline each, so discarding against one instance would erase the other two. The next push there sees no baseline against a non-empty remote, which is exactly the shape the drift guard refuses, and the way out it names is --force: overwriting production with no comparison because a staging draft was thrown away.
Removal rather than an empty entry, because For then answers nil — the same answer a folder that has never synced here gives, which is what this instance now is. Every local file correctly reads as added.
func (*State) For ¶
func (s *State) For(key InstanceKey) *InstanceState
For returns one binding's baseline, or nil when it has never been synced from this folder.
The ambiguity error Find can raise is deliberately swallowed here: a baseline is a sync optimisation, and "no baseline" degrades to treating every local file as changed — which is safe. The MANIFEST lookup for the same key reports the ambiguity properly, and that is the one that decides where a push lands.
func (*State) Set ¶
func (s *State) Set(key InstanceKey, inst *InstanceState)
Set records a baseline for one binding.
type TableState ¶ added in v0.27.0
type TableState struct {
TableID string `json:"tableID"`
DraftID string `json:"draftID,omitempty"`
DraftSHA256 string `json:"draftSHA256,omitempty"`
LiveSHA256 string `json:"liveSHA256,omitempty"`
}
TableState is one pipeline file's remote identity as this checkout last saw it, plus the fingerprints of the two REMOTE ROWS that file is synced with.
The invariant the two hashes exist for, and the one to keep true when adding a third: A HASH IS ONLY EVER COMPARED AGAINST THE ROW IT WAS TAKEN FROM. A pipeline file is synced with three different things, and folding them into one fingerprint was a real bug rather than a tidiness question — the single value meant "the bytes I wrote into my draft" after a push and "the bytes the live table holds" after a clone or a publish, while the drift guard compared BOTH rows against it. An ordinary push → edit → push was then refused as drift on a live table that had never moved.
InstanceState.Files[path].SHA256 the last COMPLETE push (written AND built).
What "changed since the last sync" means,
for `status` and for what a bare push sends.
TableState.DraftSHA256 what this checkout last WROTE into DraftID.
Leg (b) of the drift guard compares the
draft against this.
TableState.LiveSHA256 the LIVE row's SQL as of the last moment
this folder agreed with it — a clone, the
fork of a draft, a publish. Leg (a)
compares the live table against this.
TableID is the LIVE row those hashes are fingerprints OF. It duplicates the manifest's Binding.Tables entry deliberately: the baseline has to say WHICH ROW it describes, not merely that it describes one. Without it a manifest edited to point a path at a different table — by hand, by a merge, by a second clone — would silently inherit the previous table's hashes, and the next push would compare a colleague's table against a baseline taken from something else entirely and conclude nothing had changed. `pipeline push` checks it against the binding before it writes anything, so the rebinding is DETECTED rather than trusted.
DraftID is the caller's OWN open draft of that table, empty when there is none. It is local-only for the reason the whole baseline is: drafts are per-user, and a colleague inheriting this id from git would be pointed at a row they cannot see. It is a HINT, never authority — a draft can be committed or discarded from the web UI between two commands, so a caller re-reads (GET /feature/model/:id/draft) rather than trusting a recorded id blindly.
Both hashes are omitempty and three-state-safe. EMPTY means "no baseline for that row", which disarms its leg of the drift guard exactly the way an absent file baseline does — the state of a folder cloned from git, of a draft this checkout never wrote to, and of a baseline written before these fields existed.