Skip to content

Commit 476a936

Browse files
Merge pull request #630 from appdevforall/feat/K2GO-443-forgejo-job-engine
K2GO-443 feat(forgejo): repo refresh on the durable job engine
2 parents bae6e9e + 87d23e4 commit 476a936

8 files changed

Lines changed: 189 additions & 51 deletions

File tree

‎controller/app/src/main/java/org/appdevforall/k2go/forgejo/presentation/ForgejoRepoRefresh.java‎

Lines changed: 97 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,26 @@
33
* Name : ForgejoRepoRefresh.java
44
* Author : AppDevForAll
55
* Copyright : Copyright (c) 2026 AppDevForAll
6-
* Description : K2GO-422. The single "Update repos" flow, shared by the module detail button and the
7-
* module action sheet row so neither duplicates it. It gates like the dashboard update
8-
* (needs internet, then metered consent), shows minimal inline progress (a description, a
9-
* live one-line output tail, an indeterminate bar and a Cancel) injected right after the
10-
* trigger view, runs the box refresh on an IO thread, and reports the outcome in a snackbar.
6+
* Description : K2GO-422 / K2GO-443. The single "Update repos" flow, shared by the module detail button
7+
* and the module action sheet row. It gates like the dashboard update (needs internet, then
8+
* metered consent), then drives the durable job engine (type "forgejo") through the shared
9+
* RestContentClient: a determinate bar (repo N of M) with the current repo name and a Cancel,
10+
* injected right after the trigger view.
1111
*
12-
* Lifecycle: there is NO persistent app-side state. The box refresh job is detached
13-
* (setsid), so a host that goes away mid-run (the sheet dismissed, the fragment detached)
14-
* just drops the UI updates (guarded by View.isAttachedToWindow()); the box finishes on its
15-
* own and the next /forgejo/status read reflects reality. The only state is the box's own
16-
* status/pid files, which the box manages. So there is nothing here to leak or to clear.
12+
* Forgejo is a git operation (fetch + fast-forward/merge + authenticated push per seeded
13+
* example repo), not a file download, so there is no speed and NO pause/resume: the progress
14+
* is repo-count, and a retry re-runs the idempotent refresh (the engine owns that).
15+
*
16+
* Lifecycle: no persistent app-side state here. The box job is durable and runs on, so a host
17+
* that goes away just drops the UI updates (guarded by View.isAttachedToWindow()); the client
18+
* polls to a terminal state and tears itself down. A terminal state (done / failed /
19+
* cancelled) removes the inline UI and reports it in a snackbar.
1720
* ============================================================================
1821
*/
1922
package org.appdevforall.k2go.forgejo.presentation;
2023

2124
import android.app.Activity;
2225
import android.content.Context;
23-
import android.os.Handler;
24-
import android.os.Looper;
2526
import android.text.TextUtils;
2627
import android.view.Gravity;
2728
import android.view.View;
@@ -35,14 +36,19 @@
3536
import com.google.android.material.progressindicator.LinearProgressIndicator;
3637

3738
import org.appdevforall.k2go.R;
38-
import org.appdevforall.k2go.forgejo.data.ForgejoSeedClient;
39-
import org.appdevforall.k2go.util.AppExecutors;
39+
import org.appdevforall.k2go.content.RestContentClient;
4040
import org.appdevforall.k2go.util.Snackbars;
41+
import org.json.JSONArray;
42+
import org.json.JSONObject;
4143

4244
public final class ForgejoRepoRefresh {
4345

4446
private ForgejoRepoRefresh() {}
4547

48+
// The box runner reads the repo set itself and ignores the job items, so a single sentinel satisfies
49+
// POST /forgejo/download (which requires a non-empty items/ids) and keys the start-or-attach / guard.
50+
private static final String SENTINEL = "repos";
51+
4652
/**
4753
* Gate (internet, then metered consent) then run the refresh with progress injected right after
4854
* {@code trigger}. The trigger stays in place (only disabled) as a visible anchor for the snackbar.
@@ -60,7 +66,6 @@ private static void run(@NonNull View trigger) {
6066
final ViewGroup parent = (ViewGroup) trigger.getParent();
6167
if (parent == null || !trigger.isAttachedToWindow()) return; // host went away during the gate
6268
final Context ctx = trigger.getContext();
63-
final Handler main = new Handler(Looper.getMainLooper());
6469
final float d = ctx.getResources().getDisplayMetrics().density;
6570
final int side = Math.round(20 * d);
6671

@@ -71,14 +76,13 @@ private static void run(@NonNull View trigger) {
7176
plp.leftMargin = side; plp.rightMargin = side; plp.topMargin = Math.round(8 * d);
7277
progress.setLayoutParams(plp);
7378

74-
// Description of what is happening (static), then a live line that tails the box output one line at
75-
// a time (the per-repo outcomes advance like the proot rows: it advances, not a log box).
7679
final TextView label = new TextView(ctx);
7780
label.setText(R.string.k2go_forgejo_updating);
7881
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
7982
label.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
8083
progress.addView(label);
8184

85+
// The current repo being refreshed (from the job's detail), advancing one at a time.
8286
final TextView liveLine = new TextView(ctx);
8387
liveLine.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
8488
liveLine.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
@@ -90,7 +94,6 @@ private static void run(@NonNull View trigger) {
9094
liveLine.setLayoutParams(llp);
9195
progress.addView(liveLine);
9296

93-
// The bar and Cancel share one line: the bar takes the width, Cancel sits beside it.
9497
final LinearLayout barLine = new LinearLayout(ctx);
9598
barLine.setOrientation(LinearLayout.HORIZONTAL);
9699
barLine.setGravity(Gravity.CENTER_VERTICAL);
@@ -100,7 +103,7 @@ private static void run(@NonNull View trigger) {
100103
barLine.setLayoutParams(barLineLp);
101104

102105
final LinearProgressIndicator bar = new LinearProgressIndicator(ctx);
103-
bar.setIndeterminate(true);
106+
bar.setIndeterminate(true); // becomes determinate once the first per-repo percent arrives
104107
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
105108
bar.setLayoutParams(blp);
106109
barLine.addView(bar);
@@ -118,43 +121,94 @@ private static void run(@NonNull View trigger) {
118121
parent.addView(progress, parent.indexOfChild(trigger) + 1);
119122
trigger.setEnabled(false); // stays in place as an anchor; re-enabled when the refresh settles
120123

124+
final RestContentClient client = new RestContentClient("forgejo");
125+
final boolean[] settled = { false }; // one terminal cleanup (done / error / cancel)
126+
// Per-repo outcome tally the runner carries in the final detail (K2GO_SUMMARY changed problems
127+
// total); -1 = unknown (an older box). Kept so "done" can still say "some blocked" / "up to date".
128+
final int[] summary = { -1, -1, -1 };
129+
121130
cancel.setOnClickListener(cv -> {
122131
cancel.setEnabled(false);
123132
label.setText(R.string.k2go_forgejo_update_cancelling);
124-
AppExecutors.get().io().execute(() -> new ForgejoSeedClient().cancelRefresh());
133+
client.cancel(); // cancel() tears down without a listener callback, so settle here
134+
terminal(settled, trigger, parent, progress, R.string.k2go_forgejo_update_cancelled);
125135
});
126136

127-
AppExecutors.get().io().execute(() -> {
128-
final ForgejoSeedClient client = new ForgejoSeedClient();
129-
final ForgejoSeedClient.Result r = client.refresh(rawLine -> {
130-
// Tail one line at a time, trimmed and without the org prefix, so it reads cleanly.
131-
final String shown = rawLine.trim().replace("AppDevForAll/", "");
132-
main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
133-
});
134-
// The box reports per-repo outcome counts on the refresh status (advanced / could-not / total).
135-
final int changed = client.lastChanged();
136-
final int problems = client.lastProblems();
137-
final int total = client.lastTotal();
138-
main.post(() -> {
139-
if (!trigger.isAttachedToWindow()) return;
140-
parent.removeView(progress);
141-
trigger.setEnabled(true);
142-
Snackbars.make(trigger, ctx.getString(messageFor(r, changed, problems, total))).show();
143-
});
137+
// The status line shows the percent and the current repo together (git progress is repo-count, so
138+
// the percent is coarse: 0/33/66 for 3 repos). Percent arrives via onProgress, the repo via onLog;
139+
// both feed render() so the line stays "<pct>% <repo>".
140+
final int[] pct = { -1 };
141+
final String[] repo = { "" };
142+
final Runnable render = () -> {
143+
if (!liveLine.isAttachedToWindow()) return;
144+
liveLine.setText(pct[0] >= 0 ? pct[0] + "% " + repo[0] : repo[0]);
145+
};
146+
147+
client.start(sentinelBody(), new RestContentClient.Listener() {
148+
@Override public void onProgress(int percent, String speed) {
149+
if (!liveLine.isAttachedToWindow()) return;
150+
bar.setIndeterminate(percent < 0);
151+
if (percent >= 0) { bar.setProgressCompat(percent, true); pct[0] = percent; render.run(); }
152+
}
153+
@Override public void onIndexing() {
154+
if (liveLine.isAttachedToWindow()) bar.setIndeterminate(true);
155+
}
156+
@Override public void onLog(String line) {
157+
final String t = line.trim();
158+
if (t.startsWith("K2GO_SUMMARY")) { // app<->runner token: the final outcome tally, not a repo
159+
String[] p = t.split("\\s+");
160+
if (p.length >= 4) {
161+
try {
162+
summary[0] = Integer.parseInt(p[1]);
163+
summary[1] = Integer.parseInt(p[2]);
164+
summary[2] = Integer.parseInt(p[3]);
165+
} catch (NumberFormatException ignore) { /* leave unknown */ }
166+
}
167+
return;
168+
}
169+
// Otherwise the job detail is the current repo ("owner/name"); drop the org prefix.
170+
repo[0] = t.replace("AppDevForAll/", "");
171+
render.run();
172+
}
173+
@Override public void onDone() {
174+
terminal(settled, trigger, parent, progress, messageFor(summary[0], summary[1], summary[2]));
175+
}
176+
@Override public void onError(String message) {
177+
terminal(settled, trigger, parent, progress, R.string.k2go_forgejo_update_failed);
178+
}
144179
});
145180
}
146181

147-
/** Map the refresh outcome to a user message covering every state. */
148-
private static int messageFor(ForgejoSeedClient.Result r, int changed, int problems, int total) {
149-
if (r == ForgejoSeedClient.Result.CANCELLED) return R.string.k2go_forgejo_update_cancelled;
150-
if (r != ForgejoSeedClient.Result.DONE) return R.string.k2go_forgejo_update_failed; // box unreachable
182+
private static JSONObject sentinelBody() {
183+
try { return new JSONObject().put("ids", new JSONArray().put(SENTINEL)); }
184+
catch (Exception e) { return new JSONObject(); }
185+
}
186+
187+
/**
188+
* Map the per-repo outcome tally to a user message. The job finished (this is onDone), so the only
189+
* question is what happened per repo. -1 counts = unknown (an older box without the summary) -> the
190+
* generic "updated". changed = repos that advanced; problems = conflict or a fetch/push failure.
191+
*/
192+
private static int messageFor(int changed, int problems, int total) {
151193
if (problems > 0) {
152-
// some repos could not be updated (conflict or a fetch/push failure); reconcile in the web UI
194+
// some repos could not be updated (reconcile in the web UI); all vs some depends on the rest
153195
return (total - problems > 0) ? R.string.k2go_forgejo_update_some_failed
154196
: R.string.k2go_forgejo_update_all_failed;
155197
}
156198
if (changed > 0) return R.string.k2go_forgejo_update_done; // at least one repo advanced
157-
if (changed == 0) return R.string.k2go_forgejo_update_none; // nothing to update
199+
if (changed == 0 && total >= 0) return R.string.k2go_forgejo_update_none; // nothing to update
158200
return R.string.k2go_forgejo_update_done; // unknown counts (older box)
159201
}
202+
203+
/** Remove the inline UI, re-enable the trigger, and report the outcome once (guarded). */
204+
private static void terminal(boolean[] settled, @NonNull View trigger, @NonNull ViewGroup parent,
205+
@NonNull View progress, int msgRes) {
206+
if (settled[0]) return;
207+
settled[0] = true;
208+
if (progress.getParent() == parent) parent.removeView(progress);
209+
trigger.setEnabled(true);
210+
if (trigger.isAttachedToWindow()) {
211+
Snackbars.make(trigger, trigger.getContext().getString(msgRes)).show();
212+
}
213+
}
160214
}

‎static/dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
44
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
55
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
66

7+
- **1.3.13** - Forgejo repo refresh on the durable job engine (K2GO-443). `forgejo` is now a job type: `POST /forgejo/download` plus `GET /forgejo/jobs/:id` (structured `{phase, percent, detail}`) and retry/cancel over the generic `/:type/*` surface. The runner (`sockets/forgejo.exec.ts`) wraps the existing box orchestration (`static/forgejo/orchestration` -> `refresh_forgejo`) and reports per-repo progress (repo N of M + the current repo name) parsed from a new `K2GO_PROGRESS` marker the orchestration emits. Forgejo is a git operation (fetch + fast-forward/merge + authenticated push per seeded example repo), not a file download, so there is NO aria2 and NO pause/resume; retry re-runs the idempotent refresh. The seed (install) path is unchanged. The older `POST /forgejo/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
78
- **1.3.12** - Add-ons gallery download on the durable job engine (K2GO-443). `code-addons` is now a job type: `POST /code-addons/download` plus `GET /code-addons/jobs/:id` (structured `{phase, percent, speed, detail}`) and pause/resume/retry/cancel over the generic `/:type/*` surface, like build-assets. The runner (`sockets/code_addons.exec.ts`) downloads only the heavy add-on binaries (.cgp + source tarballs) with aria2c (resilient: `--continue` resume, survives a network change) using the shared `downloadWithAria2` helper; the mirror stages the small files (shell, catalog, icons, pages) with its Cloudflare clean + catalog base rewrite and prints the aria2 input for the heavy ones (`mirror_addons.py --print-aria2-input`), then verifies them (`--finalize-only`), and the runner swaps the staged tree in atomically. The older `POST /addons/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
89
- **1.3.11** - Build-assets download on the durable job engine (K2GO-443). `code-assets` is now a job type: `POST /code-assets/download` plus `GET /code-assets/jobs/:id` (structured `{phase, percent, speed, detail}`) and pause/resume/retry/cancel over the generic `/:type/*` surface, like kiwix/maps. The runner (`sockets/code_assets.exec.ts`) downloads the build assets with aria2c (resilient: `--continue` resume, survives a full interface loss via the outer retry loop) using the shared `downloadWithAria2` helper, then the mirror verifies each file against its published `.md5` and writes the browse page (`mirror_code_assets.py --finalize-only`), and the runner swaps the staged tree in atomically. The older `POST /code-assets/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
910
- **1.3.10** - Code on the Go build-assets refresh (K2GO-437). New `POST /code-assets/refresh` re-mirrors the Code on the Go build assets into `/library/www/code-assets` LIVE (box up, no runrole) through a detached wrapper (`tools/code-assets-refresh.sh`) that mirrors into a staging dir and swaps it in only on success (a failed or cancelled refresh never serves a half-mirror), `GET /code-assets/refresh/status` returns `{state, lines, downloaded, reused, failed, upToDate}` to poll (counts parsed from the mirror's final log line; `upToDate` when nothing changed), and `POST /code-assets/refresh/cancel` stops a running refresh (SIGKILL to the wrapper process group; safe because the live tree is only replaced after a clean run). The wrapper reuses the role's `mirror_code_assets.py` (manifest-driven, per-file `.md5` verify and reuse, generates the browse page), so install (bake) and refresh (live) share one mechanism. Localhost-only. (K2GO-437)

‎static/dashboard/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dashboard-console",
3-
"version": "1.3.12",
3+
"version": "1.3.13",
44
"description": "",
55
"main": "index.js",
66
"scripts": {

‎static/dashboard/routes.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const ZIMS_DIR = '/library/zims/content/';
4040
const KIWIX_INDEXER = '/usr/bin/iiab-make-kiwix-lib';
4141
const ZIM_NAME_RE = /^[A-Za-z0-9._-]{1,150}\.zim$/;
4242

43-
const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books', 'kolibri', 'basemaps', 'code-assets', 'code-addons'];
43+
const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books', 'kolibri', 'basemaps', 'code-assets', 'code-addons', 'forgejo'];
4444
function isType(t: string): t is JobType {
4545
return (VALID_TYPES as string[]).includes(t);
4646
}
@@ -1117,11 +1117,11 @@ apiRouter.post('/:type/download', (req: Request, res: Response): void => {
11171117
? body.items
11181118
: Array.isArray(body?.ids) ? body.ids : [];
11191119
if (items.length === 0) { res.status(400).json({ error: 'items (or ids) required' }); return; }
1120-
// K2GO-443: these content types stage into ONE shared tree (/library/www/<type>.new), unlike kiwix's
1121-
// independent files, so only one job per such type may run at a time. The app re-attaches via
1122-
// start-or-attach; this is the hard guard behind it (two concurrent jobs would corrupt the staging).
1123-
const SINGLE_TREE_TYPES = ['code-assets', 'code-addons'];
1124-
if (SINGLE_TREE_TYPES.includes(type)
1120+
// K2GO-443: these types must run one job at a time: code-assets / code-addons stage into ONE shared
1121+
// tree (/library/www/<type>.new), and forgejo fetches+pushes the same repo set (two concurrent runs
1122+
// would race the staging / the repos). The app re-attaches via start-or-attach; this is the hard guard.
1123+
const SINGLE_RUN_TYPES = ['code-assets', 'code-addons', 'forgejo'];
1124+
if (SINGLE_RUN_TYPES.includes(type)
11251125
&& jobs.list(type).some((j) =>
11261126
['queued', 'downloading', 'indexing', 'processing', 'paused'].includes(j.phase))) {
11271127
res.status(409).json({ error: `a ${type} job is already running` });

‎static/dashboard/server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import './sockets/books.exec';
1212
import './sockets/kolibri.exec';
1313
import './sockets/code_assets.exec'; // K2GO-443: build-assets runner (aria2 job engine)
1414
import './sockets/code_addons.exec'; // K2GO-443: add-ons gallery runner (aria2 job engine)
15+
import './sockets/forgejo.exec'; // K2GO-443: forgejo repo-refresh runner (git, per-repo progress)
1516
import { apiRouter } from './routes';
1617
import { startServiceHeal } from './sockets/service-heal';
1718
import { startLogRotation, stopLogRotation } from './sockets/log-rotate';

0 commit comments

Comments
 (0)