✨ (ci): Add action_run/task/log persistence foundation
Changes
7 files changed, +465 -6
MODIFY
docs/admins/ci-runners.md
+6 -2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}