Files
zeekay 0e7a53d035 Add the iOS lane: hanzo-templates had zero of them
The catalogue shipped 71 web templates and one EMPTY `mobile` repo, so
"build an app" had no starting point on the platform people actually
ship to. This is the iOS lane — Expo/React Native and Flutter for
cross-platform, SwiftUI for native.

The constraint that shaped every file here: an iOS archive needs macOS +
Xcode + a signing identity, and our arc fleet has none. hanzoai has 519
registered runners and every one reports `os: linux`; luxfi has 151, same
story; zooai has none. The only macOS builds in the org today
(hanzoai/world build-desktop.yml) run on GitHub-HOSTED macos-14, which is
exactly what the house CI rule says not to do.

So `hanzo.yml` declares only what a Linux arc runner can genuinely
execute — typecheck + web bundle for Expo, analyze + test + web for
Flutter, `swiftc -parse` for SwiftUI — and the macOS archive lives in a
separate `workflow_dispatch`-only `ios.yml`. A job pinned to a label no
runner advertises would sit queued forever if it fired on push; parking
it is honest, a permanently-yellow check is not. The README of every
template states the gap and the one command that closes it.

Same reasoning on the demos. Expo and Flutter both have a real web
target, so `<slug>.hanzo.app` serves the SAME source the simulator runs.
SwiftUI has no web target, so those two templates ship with no demo link
rather than a screenshot pretending to be one.

Upstreams are permissive only and recorded per template: expo/expo
templates (MIT), flutter/samples (BSD-3-Clause), XcodeGen (MIT).

Second measured gap, found while wiring this up: `hanzo-templates` is not in
arcd's `orgs:` list either (hanzoai, luxfi, zooai, parsdao, zenlm), so the
Linux jobs would queue with no runner too. Both gaps are one installer
re-run, no code change; both are spelled out in every README rather than
left to be discovered on the first red build.

Third gap, found by probing rather than assuming, and FIXED here: every
run in this org died as `startup_failure` with no logs and no annotation.
A bare `runs-on: [self-hosted, linux]` probe queued fine while the same
job importing hanzoai/ci failed identically at @v1 and at a pinned SHA —
so it was never the tag. The org defaulted GITHUB_TOKEN to `read`, and
the reusable declares `permissions: packages: write`; a caller cannot
exceed the org maximum, so GitHub kills the run before creating a job.
hanzo-templates now defaults to `write` like hanzoai/luxfi/zooai, and
runs reach `queued` — the honest state, waiting on gap 2.
2026-07-27 23:25:27 -07:00

60 lines
2.0 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// The whole Hanzo integration: one streaming call to /v1/chat/completions.
///
/// Config is read at RUNTIME (Settings) rather than baked in with
/// --dart-define, because a compiled-in key ships inside every copy of the app.
/// Ship the app, let the user bring the key.
class Msg {
const Msg(this.role, this.content);
final String role;
final String content;
Map<String, String> toJson() => {'role': role, 'content': content};
}
class Hanzo {
Hanzo._();
static final Hanzo instance = Hanzo._();
String base = 'https://api.hanzo.ai/v1';
String model = 'zen-omni';
String key = '';
/// Yields the assistant reply token by token. The caller appends each chunk;
/// nothing here knows about widgets.
Stream<String> complete(List<Msg> messages) async* {
if (key.isEmpty) {
throw StateError('No API key. Open Settings and paste a Hanzo key.');
}
final req = http.Request('POST', Uri.parse('$base/chat/completions'))
..headers.addAll({'Content-Type': 'application/json', 'Authorization': 'Bearer $key'})
..body = jsonEncode({
'model': model,
'messages': messages.map((m) => m.toJson()).toList(),
'stream': true,
});
final res = await req.send();
if (res.statusCode != 200) {
throw http.ClientException('${res.statusCode} ${await res.stream.bytesToString()}');
}
// LineSplitter reassembles SSE frames that arrive split across chunks.
await for (final line in res.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (!line.startsWith('data:')) continue;
final payload = line.substring(5).trim();
if (payload == '[DONE]') return;
try {
final delta = (jsonDecode(payload)['choices'] as List).first['delta']['content'];
if (delta is String && delta.isNotEmpty) yield delta;
} catch (_) {
// keep-alive comment or a frame we do not model — skip it
}
}
}
}