Train's first release (31 Oct 2026) stopped twice after its tag was pushed: - Forgejo answered 500 (a unique constraint) to the release: it enters a pushed tag in its releases itself, a moment after the push. Asked again a few seconds later, it makes the release of that entry. - The F-Droid upload filled the halkyon.app server's disk: rsync compared by time, and this laptop's working folder, restored from the repository, had every APK anew, so all 1.9 GB went again. It compares by content now. tool/with_signing_keys.sh fdroid [APK] runs tool/fdroid_repo.sh alone with the index's key, for an upload that failed after its release was made, and the script skips an APK the working folder holds already rather than adding a second file of the same build, which fdroid refuses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
24 KiB
The Halkyon project
Halkyon's apps and the packages they share, in one repository and one pub workspace (ADR 0015, decided 2026-10-19). This file holds what applies to every app and package; each app's own AGENTS.md holds what is its alone, and is read before working in it: Halkyon Learn, Halkyon Todo, Halkyon Train.
- Working notes: if
NEXT.mdexists at the repository's root, read it first. It holds the current step, the plan and the reasoning behind each decision. It is local and never committed.
Layout
apps/<app>/: each app's packages, asserverpod createlays them out (halkyon_<app>_server,_client,_flutter), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets inapps/learn/tool/, run fromapps/learn; the apps' icons are the root'stool/app_icons.py, ADR 0031). Learn is the app that runs; Todo is still Serverpod's scaffold, and Train a shell like it (ADR 0030); Meditate is to come.packages/: what the apps share, each with its README and licence, which the apps take by path (path: ../../../packages/<package>), so a change to a shared package and to the apps using it is one commit:halkyon_ui: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail,FoldingTabs' tab row folding into the title), with texts of its own (English, German).halkyon_base_server,halkyon_base_client: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin onhalkyon_oidc, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (SIGN_UP: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (ACCOUNT_REGISTER_URL), all sharing a secret (INSTANCE_SECRET); its README says how.halkyon_app: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (AccountTiles), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (AssistantsCard: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (AssistantGrantsScreen: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (providerTermsOf,OwnAssistant.onThisMachine, step 3), About with the instance's source link (showAppAbout), what's new after an update (WhatsNew,WhatsNewOnUpdate,WhatsNewTile, from the app'sassets/whats_new/, Train's ADR 0030, D61), the language every app picks (resolveAppLocale) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.halkyon_core(low-level primitives in pure Dart: ids, a logger,SerialQueue,CoalescingRunner) andhalkyon_interop(contracts between apps, pure Dart).halkyon_keys(pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (keyedRowId). SRP-6a's two halves are the module's (halkyon_base_server'sbase.dart,halkyon_base_client'ssrp.dart), as are TOTP and the recovery codes.sodium's build hook compiles libsodium, so every machine that builds or tests needs a C compiler andmake.halkyon_llm(pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (LlmClient), withFakeLlmClientfor tests; tested on replies written from the providers' documentation and on thosetool/record_llm.dartrecords with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause ashalkyon_oidc,publish_to: none.halkyon_oidc_server,halkyon_oidc_client(ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on;halkyon_oidc_flutter, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows,LoopbackWebAuth) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL;publish_to: noneuntil it is published to pub.dev, which is asked again first.
deploy/: the instance deploy at the root, learn.halkyon.eu's today (Learn'sAGENTS.md, CI and deploys), where other services' compose files are to join Learn's.snap/: Learn's snap, built from the root, which its build needs whole.docs/adr/: the decision records of every app and package, one numbering.tool/: the repository's tools (below).- One workspace, one lock: the root
pubspec.yamllists every package,pubspec.lockholds one version of each dependency for all of them, and itsdependency_overrideshold for every app (the offline sync fork, Learn'sAGENTS.md;cli_util, below). Serverpod's version moves as one: every package pins Serverpod's packages to the same version (4.0.4), the module's, Todo's and Train's too, together with the CLI version CI generates code with (SERVERPOD_VERSIONin.forgejo/workflows/ci.yaml) and the sync packages Learn's server depends on.
How work is done here
- The agent implements; the developer decides. Put every decision to the developer before building (design choices, alternatives, framework defaults kept or changed). The first time a new pattern appears (e.g. the first repository/gateway pair, the first synced model, the first endpoint with its denial tests, the first Serverpod module), walk through it in detail with the developer; afterwards, implement exceedingly similar cases directly and point out only deviations.
- Tests come first and state the specification. Every access rule gets a test that asserts denial, not only permission.
- Explain every migration's SQL.
- Before keeping a framework default, name the alternatives and recommend one, especially where a choice becomes expensive once users or data exist.
- Every decision carries its reason (in
NEXT.mdor an ADR underdocs/adr/). - Commits are GPG-signed and carry a DCO
Signed-off-bytrailer (git commit -s; a localprepare-commit-msghook adds it automatically), and keep their type prefix (feat,fix,perf,refactor,docs,build,chore, a!or aBREAKING CHANGE:line for what breaks): releases work out their versions from them (Learn'sAGENTS.md, Releases). - Servers are named in prose by the domain they serve (learn.halkyon.eu's server, the halkyon.app server), which says what a reader needs; the SSH aliases and the CI runner's label keep the servers' own names (
sahaquiel-deploy,ramiel-fdroid,sahaquiel:host). - Pushes pass the checks first, through a local
pre-pushhook (in a fresh clone:ln -s ../../tool/pre-push .git/hooks/pre-push):tool/pre-pushfirst refuses a push tomainthat brings a commit not signed by a key indeploy/allowed-signers(tool/check_signatures.sh, the deploy's own rule), so it fails on the laptop rather than after CI, then runstool/check.dart. CI runs the checks again, and a greenmaindeploys learn.halkyon.eu (see CI and deploys), so an unsigned commit onmainblocks every deploy after it. - Lints: LeanCode's strict set (
leancode_lint), configured once in the rootanalysis_options.yaml, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file: Learn's, Todo's and Train's servers and apps, Learn's curator and every shared package include it; the generated client packages keep their own, but for the module's, which includes it for its SRP half and leaveslib/src/protocol/out. Every// ignore:gets a comment saying why (document_ignores). Each server package switches off the four rules for Dart v3.13's constructor syntax (use_primary_constructors,use_declaring_parameters,empty_container_bodies,unnecessary_type_name_in_constructor): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
Flutter and Serverpod
Each app is a Flutter app (frontend) backed by a Serverpod server (backend). Always build an app's backend with Serverpod. Build for multiple users, with Serverpod's built-in authentication, which a server gets from halkyon_base (Learn's and Todo's server.dart configure the module; people sign in with a password and TOTP, ADR 0017, through halkyon_app's screens).
The user starts an app's server and Flutter app with serverpod start in that app's server package (Learn's: apps/learn/halkyon_learn_server). There is no need to check if the server is running: make the changes and call the serverpod MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool spawn_flutter_app.
While running, serverpod start watches for file changes to run incremental code generation and hot reload both the server and the Flutter app. Calling serverpod generate directly is not needed, but might be useful to troubleshoot when an incremental generation fails.
ALWAYS use the MCP server instead of the command line. Use the MCP server to:
create_migrationandapply_migrationsfor database (after you change data models).create_repair_migrationif the database has drifted out of sync with the migrations.tail_server_logsto read logs from the server.tail_flutter_logsto read the raw stdout/stderr of the Flutter app.hot_reload/hot_restartto reload or restart the server and the Flutter app. ALWAYS callhot_restartafter doing changes in the Flutter app that may not work with normal hot reload (which is automatically applied).spawn_flutter_appto start a Flutter app declared underserverpod: flutter_apps:in the serverpubspec.yaml.get_flutter_app_dtd(Dart tooling daemon) for connecting to the app through thedartMCP.
The serverpod MCP tools come from .mcp.json: serverpod mcp-server, a bridge to the running serverpod start of one app's server, the one its --server-dir names (each app's AGENTS.md gives its own). They load when a session starts; if they are missing, the server wasn't running or the session predates .mcp.json. Like all agent configuration, .mcp.json is git-ignored. A session works with one app's server at a time; work on another app's server package with the CLI below, in a worktree if that app's serverpod start runs.
NEVER edit generated code. Each server's lib/src/generated/ directory and its whole client package (apps/<app>/halkyon_<app>_client, packages/halkyon_base_client) are rewritten by the code generator, but for the module client's SRP half, written by hand (lib/srp.dart, lib/src/srp/, test/): the generator writes only a client's lib/src/protocol/ (checked with v4.0.4). Change the .spy.yaml models, the endpoints, or the server's lib/server.dart instead.
Migrations are a narrow exception: the migration.sql of a generated migration MAY be edited by hand when the generated SQL would lose data — to add a data transformation, or to reach a destructive change through non-destructive steps. Never touch the other files in the migration directory, and keep the schema the SQL ends up with identical to definition.sql — new databases are created from that file and never run migration.sql. A module's tables are part of each server's migrations that includes it; moving a table into a module unchanged needs no SQL but the version rows (create-migration --empty, ADR 0014).
Pass --no-analytics to every serverpod call (before the command: serverpod --no-analytics generate). Otherwise the CLI reports the commands run and the Serverpod features the project uses to PostHog; there is no setting or environment variable to switch that off. The VS Code task, .mcp.json and Melos' generate script pass it already.
Only when the server cannot be started at all, or the MCP tools serve another app's server or checkout, fall back to the CLI in the server package:
serverpod generateto regenerate the client and the generated server code (dart run melos run generatefor every server package at once).serverpod create-migrationafter changing a model with atable(add--forcefor destructive changes). It only writes the migration;serverpod startapplies pending migrations when it boots the server.
Tests need no Docker. Each server's config/test.yaml sets database.dataPath, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run dart test in the server package, which needs the test passwords in its git-ignored config/passwords.yaml (throwaway values for the module's, Todo's and Train's tests; Learn's real file for Learn's) or from the environment as CI gives them.
Tooling traps: rules of leancode_lint's analyzer plugin are ignored with its prefix (// ignore: leancode_lint/missing_equatable_props); dart fix --apply moves flutter_driver into an app's dependencies (as any) on every run, so restore it as a dev dependency (sdk: flutter) afterwards; never dart format generated code, only the hand-written paths, as tool/check.dart does (a server's test/integration/test_tools/ is generated too, so format test files one by one, never the whole test/ folder); a UUID table's id column takes only a set typed Set<UuidValue> in inSet and notInSet (_uuids in Learn's sync_tag_gateway.dart): it is nullable as declared, so a set literal in its place becomes a set of nullable ids, which Serverpod (v4.0.4) refuses at run time with a type error fake gateways never meet (found 2026-10-17: taking a tag off anything had failed on devices since v0.1.0); and never rebase or check out other commits while serverpod start runs: its watcher regenerates code for each intermediate state, leaves stale generated files behind and hot-reloads them. Stop it first, or work in a separate worktree.
If the user asks you to test an app:
- Use
get_flutter_app_dtd(serverpodMCP) to get the Flutter app's DTD - Pass the DTD to
connect_dart_tooling_daemon(dartMCP) to connect to the app - Use
flutter_driver(dartMCP) to navigate through the app (each app'sAGENTS.mdsays where it starts the driver extension)
Checks, Melos and tools
Checklist after doing changes, in this order:
-
dart format(CLI) on the files you changed -
create_migrationandapply_migrations(MCP - only if necessary) -
dart run tool/check.dart(CLI): the packages the lock names (flutter pub get --enforce-lockfile, so a merge never leaves the resolution behind), the generated code (every server's,dart run melos run generate, about 15 s: it fails, naming the files, where they differ from what the generator makes of the models and endpoints now, leaving them written anew to be committed, and refuses a Serverpod CLI at another version than the lock holds Serverpod at,dart install serverpod_cli@<version>), format check of our own Dart files,dart analyze --fatal-infosover the whole workspace, the tools' own tests, and every package's tests through Melos. With--changed-since=origin/mainit tests only the packages changed since then and those depending on them. The pre-push hook runs it in full, so a push only goes through when it passes. -
Do
serverpodMCPhot_restartif required (hot reload is done automatically). Will also hot restart Flutter app -
Check
serverpodMCPtail_server_logsandtail_flutter_logsfor any issues. -
Melos (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (
dart run melos …, its version in the lock), its scripts undermelos:in the rootpubspec.yaml:analyze;test:dart(the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port);test:flutter(flutter test -x requires-server, one package at a time);generate(Serverpod's code generation in every package with aconfig/generator.yaml, the CLI on the PATH);check(tool/check.dart). Every script takes Melos' filters:dart run melos run test:flutter --scope=halkyon_app, or--diff=origin/main --include-dependentsfor what changed and what depends on it;dart run melos listlists the packages. The root overridescli_utilto 0.5.2 for it: Learn'sflutter_launcher_icons(0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5. -
Tools are Dart where they hold logic (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as
tool/check.dart(its file selection intool/src/check_files.dart, tested in the root'stest/). Bash for glue, short and linear (tool/pre-push,tool/check_signatures.sh,tool/workspace_only.sh, which narrows the root pubspec's workspace to the packages an image builds), and for everything run on the production host, which has no Dart SDK and gets none (deploy/redeploy.sh,deploy/backup.sh). Python only where a library exists only there (the apps' icons with librsvg and Pillow,tool/app_icons.py, its Anki fixtures with Anki itself); Learn's data scripts andpackages/halkyon_app/tool/subdivision_names.pyneed nothing but Python's standard library and may move to Dart when they next change. Releases come fromtool/release.dart(decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (apps/<id>/release.dart, typed and commented, registered intool/src/release/apps.dart):dart run tool/release.dart learn [major | minor | patch] [--dry-run], on a developer's laptop, never in CI, throughtool/with_signing_keys.sh learn …(Bash glue): the keystores are kept in Proton Pass alone (vault Infra · Signing, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory ($XDG_RUNTIME_DIR), hands their paths and, but for a dry run, their passwords (each item's hidden field Password, in the environment as<keystore variable>_PASSWORD, so the tool asks for neither) and the Forgejo token (a login in the vault Infra,HALKYON_FORGEJO_TOKEN) to the release tool and removes the files however the run ends (test/release/signing_keys_test.dartholds it to every app released). Tags are per app (learn-v0.19.0; an app's earlier tags, Learn'sv…, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. Each release's What's new (Train's ADR 0030, D61), one file a language in the app'sassets/whats_new/(<version>.<language>.txt, English and German), is drafted by it from the commits after Go on? and edited in$VISUAL/$EDITORuntil it fits (noTRANSLATE:left, F-Droid's 500 characters), committed with the version, and is the app's sheet after the update, the Forgejo notes and F-Droid's changelog.--dry-runreads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is intool/src/release/, tested in the root'stest/release/.tool/fdroid_repo.shstays Bash (glue for fdroidserver);tool/with_signing_keys.sh fdroid [APK]runs it alone with the index's key, e.g. when a release's upload failed after its release was made; a release colliding with the entry Forgejo makes of the pushed tag itself asks again, a few seconds later. Build 21 (v0.18.0) was the last release oftool/release.sh, the Bash script it replaced, removed since.
CI and deploys
- CI (
.forgejo/workflows/ci.yaml) runsdart run tool/check.darton every push of a branch (not of a tag: a release pushes its tag right aftermain, on the same commit), on the Forgejo runner on learn.halkyon.eu's server (deploy/runner/), except for a commit that passed before: the job marks passed commits in the runner's home (checked/, kept 90 days), so a branch's commit fast-forwarded intomaingoes straight to its deploy. It checks every package, whichever a push changed. Jobs run directly on the host as the unprivileged userrunner, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no/home, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it.runnerhas user ID 61000 and must never share one with a container's processes (PostgreSQL and Learn's server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (Sandbox) fails if any of this leaks. Its second (Leftovers) stops whatever an earlier job left running in a job's working folder: the runner (forgejo-runner v13.1.0) kills a cancelled or timed-out step's process group and runs no later step, not even anif: always()one, and on 22 Oct 2026 a cancelled job still left its 4 GB analysis server behind; with one job at a time, any such process is a leftover. Flutter is pinned in the workflow (FLUTTER_VERSION): move it together with the local one. Every server package's tests get throwaway passwords from the environment (SERVERPOD_…, new for every job), since nopasswords.yamlis ever committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI installs the Serverpod CLI atSERVERPOD_VERSION(which the root's tests hold to the lock's Serverpod), so the checks' generated-code step fails there too if the committed generated code of any server differs from what the generator makes, as it does on the laptop since 23 Oct 2026 (before, only CI compared, and a push went red for an endpoint's changed comment). So never rundart formatover a server'slib/ortest/as a whole: it reformats the generated code (lib/src/generated/,serverpod_test_tools.dart); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the production server. - A green
maindeploys learn.halkyon.eu: this repository'smain, whichever app or package a push changed, throughdeploy/redeploy.sh(Learn'sAGENTS.md, CI and deploys, with the rules a deploy checks, the sidecars, the backups and the releases).