Skip to main content
Web is the HTTP front door of the Yukta server. It accepts REST calls and long-lived event streams, checks them, hands them to core (the engine) and wraps the answer in one standard envelope. It holds almost no state and makes almost no decisions: its job is to be a faithful, safe translation layer. This page is for thinking about it, not documenting it: open a component, replay a scenario, then challenge the weak spots.
Open risks, bugs and improvement ideas for this module are on the internal pages (sign-in required): Risks, Bugs and Improvements.

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.
Think of a hotel front desk. Guests (clients) never walk into the kitchen. The doorman checks who you are (the key check) and writes your name in the visitors’ book (request id and access log). The receptionist checks your booking form is filled in correctly (validation) and passes it to the right department (core). If something is wrong, one complaints desk answers in a standard letter (the error handler). For a long job, the desk gives you a live display (a stream) rather than making you ask again and again.

Where web sits

The mcp module is a parallel front door for AI agents. It calls core directly and has its own key (yukta.mcp.api-key); it does not go through web. Web knows nothing about plugins: it only calls core services.

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.
1

Request in

What happens. A client sends POST /api/workflow/start with a JSON body naming a session, a workflow and an optional trigger payload, and optionally an Idempotency-Key header.Why it works this way. The server is non-blocking (Spring WebFlux): the request is handled by a small pool of event-loop threads, not one thread per call, so thousands of connections (including streams) are cheap.Can go wrong. There is no limit here on request rate, body size beyond 1 MiB (spring.codec.max-in-memory-size, YUKTA_LIMITS_MAX_BODY), or open connections (see the limits risk).
2

API key check

What happens. If a key is set (yukta.api-key or yukta.api-keys) and the path is under /api or /actuator (except /actuator/health), the filter requires a key as ‘Authorization: Bearer <key>’ or ‘X-API-Key’. It compares SHA-256 digests in constant time, then checks the key’s role and sessions: a miss ends the request with 401 and ‘WWW-Authenticate: Bearer’, a role or session that does not allow the request gives 403, and a path with encoded separators, parameters or dot segments gives 400.Why it works this way. It runs first, so a caller without the key costs almost nothing and never reaches validation or core. Constant-time comparison means response time does not reveal how many leading characters were right.Can go wrong. If no key is configured the check is off and the API is open. A rejected request gets no request id and no access-log line, because the later filters never run.
3

Request id

What happens. Keeps the client’s X-Request-ID if it is 1 to 64 letters, digits or hyphens; otherwise makes a UUID. Echoes it as X-Request-ID and X-Correlation-ID and stores it in the logging context.Why it works this way. The id lets support match a client’s failed call to the server’s log lines, which is the only detail a 500 reply gives out. The strict character rule stops a hostile header from forging log lines.Can go wrong. The access log line still has no caller. With named keys, control commands are recorded in the execution log with the actor ‘key:<name>’, but reads and starts are not attributed anywhere.
4

Validate body

What happens. The JSON is read into a WorkflowStartRequest record. The session id must be non-blank, at most 256 characters and free of slashes and ’..’; the workflow id must be non-blank. Unknown JSON fields are rejected.Why it works this way. Bad input is stopped at the door with a precise message (which field, why). Rejecting unknown fields turns a typo like ‘workflowID’ into an immediate 400 instead of a silently ignored value.Can go wrong. Web checks the shape only. Whether the session and workflow exist, and whether the graph is valid, is core’s job and shows up later as a 404 or 400.
5

Core starts run

What happens. The controller calls WorkflowService.validateAndStartWorkflow (with the key if given). Core finds the workflow, asks the scheduler for a slot and returns an execution id.Why it works this way. Web adds no rules of its own. Limits, queueing, retry of the same key and ordering are core’s, so the same behaviour holds for the REST API, the UI and the MCP tools.Can go wrong. Core can refuse: unknown session or workflow gives NotFoundException, a full server gives ExecutionRejectedException. Either becomes an HTTP error in the next steps.
6

Reply 202

What happens. The id is wrapped as status 202 ‘Workflow start accepted’ with data.executionId. 202 means accepted, not finished: the run continues after the reply.Why it works this way. One envelope for every reply means clients write one parser. The client then watches progress with GET /api/executions/{id}/status/stream.Can go wrong. A 202 does not mean the run will succeed, only that it was admitted. The client must watch the status or history to learn the outcome.
7

Access log

What happens. When the exchange ends, one INFO line is written: method, path, status and duration, for example ‘POST /api/workflow/start -> 202 in 14ms’, tagged with the request id.Why it works this way. One line per request, written once the answer is known, is cheap and enough to see traffic and slow calls.Can go wrong. For a stream the line appears only when the connection closes, so duration means connection time. The query string and caller identity are not logged.
8

Error mapping

What happens. If binding fails or core throws, GlobalExceptionHandler turns the exception into an ApiResponse error: 400 for bad input, 404 not found, 403 vetoed, 429 too many runs, 500 for anything else.Why it works this way. Controllers never catch errors, so every endpoint, including future ones, answers failure in the same shape. A 500 reply contains no exception text, only ‘Reference: <request id>’; the real cause goes to the log.Can go wrong. 4xx replies do echo the exception message from core. A core message that names internal detail would reach the client. Some exceptions that mean ‘conflict’ (such as a restart already running) arrive as IllegalStateException and become 500.

A request in plain words

1

Knock

a client sends an HTTP request to /api/…
2

Door

the key filter lets it in or sends 401.
3

Name tag

it gets a request id that follows it into every log line.
4

Form check

the body is read into a record and validated; typos and missing fields stop here with a 400.
5

Hand-off

one core service is called; core does the real work.
6

Reply

the result is wrapped in the standard envelope, or the one error handler explains the failure.
7

Live view

for long jobs the client opens a stream and receives events with ids and a heartbeat.

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.
Settings: 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.
Settings: 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.
Settings: 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).
Settings: 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.
Settings: 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.
Settings: 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.
Settings: 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.
Settings: 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.
Settings: 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.
1

Request arrives

A client posts to /api/workflow/start with a session id, a workflow id and an optional payload. The key filter checks the key (if one is set) and the tracing filter gives the request an id.
2

Bind and validate

The JSON is read into a record. A bad session id, a blank workflow id or an unknown field is a 400 right here, so core never sees malformed input.
3

Call core

The controller calls core’s WorkflowService, passing the Idempotency-Key if the client sent one. Core finds the workflow, asks the scheduler for a slot and returns an execution id. Web adds no rules of its own.
4

Reply 202

The id is wrapped in the envelope with status 202 ‘accepted’. The run goes on after the reply. The access-log filter writes ‘POST /api/workflow/start -> 202 in 14ms’.
5

Follow the run

The client opens GET /api/executions/{id}/status/stream. The canonical route has no session, so only existence is checked. The legacy route (/api/workflow/{sid}/status/{id}/stream) carries the session and checks ownership too.

Watch logs, no gap

A log stream that joins stored lines and live lines with no gap, and what happens on reconnect.
1

Connect

GET /api/sessions/{sid}/executions/{eid}/logs. The ownership check looks in that session’s own records; a run of another session is a 404, so existence is not leaked.
2

Finished or running?

If the run is already in a terminal state, only the stored lines are sent and the stream ends. A running run needs both history and live lines, which is where a gap could appear.
3

Subscribe to live first

The live feed is subscribed before anything is read, into a 1024-line buffer, filtered to this execution. Reason: if history were read first, a line written during the read would be in neither part.
4

Read history and join

After the stored lines, live lines follow. A line written during the read may be in both, so history lines from the seam are remembered by text and the first live line with the same text and a timestamp not after the snapshot is dropped once.
5

Ids and heartbeat

Every event gets id 0, 1, 2… and an idle stream sends ’: ping’ every 15 s. The stream ends when core reports the run in a terminal state.
6

Reconnect

The connection drops. The client reconnects with Last-Event-ID: 41. Web rebuilds the stream and skips ids up to 41. That is exact only if the history did not change in between (for example it was not trimmed by retention).

A bad call

Five ways a call fails, what status each gets, and the one place that does not follow the rule.
1

A typo

A client sends workflowID instead of workflowId. The unknown field is rejected: 400 ‘Invalid request body’ with errors: [{field: workflowID, message: Unknown field}]. Without this rule the typo would be ignored.
2

Missing fields

A session body without name and initiator is a 400 ‘Validation failed’ listing every bad field at once, so the client fixes them in one go.
3

Unknown workflow

The body is fine but the workflow does not exist. Core throws NotFoundException, the handler maps it to 404 and echoes the message (‘Workflow not found…’).
4

Too many runs

Core’s limit is reached and it refuses the start with ExecutionRejectedException. The handler answers 429. The client should retry later.
5

An unmapped failure

Two restarts of the same run arrive together. Core throws IllegalStateException (‘Restart already in progress’). The handler maps it to 500 with only ‘Reference: <request id>’ (a ConflictException would give 409, but this path does not use it); the cause is in the log.
6

The exception to the rule

POST /api/sessions/validate catches every error itself and puts e.getMessage() in the reply. That is useful for an editor but it is the one path that can show exception text for an unexpected failure.

Steer a live run

Pausing, breaking, stepping and retrying a run that is already going, and one dangerous switch.
1

Pause the run

POST /api/executions/{eid}/pause. The helper checks the execution exists, sends the command to core and waits for the acknowledgement. The reply is 200 with the execution id; the status confirms the effect.
2

Break on a value

POST …/nodes/classify/breakpoint?field=payload.status&equals=error. The node pauses when a message matches, so a person can inspect the first bad case. DELETE on the same path clears it.
3

Step through

Step mode holds messages at a node; each POST …/step lets exactly one through. Useful to watch a transformation message by message.
4

A supervisor says no

A rule or AI supervisor attached to the control bus can review commands. If it vetoes one, core throws ControlRejectedException and web answers 403 with the reason.
5

Retry a failed node

POST …/nodes/{nid}/retry restarts from the failed node and returns a new execution id. The caller then follows the new id.
6

Lift the limits

POST /api/scheduler/limits?maxConcurrent=0&maxPerWorkflow=0 removes the two global caps at once (0 means no limit). It needs an ADMIN key, is audited with the actor and survives a restart, but the controller does not check the range or ask for confirmation.

A server exposed with a key

What an API key does and does not protect once the server is reachable from the network.
1

Open the door deliberately

An operator sets YUKTA_SERVER_ADDRESS=0.0.0.0 and a key. Loopback is the default so that exposure is a decision, not an accident, and boot now refuses to start on another address without a key. Caution: the guard also counts yukta.mcp.api-key as a key, so a server with only the MCP key set starts and leaves /api open (boot guide, guardMcpKeyOnly).
2

A call without the key

The filter answers 401 with ‘WWW-Authenticate: Bearer’ and an empty body. The request-id and access-log filters never run, so the rejected call is not in the access log (nor is a 400 or 403 from the same filter).
3

A call with the key

The key is compared in constant time and the call proceeds. Nothing in the request says who the caller is; the key is the same for everyone.
4

What the key allows

Everything under /api/**: create sessions, start and stop any run, read any log. Some plugins run processes, scripts and SQL, so a caller who can write a workflow can run code on the server.
5

The bundled UI

The UI’s API client sends no Authorization or X-API-Key header, so against a keyed server its calls would be 401 unless a proxy adds the header. The CLI can send a bearer token.
6

What the key does not guard

Paths outside /api and /actuator: the Swagger UI and the OpenAPI document are still open. The MCP endpoint has its own filter (same keys, plus a per-tool role check).

Page through history

Reading a long history in pages, and what the cursor design costs.
1

First page

GET /api/workflow/{sid}/history with no limit returns up to 500 runs, newest first. A limit above 1000 is cut to 1000; below 1 means 500.
2

A next cursor

If more remain, the reply has X-Next-Cursor: the id of the last run on this page. The client sends it back as ?cursor=. Because it is an id, not an offset, new runs added at the top do not shift the pages.
3

How the page is cut

Web asks core for the whole history, finds the cursor by walking the list, and slices. Cost grows with the history length on every page call, not with the page size.
4

A cursor that vanished

If the run named by the cursor has been trimmed from history, web does not fail; it silently starts again from the first item. A client that loops until there is no next cursor could see page 1 again.

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

Context. A REST layer tends to grow its own rules and its own error formats per endpoint, which drift apart and sometimes leak internals.Decision. Controllers bind, call one core service and wrap the result. They do not catch errors. Core throws typed exceptions and GlobalExceptionHandler maps them to 400, 403, 404, 429 and 500. A 500 shows only a reference id.Consequences. Every endpoint fails in the same shape and rules live in one place (core), so REST, UI and MCP behave the same. But web depends on core’s exceptions and messages, and unmapped types become 500.Alternatives considered. Catch and map in each controller: flexible but inconsistent and easy to leak. Return a result type instead of exceptions: explicit but noisy in reactive chains.Revisit when. a core failure needs a status the mapping cannot express (such as 409), or messages from core stop being safe to show.
Context. Yukta started as a local developer tool; remote access must be possible but must not happen by accident.Decision. server.address defaults to 127.0.0.1. One optional key, yukta.api-key, guards /api/**; it is checked in a filter with a constant-time comparison.Consequences. Very easy to set up and a safe default on a laptop. Open on loopback; for anything else the server now requires a key (named keys give roles and session limits).Alternatives considered. Full identity (OIDC, roles) from day one: right for multi-tenant use but heavy for a local tool. No auth and rely on a reverse proxy: simplest, but invisible in the product and easy to forget.Revisit when. more than one trusted party uses a server, or the server holds multi-tenant data, or any deployment is reachable from an untrusted network.
Context. Clients (UI, CLI, scripts, agents) need one predictable parsing path.Decision. Every reply is an ApiResponse with timestamp, status, message and data, or error, path and field errors. Lists are bare arrays inside data.Consequences. One parser everywhere and uniform OpenAPI. It differs from the standard ProblemDetail error format, so generic HTTP tooling does not understand it, and the status appears twice (HTTP and body).Alternatives considered. RFC 9457 ProblemDetail for errors and plain bodies for success: standard but two shapes. Wrapper types per endpoint: verbose and inconsistent.Revisit when. external integrations need standard error documents, or the envelope blocks streaming or large downloads.
Context. Clients need live status and logs; the data flows one way, from server to browser.Decision. SSE over plain HTTP. Events are numbered from 0 per connection, a ’: ping’ comment goes out every 15 s, and Last-Event-ID resumes by skipping already-seen ids. Logs subscribe to live before reading history.Consequences. Simple, proxy-friendly, automatic reconnect in browsers. Ids are positions, not durable sequence numbers, and live parts are best effort.Alternatives considered. WebSocket: two-way but heavier to proxy and no built-in resume. Polling: simplest but slower and more load. Message broker: durable but a large new dependency.Revisit when. clients need guaranteed, gap-free delivery or two-way interaction, or log lines get durable sequence numbers.
Context. Lists (sessions, history) can be long; offsets break when new runs appear at the top.Decision. limit (default 500, max 1000) plus cursor = id of the last item; the next cursor is returned in X-Next-Cursor and the list stays a bare array. Web slices the list core returns.Consequences. Stable pages and a small client API. But cost grows with the full list, and an unknown cursor restarts at the first item.Alternatives considered. Offset paging: simple but shifts under inserts. Paging inside the store: efficient but needs core and store changes. Return everything: simplest, unbounded.Revisit when. a list grows past a few thousand items or paging cost shows up in latency.
Context. Execution operations had grown several URL shapes; clients (UI, CLI) already depended on them.Decision. Every execution operation has one canonical path without a session, and the older session-carrying path stays as a deprecated alias that also checks ownership.Consequences. Existing clients keep working while the shape converges. Two route families to test, and the check and the alias are coupled.Alternatives considered. Hard cut: clean but breaks clients. Keep both forever: no migration but permanent clutter.Revisit when. the CLI and UI have moved, or ownership moves to server-side resolution.
Context. An editor wants to check a draft on every change and show the first problem inline.Decision. POST /api/sessions/validate runs the real validator without saving and returns valid, the first problem and each node’s output ports. Only a malformed body is a 400.Consequences. Easy for an editor, no HTTP error handling for a normal ‘no’. But it needs its own error catching, which reports e.getMessage() for every kind of failure.Alternatives considered. Return 400 with errors: standard but treats a normal answer as an error. Validate only on save: simpler but a slower edit loop.Revisit when. validation needs to report many problems at once, or unexpected errors must not be shown to callers.
Context. REST, the UI, the CLI and MCP must behave identically for starts, limits and control commands.Decision. Web keeps no state of its own and adds no rules. Limits, ordering, idempotency, supervisors and checkpoints are core’s. Even ownership uses core’s tracker.Consequences. No duplicated logic between REST and MCP; web is easy to test. But web cannot protect itself (no limits, no identity) unless core or a gateway does.Alternatives considered. Put policy (rate limits, quotas, per-user rules) in web: protects the edge but duplicates MCP’s path. A shared policy layer in core: one place, more coupling.Revisit when. web needs per-client policy that MCP should not share, or the module is deployed apart from core.

Glossary