Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 0175c9e5a5 feat: add Gradle cache to Android release builds (#251)
Introduce androidBase() and firebaseBase() helpers that wrap setup() with
the Gradle named-cache volume, mirroring the pattern already used in
BuildAndroidDebugApks(). Use these in BuildAndroidRelease(), setupKeystore(),
and BuildAndroidDebugApks() so Gradle dependencies survive Dagger
execution-cache misses instead of being re-downloaded on every source change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:26:17 +02:00
Bot of Thomas Güttler 9f9bf14bbe feat: inject GIT_HASH into Dagger builds so About page shows git hash (#249) (#250) 2026-05-25 15:10:12 +02:00
Bot of Thomas Güttler a7783d46cf fix: disable Save button when no password available; fix changelog fetch-depth (#246, #229) (#248) 2026-05-25 14:47:25 +02:00
Bot of Thomas Güttler 3868c160d3 fix: disable Try connection button when no password is available (#235) (#247) 2026-05-25 14:30:13 +02:00
Bot of Thomas Güttler 50fc012e81 Merge pull request 'fix: show password required error instead of crashing when no stored password (#235)' (#238) from issue-235-fix into main 2026-05-25 13:00:44 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 94b20f50be style: format edit_account_screen_test.dart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 885906b204 fix: show password required error instead of crashing when no stored password (#235)
During _load(), check whether a password exists in secure storage and track the result
in _hasStoredPassword. The password field validator now requires user input when no
password is stored, so _tryConnection() fails fast at form validation instead of
throwing an unhandled StateError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:49:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 06df3ee200 feat: monitor agent loop health every 2 hours (#217)
- Track a heartbeat timestamp in ~/.sharedinbox-agent-heartbeat at the
  start of each _run_loop() invocation so we can tell when it last ran.
- Add `agent_loop.py monitor` subcommand that exits 1 with a WARNING
  message if the heartbeat is missing, corrupted, or older than 2 hours.
- Add .forgejo/workflows/monitor.yml scheduled workflow that runs the
  monitor check every 2 hours on the self-hosted runner; a CI failure
  serves as the warning when the loop is stalled.
- Add 7 unit tests covering all monitor / heartbeat scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 12:48:45 +02:00
Bot of Thomas Güttler e03c7708ba feat: show app version as link on crash screen and in MD report (#236) (#245) 2026-05-25 11:40:53 +02:00
9 changed files with 304 additions and 26 deletions
+2 -2
View File
@@ -136,7 +136,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 100
- name: Check runner tools
run: |
@@ -178,7 +178,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: 100
- name: Check runner tools
run: |
+18
View File
@@ -0,0 +1,18 @@
name: Monitor Agent Loop
on:
schedule:
- cron: '0 */2 * * *' # every 2 hours
workflow_dispatch:
jobs:
monitor:
name: Check Agent Loop Health
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check agent loop heartbeat
run: python3 scripts/agent_loop.py monitor
+2 -2
View File
@@ -224,7 +224,7 @@ tasks:
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
cmds:
- mkdir -p build/app/outputs/bundle/release
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
upload-android-bundle:
desc: Upload AAB from build/ to Play Store via Dagger
@@ -247,7 +247,7 @@ tasks:
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
cmds:
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
deploy-apk:
desc: Build and deploy Android APK via Dagger
+57 -13
View File
@@ -286,6 +286,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
})
}
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
// Gradle dependencies survive across Dagger execution-cache misses.
func (m *Ci) androidBase() *dagger.Container {
return m.setup(m.androidSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
func (m *Ci) firebaseBase() *dagger.Container {
return m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
}
// linuxSrc is the source subset for Linux builds and integration tests.
func (m *Ci) linuxSrc() *dagger.Directory {
return m.Source.Filter(dagger.DirectoryFilterOpts{
@@ -584,9 +599,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
}
// BuildLinuxRelease builds the Linux release bundle.
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
func (m *Ci) BuildLinuxRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.Directory {
args := []string{"flutter", "build", "linux", "--release"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setup(m.linuxSrc()).
WithExec([]string{"flutter", "build", "linux", "--release"}).
WithExec(args).
Directory("build/linux/x64/release/bundle")
}
@@ -599,7 +622,7 @@ func (m *Ci) DeployLinux(
sshHost string,
commitHash string,
) (string, error) {
bundle := m.BuildLinuxRelease()
bundle := m.BuildLinuxRelease(commitHash)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -615,16 +638,27 @@ func (m *Ci) DeployLinux(
// setupKeystore decodes the base64 keystore into the android build container.
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
return m.setup(m.androidSrc()).
return m.androidBase().
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
}
// BuildAndroidApk builds a release APK signed with the upload key.
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
func (m *Ci) BuildAndroidApk(
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
buildNumber string,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.setupKeystore(keystoreBase64, keystorePassword).
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
WithExec(args).
File("build/app/outputs/flutter-apk/app-release.apk")
}
@@ -640,7 +674,7 @@ func (m *Ci) DeployApk(
keystorePassword *dagger.Secret,
buildNumber string,
) (string, error) {
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
datePath := time.Now().Format("2006/01/02")
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
@@ -656,8 +690,7 @@ func (m *Ci) DeployApk(
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
built := m.setup(m.firebaseSrc()).
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
built := m.firebaseBase().
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
WithWorkdir("/src/android").
// --no-daemon avoids connecting to a stale daemon whose registry file was
@@ -716,9 +749,17 @@ func (m *Ci) TestAndroidFirebase(
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
func (m *Ci) BuildAndroidRelease() *dagger.File {
return m.setup(m.androidSrc()).
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
func (m *Ci) BuildAndroidRelease(
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) *dagger.File {
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
if commitHash != "" {
args = append(args, "--dart-define=GIT_HASH="+commitHash)
}
return m.androidBase().
WithExec(args).
File("build/app/outputs/bundle/release/app-release.aab")
}
@@ -790,9 +831,12 @@ func (m *Ci) PublishAndroid(
playStoreConfig *dagger.Secret,
keystoreBase64 *dagger.Secret,
keystorePassword *dagger.Secret,
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
// +optional
commitHash string,
) (string, error) {
versionCode := int(time.Now().Unix())
aab := m.BuildAndroidRelease()
aab := m.BuildAndroidRelease(commitHash)
stamped := m.StampAndroidVersionCode(aab, versionCode)
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
+21 -4
View File
@@ -38,6 +38,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
var _sieveSsl = true;
var _verbose = false;
final _jmapUrlCtrl = TextEditingController();
bool _hasStoredPassword = false;
// -- "Try connection" state ------------------------------------------------
bool _tryTesting = false;
@@ -50,6 +51,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.addListener(_rebuild);
_sieveHostCtrl.addListener(_rebuild);
_imapHostCtrl.addListener(_rebuild);
_passwordCtrl.addListener(_rebuild);
unawaited(_load());
}
@@ -63,6 +65,11 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
context.pop();
return;
}
try {
await repo.getPassword(account.id);
_hasStoredPassword = true;
} catch (_) {}
if (!mounted) return;
_account = account;
_displayNameCtrl.text = account.displayName;
_usernameCtrl.text = account.username;
@@ -84,6 +91,7 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
_smtpHostCtrl.removeListener(_rebuild);
_sieveHostCtrl.removeListener(_rebuild);
_imapHostCtrl.removeListener(_rebuild);
_passwordCtrl.removeListener(_rebuild);
for (final c in [
_displayNameCtrl,
_usernameCtrl,
@@ -267,10 +275,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
),
_field(
_passwordCtrl,
'New password (leave blank to keep)',
_hasStoredPassword
? 'New password (leave blank to keep)'
: 'Password',
key: const Key('editPasswordField'),
obscure: true,
required: false,
required: !_hasStoredPassword,
),
if (account.type == AccountType.jmap) ...[
const Divider(height: 32),
@@ -345,10 +355,17 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
testing: _tryTesting,
okMessage: _tryOk,
errorMessage: _tryErr,
onPressed: _tryConnection,
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _tryConnection
: null,
),
const SizedBox(height: 8),
FilledButton(onPressed: _save, child: const Text('Save')),
FilledButton(
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
? _save
: null,
child: const Text('Save'),
),
],
),
),
+43
View File
@@ -57,7 +57,9 @@ os.environ["PATH"] = (
REPO = "guettli/sharedinbox"
REPO_URL = f"https://codeberg.org/{REPO}"
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
HEARTBEAT_FILE = Path.home() / ".sharedinbox-agent-heartbeat"
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
MAX_HEARTBEAT_AGE_SECONDS = 7200 # 2 hours
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
"-" + str(Path.home())[1:].replace("/", "-")
)
@@ -309,6 +311,12 @@ def _clear_state() -> None:
STATE_FILE.unlink(missing_ok=True)
def _update_heartbeat() -> None:
"""Record that the agent loop ran right now."""
HEARTBEAT_FILE.write_text(datetime.now(timezone.utc).isoformat())
HEARTBEAT_FILE.chmod(0o600)
def _find_session_uuid(session_name: str) -> str | None:
"""Return the Claude session UUID for *session_name*, or None if not found.
@@ -478,12 +486,44 @@ def cmd_list() -> int:
return 0
# ── monitor subcommand ────────────────────────────────────────────────────────
def cmd_monitor() -> int:
"""Check that the agent loop has run within the last 2 hours.
Exits 0 if healthy, 1 if the heartbeat is missing or stale.
Intended to be called from a scheduled CI job or cron every 2 hours.
"""
if not HEARTBEAT_FILE.exists():
print(
f"WARNING: Agent loop heartbeat file missing — "
f"the loop may not have run yet or the file was deleted ({HEARTBEAT_FILE})."
)
return 1
try:
last_run = datetime.fromisoformat(HEARTBEAT_FILE.read_text().strip())
except ValueError:
print(f"WARNING: Agent loop heartbeat file is corrupted: {HEARTBEAT_FILE}")
return 1
age = (datetime.now(timezone.utc) - last_run).total_seconds()
if age > MAX_HEARTBEAT_AGE_SECONDS:
print(
f"WARNING: Agent loop last ran {age / 3600:.1f}h ago "
f"(limit: {MAX_HEARTBEAT_AGE_SECONDS // 3600}h) — the loop may be stalled."
)
return 1
print(f"Agent loop is healthy. Last run: {age / 60:.0f} min ago.")
return 0
# ── main flow ─────────────────────────────────────────────────────────────────
def _run_loop() -> int:
now = datetime.now(timezone.utc)
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
_update_heartbeat()
state = _read_state()
@@ -884,10 +924,13 @@ def main() -> int:
parser = argparse.ArgumentParser(prog="agent_loop")
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("list", help="List recent agent sessions")
sub.add_parser("monitor", help="Check that the loop ran within the last 2 hours")
args = parser.parse_args()
if args.cmd == "list":
return cmd_list()
if args.cmd == "monitor":
return cmd_monitor()
return _run_loop()
+66
View File
@@ -6,6 +6,7 @@ import json
import os
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -784,5 +785,70 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
mock_merge.assert_called_once_with(50)
class TestHeartbeat(unittest.TestCase):
"""Tests for _update_heartbeat() and cmd_monitor()."""
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".heartbeat")
self._tmp.close()
self._orig = agent_loop.HEARTBEAT_FILE
agent_loop.HEARTBEAT_FILE = Path(self._tmp.name)
Path(self._tmp.name).unlink() # Start with no heartbeat file.
def tearDown(self):
agent_loop.HEARTBEAT_FILE = self._orig
Path(self._tmp.name).unlink(missing_ok=True)
def test_update_heartbeat_writes_timestamp(self):
agent_loop._update_heartbeat()
content = Path(self._tmp.name).read_text().strip()
dt = datetime.fromisoformat(content)
age = (datetime.now(timezone.utc) - dt).total_seconds()
self.assertLess(age, 5)
def test_update_heartbeat_creates_file(self):
self.assertFalse(Path(self._tmp.name).exists())
agent_loop._update_heartbeat()
self.assertTrue(Path(self._tmp.name).exists())
def test_monitor_healthy_when_recent(self):
agent_loop._update_heartbeat()
result = agent_loop.cmd_monitor()
self.assertEqual(result, 0)
def test_monitor_warns_when_heartbeat_missing(self):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_stale(self):
stale = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
Path(self._tmp.name).write_text(stale)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_monitor_warns_when_corrupted(self):
Path(self._tmp.name).write_text("not-a-timestamp")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
result = agent_loop.cmd_monitor()
self.assertEqual(result, 1)
self.assertIn("WARNING", buf.getvalue())
def test_run_loop_updates_heartbeat(self):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
self.assertTrue(Path(self._tmp.name).exists())
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -105,6 +105,88 @@ void main() {
expect(find.text('Edit account'), findsNothing);
});
testWidgets(
'try connection button is disabled when no password stored or entered',
(
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNull);
});
testWidgets(
'try connection button is enabled after typing password with no stored password',
(tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('editPasswordField')),
'mypassword',
);
await tester.pump();
final button = tester.widget<OutlinedButton>(
find.byKey(const Key('editTryConnectionButton')),
);
expect(button.onPressed, isNotNull);
});
testWidgets('save button is disabled when no password stored or entered', (
tester,
) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/edit',
overrides: baseOverrides(
accounts: [kTestAccount],
hasStoredPassword: false,
),
),
);
await tester.pumpAndSettle();
final button = tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
expect(button.onPressed, isNull);
});
testWidgets('connection error shows error message', (tester) async {
tester.view.physicalSize = const Size(800, 1400);
tester.view.devicePixelRatio = 1.0;
+13 -5
View File
@@ -44,11 +44,12 @@ import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
// ---------------------------------------------------------------------------
class FakeAccountRepository implements AccountRepository {
final List<Account> _accounts;
FakeAccountRepository([List<Account>? accounts])
: _accounts = List.of(accounts ?? []);
final List<Account> _accounts;
bool hasPassword = true;
@override
Stream<List<Account>> observeAccounts() => Stream.value(List.of(_accounts));
@@ -75,7 +76,12 @@ class FakeAccountRepository implements AccountRepository {
_accounts.removeWhere((a) => a.id == id);
@override
Future<String> getPassword(String accountId) async => 'test-password';
Future<String> getPassword(String accountId) async {
if (!hasPassword) {
throw StateError('No password stored for account $accountId');
}
return 'test-password';
}
}
class FakeShareKeyRepository implements ShareKeyRepository {
@@ -514,10 +520,12 @@ List<Override> baseOverrides({
DiscoveryResult? discovery,
Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
}) =>
[
accountRepositoryProvider
.overrideWithValue(FakeAccountRepository(accounts)),
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository(accounts)..hasPassword = hasStoredPassword,
),
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository(mailboxes)),
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),