gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (ci): Add action_run/task/log persistence foundation

7637c5fead6d51632d953140db385345a0c76224 · Michael Hainz · 2026-07-22T09:18:24Z

Changes

7 files changed, +465 -6

MODIFY docs/admins/ci-runners.md +6 -2
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index a7d59ef..45932b5 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -88,9 +88,13 @@
88 88 |---|---|
89 89 | `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
90 90 | `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `last_seen`, `created_at`. |
91 +| `action_run` | One workflow run per repository: `number` (per-repo sequential), `workflow_name`, `workflow_file`, `event`, `ref`, `commit_sha`, `triggered_by_id`, `status` (`PENDING`/`RUNNING`/`SUCCESS`/`FAILURE`/`CANCELLED`), timestamps. Deleted with its repository. |
92 +| `action_task` | One job within a run: `run_id`, `name`, `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
93 +| `action_log` | One log row of a task: `task_id`, `line_index` (0-based), `content`, `timestamp`. Deleted with its task. |
91 94
92 -Both are introduced by migration `V19__ci_runners.sql`. They hold no repository data; losing them
93 -only means runners must re-register.
95 +`ci_runner*` are introduced by migration `V19__ci_runners.sql`; `action_*` by `V23__action_runs.sql`.
96 +The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
97 +`action_*` tables hold run history and logs, tied to their repository by cascade.
94 98
95 99 ## Troubleshooting
96 100
MODIFY docs/maintainers/ci-runners.md +9 -4
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index 5b4d03b..dd72524 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -12,7 +12,8 @@
12 12 | Generated messages | `de.workaround.ci.proto.*` (build output) | Protobuf message classes generated by `protobuf-maven-plugin` at `generate-sources`. |
13 13 | Connect endpoint | `ci/ConnectRunnerResource.java` | JAX-RS resource serving the Connect unary RPCs under `/api/actions`. |
14 14 | Registration/presence | `ci/RunnerRegistrationService.java` | Token issue, runner register/declare/authenticate, list/delete. |
15 -| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Persisted state (migration `V19`). |
15 +| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
16 +| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migration `V23`). Not yet wired to a run loop. |
16 17 | Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
17 18 | Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
18 19
@@ -47,14 +48,18 @@
47 48 runner.
48 49 - Admin UI: generate/delete registration tokens, list/delete runners; gated by `AdminAccess` and the
49 50 `/admin/*` authenticated policy.
51 +- **Run-persistence tables:** `action_run`, `action_task`, `action_log` (migration `V23`) with their
52 + Panache entities. `action_task.log_length` is the durable log-row count that doubles as the
53 + UpdateLog resume/ack offset; `action_task.deadline` is the zombie-timeout anchor. Schema and
54 + entities only — nothing writes to them yet.
50 55 - Tests: `RunnerRegistrationServiceTest` (service), `ConnectRunnerResourceTest` (protobuf-over-HTTP
51 - round-trip for Ping/Register/Declare + auth failures), `AdminAccessTest` (admin gate).
56 + round-trip for Ping/Register/Declare + auth failures), `AdminAccessTest` (admin gate),
57 + `ActionRunPersistenceTest` (run/task/log persistence, per-repo run numbering, pending-task lookup).
52 58
53 59 ## What still needs to be implemented
54 60
55 61 - **Run loop:** `FetchTask` (long-poll with `tasks_version`), `UpdateTask`, `UpdateLog` (offset /
56 - `ack_index` resume). Not served yet.
57 -- **Tables:** `action_run`, `action_task`, `action_log` (or log-blob storage).
62 + `ack_index` resume). Not served yet — the tables above are the foundation for it.
58 63 - **Workflow pipeline:** parse `.forgejo/workflows/*.yml` at the pushed head, evaluate `on:` triggers
59 64 (push only for the MVP), expand a single job into a Task payload. No `needs`/`matrix` yet.
60 65 - **Run UI:** per-repository run list + run detail with live per-step status and logs.
ADD src/main/java/de/workaround/model/ActionLog.java +50 -0
diff --git a/src/main/java/de/workaround/model/ActionLog.java b/src/main/java/de/workaround/model/ActionLog.java
new file mode 100644
index 0000000..81cd998
--- /dev/null
+++ b/src/main/java/de/workaround/model/ActionLog.java
@@ -0,0 +1,50 @@
1 +package de.workaround.model;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.UUID;
6 +
7 +import org.hibernate.annotations.processing.HQL;
8 +
9 +import io.quarkus.hibernate.panache.PanacheEntity;
10 +import io.quarkus.hibernate.panache.PanacheRepository;
11 +import jakarta.persistence.Entity;
12 +import jakarta.persistence.GeneratedValue;
13 +import jakarta.persistence.GenerationType;
14 +import jakarta.persistence.Id;
15 +import jakarta.persistence.ManyToOne;
16 +import jakarta.persistence.Table;
17 +
18 +/**
19 + * One log row of an {@link ActionTask} (issue #2, phase 1). Rows are appended by UpdateLog in a
20 + * runner-supplied, contiguous {@link #lineIndex} order (0-based); the resume protocol replays from
21 + * {@link ActionTask#logLength}. Rows are removed with their task (DB-level ON DELETE CASCADE).
22 + */
23 +@Entity
24 +@Table(name = "action_log")
25 +public class ActionLog implements PanacheEntity.Managed
26 +{
27 + @Id
28 + @GeneratedValue(strategy = GenerationType.UUID)
29 + public UUID id;
30 +
31 + @ManyToOne(optional = false)
32 + public ActionTask task;
33 +
34 + /** 0-based position of this row within the task's log stream. */
35 + public int lineIndex;
36 +
37 + public String content;
38 +
39 + public Instant timestamp = Instant.now();
40 +
41 + public interface Repo extends PanacheRepository.Managed<ActionLog, UUID>
42 + {
43 + @HQL("select l from ActionLog l where l.task = :task order by l.lineIndex asc")
44 + List<ActionLog> findByTask(ActionTask task);
45 +
46 + @HQL("select count(l) from ActionLog l where l.task = :task")
47 + long countByTask(ActionTask task);
48 + }
49 +
50 +}
ADD src/main/java/de/workaround/model/ActionRun.java +105 -0
diff --git a/src/main/java/de/workaround/model/ActionRun.java b/src/main/java/de/workaround/model/ActionRun.java
new file mode 100644
index 0000000..093c3ac
--- /dev/null
+++ b/src/main/java/de/workaround/model/ActionRun.java
@@ -0,0 +1,105 @@
1 +package de.workaround.model;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.Optional;
6 +import java.util.UUID;
7 +
8 +import org.hibernate.annotations.processing.Find;
9 +import org.hibernate.annotations.processing.HQL;
10 +
11 +import io.quarkus.hibernate.panache.PanacheEntity;
12 +import io.quarkus.hibernate.panache.PanacheRepository;
13 +import jakarta.persistence.Entity;
14 +import jakarta.persistence.EnumType;
15 +import jakarta.persistence.Enumerated;
16 +import jakarta.persistence.GeneratedValue;
17 +import jakarta.persistence.GenerationType;
18 +import jakarta.persistence.Id;
19 +import jakarta.persistence.ManyToOne;
20 +import jakarta.persistence.Table;
21 +
22 +/**
23 + * A single workflow run (issue #2, phase 1): one execution of a workflow file triggered by an event
24 + * against a repository. A run owns one or more {@link ActionTask}s (jobs). Phase 1 materializes a
25 + * single task per run ({@code on: push}, single job); {@code needs}/matrix fan-out arrives in phase 2.
26 + * Runs carry a per-repository sequential {@link #number} for stable UI URLs, mirroring {@link Issue}.
27 + */
28 +@Entity
29 +@Table(name = "action_run")
30 +public class ActionRun implements PanacheEntity.Managed
31 +{
32 + @Id
33 + @GeneratedValue(strategy = GenerationType.UUID)
34 + public UUID id;
35 +
36 + @ManyToOne(optional = false)
37 + public Repository repository;
38 +
39 + /** Per-repository, human-facing run number (#1, #2, ...) assigned on creation; unique within the repository. */
40 + public int number;
41 +
42 + /** The workflow's {@code name:} if set, else the file name; shown in the run list. */
43 + public String workflowName;
44 +
45 + /** Repository-relative path of the workflow file, e.g. {@code .forgejo/workflows/ci.yml}. */
46 + public String workflowFile;
47 +
48 + /** The triggering event, {@code push} in phase 1. */
49 + public String event;
50 +
51 + /** The git ref that triggered the run, e.g. {@code refs/heads/main}. */
52 + public String ref;
53 +
54 + /** Full commit SHA the run was created for. */
55 + public String commitSha;
56 +
57 + /** The user whose push triggered the run; null when the trigger has no associated account. */
58 + @ManyToOne
59 + public User triggeredBy;
60 +
61 + @Enumerated(EnumType.STRING)
62 + public Status status = Status.PENDING;
63 +
64 + public Instant createdAt = Instant.now();
65 +
66 + public Instant startedAt;
67 +
68 + public Instant finishedAt;
69 +
70 + /** Lifecycle shared by {@link ActionRun} and {@link ActionTask}. */
71 + public enum Status
72 + {
73 + PENDING("Pending"),
74 + RUNNING("Running"),
75 + SUCCESS("Success"),
76 + FAILURE("Failure"),
77 + CANCELLED("Cancelled");
78 +
79 + /** Human-readable label for the UI; the enum name is the stable value used in the DB. */
80 + public final String label;
81 +
82 + Status(String label)
83 + {
84 + this.label = label;
85 + }
86 +
87 + public boolean isTerminal()
88 + {
89 + return this == SUCCESS || this == FAILURE || this == CANCELLED;
90 + }
91 + }
92 +
93 + public interface Repo extends PanacheRepository.Managed<ActionRun, UUID>
94 + {
95 + @HQL("select r from ActionRun r where r.repository = :repository order by r.number desc")
96 + List<ActionRun> findByRepository(Repository repository);
97 +
98 + @Find
99 + Optional<ActionRun> findByRepositoryAndNumber(Repository repository, int number);
100 +
101 + @HQL("select coalesce(max(r.number), 0) from ActionRun r where r.repository = :repository")
102 + int maxNumber(Repository repository);
103 + }
104 +
105 +}
ADD src/main/java/de/workaround/model/ActionTask.java +82 -0
diff --git a/src/main/java/de/workaround/model/ActionTask.java b/src/main/java/de/workaround/model/ActionTask.java
new file mode 100644
index 0000000..5560e5c
--- /dev/null
+++ b/src/main/java/de/workaround/model/ActionTask.java
@@ -0,0 +1,82 @@
1 +package de.workaround.model;
2 +
3 +import java.time.Instant;
4 +import java.util.List;
5 +import java.util.Optional;
6 +import java.util.UUID;
7 +
8 +import org.hibernate.annotations.processing.Find;
9 +import org.hibernate.annotations.processing.HQL;
10 +
11 +import io.quarkus.hibernate.panache.PanacheEntity;
12 +import io.quarkus.hibernate.panache.PanacheRepository;
13 +import jakarta.persistence.Entity;
14 +import jakarta.persistence.EnumType;
15 +import jakarta.persistence.Enumerated;
16 +import jakarta.persistence.GeneratedValue;
17 +import jakarta.persistence.GenerationType;
18 +import jakarta.persistence.Id;
19 +import jakarta.persistence.ManyToOne;
20 +import jakarta.persistence.Table;
21 +
22 +/**
23 + * A single job within an {@link ActionRun} (issue #2, phase 1). A runner claims a pending task via
24 + * FetchTask (which assigns {@link #runner}), executes it, streams rows through UpdateLog and reports
25 + * progress via UpdateTask. {@link #logLength} is the number of durably-persisted {@link ActionLog}
26 + * rows and doubles as the resume offset (ack index) for UpdateLog. {@link #deadline} bounds execution
27 + * so a vanished runner's task can be failed as a zombie.
28 + */
29 +@Entity
30 +@Table(name = "action_task")
31 +public class ActionTask implements PanacheEntity.Managed
32 +{
33 + @Id
34 + @GeneratedValue(strategy = GenerationType.UUID)
35 + public UUID id;
36 +
37 + @ManyToOne(optional = false)
38 + public ActionRun run;
39 +
40 + /** Job identifier from the workflow file, e.g. {@code build}. */
41 + public String name;
42 +
43 + /** The expanded single-job workflow payload delivered to the runner in FetchTask; null until materialized. */
44 + public String payload;
45 +
46 + /** The runner that claimed this task via FetchTask; null while pending. Cleared if the runner is deleted. */
47 + @ManyToOne
48 + public CiRunner runner;
49 +
50 + @Enumerated(EnumType.STRING)
51 + public ActionRun.Status status = ActionRun.Status.PENDING;
52 +
53 + /** Count of durably-persisted log rows; the resume/ack offset for UpdateLog. */
54 + public int logLength = 0;
55 +
56 + /** When a claimed task must be finished by; a task still running past this is reclaimed as a zombie. Null while pending. */
57 + public Instant deadline;
58 +
59 + public Instant createdAt = Instant.now();
60 +
61 + public Instant startedAt;
62 +
63 + public Instant finishedAt;
64 +
65 + public interface Repo extends PanacheRepository.Managed<ActionTask, UUID>
66 + {
67 + @HQL("select t from ActionTask t where t.run = :run order by t.createdAt asc")
68 + List<ActionTask> findByRun(ActionRun run);
69 +
70 + @HQL("select t from ActionTask t where t.status = PENDING order by t.createdAt asc")
71 + List<ActionTask> listPending();
72 +
73 + default Optional<ActionTask> findOldestPending()
74 + {
75 + return listPending().stream().findFirst();
76 + }
77 +
78 + @Find
79 + Optional<ActionTask> findByIdAndRunner(UUID id, CiRunner runner);
80 + }
81 +
82 +}
ADD src/main/resources/db/migration/V23__action_runs.sql +64 -0
diff --git a/src/main/resources/db/migration/V23__action_runs.sql b/src/main/resources/db/migration/V23__action_runs.sql
new file mode 100644
index 0000000..6fe792e
--- /dev/null
+++ b/src/main/resources/db/migration/V23__action_runs.sql
@@ -0,0 +1,64 @@
1 +-- CI/CD run loop (issue #2, phase 1): the run-persistence trio deferred by V19.
2 +--
3 +-- action_run -- one workflow run triggered by an event against a repository
4 +-- action_task -- one job within a run; claimed and executed by a ci_runner (FetchTask)
5 +-- action_log -- one log row of a task; appended by UpdateLog, replayed from action_task.log_length
6 +--
7 +-- Phase 1 materializes a single task per run (on: push, single job). needs/matrix fan-out is phase 2.
8 +
9 +create table action_run
10 +(
11 + id uuid primary key,
12 + repository_id uuid not null references repositories (id) on delete cascade,
13 + -- Per-repository sequential run number (#1, #2, ...); stable UI URLs, mirrors issues.number.
14 + number integer not null,
15 + workflow_name text not null,
16 + workflow_file text not null,
17 + event varchar(64) not null,
18 + ref text not null,
19 + commit_sha varchar(64) not null,
20 + triggered_by_id uuid references users (id) on delete set null,
21 + status varchar(32) not null default 'PENDING'
22 + check (status in ('PENDING', 'RUNNING', 'SUCCESS', 'FAILURE', 'CANCELLED')),
23 + created_at timestamptz not null default now(),
24 + started_at timestamptz,
25 + finished_at timestamptz,
26 + unique (repository_id, number)
27 +);
28 +
29 +create index idx_action_run_repository on action_run (repository_id);
30 +
31 +create table action_task
32 +(
33 + id uuid primary key,
34 + run_id uuid not null references action_run (id) on delete cascade,
35 + name text not null,
36 + payload text,
37 + -- The runner that claimed the task via FetchTask; null while pending. Cleared if the runner is deleted.
38 + runner_id uuid references ci_runner (id) on delete set null,
39 + status varchar(32) not null default 'PENDING'
40 + check (status in ('PENDING', 'RUNNING', 'SUCCESS', 'FAILURE', 'CANCELLED')),
41 + -- Count of durably-persisted log rows; the resume/ack offset for UpdateLog.
42 + log_length integer not null default 0,
43 + -- Deadline for a claimed task; still running past it is reclaimed as a zombie. Null while pending.
44 + deadline timestamptz,
45 + created_at timestamptz not null default now(),
46 + started_at timestamptz,
47 + finished_at timestamptz
48 +);
49 +
50 +create index idx_action_task_run on action_task (run_id);
51 +create index idx_action_task_status on action_task (status);
52 +
53 +create table action_log
54 +(
55 + id uuid primary key,
56 + task_id uuid not null references action_task (id) on delete cascade,
57 + -- 0-based position of this row within the task's log stream.
58 + line_index integer not null,
59 + content text not null,
60 + timestamp timestamptz not null default now(),
61 + unique (task_id, line_index)
62 +);
63 +
64 +create index idx_action_log_task on action_log (task_id, line_index);
ADD src/test/java/de/workaround/model/ActionRunPersistenceTest.java +149 -0
diff --git a/src/test/java/de/workaround/model/ActionRunPersistenceTest.java b/src/test/java/de/workaround/model/ActionRunPersistenceTest.java
new file mode 100644
index 0000000..30fc394
--- /dev/null
+++ b/src/test/java/de/workaround/model/ActionRunPersistenceTest.java
@@ -0,0 +1,149 @@
1 +package de.workaround.model;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import io.quarkus.test.TestTransaction;
9 +import io.quarkus.test.junit.QuarkusTest;
10 +import jakarta.inject.Inject;
11 +
12 +import static org.junit.jupiter.api.Assertions.assertEquals;
13 +import static org.junit.jupiter.api.Assertions.assertNotNull;
14 +import static org.junit.jupiter.api.Assertions.assertTrue;
15 +
16 +/**
17 + * Unit 1 of the CI/CD run loop (issue #2, phase 1): the persistence foundation
18 + * (action_run / action_task / action_log) that FetchTask/UpdateTask/UpdateLog build on.
19 + */
20 +@QuarkusTest
21 +class ActionRunPersistenceTest
22 +{
23 + @Inject
24 + ActionRun.Repo runs;
25 +
26 + @Inject
27 + ActionTask.Repo tasks;
28 +
29 + @Inject
30 + ActionLog.Repo logs;
31 +
32 + @Test
33 + @TestTransaction
34 + void persistsRunWithTaskAndLogs()
35 + {
36 + Repository repo = newRepo("alpha");
37 +
38 + ActionRun run = new ActionRun();
39 + run.repository = repo;
40 + run.number = 1;
41 + run.workflowName = "CI";
42 + run.workflowFile = ".forgejo/workflows/ci.yml";
43 + run.event = "push";
44 + run.ref = "refs/heads/main";
45 + run.commitSha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
46 + run.persist();
47 +
48 + ActionTask task = new ActionTask();
49 + task.run = run;
50 + task.name = "build";
51 + task.payload = "jobs:\n build:\n runs-on: ubuntu-latest";
52 + task.persist();
53 +
54 + ActionLog line0 = new ActionLog();
55 + line0.task = task;
56 + line0.lineIndex = 0;
57 + line0.content = "Running build";
58 + line0.persist();
59 +
60 + assertNotNull(run.id);
61 + assertNotNull(task.id);
62 + assertNotNull(line0.id);
63 + assertEquals(ActionRun.Status.PENDING, run.status);
64 + assertEquals(ActionRun.Status.PENDING, task.status);
65 +
66 + List<ActionTask> ofRun = tasks.findByRun(run);
67 + assertEquals(1, ofRun.size());
68 + assertEquals("build", ofRun.get(0).name);
69 + assertEquals(run.id, ofRun.get(0).run.id);
70 +
71 + List<ActionLog> ofTask = logs.findByTask(task);
72 + assertEquals(1, ofTask.size());
73 + assertEquals("Running build", ofTask.get(0).content);
74 + }
75 +
76 + @Test
77 + @TestTransaction
78 + void runNumbersAreScopedPerRepository()
79 + {
80 + Repository repoA = newRepo("beta");
81 + Repository repoB = newRepo("gamma");
82 +
83 + assertEquals(0, runs.maxNumber(repoA));
84 +
85 + newRun(repoA, 1);
86 + newRun(repoA, 2);
87 + newRun(repoB, 1);
88 +
89 + assertEquals(2, runs.maxNumber(repoA));
90 + assertEquals(1, runs.maxNumber(repoB));
91 +
92 + ActionRun found = runs.findByRepositoryAndNumber(repoA, 2).orElseThrow();
93 + assertEquals(2, found.number);
94 + assertEquals(repoA.id, found.repository.id);
95 + }
96 +
97 + @Test
98 + @TestTransaction
99 + void findsOldestPendingTaskForDispatch()
100 + {
101 + Repository repo = newRepo("delta");
102 + ActionRun run = newRun(repo, 1);
103 +
104 + ActionTask done = new ActionTask();
105 + done.run = run;
106 + done.name = "old";
107 + done.status = ActionRun.Status.SUCCESS;
108 + done.persist();
109 +
110 + ActionTask pending = new ActionTask();
111 + pending.run = run;
112 + pending.name = "new";
113 + pending.persist();
114 +
115 + ActionTask next = tasks.findOldestPending().orElseThrow();
116 + assertEquals("new", next.name);
117 + assertTrue(next.status == ActionRun.Status.PENDING);
118 + }
119 +
120 + private ActionRun newRun(Repository repo, int number)
121 + {
122 + ActionRun run = new ActionRun();
123 + run.repository = repo;
124 + run.number = number;
125 + run.workflowName = "CI";
126 + run.workflowFile = ".forgejo/workflows/ci.yml";
127 + run.event = "push";
128 + run.ref = "refs/heads/main";
129 + run.commitSha = "0000000000000000000000000000000000000000";
130 + run.persist();
131 + return run;
132 + }
133 +
134 + private static Repository newRepo(String name)
135 + {
136 + User owner = new User();
137 + owner.oidcSub = "sub-" + UUID.randomUUID();
138 + owner.username = name + "-" + UUID.randomUUID();
139 + owner.persist();
140 +
141 + Repository repo = new Repository();
142 + repo.name = name;
143 + repo.ownerUser = owner;
144 + repo.visibility = Repository.Visibility.PUBLIC;
145 + repo.persist();
146 + return repo;
147 + }
148 +
149 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog