refactor!: packages, paths and app IDs under Halkyon Days' names (ADR 0045, step 3)

Learn's folder is apps/halkyon, Todo's apps/myrmex; every package of
ours takes the prefix halkyone_ (halkyone_halkyon_*, halkyone_myrmex_*,
halkyone_train_*, halkyone_meditate_*, halkyone_app, halkyone_base_* and
the other shared ones; halkyon_oidc_* keeps its name, D11). Halkyon and
Myrmex take the app IDs one.halky.halkyon and one.halky.myrmex (D10);
Train and Meditate keep theirs, while every app's Android namespace,
Kotlin package and method channels move to one.halky. The snaps are
halkyon and myrmex (D17), each built in a clone with its recipe in
place of snap/, as Train's and Todo's were.

Serverpod finds a database's migrations by its module's name, which
changed with the packages':
- each server's new, empty migration ("rename") records itself under
  the new names and deletes the rows under the old ones;
- deploy/redeploy.sh copies each database's record to the new name
  before the migrations (carry_module), so the renamed servers find
  their databases migrated; a copy, never a move, so a deploy failing
  before the migration commits leaves the old rows in place;
- Train and Meditate keep their devices' databases: halkyone_app's
  carryMigrationRecord copies their record before the sync engine
  opens them (openPersonDatabase);
- the client migrations' generated moduleName getters, one line each,
  say the new names: Serverpod's client asserts in debug builds that
  every migration names the module, and create-migration never writes
  a migration's files again.

Train's WorkManager work is enqueued under new names, the former ones
cancelled: the work kept names the workers' former classes.

The release tool: an app's tags of its earlier names count as its
history (legacyTagPrefixes), the shared packages' former paths count
for its version, and every snap is built in a clone (atRoot gone).

The databases, Docker images and Compose project keep their names
until step 5.

BREAKING CHANGE: Halkyon (Learn) and Myrmex (Todo) are new apps to
Android and the Snap Store: installs of app.halkyon.learn and
app.halkyon.todo don't update to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-12 23:21:45 +01:00
commit b4290f71c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,362 +0,0 @@
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:serverpod/serverpod.dart' show UuidValue;
import 'package:test/test.dart';
void main() {
final idKey = 'i' * assistantIdKeyMinLength;
final claude = {
'ASSISTANTS': 'claude',
'ASSISTANT_ID_KEY': idKey,
'ASSISTANT_CLAUDE_NAME': 'Claude Sonnet',
'ASSISTANT_CLAUDE_PROTOCOL': 'anthropic',
'ASSISTANT_CLAUDE_MODEL': 'claude-sonnet-5-5',
'ASSISTANT_CLAUDE_KEY': 'sk-ant-key',
};
Matcher refusedNaming(String text) => throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains(text),
),
);
group("The operator's assistants (deploy/assistants.env, ADR 0021, D37)", () {
test('none set: no assistant, no personal keys', () {
final assistants = assistantsFrom(<String, String>{}.get);
expect(assistants.all, isEmpty);
expect(assistants.ownKeys, isFalse);
expect(assistants.currency, 'USD');
expect(assistants.idKey, isNull);
});
test('empty values count as unset, as Compose passes them on', () {
expect(
assistantsFrom({'ASSISTANTS': '', 'ASSISTANT_OWN_KEYS': ''}.get).all,
isEmpty,
);
});
test('one is read with its name, protocol, model and key, on the '
"provider's own address", () {
final assistant = assistantsFrom(claude.get).all.single;
expect(assistant.id, 'claude');
expect(assistant.name, 'Claude Sonnet');
expect(assistant.endpoint.protocol, LlmProtocol.anthropic);
expect(assistant.endpoint.model, 'claude-sonnet-5-5');
expect(assistant.endpoint.apiKey, 'sk-ant-key');
expect(
assistant.endpoint.baseUrl,
Uri.parse('https://api.anthropic.com'),
);
expect(assistant.images, isFalse);
expect(assistant.prices, isNull);
});
test('several, in the order listed, by comma or space, whatever their '
'case; a name defaults to the ID', () {
final assistants = assistantsFrom(
{
...claude,
'ASSISTANTS': ' Claude, gpt local ',
'ASSISTANT_GPT_PROTOCOL': 'openai-responses',
'ASSISTANT_GPT_MODEL': 'gpt-6-astra',
'ASSISTANT_GPT_KEY': 'sk-key',
'ASSISTANT_LOCAL_PROTOCOL': 'openai',
'ASSISTANT_LOCAL_BASE_URL': 'http://ollama:11434/v1',
'ASSISTANT_LOCAL_MODEL': 'qwen3:8b',
}.get,
);
expect(
[for (final a in assistants.all) a.id],
['claude', 'gpt', 'local'],
);
expect(assistants.named('gpt')!.name, 'gpt');
expect(
assistants.named('gpt')!.endpoint.protocol,
LlmProtocol.openaiResponses,
);
expect(assistants.named('local')!.endpoint.apiKey, '');
expect(
assistants.named('local')!.endpoint.baseUrl,
Uri.parse('http://ollama:11434/v1'),
);
expect(assistants.named('nobody'), isNull);
});
test('images and personal keys only where on', () {
final assistants = assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_IMAGES': 'on',
'ASSISTANT_OWN_KEYS': 'ON',
}.get,
);
expect(assistants.all.single.images, isTrue);
expect(assistants.ownKeys, isTrue);
expect(
assistantsFrom({...claude, 'ASSISTANT_CLAUDE_IMAGES': 'off'}.get)
.all
.single
.images,
isFalse,
);
expect(assistantsFrom({'ASSISTANT_OWN_KEYS': 'on'}.get).ownKeys, isTrue);
});
test('prices per million tokens, both or none, in the currency set '
'(D38)', () {
final assistants = assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_PRICE_INPUT': '3',
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15.50',
'ASSISTANT_CURRENCY': 'eur',
}.get,
);
final prices = assistants.all.single.prices!;
expect((prices.input, prices.output), (3.0, 15.5));
expect(assistants.currency, 'EUR');
// 1,000 input tokens at 3 and 200 output at 15.50 per million:
// 0.003 + 0.0031, in millionths.
expect(prices.costMicros(inputTokens: 1000, outputTokens: 200), 6100);
});
test("the effort: the operator's default and highest level, by "
"Anthropic's names, whatever their case (D63)", () {
final assistant = assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_EFFORT': 'Medium',
'ASSISTANT_CLAUDE_MAX_EFFORT': 'xhigh',
}.get,
).all.single;
expect(assistant.effort, LlmEffort.medium);
expect(assistant.efforts, [
LlmEffort.low,
LlmEffort.medium,
LlmEffort.high,
LlmEffort.xhigh,
]);
});
test("a default alone is the highest too; none: the model's own, and "
'none to choose', () {
final fixed = assistantsFrom(
{...claude, 'ASSISTANT_CLAUDE_EFFORT': 'high'}.get,
).all.single;
expect(fixed.effort, LlmEffort.high);
expect(fixed.efforts, [LlmEffort.low, LlmEffort.medium, LlmEffort.high]);
final own = assistantsFrom(claude.get).all.single;
expect(own.effort, isNull);
expect(own.efforts, isEmpty);
});
group('refuse what would go wrong later, so the server stops', () {
test('an ID that is no name of a variable', () {
expect(
() => assistantsFrom({...claude, 'ASSISTANTS': 'claude, gpt-4'}.get),
refusedNaming('gpt-4'),
);
});
test('an ID twice', () {
expect(
() => assistantsFrom({...claude, 'ASSISTANTS': 'claude claude'}.get),
refusedNaming('twice'),
);
});
test('a protocol missing or unknown, naming those there are', () {
expect(
() => assistantsFrom(
({...claude}..remove('ASSISTANT_CLAUDE_PROTOCOL')).get,
),
refusedNaming('ASSISTANT_CLAUDE_PROTOCOL'),
);
expect(
() => assistantsFrom(
{...claude, 'ASSISTANT_CLAUDE_PROTOCOL': 'claude'}.get,
),
refusedNaming('anthropic, openai, openai-responses'),
);
});
test('a model missing', () {
expect(
() => assistantsFrom(
({...claude}..remove('ASSISTANT_CLAUDE_MODEL')).get,
),
refusedNaming('ASSISTANT_CLAUDE_MODEL'),
);
});
test("a key missing for the provider's own address", () {
expect(
() =>
assistantsFrom(({...claude}..remove('ASSISTANT_CLAUDE_KEY')).get),
refusedNaming('ASSISTANT_CLAUDE_KEY'),
);
});
test('a base URL that is no address, or http beyond this network', () {
expect(
() => assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_BASE_URL': 'http://proxy.example.org',
}.get,
),
refusedNaming('ASSISTANT_CLAUDE_BASE_URL'),
);
});
test('one price without the other, or one that is no number', () {
expect(
() => assistantsFrom(
{...claude, 'ASSISTANT_CLAUDE_PRICE_INPUT': '3'}.get,
),
refusedNaming('ASSISTANT_CLAUDE_PRICE_OUTPUT'),
);
expect(
() => assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_PRICE_INPUT': '3,00',
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15',
}.get,
),
refusedNaming('ASSISTANT_CLAUDE_PRICE_INPUT'),
);
expect(
() => assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_PRICE_INPUT': '-1',
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15',
}.get,
),
refusedNaming('ASSISTANT_CLAUDE_PRICE_INPUT'),
);
});
test('an effort that is no level, naming those there are', () {
expect(
() => assistantsFrom(
{...claude, 'ASSISTANT_CLAUDE_EFFORT': 'minimal'}.get,
),
refusedNaming('low, medium, high, xhigh, max'),
);
});
test('a highest effort without a default, or below it', () {
expect(
() => assistantsFrom(
{...claude, 'ASSISTANT_CLAUDE_MAX_EFFORT': 'high'}.get,
),
refusedNaming('ASSISTANT_CLAUDE_EFFORT'),
);
expect(
() => assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_EFFORT': 'high',
'ASSISTANT_CLAUDE_MAX_EFFORT': 'low',
}.get,
),
refusedNaming('ASSISTANT_CLAUDE_MAX_EFFORT'),
);
});
test("an effort the provider takes not: Mistral's none, Gemini's "
'above high', () {
final atMistral = {
...claude,
'ASSISTANT_CLAUDE_PROTOCOL': 'openai',
'ASSISTANT_CLAUDE_BASE_URL': 'https://api.mistral.ai/v1',
};
expect(
() => assistantsFrom(
{...atMistral, 'ASSISTANT_CLAUDE_EFFORT': 'low'}.get,
),
refusedNaming('ASSISTANT_CLAUDE_EFFORT'),
);
expect(assistantsFrom(atMistral.get).all.single.efforts, isEmpty);
expect(
() => assistantsFrom(
{
...claude,
'ASSISTANT_CLAUDE_PROTOCOL': 'openai',
'ASSISTANT_CLAUDE_BASE_URL':
'https://generativelanguage.googleapis.com/v1beta/openai',
'ASSISTANT_CLAUDE_EFFORT': 'high',
'ASSISTANT_CLAUDE_MAX_EFFORT': 'xhigh',
}.get,
),
refusedNaming('ASSISTANT_CLAUDE_MAX_EFFORT'),
);
});
test('a switch neither on nor off', () {
expect(
() =>
assistantsFrom({...claude, 'ASSISTANT_CLAUDE_IMAGES': 'yes'}.get),
refusedNaming('ASSISTANT_CLAUDE_IMAGES'),
);
expect(
() => assistantsFrom({'ASSISTANT_OWN_KEYS': 'true'}.get),
refusedNaming('ASSISTANT_OWN_KEYS'),
);
});
test('a currency that is no ISO 4217 code', () {
expect(
() => assistantsFrom({...claude, 'ASSISTANT_CURRENCY': 'Euro'}.get),
refusedNaming('ASSISTANT_CURRENCY'),
);
});
test("the opaque IDs' key missing or short where assistants are set "
'(D30)', () {
expect(
() => assistantsFrom(({...claude}..remove('ASSISTANT_ID_KEY')).get),
refusedNaming('ASSISTANT_ID_KEY'),
);
expect(
() => assistantsFrom({...claude, 'ASSISTANT_ID_KEY': 'short'}.get),
refusedNaming('ASSISTANT_ID_KEY'),
);
});
});
});
group("The provider's opaque ID of a person (D30)", () {
final assistants = assistantsFrom(claude.get);
final anna = UuidValue.fromString('0193a3d4-5c6e-7f80-9a1b-2c3d4e5f6a7b');
final ben = UuidValue.fromString('0193a3d4-5c6e-7f80-9a1b-2c3d4e5f6a7c');
test('is the same for a person each time, and differs between people', () {
expect(assistants.personIdOf(anna), assistants.personIdOf(anna));
expect(assistants.personIdOf(anna), isNot(assistants.personIdOf(ben)));
});
test('is 43 characters of base64url, within what every provider takes, '
'and never the account', () {
final id = assistants.personIdOf(anna);
expect(id, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
expect(id, isNot(contains(anna.uuid)));
});
test('changes with the key', () {
final other = assistantsFrom(
{...claude, 'ASSISTANT_ID_KEY': 'j' * assistantIdKeyMinLength}.get,
);
expect(other.personIdOf(anna), isNot(assistants.personIdOf(anna)));
});
});
}
extension on Map<String, String> {
String? get(String name) => this[name];
}