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:
parent
016f9b7e26
commit
b4290f71c8
1 changed files with 118206 additions and 13301 deletions
|
|
@ -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];
|
||||
}
|
||||
Loading…
Reference in a new issue