Primer
Yukta runs workflows (graphs of steps) inside a Java server. People and programs need a way in: a web page that starts a run, a command-line tool that stops one, a script that reads logs. That way in must be predictable (same shape of answer every time), safe (reject bad input early, never leak internals) and live (show progress as it happens). Web is that way in. It is the HTTP boundary: it receives REST calls and streaming connections and translates them into calls on core, the engine. It does not run anything itself.Where web sits
The vocabulary in eight cards
One example, end to end
A client starts a workflow and web answers. Click a step to see what happens, why it is built that way, and what can go wrong. The grey step is core, outside this module.Request in
API key check
Request id
Validate body
Core starts run
Reply 202
Access log
Error mapping
A request in plain words
Knock
Door
Name tag
Form check
Hand-off
Reply
Live view
Components
Filter chain
key, request id, access log Runs on every request before any controller: checks the API key, gives the request an id and writes one access-log line when it ends.- Order matters. The key filter runs first, the request-id filter second, the access-log filter last (it has no explicit order, so it runs after the others). The reason for key-first is that an unauthenticated caller should cost as little as possible. The consequence is that a rejected 401 has no request id and no log line.
- The key check applies when yukta.api-key or yukta.api-keys is set, to /api and to the actuator except /actuator/health. The path is decoded first; encoded separators, ’;’ and dot segments are refused with 400. The key comes from ‘Authorization: Bearer <key>’ or X-API-Key and is compared as a SHA-256 digest with MessageDigest.isEqual. A named key has a role (VIEWER reads; OPERATOR also POST/DELETE under /api/executions/ and /api/workflow/; ADMIN everything, including the actuator) and optionally a list of sessions; a miss gives 401 and an empty body, a role or session that does not allow the request gives 403. Without any key, HostOriginFilter answers 403 to a Host that is not loopback (or in yukta.allowed-hosts) and to a non-GET whose Origin is not the Host.
- The request id: a client’s X-Request-ID is kept only if it matches [A-Za-z0-9-]{1,64}; otherwise a UUID is generated. X-Correlation-ID follows the same rule and falls back to the request id. Both are returned on the response. The id goes into the Reactor context and the logging MDC, so every log line of that request shows [requestId] even when work hops threads. The strict pattern exists because the value is written to logs and headers: a newline in it could forge a log line.
- The access log writes ‘POST /api/workflow/start -> 202 in 14ms’ at INFO when the exchange ends. For a stream that is when the connection closes. The line has method, path, status and duration, with no query string and no caller identity (the actor of a control command goes to the execution log instead).
- Why filters and not controller code: a filter covers every route, including endpoints added later, so a forgotten check cannot leave a hole.
- Guarantees. A request that passes the key check has a request id that is echoed in X-Request-ID. The key comparison is constant-time. Client-supplied ids can only contain letters, digits and hyphens.
- State. Only the key bytes, read once when the server starts. Nothing is stored per request. Changing the key needs a restart.
- Ceiling. Keys are configuration: no expiry, and a change needs a restart. Roles are matched by path prefix. No rate limiting. Rejections (400, 401, 403) leave no log line.
- On failure. Missing or wrong key: 401 with an empty body. If the key is empty the filter lets everything through.
yukta.api-key (empty = API open), Authorization: Bearer <key> or X-API-Key, X-Request-ID, X-Correlation-ID
Binding & mapping
JSON to records, mappers Turns JSON bodies into validated Java records and maps them to and from core’s own types.- Request bodies are records: ConfigRequest (a session), WorkflowDefinitionRequest (a workflow, also used by the dry-run validator), WorkflowStartRequest and NodeCommandRequest. Rules are annotations: a session id is at most 256 characters and must not contain ’/’, backslash or ’..’; names and descriptions are at most 256; the project path is non-blank and at most 1024; a session needs at least one workflow, a workflow at least one node; a node command payload has 1 to 100 entries. In our view the id pattern exists so an id is safe to use as a file or path segment.
- Unknown JSON fields are rejected. The setting lives in boot (spring.jackson.deserialization.fail-on-unknown-properties: true), so it applies to every JSON body. Example: a client sends workflowID instead of workflowId. Without the rule that field would be dropped and the call would fail later with a vague ‘workflowId is required’.
- Records copy their lists and maps and turn null into empty (a missing tags map means no tags), so nothing downstream has to null-check and nobody can change a request after validation.
- Three MapStruct mappers convert records to core types and back. MapStruct generates plain Java at compile time (no reflection), which also suits a GraalVM native image. SessionMapper maps a session request to core’s SessionConfigData and builds the summary rows; AppConfigMapper maps a workflow request for the dry-run validator; WorkflowMapper builds graph edges and log entries.
- The two mappers differ on one default: SessionMapper turns a missing edge sourcePort into ‘default’, AppConfigMapper leaves it null. So the validator and the saving path can see the same edge slightly differently.
- Guarantees. Anything that fails validation is stopped with a 400 before core is called, with every failing field listed. Request records are immutable.
- State. None.
- Ceiling. No explicit body-size limit is set in this repository, so the framework default applies. Validation checks shape and length, not the meaning of a node’s config (core’s validator does that).
- On failure. Bean-validation failure: 400 ‘Validation failed’ with one entry per bad field. Unknown field: 400 ‘Invalid request body’ naming the field. Malformed JSON: 400 with a generic reason.
spring.jackson.deserialization.fail-on-unknown-properties=true (boot), @SessionId, @ProjectPath, @NotBlank, @Size
Session & config API
sessions, validate, graph Creates or replaces a session with its workflows, reads them back in several shapes, dry-runs a draft and describes a workflow’s graph for the editor.- POST /api/sessions applies a whole configuration: it creates the session or replaces its config. There is no partial update. The editor calls GET /api/sessions/{sid}/config, changes it and posts it back, which is why /config returns exactly the shape the POST accepts.
- Reads come in sizes for different screens. GET /api/sessions lists ids alphabetically (paged). /summaries returns lean rows (name, tag names, workflow count) and loads only the page’s configs, one after another, skipping any it cannot read with a warning in the log. /{sid} returns details (tag names and workflow ids, but not tag values). /config returns everything. /workflows returns each workflow with node count, edge count and latest run status. /workflows/{wid} returns one definition.
- POST /api/sessions/validate dry-runs a draft with the same WorkflowValidator that execution uses. It always answers 200: valid:true, or valid:false with the first problem as the message. The reason: the question ‘is this draft OK?’ was answered successfully, and an editor wants to show the problem inline, not handle an HTTP error. Malformed bodies still get 400 first.
- The validate reply also lists each node’s output ports for its current config (nodes whose plugin type is unknown are left out). Ports can depend on config, for example whether an error port exists, so the editor asks instead of guessing.
- GET …/workflows/{wid}/graph builds the picture for the canvas: each node enriched with plugin category, description, UI hints and ports, the edges, and a topological order (an order in which every step comes after the steps it depends on).
- This is the one controller that catches errors itself: validate turns any exception into valid:false plus e.getMessage().
- Guarantees. A saved session is exactly what core’s store holds; reads never change anything. Validate never saves and never starts a run.
- State. None in web. Sessions live in core’s store, which is in memory by default (yukta.session.store-type: in-memory) and is lost on restart unless core is configured otherwise.
- Ceiling. Listing sessions loads every id and sorts them on each page call. A config POST carries a whole session in one body. /config and /workflows/{wid} return node config exactly as stored.
- On failure. Unknown session or workflow: 404. Bad body: 400 with field errors. A draft that fails validation: 200 with valid:false.
POST /api/sessions, GET /api/sessions[/summaries], GET /api/sessions/{sid}[/config|/workflows], POST /api/sessions/validate, GET .../workflows/{wid}/graph
Run lifecycle API
start, stop, restart, status Starts a workflow, stops one run or every run of a workflow, restarts a run whole or from a node, and reads status and history.- POST /api/workflow/start answers 202 ‘accepted’ with an execution id. The run continues after the reply, so the client follows it with a status call or stream. An optional Idempotency-Key header makes a retry safe: the same key with the same session and workflow returns the first run’s id instead of starting a second one. The window is core’s setting (yukta.start.idempotency-ttl, 10 minutes by default).
- An unknown session or workflow gives 404. If core refuses to admit the run because of its limits the reply is 429.
- Stopping has two scopes. POST /api/workflow/{sid}/{wid}/stop?mode= stops every active run of a workflow and returns the ids it signalled. DRAIN (default) lets in-flight work finish, CANCEL cancels it, KILL stops at once. POST /api/executions/{eid}/stop stops one run.
- POST /api/executions/{eid}/restart stops the run and starts the workflow again with the original trigger payload and a new execution id. …/restart/{node} restarts from a node, replaying the stored outputs of its parent nodes. …/nodes/{nid}/retry restarts from a node that failed. Each returns the new id.
- Status: GET /api/executions/{eid} returns a progress snapshot; GET /api/workflow/{sid}/history lists a session’s runs newest first (paged), and GET /api/sessions/{sid}/workflows/{wid}/executions does the same for one workflow.
- Every execution route has a canonical form (/api/executions/…) and a deprecated alias (/api/workflow/…). The CLI still calls almost all of the aliases and the UI calls the two status aliases.
- Guarantees. A 202 means core admitted the start. The same Idempotency-Key never starts a second run inside core’s window. Stop returns only the ids actually signalled.
- State. None in web. Run state and history live in core.
- Ceiling. History is loaded in full from core and then cut into a page by web. Admission limits, queues and the idempotency window are core’s.
- On failure. 404 unknown session, workflow, execution or node. 429 when core refuses a start. 500 if core throws something unmapped, such as a restart already in progress (see risks).
POST /api/workflow/start (Idempotency-Key), POST /api/workflow/{sid}/{wid}/stop?mode=DRAIN|CANCEL|KILL, POST /api/executions/{eid}/stop|restart|restart/{node}, GET /api/executions/{eid}, GET /api/workflow/{sid}/history
Errors & envelope
ApiResponse, one handler Defines the one JSON shape for every reply and the one place that turns exceptions into HTTP status codes.- ApiResponse has timestamp, status, message, data (success only), error, path and a list of field errors (failure only). Empty fields are left out of the JSON. Lists are returned as bare arrays inside data.
- Controllers do not catch errors. Core throws typed exceptions and GlobalExceptionHandler maps them: NotFoundException (core) or ResourceNotFoundException (web) gives 404; IllegalArgumentException, ValidationException and bean-validation errors give 400; ControlRejectedException (a supervisor vetoed a command) gives 403; ExecutionRejectedException gives 429; anything else, including IllegalStateException, gives 500.
- A 500 never carries exception text. The message is ‘An unexpected error occurred. Reference: <request id>’ and the cause is logged with the same id. The reason: exception messages can name classes, paths or SQL; a client should get a ticket number, not internals.
- 4xx replies do carry the exception message, because ‘Session not found: team-x’ is what the caller needs. This relies on core writing client-safe messages.
- Framework errors (unsupported method, wrong content type, unknown route) pass through the same handler, so even those get the envelope.
- Two places bypass the handler: the validate endpoint (returns e.getMessage() inside a 200) and the plugin list (a 500 whose message includes ‘Failed to retrieve plugins: ’ plus the exception text).
- Guarantees. Every error reply has the envelope with status, error, message and path. A 500 reveals no exception text.
- State. None.
- Ceiling. The mapping is by exception type, so a new kind of core failure becomes 500 until someone adds a handler. ConflictException maps to 409 (the recover endpoint uses it), but the restart race still throws IllegalStateException, which is a 500.
- On failure. If a core exception is not mapped it becomes 500 and the caller gets only the reference id.
ApiResponse<T>, GlobalExceptionHandler, X-Request-ID in 5xx replies
Run control API
pause, step, retry, limits Steers a run while it is running: pause and resume, per-node pause, resume, stop, skip and step, breakpoints, queue priority and the global limits.- All control calls are POST (clearing a breakpoint is DELETE). Each one checks the execution exists, sends one command to core, waits for core’s acknowledgement and answers 200 with the execution id. It does not wait for the effect to show; read the status to confirm. Core orders commands per run and its supervisors may veto one, which is the 403.
- Whole run: pause and resume. Per node: pause, resume, stop (?immediate=false drains in-flight work, true stops at once; ?reason= is recorded), skip (?skip=true or false; core currently rejects skipping on a running execution).
- Debugging: ‘step mode’ makes a node hold messages; ‘step’ lets exactly one through. A breakpoint pauses a node when a payload field equals a value, for example field=payload.status&equals=error. Example: put a breakpoint on classify, run, and the node stops at the first error ticket so you can look at it.
- Operating: POST /api/executions/{eid}/priority?priority=n changes the priority of a start still waiting for a slot (higher runs first; otherwise 400). POST /api/scheduler/limits?maxConcurrent=&maxPerWorkflow= sets the two global limits at run time (ADMIN key only; reviewed, audited with the actor and restored from the log at startup); 0 means no limit and a negative number is treated as 0.
- Older routes take a session id and run the ownership check. The newer routes (retry, breakpoint, priority, limits) have no session in the path, so they check existence only or nothing at all.
- Guarantees. A command reaches core only if the execution exists. Core applies commands in order per run and records them.
- State. None in web. Limits changed at run time are not restored after a restart (core logs them).
- Ceiling. Limits can only be set globally and per workflow here, not per session. There is no batching: one command per call.
- On failure. 404 for an unknown execution or node. 400 for a rejected state change (such as priority of a run that is not waiting). 403 when a supervisor vetoes. Conflicts surface as 500.
POST /api/executions/{eid}/pause|resume, POST .../nodes/{nid}/pause|resume|stop|skip|step|step/enable|step/disable|retry, POST|DELETE .../nodes/{nid}/breakpoint, POST .../priority, POST /api/scheduler/limits
Streams (SSE)
status, logs, events Pushes run status, execution logs and a global event feed to clients as Server-Sent Events, with ids and a heartbeat.- A shared helper numbers each event from 0 and merges a keep-alive comment (’: ping’) every 15 seconds so proxies do not close an idle connection. A reconnecting client sends Last-Event-ID; the stream skips everything up to that id.
- Status stream: GET /api/executions/{eid}/status/stream?includeHistory=true asks core to replay recent status changes (core keeps five minutes by default) and then follow live ones.
- Log streams: GET /api/sessions/{sid}/executions/{eid}/logs (formatted lines) and …/logs/entries (structured: level, stream, message). If the run already ended they send the stored lines and finish. If it is running they send history then live lines, and finish when the run reaches a terminal state.
- How history and live join without a gap: the live feed is subscribed first, into a buffer of 1024 lines, and only then is the history read. A line written during the read may appear in both. For history lines stamped at or after the seam start, the stream remembers their text; the first live line with the same text and a timestamp not after the history snapshot is dropped once. Example: history ends with ‘step 7 done’; the live buffer also holds ‘step 7 done’ from the same moment; it is sent once.
- Event feed: GET /api/events/stream?executionId=&type= pushes node status changes, workflow status changes, control-command outcomes and log lines (types NodeStatusChanged, WorkflowStatusChanged, ControlOutcome, LogLine) for all runs. It has no replay and no session filter.
- The live parts are best-effort: if a client reads more slowly than events arrive, it misses events instead of slowing the server.
- Guarantees. Event ids are sequential within one connection and a heartbeat is sent every 15 s. Log streams subscribe to live lines before reading history, so nothing falls into the gap (duplicates are suppressed by a text heuristic).
- State. Per connection only: a position counter and, for running log streams, a 1024-line buffer. Everything is gone when the connection closes or the server restarts.
- Ceiling. Every open stream holds a subscription and (for logs) a buffer. There is no cap on open connections and no maximum stream lifetime. A slow reader silently misses live events.
- On failure. A foreign or unknown execution: 404 before the stream starts. A dropped connection: the client reconnects with Last-Event-ID; ids are positions in a rebuilt stream, so they line up only if history is unchanged.
Heartbeat 15 s, Live buffer 1024 lines, yukta.streaming.status-history-ttl-minutes (core, 5), Last-Event-ID, includeHistory=true
Settings & exposure
bind address, key, API docs The few settings that decide who can reach the server and how strict it is, plus the generated API documentation.- Bind address: server.address defaults to 127.0.0.1 (loopback), so a fresh install is not reachable from the network. Setting YUKTA_SERVER_ADDRESS to another address now makes ExposureGuard (boot) refuse to start unless a key is configured; the Docker image sets 0.0.0.0, so a container without a key does not start.
- The API key is the one security setting in web: yukta.api-key. Empty (the default) leaves the API open. The README says to set it whenever the address is opened.
- Surfaces the key does not cover: the Swagger UI and the OpenAPI document (the filter guards only /api and /actuator). The MCP endpoint (/mcp) has its own filter that accepts the same keys plus yukta.mcp.api-key. The actuator (health, info, metrics) is guarded except /actuator/health.
- OpenAPI is generated by springdoc from the controller annotations: UI at /swagger-ui.html, JSON at /v3/api-docs. The UI has a generated types file (api.generated.ts) for these endpoints.
- Web has no settings class of its own. It also relies on boot for the log pattern that prints the request id and for virtual threads being enabled.
- Guarantees. On a default install the server accepts connections only from the same machine.
- State. Read once at startup.
- Ceiling. Settings are changed by restarting; there is no live reload of the key.
- On failure. If the address is opened without a key, the whole API (including workflows that run code) is open to the network.
server.address / YUKTA_SERVER_ADDRESS (127.0.0.1), yukta.api-key, yukta.mcp.api-key (mcp), /swagger-ui.html, /v3/api-docs, management.endpoints.web.exposure.include=health,info,metrics
Catalogue & control bus
plugins, nodes, supervisors Read-only views of what the server offers and what is attached to it: plugin types, live nodes and heartbeats, and supervisors; plus an admin command to a live node.- GET /api/plugins lists every plugin type and its category. GET /api/plugins/{type} adds the description, a usage pattern, UI design hints, the output ports and the configSchema: the list of config fields the plugin declares. An editor uses it to build a form for a node.
- The control-bus views identify nodes by workflow id and node id only, with no session or execution in the path: GET /api/control/nodes (all live nodes), /api/control/workflows/{wid}/nodes, and …/nodes/{nid}/heartbeat (the last heartbeat message).
- POST /api/control/workflows/{wid}/nodes/{nid}/command sends an administrative command to a live plugin. The body is a map of 1 to 100 entries (it is the map itself, not wrapped in a field). Web builds a control message marked with source ‘CONSOLE’ and returns the plugin’s response payload. Example: {“action”: “flush”}.
- GET /api/supervisors lists the supervisors and control-plane plugins attached to the control bus, in review order, with id, kind, type, dry-run flag, rate limit and counters since startup: applied, rejected, recorded (dry-run) and rate-limited.
- The plugin catalogue handles its own errors: an unknown type is a locally built 404, and a failure listing plugins becomes a 500 with the exception text.
- Guarantees. The catalogue is read-only. The admin command goes through the control bus like any other command.
- State. None. Counters live in core’s supervisor host.
- Ceiling. Control-bus views are not scoped to a session; two sessions that reuse a workflow id are not told apart by the path.
- On failure. Unknown plugin type or node: 404. Empty or oversized command: 400.
GET /api/plugins[/{type}], GET /api/control/nodes, GET /api/control/workflows/{wid}/nodes[/{nid}/heartbeat], POST .../nodes/{nid}/command, GET /api/supervisors
Ownership check
ExecutionAccess One helper that confirms that the execution you name exists and, when a session is named, belongs to that session.- require(sessionId, executionId) has two modes. With a session id it looks in that session’s own records, so another session’s run looks like ‘not found’ and its existence is not revealed. Without one (null) it looks the execution up globally.
- Who calls it with a session: the log streams and the older /api/workflow/{sid}/… routes. Who calls it without one: the newer canonical routes (/api/executions/…), which have no session in the path. In that mode it only proves the execution exists.
- Who does not call it at all: stop, restart and restart-from-node (core reports a missing run itself), history by workflow, the control-bus views and the events stream.
- Why it exists: it catches client mistakes, such as a UI addressing session A’s run through session B’s screen. It is a consistency check, not authorization: a key limited to sessions is enforced by the key filter, and a key without sessions reaches every session.
- Guarantees. A named session cannot read or steer another session’s execution through the routes that use it.
- State. None.
- Ceiling. It reads core’s in-memory tracker, so it only knows runs core still remembers.
- On failure. Always the same 404 ‘Execution not found: <id>’ for unknown or foreign runs.
Scenarios
Start a run
One start request from the door to a 202 reply, and how the client then follows the run.Request arrives
Bind and validate
Call core
Reply 202
Follow the run
Watch logs, no gap
A log stream that joins stored lines and live lines with no gap, and what happens on reconnect.Connect
Finished or running?
Subscribe to live first
Read history and join
Ids and heartbeat
Reconnect
A bad call
Five ways a call fails, what status each gets, and the one place that does not follow the rule.A typo
Missing fields
Unknown workflow
Too many runs
An unmapped failure
The exception to the rule
Steer a live run
Pausing, breaking, stepping and retrying a run that is already going, and one dangerous switch.Pause the run
Break on a value
Step through
A supervisor says no
Retry a failed node
Lift the limits
A server exposed with a key
What an API key does and does not protect once the server is reachable from the network.Open the door deliberately
A call without the key
A call with the key
What the key allows
The bundled UI
What the key does not guard
Page through history
Reading a long history in pages, and what the cursor design costs.First page
A next cursor
How the page is cut
A cursor that vanished
Capabilities
Capabilities grouped by what a client wants to do. Each card says what it does, how, what you can rely on, and where it stops.Edge
Sessions
Runs
Control
Streams
Catalogue
What is enforced, and what is not
Web keeps no state, so the interesting questions are about what it enforces at the edge, what its defaults are and what a client can rely on.Which surfaces the API key protects
The key filter checks paths under /api and /actuator (except /actuator/health) when yukta.api-key or yukta.api-keys is set.Semantics clients can rely on
Defaults and limits
Enforced or not
What the web layer remembers
It holds nothing that matters across a restart.Reference
Endpoints: sessions, workflows and drafts
42 operations in total; 56 routes counting 14 deprecated aliases. All paths are under /api and answer with the ApiResponse envelope unless noted.Endpoints: starting, stopping and reading runs
Canonical routes are /api/executions/…; the /api/workflow/… forms are deprecated aliases. The CLI still calls almost all aliases; the UI calls the two status aliases.Endpoints: controlling a live run
Each answers 200 with the execution id after core acknowledges the command. Aliases are /api/workflow/{sid}/{eid}/… and /api/workflow/{sid}/{eid}/node/{nid}/…; they check ownership, the canonical routes check existence.Endpoints: streams (Server-Sent Events)
All send numbered events and a ’: ping’ comment every 15 s. Reconnect with the Last-Event-ID header.Endpoints: catalogue, control bus and supervisors
Error mapping
One handler for the whole module. Every row answers with the ApiResponse error envelope.Controller conventions
What a new endpoint should do to fit in.Deprecated aliases and who uses them
Architecture decisions
Thin controllers; errors thrown, mapped in one place
Thin controllers; errors thrown, mapped in one place
One ApiResponse envelope for success and error
One ApiResponse envelope for success and error
Server-Sent Events with positional ids and a heartbeat
Server-Sent Events with positional ids and a heartbeat
Cursor paging by item id over a loaded list
Cursor paging by item id over a loaded list
Canonical /api/executions routes with deprecated /api/workflow aliases
Canonical /api/executions routes with deprecated /api/workflow aliases
Dry-run validate answers 200 with valid:false
Dry-run validate answers 200 with valid:false
Web holds no state and delegates every decision to core
Web holds no state and delegates every decision to core