No reviewers
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
albert/shepherd-launcher!107
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "u/albert/token-system"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds two related ways to shape when an activity is available, plus the
management-app support to see and override them. Along the way it fixes a
pre-existing break in the Android companion and replaces its hand-written wire
mirrors with generated ones, so that class of break can't recur.
Closes #8. Closes #5.
Issue #8 — token system
An activity can carry an
[entries.tokens]block naming the activities whosetime unlocks it. Time works as a currency: sessions on the source activities
bank a balance on the target, and the target's own sessions spend it back down.
The session is capped at the banked balance, so it can never be overspent, and
the activity re-locks once the balance is gone. The issue's other two bullets —
manual and external-API conditions — are already covered by daily overrides and
the management API, so only the time-based case is implemented.
Issue #5 — grouped time limits
A
[[groups]]block defines a category that shares one schedule and onebudget; entries join with
group = "<id>".The daily quota is the combined usage of the members, so one game can spend
the category's whole budget and take its siblings down with it — the behaviour
the issue asks for. A group cooldown is started by any member and applies to
all of them, which is the main reason to set one: a per-entry cooldown is
trivially dodged by starting a different game in the same category.
Groups interoperate with tokens in both directions:
from = ["group:<id>"]banks time from every member of a category, and
[groups.tokens]gates a wholecategory so earning unlocks all of it at once.
How the limits compose
Every limit now exists at two levels and composes strictest-wins: a member
must satisfy both its own limits and its group's, and the session is capped by
the tighter of each. A group-level failure is reported as
GroupRestrictedwrapping the underlying reason, so a UI can say "Games: daily limit reached"
and every future entry-level reason works at group level for free.
availability = trueoverride bypassesmax_runStorage
Cooldowns, token balances, and daily overrides are keyed by a
LimitSubjectrather than an entry ID, so a group holds the same state an entry does —
including its own daily override.
The key format is what makes this cheap: an entry subject renders as the bare
entry ID, and only groups take a
group:prefix. Every pre-existing row andAPI call is therefore already valid, and the migration is metadata-only — an
ALTER TABLE … RENAME COLUMNguarded by aPRAGMA table_infocheck, with norow read or rewritten.
test_legacy_entry_keyed_tables_are_migrated_in_placebuilds a real pre-groups database on disk and asserts the old cooldown and
override survive.
Entry and group IDs are both forbidden from starting with
group:, which keepsthe encoding unambiguous.
⚠️ Breaking change
DailyOverride.entry_idis nowDailyOverride.subjecton the wire.The web UI, the Android companion, the dispatch tests, and the generated RPC
schema are updated here. Any other consumer of that field needs the same
change. Input is backward compatible —
upsert_overridestill accepts a bareentry ID — but the response field is renamed.
Management apps
Groups were initially invisible to both apps, and auditing that turned up a
pre-existing break in the companion.
Android repair. The companion's
ReasonCodemirror had drifted fourvariants behind the device:
not_ready(#76) andrequired_input_unavailable(#96) were already missing before this branch added two more. kotlinx throws on
an unknown polymorphic discriminator —
ignoreUnknownKeysonly covers unknownfields — and
reasonsis nested insideEntryView, so a single unrecognisedreason failed the decode of the whole
list_entriesresponse.DailyOverridealso still read
entry_id, andloadOverrideswallowed the resultingexception with
runCatching{}.getOrNull(), making a failed loadindistinguishable from "no override set" — the editor rendered a blank form
over a real override, and saving would have silently overwritten it.
Both apps now show a Categories section with combined-usage progress and
override controls addressing
group:<id>, and activity rows name theircategory. The web app's override mutations take a
LimitTarget; Android'sOverrideSectionis keyed by subject — in both cases category and activitycontrols are the same code path.
Generated wire types
RpcMethods.ktwas method-name constants only, andManagementClientused rawstring literals rather than those constants, so the drift check was guarding
nothing that mattered. Neither Android break was catchable from the method
schema, which records only type names from the trait signature.
The wire types now carry
JsonSchemaderives, and a newshepherd-wire-codegencrate renders that schema into
WireTypes.generated.kt— 38 declarations, doccomments carried across from Rust. The drift test covers that file, so a Rust
field change fails CI until the Kotlin is regenerated.
Every generated sealed interface gets an
Unknownvariant and a matchingpolymorphic default, which generalises the
ReasonCodefix: any tagged enumcan gain a variant, and an older companion degrades that one value instead of
failing everything around it.
The crate is a leaf, deliberately outside
default-members. That placementdoes two jobs: it sits above
shepherd-ble, so the BLE claim types generatetoo (a generator inside
shepherd-managementwould be a dependency cycle); andit keeps
schemarsbehind aschemafeature that nothing shipped enables —cargo tree -p shepherddshows no schemars at all.Generation immediately improved three types that had been hand-typed as
strings:
RequiredInputUnavailable.devices→List<InputDeviceType>,AdminRecord.role→AdminRole, andDeviceInfo.protocolVersion→Long.Three types stay hand-written, and the generator panics rather than
emitting something subtly wrong if it meets their shapes:
LaunchOutcomeisexternally tagged, and
Event/EventPayloadhave a variant that flattens a$refbeside its tag, which kotlinx cannot express.Testing
clippy --all-targets -- -D warnings,cargo fmtreason variant, subject-keyed overrides, and an unknown-code payload
asserting it degrades without taking its siblings down
tsc --noEmitand a production web buildconfig.example.tomlpassesshepherd config validatedoc-comment change and watching CI-equivalent failure
End-to-end via the headless dev session:
127-second source session, and reported
max_run_if_started_now = 7620s—127 × the ratio of 60, exactly the banked balance — on an entry with no
configured
max_run.max_runclamped to the shared120-second quota while an ungrouped activity stayed uncapped; one member's
session then removed both members with two stacked
group_restrictedreasons (shared cooldown + exhausted combined quota); a
group:gamesoverride cleared the quota reason, leaving only the cooldown, which nothing
bypasses by design.
"2m of 30m used today" with its members reading "Games: Cooling down".
Known gaps
paired BLE device; CI runs JVM unit tests only and the pair/claim path is a
documented manual smoke test. Coverage there is wire tests plus compilation.
quota is spent, the entry is hidden and the banked time expires at midnight
unless
carry_over = true. Documented with aCAUTIONinconfig.example.toml; the guidance is to not put a quota on a gated entry.Coherent, but hard to explain to a child; the docs steer toward gating at one
level or the other.
disappears from the launcher. A locked tile showing progress is the obvious
follow-up and would serve both features.
Docs
config.example.tomlgains worked examples for both features and a "how thisinteracts with the other time restrictions" section. Crate READMEs
(
shepherd-config,shepherd-core,shepherd-store) cover the compositionrules, the combined-quota semantics, and the schema-migration constraint.
Design notes and as-built records are in
docs/ai/history/2026-07-19 003and004.