Flows: automation defined in the repository
Dokumentace/Webové rozhraní
Flows: automation defined in the repository30 min čtení

Flows: automation defined in the repository

Zdrojová dokumentace je zatím dostupná v angličtině. Ovládání čtečky odpovídá zvolenému jazyku.

Na této stránce

A flow is a YAML file under .scribe/flows/. It is ordinary versioned content: it lives on a branch, changes with a submit, and can be reviewed in a proposal like any other file. The web interface reads the definitions at a branch head, composes them, draws each flow as a graph of jobs, starts runs, and keeps the history of every run with its logs. Runners are machines that execute jobs; they register with the API and claim work through the REST endpoints below.

This document covers the definition format, composition, runs, runners, the roles each action needs, and the REST API. The runner is scribe-runner, one binary with the Scribe client compiled in; its command line is in the scribe-runner reference.

Files and kinds

Every *.yml or *.yaml under .scribe/flows/ (at any depth) is read. A file's kind says what it is:

kindPurposeRunnable
flow (default)A runnable flow: triggers and jobsyes
jobA reusable job a flow includes with uses:no
stepsA reusable step sequence a job includes with uses:no

A flow's identity is its path under .scribe/flows without the extension: ci/build.yml is the flow ci/build. Names are for display; the ID is what runs and URLs carry.

Files that do not parse are reported on the Flows tab next to the flows that did, with the file and the first problem. One broken file never hides the others. Bounds: 64 files per revision, 64 KiB per file, 50 jobs per flow, 100 steps per job after expansion, uses: chains at most 8 deep.

A flow

yaml
# .scribe/flows/build.yml
name: Build
description: Compile and package the game.
on:
  manual:
  submit:
    branches: [main]
inputs:
  configuration:
    description: Build configuration
    default: Release
env:
  CI: "1"
jobs:
  build:
    runs-on: [windows, unreal]
    steps:
      - uses: scribe/checkout
      - name: Compile
        run: ./build.ps1 -Configuration ${{ inputs.configuration }}
  test:
    needs: build
    uses: parts/test.yml
    with:
      suite: unit
  package:
    needs: [build, test]
    runs-on: windows
    timeout-minutes: 30
    steps:
      - uses: ./parts/steps/archive.yml
        with:
          target: ${{ inputs.configuration }}
  • on: declares triggers. manual shows a Run flow button; submit records that the flow should run when a change lands on a branch matching one of the listed patterns (any branch when the list is empty). A pattern is a branch name, or one with * standing for any run of characters, slashes included: release/*. on: manual, on: [manual, submit] and the mapping form are equivalent spellings. A flow with no on: is manual.

  • inputs: are values a manual run may pass. Each has an optional description, default, required and options (a list the value must come from; the run dialog then offers a choice, and the default must be one of them). A free-text input may instead carry suggestions, values the run dialog offers as pills that fill the field without ruling out typing something else; with list: true the value is a comma-separated list and the pills add and remove items (the first pill replaces a default that is not itself a suggestion, so default: all gives way to the first choice). They are referenced as ${{ inputs.name }} anywhere a string appears in the flow and substituted when the run is created; an optional input without a default reads as empty.

  • env: at the flow level applies to every job; a job's env: adds to it.

  • workspaces: names folders that runs on certain branches share on a runner, instead of the default of one workspace directory per branch:

    yaml
    workspaces:
      ps5:
        branch: feature/ps5/*      # one pattern or a list
      trunk:
        branch: [main, develop]
        folder: main               # defaults to the rule's name

    A run whose branch matches a rule is checked out in that rule's folder, and a runner switches that one workspace between the matching branches; runs on branches no rule matches get a directory each. The first matching rule wins. Names and folders are single path segments (letters, digits, ., _, -). Use it where a branch switch is cheap, or where a long-lived branch should have a stable path on the build machine; leave it out for projects whose build caches make switching expensive.

  • bundles: names what runs publish and how long it is kept; see Bundles. A job may add a bundles: list of its own.

  • jobs: is a mapping of job key ([a-z][a-z0-9_-]*) to job. Jobs run in parallel unless needs: orders them. needs: must name jobs in the same flow, and cycles are refused.

  • Every job needs runs-on:: one label or a list. A runner may take the job only when it carries every label listed. Labels are lower-cased.

  • timeout-minutes: defaults to 60.

  • A step has exactly one of run: (a shell command) or uses:. A uses: ending in .yml/.yaml includes a kind: steps file; any other uses: (scribe/checkout) is a built-in action the runner implements. Steps also accept name, with, env, shell, working-directory and continue-on-error.

${{ ... }} accepts only inputs.<name>. Anything else is a definition problem, so a typo never silently becomes literal text.

Reusable pieces

Duplicated YAML is what pieces are for. A kind: job file is a whole job a flow includes; a kind: steps file is a step sequence a job splices in.

yaml
# .scribe/flows/parts/test.yml
kind: job
name: Test ${{ inputs.suite }}
inputs:
  suite:
    required: true
runs-on: linux
env:
  SUITE: ${{ inputs.suite }}
steps:
  - uses: scribe/checkout
  - run: ./test.sh ${{ inputs.suite }}
yaml
# .scribe/flows/parts/steps/archive.yml
kind: steps
inputs:
  target:
    default: Debug
steps:
  - name: Archive ${{ inputs.target }}
    run: zip out-${{ inputs.target }}.zip build/
    working-directory: dist
  • A piece declares its own inputs:; the caller passes them with with:. Unknown names, and missing required ones, are refused.
  • A piece's inputs are substituted at composition time. A caller may pass one of the flow's own inputs through (with: { target: "${{ inputs.configuration }}" }); that expression stays until the run is created.
  • Paths in uses: are relative to .scribe/flows (parts/test.yml), or to the file that contains them when they start with ./ or ../. A path may not leave .scribe/flows.
  • A job that uses: a piece may set needs, name, env (added to the piece's), runs-on and timeout-minutes (overriding the piece's), but not steps.
  • A steps piece may include other steps pieces. Cycles are refused.

Composition happens in the API. What the Flows tab shows, what a run freezes and what a runner receives is the composed form: only run: commands and built-in actions remain, with a source on every step naming the file it came from.

Runs

Starting a flow freezes its composed definition at one branch revision, with the run's inputs substituted. Later edits to the YAML never change a run that already exists. Each run has a per-repository number (#12), a trigger (who started it, or the change that did), and one job per definition job. A job has steps; each step keeps its own status, timing, exit code and log.

Job states: waiting (dependencies not finished), queued (ready for a runner), running, succeeded, failed, skipped (a dependency did not succeed, or the run was cancelled first) and cancelled. A run is queued until a job starts, running while any job is unfinished, then succeeded, failed or cancelled. The first failed job and step are recorded as the run's failure and shown in the run list.

A runner that stops reporting loses its job: leases last five minutes and are renewed by every report, log append and heartbeat. An expired lease fails the job with "runner stopped reporting" and the run settles accordingly. Expiry is applied when a runner next asks for work in that repository and, so that a lone runner's death is never invisible, by every tick of the worker's submit watcher. timeout-minutes is enforced by the runner on the job it executes.

Logs are stored per step, in the order the runner sent them, up to 1 MiB per step. A runner streams a step's output as it happens, and the run page follows it over an event stream: a line shows the moment the step prints it, with no polling on either side (see live streams).

Live streams

Nothing about a running job is polled. The runner holds one text/plain request open per step and writes output into it as the step produces it — each pipe read goes out as one chunk, so a line is on its way the moment it is printed. The API stores the text in chunks as it arrives (when 64 KiB waits, or a quarter second after the first byte of a quiet step's line) and announces every stored chunk and every change to the run on Valkey, where any API replica's viewers hear it. The stream ends when the step ends, after 20 s of silence, or once it has carried a megabyte or run five minutes; the reply then confirms how far the log reaches. A stream lost mid-way is reopened from the offset the runner last had confirmed, with what it sent since resent, and the API skips what it already stores, so a lost connection loses nothing and repeats nothing.

A browser tab holds one event stream for everything it watches: GET /api/v1/events (text/event-stream) answers hello { stream } and the tab then adds and removes topics with POST /api/v1/events/:stream/subscriptions { add: [{ key, topic }], remove: [key] }, each topic under a key of the tab's choosing. Every event on the stream is { topic, data } under the topic's own event name; end says a topic is over, error (with a code) that it was refused. The topics:

TopicEventsWhat it follows
{ kind: "run", repo, run }run (the whole FlowRun), then endone run as it changes — a step starting, a job ending, a bundle landing
{ kind: "log", repo, run, job, step, after? }chunk ({ seq, at, text }, id = seq), then endone step's log: what is stored past after, then each chunk as it lands
{ kind: "runs", repo }run (a FlowRunSummary), gonethe repository's runs as they are created, change and are deleted, for a list to update in place
{ kind: "notifications" }sync when followed, changed ({ read })the signed-in person's notifications: read: false when one arrived, true when another tab read some; the list itself comes over REST

Each topic is checked against a token's scope when it opens — the run topics need flows:read, notifications notifications:read — and refused on the stream with insufficient_scope; the stream routes themselves need no scope.

A subscription request may reach any API replica; it travels over the live-event bus to the process holding the stream, which opens the topic with the stream's own session, so permission is checked where the stream lives. An open stream is checked again about once a minute, and before anything is emitted once a minute has passed since the last check: the session must still be live (not signed out, revoked or expired; a token's credential still accepted), and every topic is opened again with it, so withdrawn repository access or a narrowed token ends that topic with an error. A session that is refused ends the stream with an error for every topic; a check that could not be made (the store or identity service down) ends the stream alone, and the tab reconnects. A stream never outlives its session. One process keeps one Valkey subscription per channel however many tabs follow it. The tab reopens a lost stream with backoff and asks for everything again, each log from its last chunk; the run page falls back to polling only while the stream is down. The run page uses the run and log topics, the Runs tab and a flow's recent runs the runs topic. The per-run streams GET …/runs/:run/events and GET …/jobs/:job/logs/stream remain for a single-purpose client (they are plain EventSource streams with Last-Event-ID). nginx passes every stream through unbuffered and holds it open for an hour; the API sends a comment line every 25 s so nothing between the two closes an idle one.

Bundles

A bundle is what a job publishes from its workspace once its steps are done: the build, its symbols, a report, the logs of a crash. A flow declares them with rules in the TeamCity artifact-path form, one per line:

yaml
bundles:
  paths:
    - build/${{ inputs.configuration }}/** => game.zip     # pack into an archive
    - build/**/*.pdb => symbols/                            # loose files under symbols/
    - -:**/*.tmp                                            # exclude, from every rule
    - report.html                                           # one file at the root
    - Saved/Logs => logs.zip!/Logs                          # a directory inside the archive
  retention:
    runs: 10          # keep the bundles of the newest 10 runs of this flow
    days: 30          # and none older than 30 days
jobs:
  build:
    runs-on: windows
    steps:
      - uses: scribe/checkout
      - run: ./build.ps1
    bundles:
      - Intermediate/Build.log => logs                      # this job only

A rule is [+:|-:]pattern [=> target]. The pattern is relative to the workspace: * and ? match within one path segment, ** any number of segments, and an exact path names a file, or a directory with everything in it. Matches keep their path below the pattern's literal prefix (build/Release/**/*.dll publishes build/Release/a/b.dll as a/b.dll) and land under the target: a directory in the run's bundles, the root when there is none, or a .zip the matches are packed into, with archive.zip!/inner placing them under inner/ inside it. Several rules may fill one archive. -: excludes apply to every include. Rules may use ${{ inputs.name }}. Targets are portable paths (no .., :, *, ? and the like), so what is published unpacks anywhere. bundles: can also be a bare list, which means paths with the default retention.

The flow's rules apply to every job; a job's own bundles: list adds to them. A job with any rule ends with a Publish bundles step that the composer appends, so publishing has a status, a duration and a log like any step, and it runs after a failed step too: a failed build's logs and dumps are exactly what its bundles are for. A cancelled, aborted or timed-out job publishes nothing. Archives are packed, not compressed: packing runs at disk speed and game builds barely compress; a step that wants a small archive makes one itself and names it in a rule. Zips carry Unix modes, so executables come back executable.

Limits: 64 rules per job, 1 000 loose files per run (pack trees into an archive past that), one part of 16 MiB per upload request, and a total per run the deployment sets (BUNDLES_RUN_LIMIT_MB, 16 GiB by default). Uploads resume: a lost connection costs one part, and the content process keeps a few uploads streaming at once (BUNDLES_UPLOADS), telling further runners to wait their turn; a runner publishes three files at a time, so archives pack while other files upload. Every file is hashed by the runner as it is sent (BLAKE3, the repository's own algorithm) and the digest travels with the last part; the worker then reads the stored file back and compares, and the run page marks a file verified or integrity check failed. Nothing about the bytes is trusted until that check has run.

Retention is per flow: the newest runs runs of the flow that have bundles keep them, and bundles older than days go; leaving one out uses the deployment default (20 runs, 90 days). The rule that counts is the one in the flow at the head of the repository's main branch, not the one frozen in the run, so changing retention on main changes what is kept without a new run; a flow main no longer defines, or a branch-only flow, gets the default. The worker applies it once a day, removing the files but not the list: a run still shows what it published, marked removed with the date. Deleting a run removes its bundles at once.

Access follows the repository: whoever may read the repository may download its runs' bundles, so a public repository's bundles are public links. They are served from /api/v1/repositories/:repo/runs/:run/bundles/<path> and listed on the run page with sizes and, when several jobs published, the job that produced each.

Storage is the bundles bucket of object storage (OBJECTS_URL, the objects service in Compose: SeaweedFS behind an S3 gateway), a file per published path under runs/<run id>/. Uploads and downloads are handled by the content process (content in Compose, apps/content in the source), which streams each uploaded part straight into object storage as it arrives and never holds a file in memory or on a disk, so a farm publishing at line rate never competes with the interactive API and the process keeps no state. A file sent as one part is stored as one object; a larger one is an S3 multipart upload whose parts (every one but the last exactly the announced part size, 16 MiB) are listed in a small record under staging/<run id>/, so an interrupted upload resumes even after the content process restarts, and nothing is visible under the file's path until its last part lands. Downloads go out with X-Accel-Redirect: the content process only authorizes, and nginx fetches the object, passing HTTP range requests through for resuming; the object carries the type and download name it was uploaded with. Retention and the worker's BLAKE3 check read the same bucket; the sweep lists stored objects, never folders, so its cost follows what retention keeps. Without object storage the publish step fails with a message saying so.

Submit triggers

The worker Compose service (the same process that follows proposals) runs a submit watcher. Every PROPOSAL_CHECK_INTERVAL_SECONDS (30 by default) it reads each repository's branch heads with its reader credential — one call per repository — and, for every branch that advanced since the previous tick, the submits on that branch since then. Each submit is matched against the flows as defined at that change, so a submit that adds or edits a flow already counts, and a run is created for every flow whose submit trigger names the branch (or names no branch). The run's trigger records the change number and its author; .scribe/flows edits that break a definition show up as problems on the Flows tab, never as failed runs.

Runs are keyed by change and flow, so a restarted or repeated tick never starts a second run for the same submit. The first time the watcher sees a repository or a branch it records the head and replays nothing; a restored or recreated repository (new incarnation) starts over the same way. A branch that advanced by more than 50 submits between ticks triggers on the newest 50 and logs flow.watch.truncated. A flow with a required input and no default cannot start from a submit; the worker logs flow.watch.inputs and skips it.

The watcher needs the worker's reader account to have read access to the repositories it should follow; a repository it cannot read is skipped and logged. There is no push path yet: latency is the poll interval, which is adequate for builds. A server-side change feed can later wake the same watcher without changing what it does.

Runners

A runner is bound to one or more repositories and carries labels. When it asks for work it receives the oldest queued job, among the repositories it is bound to, whose runs-on labels it satisfies. The job assignment is complete: the composed job, the run's branch and revision, its trigger and environment. The runner needs nothing else from the definition.

Registration and tokens

A repository Maintainer registers a runner on the Flows tab (Runners → Register a runner) or with POST /repositories/{repo}/runners: a name, labels, and further repositories to bind. Registration involves no secret; it returns the runner with its ID.

The token that drives a runner is the operator's own: any API token carrying runners:write and flows:read, created at /settings/api or with the one-click shortcut in the registration guide (which creates a token with exactly those scopes and marks it as made for runners). One token may drive any number of runners. The process is started with the web address, the runner ID and the token, from a file or the environment and never as an argument:

text
scribe-runner run --server https://scribe.example --runner <id> --token-file ~/.scribe-runner-token
SCRIBE_RUNNER_TOKEN=scribe_pat_… scribe-runner run --server https://scribe.example --runner <id>

service install in place of run registers it with the machine's service manager (systemd, launchd or the Windows service manager) so it starts with the machine; see the runner below. Either way, a running runner keeps scribe-runner.pid in the root of its work directory and removes it when it exits.

A token may operate a runner when its owner is the runner's owner, or a Maintainer of every repository the runner serves — the same rule as disabling or removing it. That is what lets a team keep runners on a shared CI account while an individual Maintainer can still start one from a laptop with a token of their own.

The web address must be https://; http:// is accepted only for a loopback host (localhost, 127.0.0.1, [::1]), and any other is refused with "… is plaintext on a network address; use https:// (http:// is accepted only for localhost)", because the token and the job scripts travel on that link. The runner offers the hybrid post-quantum key exchange X25519MLKEM768 first and also accepts classical X25519, since a proxy in front of the web API may offer only that.

Every web token is backed by a key-backed Scribe automation credential that auth issued with a role ceiling derived from the token's scopes; for runners:write plus flows:read that ceiling is reader (see web automation credentials). The token itself is a web-level secret for the HTTP API only; it never reaches the storage servers.

The runner has a device key of its own, device-key.pem, created on first use in a file only its account may read (mode 0600 on Unix): beside the token file when the runner is started with --token-file, otherwise in runner-credentials/<id>/ under the user configuration root (always there for service install, whose check-in certifies the key the service will use). The private key never leaves the machine. At check-in (POST /runners/{id}/checkin) the runner sends the public key, and the API has auth derive a credential for that key from the token's automation credential (the auth verb automation-derive). The derived credential has the same account, role ceiling and expiry as the token's, and is revoked with it. The API answers with the credential certificate auth issued, together with the storage and auth endpoints and the CA bundles that verify them (plus the server-name overrides the deployment uses, when any); the answer carries no secret. The runner then fetches its hour-long access certificates from auth itself and checks repositories out over mutual TLS as the token's identity, with nothing configured but the three arguments (see identity). The runner reached the API over HTTPS, so this is where trust in the deployment's private PKI is established; a tcp:// storage endpoint carries no CA. A deployment whose web API has no tls:// auth endpoint and auth CA configured cannot certify a runner: check-in answers 409 runner_auth_unconfigured. The certificates stay in memory; a restarted machine checks in again.

The CA bundles from check-in are the runner's only trust, and check-in stays authoritative for them: nothing is saved. During a CA rotation the runner follows the handover chain storage or auth present (see CA handover) in memory. A server whose CA changed in any other way is refused; the job fails with both fingerprints, and before its next job the runner checks in again and uses the CAs the API delivers then.

A stolen runner token, or the runner's device-key.pem with its certificate, therefore yields read access, never a write, an unlock or an administrative action; but the token also lets its holder act as that account's runners — check in, claim their jobs, report results and upload bundles — for every repository the account can reach through them. Choose the token's account with the trust boundary below in mind.

Revoking the token at /settings/api stops every runner started with it: their derived credentials are revoked with the token's. Removing a runner deletes its registration; a disabled runner is refused check-in and work until a Maintainer enables it again. A runner is online while it heartbeats, busy while it holds a job, offline after 90 seconds of silence, and disabled when turned off. The registration guide assumes scribe-runner is already installed and on PATH and links to no download: take the scribe-runner-<platform> archive from the manual build workflow, or let scripts/install.sh (scripts/install.ps1 on Windows) build it beside the other executables.

The runner

scribe-runner speaks the routes below and nothing else; it holds no YAML and needs no scribe on the machine. Its loop is check in, then ask for work every few seconds while idle (5 by default, with jitter; doubling up to a minute while the API is unreachable) and execute what it gets, one job at a time. A heartbeat every 30 seconds keeps it listed and renews the lease of the job it holds. A runner that is disabled, whose token was rejected, or whose registration is gone says so once in its log and keeps asking at a slower pace, because the fix is on the other side; it never exits over a state the API named. Run several runners on one machine for parallel jobs, each with its own --workdir.

Workspaces. By default the runner keeps one workspace per repository and branch under <workdir>/workspaces/<repository>/<branch>-<id>, and never switches a workspace between branches. Switching rewrites much of a game project and invalidates what the build left beside the sources (Unity's Library, Unreal's Intermediate), so alternating branches in one directory would pay the expensive part of every build again. A job on another branch simply gets another directory; a job on an older or newer change of the same branch syncs that branch's workspace to it, backwards or forwards. A flow's workspaces: rules override this per branch: the matching runs share <workdir>/workspaces/<repository>/<folder>, which the runner switches to the run's branch and revision in one sync. The folder arrives resolved in the job assignment; the runner never reads the rules. Disk is bounded per repository by --keep-workspaces (8 by default): beyond it the least recently used workspaces are retired, their server registrations with them. Every registration a runner makes is labelled <runner name>@<host> and tagged runner and runner-id=<id>, so scribe workspace list --tag runner-id=<id> and the web Workspaces page find one runner's workspaces, and a machine that was retired without service uninstall can be cleaned up from anywhere. A workspace the sync cannot bring into order (a job crashed mid-operation, the repository was restored under a new incarnation, the requested view differs) is retired and recreated; the runner's copies hold no local work worth protecting. A workspace whose sync merely failed to reach storage is kept for the next attempt. Restrict a flow's submit trigger to the branches it should build so a runner's disk holds the branches that matter.

Steps. uses: scribe/checkout brings the branch workspace to the run's revision; with: clean: true retires it first for a build from nothing, and with: view: Content, Source limits it to those inclusion prefixes. A run: step is written to a script file and executed by the shell: bash (with -eo pipefail) or sh on Unix, pwsh or powershell (with $ErrorActionPreference = 'Stop') on Windows, unless shell: names one of bash, sh, pwsh, powershell, cmd, python, or a command line with {0} for the script path. Steps run in the workspace directory, or working-directory under it, with the flow's and job's env and these variables: SCRIBE_WEB, SCRIBE_RUNNER, SCRIBE_RUNNER_ID, SCRIBE_RUN_ID, SCRIBE_RUN_NUMBER, SCRIBE_FLOW, SCRIBE_FLOW_NAME, SCRIBE_JOB, SCRIBE_REPOSITORY, SCRIBE_REPOSITORY_ID, SCRIBE_BRANCH, SCRIBE_BRANCH_ID, SCRIBE_REVISION, SCRIBE_WORKSPACE, SCRIBE_TRIGGER (manual or submit), SCRIBE_ACTOR, SCRIBE_CHANGE (submit triggers), SCRIBE_INPUT_<NAME> for each manual input, and CI=true. Output streams to the step's log as it appears, over one request held open for the step; the runner's own lines are marked with ». timeout-minutes and a cancellation stop the step's whole process tree (a process group on Unix, a job object on Windows), so a build script's compiler does not outlive it.

Nothing else from the runner's own environment reaches a step: a step starts from an empty environment plus a fixed list of harmless host variables (PATH, HOME, USER, LOGNAME, SHELL, LANG, LANGUAGE, LC_*, TZ, TERM, TMPDIR, TEMP, TMP, and on Windows the system, profile and program-directory variables its shells and toolchains expect; the full list is in the runner reference). The runner's token, its certificates and any other variable its operator exported stay with the runner; a build that needs a host variable sets it in the flow's env.

Stopping. Ctrl+C or SIGTERM (a systemd or launchd stop) lets the current job finish and then exits; a second signal aborts the job, which is reported failed with "the runner was stopped". The generated service units allow an hour for that. A Windows service stop behaves like the first signal.

Trust boundary

A runner executes code written by anyone who can write the flows of a repository it is bound to: a Writer can push a run: step and start a run. That code runs as the runner's OS account, so binding a runner to a repository gives the repository's Writers what that account has.

  • Steps get no runner credentials: not the token, not the certificates the runner holds, not the runner's environment (see Steps above). A service installation keeps the token and device-key.pem outside the work directory, where job workspaces and scripts live.
  • That is protection against accidents, not against a step written to look. Job steps run as the runner's OS user, so they can read its token file and its device-key.pem exactly as the runner does, and inspect the runner process itself. The device key does not make the runner's credentials safer from its own jobs than the token alone. The OS account is the boundary. Run runners under a dedicated low-privilege account that holds nothing else of value — never a developer's or an administrator's.
  • Drive runners with the token of an account whose read access you are comfortable exposing to the Writers of every repository those runners are bound to (a shared CI account, say), since such a Writer can take the token and the runner's key, read what that account reads, and act as that account's runners elsewhere. Bind the runners of one token only to repositories with the same trusted Writers.

Who may do what

Flows reuse the repository roles from auth; there is no separate permission model.

ActionRequires
See flows, runs, logs and runnersread access to the repository (anonymous on a public one)
Start a run, cancel a runWriter
Delete a run and its logsMaintainer
Register a runner for a repositoryMaintainer of that repository and of every further repository bound
Enable, disable or remove a runnerMaintainer of every repository the runner is bound to
Check in, heartbeat, claim, report, append logs and upload bundlesan API token with runners:write and flows:read (either alone is refused with 403 insufficient_scope) whose owner is the runner's owner or a Maintainer of every repository it serves

A runner only receives jobs from repositories its owner can still read; the decision to bind it was a Maintainer's, and revoking the owner's read grant stops the jobs without touching the registration.

Token scopes: flows:read (definitions, runs, logs, runners), flows:write (start and cancel runs, writer ceiling) and runners:write (register and operate runners, reader ceiling).

REST API

All routes are under /api/v1 and accept a browser session or a personal access token. The generated reference at /developer/rest and /api/v1/openapi.json carries the exact schemas.

Repository routes (:repo is the percent-encoded <namespace>/<name>; :flow is the percent-encoded flow ID):

Method and pathScopePurpose
GET /repositories/:repo/flows?branch=&revision=flows:readComposed catalog at a revision (head when omitted), with problems and the caller's permissions
GET /repositories/:repo/flows/:flow?branch=flows:readOne composed flow
GET /repositories/:repo/runs?flow=&branch=&status=&since=&until=&cursor=flows:readRuns, newest first; status is a comma-separated list of run statuses, since/until a creation range in ms (inclusive / exclusive)
POST /repositories/:repo/runsflows:writeStart a manual flow: { flow, branch, revision?, inputs, operationId? }; revision defaults to the branch head, and a retry with the same operationId returns the same run
GET /repositories/:repo/runs/:runflows:readA run with its frozen definition, jobs and steps
POST /repositories/:repo/runs/:run/cancelflows:writeCancel
DELETE /repositories/:repo/runs/:runflows:writeDelete a finished run and its logs
GET /repositories/:repo/runs/:run/jobs/:job/logs?step=&after=flows:readOne page of a step's log; continue with after while complete is false. Own rate budget of 300/min; a live viewer follows the event stream below instead
GET /eventsper topicThe tab's event stream (text/event-stream): hello { stream }, then every followed topic's events as { topic, data }; see live streams
POST /events/:stream/subscriptionsper topic{ add: [{ key, topic }], remove: [key] }; answers 202 at once, refusals arrive on the stream as error events
GET /repositories/:repo/runs/:run/eventsflows:readThe run as text/event-stream: a run event carrying the whole run now and after every change, then end once it is over
GET /repositories/:repo/runs/:run/jobs/:job/logs/stream?step=&after=flows:readOne step's log as text/event-stream: a chunk event ({ seq, at, text }, id = seq) for each stored chunk past after and then for each as it lands, then end once the step is over. Last-Event-ID on a reconnect stands in for after
GET /repositories/:repo/runs/:run/bundles/<path>flows:readDownload one published file; 410 once retention removed the run's bundles
GET /repositories/:repo/runnersflows:readRunners bound to the repository with live status
GET /repositories/:repo/runners/:runnerflows:readOne runner bound to the repository, with live status and activity: the run, job and running step it holds when that job is in this repository, else null
POST /repositories/:repo/runnersrunners:write (Maintainer)Register a runner: { name, labels, repositories }; returns it with its id
POST /repositories/:repo/runners/:runner/enabledrunners:write (Maintainer){ enabled }
PATCH /repositories/:repo/runners/:runnerrunners:write (Maintainer){ labels }; the next claim uses the new set
DELETE /repositories/:repo/runners/:runnerrunners:write (Maintainer)Remove the registration

The repository routes are also MCP tools, generated from the same declarations with the same scopes and error shapes: list_flows, read_flow, list_runs, start_run, read_run, cancel_run, delete_run, read_run_log, list_runners, read_runner, register_runner, update_runner, set_runner_enabled and remove_runner. An agent starts a flow with just flow, branch and inputs, reads the run until its status is terminal, and follows a step with read_run_log (after from the previous nextCursor). Bundle downloads are bytes, not a tool; the run lists their paths and GET .../bundles/<path> serves them. The runner protocol below is a machine interface and is not exposed to agents.

Runs also reach the home feed: every sampled repository's runs merge into it beside its submits and proposal events, for anyone who may read the repository — a public repository's runs are on the anonymous feed too. See the web interface guide.

Runner routes, used by the runner process with a token carrying runners:write and flows:read that may operate the runner (see above):

Method and pathPurpose
POST /runners/:runner/checkin{ platform, version, publicKey } (publicKey is the runner's device public key, SubjectPublicKeyInfo DER in hex); returns the runner and credential: { certificate, endpoint, ca, serverName, authEndpoint, authCa, authServerName }: the credential certificate auth derived from the token's automation credential for that key, with what verifies the endpoints; 409 runner_auth_unconfigured when the deployment has no TLS auth configured
POST /runners/:runner/heartbeat{ job?: { runId, jobId, lease } }; renews the lease, reports idle when absent
POST /runners/:runner/claimThe next job as a JobAssignment (run.workspace is the workspaces: folder for the run's branch, or null), or { job: null }
POST /runners/:runner/runs/:run/jobs/:job/events{ lease, events } with step_started, step_finished and job_finished events, at most 64 per call
POST /runners/:runner/runs/:run/jobs/:job/logs{ lease, step, text }, at most 64 KiB per call: one append, for a runner that cannot hold a request open
GET /runners/:runner/runs/:run/jobs/:job/logs/stream?step=With X-Scribe-Lease: where the step's log stands, { received, full, continue }
POST /runners/:runner/runs/:run/jobs/:job/logs/stream?step=&offset=With X-Scribe-Lease: the step's output as one text/plain (UTF-8) request, chunked, held open and written as the step writes; offset is the count the runner last had confirmed, and what is already stored past it is skipped. The reply, { received, full, continue }, comes when the stream ends; 409 log_offset when offset runs past what is stored, 413 log_limit when the step is already full
POST /runners/:runner/runs/:run/jobs/:job/bundles{ lease, path, size }: announce a file; returns { upload, received, partBytes }, with received past zero when an earlier attempt got that far
PUT /runners/:runner/runs/:run/jobs/:job/bundles/:upload?offset=One application/octet-stream part with X-Scribe-Lease and Content-Length; offset must equal received; the file is recorded on the run when the last part lands ({ received, complete }), and that part may carry X-Scribe-Blake3, the hex digest of the whole file the worker verifies

The runner's own routes are rate limited per runner (address and runner id together), each with its own bucket, not under the per-address limit the rest of the API shares: a runner sits behind the same address as the browsers around it, and its traffic must not be refused because a run page is open on the same network. The limits are 60 check-ins and 60 heartbeats, 120 claims, 300 event reports, 600 log appends and 120 log streams a minute. The bundle upload routes are served by the content process and have no rate limit; bundle downloads there allow 600 requests a minute per address.

Every reply to a runner carries { continue }. False means stop the job: it was cancelled, its lease expired, or it already finished. A report against a job the lease does not hold is not an error; the runner simply learns to stop. Reports that contradict the recorded state (a step starting twice, events after job_finished) are refused whole with 409, so a job never records a half-applied report.

A runner checks in at start, polls claim while idle, heartbeats at least once a minute while executing, sends events as they happen, streams log output, and publishes bundles in resumable parts; scribe-runner does exactly this, and another implementation would speak the same routes. The assignment's job.steps are executed in order; continueOnError steps do not end the job when they fail.

Every field of an assignment is always sent: unset values as null (run.workspace, a step's run, uses, shell and workingDirectory, a bundle rule's archive) and maps as {} when empty (run.env, the manual trigger's inputs, job.env, a step's with and env). The one exception is job.bundles, which a run started before jobs published bundles never froze; such a job publishes nothing. scribe-runner refuses an assignment that lacks any other field rather than filling it in, and unknown fields are ignored.

What is not here yet

  • Runner distribution: the manual build workflow produces scribe-runner-<platform> archives as workflow artifacts, or attached to a release when given a tag; the registration guide assumes scribe-runner is installed and on PATH and does not link to a download.
  • A push path for submits; the watcher polls at the worker interval.
  • Secrets: a flow has no secret store; env values are plain text in the versioned YAML.
  • Other triggers: on: accepts only manual and submit; there are no scheduled or proposal triggers.
  • Check reporting: run results are not reported on proposals.
  • Re-running a finished run; start a new run instead.
Zdroj docs/flows.mdVerze 2b8dcccb