Pipeline as First-Class Entity#

Problem#

The current domain model collapses “trigger” and “pipeline/workflow” into a single entity (RunRecord). A run triggered by a push can execute multiple workflow files (ci.yml, docs.yml), but all jobs land in a flat Vec<JobResult> with no workflow grouping.

Consequences:

  • Two jobs named build in different workflows collide.

  • Aggregate status hides which workflow failed.

  • Forge commit status is per-run, not per-workflow. Forgejo/GitHub post one check per workflow — MyCI posts one check for everything.

  • No workflow identity in the event stream.

  • Users cannot re-run a single workflow.

  • The runs list cannot show a workflow name column.

Current model#

One RunRecord per trigger event. All workflows merged.

{data_dir}/runs/{run_id}.json
{data_dir}/events/{run_id}.jsonl

RunRecord                          (1 per trigger)
+-- id, repo, ref_name, sha
+-- status                        (aggregate, treated as singular)
+-- started_at, finished_at
+-- repository_url
+-- jobs: Vec<JobResult>           (flat -- all workflows merged)
      +-- name, status, steps

EventKind:
  RunStarted { run_id, repo, ref, sha }
  JobQueued/Started/Finished { job }
  StepStarted/Finished { job, step }
  Log { job, step, stream, content }
  RunFinished { status }

Proposed model#

A trigger event (push webhook, CLI invocation) is the cause. Each matched workflow file becomes its own run — the entity with a lifecycle, status, commit check, and event stream.

TriggerEvent                           (1 per webhook / CLI invocation)
+-- id                                 (correlation ID)
+-- repo, ref_name, sha
+-- event: String                      ("push", "pull_request")
+-- received_at: DateTime

RunRecord                              (1 per matched workflow)
+-- id
+-- trigger_id: Option<String>         (links back to TriggerEvent)
+-- workflow: String                   (relative path)
+-- name: Option<String>              (YAML name: field, display label)
+-- repo, ref_name, sha               (denormalized from trigger)
+-- status: RunStatus
+-- started_at, finished_at
+-- repository_url
+-- jobs: Vec<JobResult>
      +-- (unchanged)

TriggerEvent is lightweight — it records “this happened” and links runs that share a cause. It has no lifecycle or status. RunRecord is the entity users interact with: it has a status, a commit check, a re-run action, and an event stream.

Runs from the same trigger share trigger_id. Grouping is a query (runs.filter(|r| r.trigger_id == id)), not nesting.

The trigger context (repo, ref, sha) is denormalized onto each run so runs are self-contained — no join needed to display a run.

Workflow identifier#

Use the relative path (.forgejo/workflows/docs.yml), not just the filename.

  • Guarantees uniqueness even if multiple workflow directories are supported in the future.

  • Matches GitHub/Forgejo commit status convention.

  • Unambiguous for debugging.

  • The YAML name: field is the human-readable display label. The path is the stable identifier.

The relative path is already computed in run_workflows (path.strip_prefix(repo_root)). discover_workflows_from_cache would need to prepend the directory prefix. The path is stored on each RunRecord in the workflow field.

Storage layout#

Group everything by trigger — the trigger is the causal boundary. Run record and event stream for each run are co-located.

{data_dir}/triggers/{trigger_id}/
  trigger.json                       (TriggerEvent metadata)
  {run_id}.json                      (RunRecord for ci.yml)
  {run_id}.events.jsonl              (event stream for ci.yml)
  {run_id}.json                      (RunRecord for docs.yml)
  {run_id}.events.jsonl              (event stream for docs.yml)

Why trigger-grouped:

  • Mirrors the domain: everything that exists was caused by a trigger. The filesystem reflects that relationship.

  • Co-location: run record and event stream live next to each other instead of in separate directory trees (runs/ and events/).

  • Natural access patterns: list all runs from a trigger = readdir. View a single run = read one file.

  • Cleanup is atomic: trimming old data removes one directory.

  • Per-run event streams: each run has its own JSONL file with independent lifecycle, writer, and broadcast channel.

Event stream#

Each run gets its own JSONL event file. The existing RunStarted and RunFinished events already bracket the stream — now scoped to one workflow execution. No new event types needed.

The old model (one run = multiple workflows) would have required new bracket events (PipelineStarted / PipelineFinished) to demarcate workflow boundaries within a shared stream, and a workflow filter on the SSE endpoint to select which workflow’s events to serve. With one run = one workflow, the existing event types and endpoints work unchanged.

The broadcast registry key remains run_id. Each SSE connection subscribes to exactly one run’s broadcast.

What this unlocks#

Per-workflow commit status

StatusReporter::report takes workflow context. One POST /statuses/{sha} per run.

Per-workflow re-run

Each run has its own identity. Re-run targets a specific workflow execution.

Workflow display name

name from YAML name: field. UI shows name if present, falls back to filename.

Parallel execution

Each run has its own event file, writer, broadcast. No shared mutable state.

Workflow-scoped job names

Jobs scoped within a single run. No collisions across workflows.

Workflow name in runs list

UI shows one row per run (= one workflow execution).

API#

GET /api/runs              -> Vec<RunRecord>
GET /api/runs/{id}         -> RunRecord
GET /api/runs/{id}/events  -> SSE

Each run is one workflow execution, so the API needs no workflow filter parameter — GET /api/runs/{id}/events returns exactly the events for that workflow. The old model would have needed ?workflow=.forgejo/workflows/docs.yml to select from a shared stream.

The UI runs list shows one row per run: workflow name (or filename), status, repo, branch, SHA. Runs from the same trigger share trigger_id for grouping.

Migration#

New fields on RunRecord (workflow, name, trigger_id) use #[serde(default)]. Old JSON files deserialize with these fields as None / empty — displayed as runs without workflow info. No data migration step needed.

The storage layout changes from flat directories to trigger-grouped. A v1→v2 migration moves existing files:

{data_dir}/runs/{run_id}.json
{data_dir}/events/{run_id}.jsonl

into:

{data_dir}/triggers/{run_id}/
  trigger.json              (synthesized, minimal)
  {run_id}.json             (moved from runs/)
  {run_id}.events.jsonl     (moved from events/)

For old records without a trigger, the run ID serves as the trigger ID (one run = one trigger). The migration synthesizes a minimal trigger.json from the run’s metadata.

Trade-offs#

Pros:

  • Correct domain model reflecting how workflows actually work.

  • Per-workflow status in UI and forge commit status.

  • Enables re-run, concurrency, filtering without further model changes.

  • Co-located storage (run + events in one directory).

  • Backward compatible via #[serde(default)] on new fields.

  • Event stream stays append-only, no new event types.

Cons:

  • Breaking change across the stack (domain, run store, worker, API, UI).

  • Storage layout migration needed.

  • Worker creates multiple records per trigger instead of one.

GitLab comparison#

GitLab supports only one workflow file (.gitlab-ci.yml) per repo — effectively one pipeline per trigger. The multi-workflow model (GitHub/Forgejo/MyCI) is more flexible: separate concerns into separate files, each with their own triggers and independent status. Making each workflow execution its own run is the natural consequence of supporting multiple workflows.