Skip to content

Latest commit

 

History

History
535 lines (395 loc) · 20.3 KB

File metadata and controls

535 lines (395 loc) · 20.3 KB

Dala — Agent Instructions

Read AGENTS.md first — it's the 5-minute orientation covering repo topology, how to drive a running app from your session (Dala.Test, MCP fallbacks), and the pre-empt-failure rules that will save you from re-deriving things the team has already learned the hard way. This file goes deeper on Claude Code-specific workflow detail.

Keep AGENTS.md up to date when you change repo conventions, add a new piece of CLI surface area, deprecate a workflow, or hit a new gotcha. Out-of-date guidance there causes wrong decisions everywhere downstream — fix it in the same commit, not in a follow-up.

See guides/agentic_coding.md for the full agent round-trip workflow: connecting to the running Erlang node, when to use Dala.Test vs MCP platform tools, and how to avoid the instinct to reach for xcrun simctl screenshots.


Pre-commit checklist

Before committing changes, run all three in this order:

mix test            # full suite must pass (call out any pre-existing flake explicitly)
mix format          # apply Elixir formatting
mix credo --strict  # address new issues; pre-existing ones are tracked separately

For native-code changes (iOS .m, Android .kt / .c), Elixir tests don't exercise the change. Deploy with mix dala.deploy --native and verify manually with a screenshot or Dala.Test interaction before committing.


Native App Test Harness — Vision

What dala is (beyond the UI framework)

Dala has two modes of use:

  1. Dala UI apps — Elixir-driven SwiftUI/Android apps. The BEAM renders the UI.
  2. Native sidecar — The BEAM runs invisibly inside any native Xcode/Android Studio app as a debug-only test and agent harness. The native app has zero Elixir dependency. In production builds the BEAM is stripped entirely.

The sidecar mode is the long-term bet. It gives native developers (who write zero Elixir) a way to let agents introspect and drive their apps during development and CI — without changing how they build or ship.

The cocoon model

The BEAM + NIF wraps the native app completely. From the OS's perspective, there is one process: the native app. The BEAM runs on a background thread. The NIF, being in-process, has privileges no external tool has:

  • Direct access to the iOS/Android UI object graph (no accessibility bridge latency)
  • Ability to intercept and synthesize touch events before they reach the app
  • Access to non-UI state: model objects, view controller hierarchy, memory
  • Faster and more reliable than Appium, XCUITest, or xcrun simctl — no IPC round-trip

The end goal is that the BEAM is the whole world to this app: it can observe every touch, inject synthetic touches, read every visible label, and report full UI state — all over Erlang distribution to a remote test runner or agent.

Why BEAM / why not XCUITest

  • XCUITest runs out-of-process and requires a separate test host target — it cannot read in-memory model state, only rendered accessibility output
  • Appium adds an HTTP layer and has significant latency
  • The BEAM runs in-process with sub-millisecond IPC via Erlang distribution
  • Tests can be written in any language that speaks Erlang distribution (Elixir, Erlang, or via the distribution protocol directly)
  • Hot code push means test logic can be updated without restarting the app or rebuilding

Development phases

Phase 1 — Attachment and reporting (complete)

  • ui_tree/0 — walks UIApplication.shared windows via UIAccessibility APIs, returns [{type, label, value, {x,y,w,h}}, ...] tuples. Works on any app with zero modification.
  • ui_debug/0 — raw accessibility dump for debugging

Phase 2 — Synthetic interaction (complete)

  • tap/1 — tap by accessibility label
  • tap_xy/2 — tap at screen coordinates (with responder-chain walk to focus text fields)
  • type_text/1 — type into the focused text field
  • delete_backward/0, key_press/1, clear_text/0 — keyboard control
  • long_press_xy/3, swipe_xy/4 — gesture synthesis

Phase 3 — Full cocoon / event interception (future)

Intercept the touch event stream before it reaches the app's responder chain. The BEAM decides whether to pass events through, suppress them, or inject new ones. At this point the BEAM is the authoritative input source and the app is fully contained.


inject / eject — native project integration

For native-only developers (no Elixir, just Xcode or Android Studio), dala is added and removed as a debug sidecar via a single command. The production app is never affected.

mix dala.inject MyApp.xcodeproj   # add sidecar to Xcode project (one time)
mix dala.eject  MyApp.xcodeproj   # remove it cleanly — git diff shows nothing

What inject does (iOS)

  • Adds dala_nif.m, dala_beam.m as Debug-only compile sources
  • Links libbeam.a and supporting static libs as Debug-only
  • Copies ERTS runtime directory as a Debug-only bundle resource
  • Adds #if DEBUG dala_start_beam() #endif to AppDelegate/SceneDelegate

What inject does (Android)

// build.gradle (app) — added by inject
debugImplementation 'io.dala:sidecar:VERSION'
// Application.onCreate() — added by inject
if (BuildConfig.DEBUG) dalaSidecar.start(this)

(The Application.onCreate line can be eliminated with a ContentProvider auto-init, making Android injection truly zero-touch.)

eject guarantee

eject is a clean inverse. git diff after eject shows nothing meaningful. This is important for the trust model — a developer can verify dala leaves no footprint.

Status: inject/eject are planned; pre-built libbeam.a fat binary (simulator + device + Android) is the prerequisite.


MCP server — dala_mcp (planned)

Design intent

The MCP server is an abstraction layer between the agent and the BEAM. The agent never sees Erlang nodes, distribution, or NIF calls directly — it talks to typed tools that happen to be backed by the BEAM internally.

The abstraction is the point. A developer using dala with an agent should not be able to accidentally write Elixir, because no tool exists to do so. The agent has everything it needs to verify and drive the native app, and nothing that lets it reach into the BEAM layer.

Package split

dala_dev   — Mix tasks: deploy, connect, push, doctor, new, inject, eject
dala_mcp   — MCP server: native-mode tools for agent-driven development

dala_mcp depends on dala_dev for device discovery and tunnel setup. dala_dev has no knowledge of MCP. Clean dependency direction.

Single mode

The MCP server has one mode. There is no dala_MODE=elixir. If a developer wants full BEAM access they open IEx directly — that is a human workflow, not an agent workflow. Giving the agent an Elixir-level tool would just be a worse IEx with predefined functions.

Tools exposed

Tool Backed by
dala_deploy mix dala.deploy --native
dala_build xcodebuild / gradlew assembleDebug + simctl install
dala_ui_tree dala_nif:ui_tree/0 via RPC
dala_tap dala_nif:tap_xy/2 via RPC (finds by label internally)
dala_type_text dala_nif:type_text/1 via RPC
dala_swipe dala_nif:swipe_xy/4 via RPC
dala_screenshot xcrun simctl io / adb screencap
dala_logs simulator console / adb logcat
dala_assert_visible ui_tree + label/value match
dala_wait_for poll ui_tree with timeout + backoff

Agent loop for native-only projects

1. edit Swift/Kotlin files (appear in Xcode/AS immediately)
2. dala_build  → xcodebuild + simctl install
3. dala_ui_tree → confirm screen state
4. dala_tap / dala_type_text → drive interactions
5. repeat

The developer sees all code changes in their IDE in real time and can intervene at any point. They never need to touch the terminal or know Erlang exists.

Project integration

mix dala.new and mix dala.inject both emit .mcp.json in the project root:

{
  "mcpServers": {
    "dala": {
      "command": "mix",
      "args": ["dala_mcp.server"]
    }
  }
}

Every developer and every agent session gets the same tools automatically. No per-session setup required.


iOS accessibility activation

SwiftUI lazily populates its accessibility tree only when an accessibility service is active. Run this once per simulator session before calling ui_tree:

UDID=<booted-simulator-udid>
xcrun simctl spawn $UDID defaults write com.apple.Accessibility VoiceOverTouchEnabled -bool YES
xcrun simctl spawn $UDID notifyutil -p com.apple.accessibility.voiceover.status.changed

Wait ~500ms for propagation. Survives app restarts within the same simulator session.

TODO: mix dala.connect should run this automatically for iOS simulator targets.


Standard debugging workflow

The preferred tool is mix dala.connect (from dala_dev package):

cd ~/code/dala_demo
mix dala.connect          # discover all devices, tunnel, restart, connect IEx
mix dala.connect --no-iex # same but print node names instead of starting IEx
mix dala.devices          # list connected devices and their status

Node names are platform-specific:

  • iOS simulator: dala_demo_ios@127.0.0.1
  • Android emulator: dala_demo_android@127.0.0.1

EPMD tunneling

iOS simulator shares the Mac's network stack — the iOS BEAM registers directly in the Mac's EPMD on port 4369. No forwarding needed.

Android is a separate network namespace. dala_dev sets up adb tunnels automatically:

adb reverse tcp:4369 tcp:4369   # EPMD: device → Mac (Android BEAM registers in Mac EPMD)
adb forward tcp:9100 tcp:9100   # dist:  Mac → device

Port assignment (handled by dala_dev)

Devices are assigned dist ports by index to avoid conflicts:

  • Device 0 (Android): port 9100
  • Device 1 (iOS sim): port 9101

iOS dist port is passed via SIMCTL_CHILD_DALA_DIST_PORT env var; dala_beam.m reads DALA_DIST_PORT at startup. Android dist port is passed as an intent extra (dala_dist_port); MainActivity.java does NOT yet read this — multi-Android support is pending.

Both iOS and Android end up registered in the same Mac EPMD. mix dala.connect sets up all tunnels automatically.

Day-to-day development loop

# Edit Elixir code, then:
mix dala.deploy          # compile + push BEAMs + restart apps
mix dala.connect         # tunnel + wait for nodes + drop into IEx

# In IEx (after dala.connect):
mix compile && nl(dalaDemo.CounterScreen)   # hot-push one module without restart
Node.list()                                # verify both devices connected
:rpc.call(:"dala_demo_android@127.0.0.1", dalaDemo.CounterScreen, :some_fn, [])

Reading live screen state

# Screen pid is logged at app start: "[dala] step 5 => {ok,<0.92.0>}"
pid = :rpc.call(:"dala_demo_android@127.0.0.1", :erlang, :list_to_pid, [~c"<0.92.0>"])
socket = :rpc.call(:"dala_demo_android@127.0.0.1", Dala.Screen, :get_socket, [pid])
socket.assigns   # live assigns

Hot code push

# After editing a screen (from the terminal):
mix dala.push          # compile + push all changed modules to all connected devices
mix dala.push --all    # force-push every module

# Or from inside IEx (after dala.connect), one module at a time:
nl(dalaDemo.CounterScreen)
# Returns: {:ok, [{:"dala_demo@127.0.0.1", :loaded, dalaDemo.CounterScreen}]}

Android distribution

Android cannot start distribution at BEAM launch (races with hwui thread pool, causes SIGABRT via FORTIFY pthread_mutex_lock on destroyed mutex). Instead, Dala.Dist.ensure_started/1 defers Node.start/2 by 3 seconds after app startup. This is handled in the dala library — app code just calls Dala.Dist.ensure_started(node: :"my_app_android@127.0.0.1", cookie: :my_secret).

ERTS helper binaries (erl_child_setup, inet_gethost, epmd) cannot be exec'd from the app data directory (SELinux app_data_file blocks execute_no_trans). They are packaged in the APK as lib*.so in jniLibs/arm64-v8a/ (gets apk_data_file label, which allows exec). dala_beam.c symlinks BINDIR/<name><nativeLibraryDir>/lib<name>.so before erl_start.

Agent round-trip workflow

The standard loop for AI-assisted feature development or debugging. Use all three layers in order — BEAM state first, then visual verification only when needed.

1. Edit and deploy

mix dala.push            # compile + push changed BEAMs to all connected nodes
# or for a native rebuild (e.g. after NIF or Swift/Kotlin change):
mix dala.deploy --native

2. Inspect BEAM state via IEx or Dala.Test

Connect (or use an already-open IEx session from mix dala.connect):

mix dala.connect --no-iex   # sets up tunnels, prints node names, exits

Then from a separate IEx session or script:

node = :"dala_demo_ios@127.0.0.1"
Dala.Test.screen(node)    # which screen is showing?
Dala.Test.assigns(node)   # live assigns — count, selected items, etc.
Dala.Test.tap(node, :some_button)   # drive a tap programmatically
Dala.Test.find(node, "Submit")      # locate a widget by visible text

This is the fastest path. BEAM state is exact and doesn't require image decoding.

3. Visual verification via MCP tools

When you need to confirm rendering, layout, or animations — use the platform MCP servers. These are available as tools in the agent environment.

iOS Simulator (mcp__ios-simulator__*):

Tool When to use
screenshot Capture the current simulator frame
ui_tap Tap at x,y coordinates
ui_type Type text into focused input
ui_swipe Swipe gesture
ui_view Inspect the accessibility tree
ui_describe_point What element is at this coordinate?
ui_describe_all Full accessibility dump
record_video / stop_recording Record an interaction sequence

Android (mcp__adb__*):

Tool When to use
dump_image Screenshot from the connected device/emulator
inspect_ui XML accessibility dump of the current view
adb_shell Run arbitrary shell commands on the device
adb_logcat Tail logcat (Elixir logs appear under the Elixir tag)

Typical round-trip

1. Edit Elixir code
2. mix dala.push
3. Dala.Test.screen(node)   ← confirm navigation / state
4. mcp__ios-simulator__screenshot  ← visual sanity check
5. Dala.Test.tap(node, :button)     ← drive interaction
6. Dala.Test.assigns(node)  ← confirm state updated correctly
7. repeat

Use Dala.Test for assertions (exact, fast, no image parsing). Use MCP screenshot/UI tools for layout checks, animation spot-checks, or when a bug is only visible in the rendered output.

Device automation with Dala.Test

After connecting via mix dala.connect, use Dala.Test to inspect and drive the running app without touching the native UI. Prefer this over screenshot-based inspection — it gives exact state, not a visual approximation.

node = :"dala_demo_ios@127.0.0.1"   # or dala_demo_android@127.0.0.1

# What screen is showing and what state is it in?
Dala.Test.screen(node)    #=> dalaDemo.NavScreen
Dala.Test.assigns(node)   #=> %{depth: 0, safe_area: %{top: 62.0, ...}}

# Find a node by visible text
Dala.Test.find(node, "Device APIs")
#=> [{[0, 0, 9], %{"type" => "button", ...}}]

# Trigger a tap by the tag atom used in on_tap: {self(), tag}
Dala.Test.tap(node, :open_device)

# Full snapshot for debugging
Dala.Test.inspect(node)
# %{screen: dalaDemo.NavScreen, assigns: ..., nav_history: [...], tree: ...}

Tag atoms come from on_tap: {self(), :tag_atom} in the render tree. Check the screen's render/1 to find them. After a tap, call Dala.Test.screen/1 again to confirm the navigation happened.

Running tests

mix test          # from ~/code/dala

Onboarding integration tests

The test/onboarding/ suite verifies the full first-run flow end-to-end: archive install, project generation, mix dala.install, mix dala.doctor, and failure modes. These tests are excluded from mix test by default (they take minutes and require Hex/network access). Run them explicitly:

# Fast subset — no simulator needed (~3 min, suitable for PR gating)
MIX_ENV=test mix test --only generator

# Failure-mode checks — no simulator needed (~2 min)
MIX_ENV=test mix test --only pre_device

# Everything above in one pass
MIX_ENV=test mix test --only onboarding

# Full suite including post-device tests (requires a booted iOS simulator)
MIX_ENV=test mix test --only failure_modes

Run one file at a time with --max-cases 1 to avoid workspace ID collisions between concurrent tests:

MIX_ENV=test mix test test/onboarding/generator_test.exs --only generator --max-cases 1
MIX_ENV=test mix test test/onboarding/failure_modes_test.exs --only pre_device --max-cases 1

What they test:

Tag File Covers
:generator generator_test.exs Archive install, mix dala.new, mix dala.install, mix dala.doctor
:pre_device failure_modes_test.exs Failure modes that don't need a running simulator
:post_device failure_modes_test.exs Failures requiring a live iOS simulator

Preserved workspaces: When a test fails, its workspace is kept at /tmp/dala_onboarding/run_<PID>/<test_id>/. Inspect logs/ for per-step output. Workspaces from passing tests are deleted automatically.

Known limitations (published dala_dev 0.1.7):

  • DALA_OTP_BASE_URL is not respected — OTP download URL cannot be overridden for failure injection. Network failure tests verify OTP reporting format instead.
  • check_elixir reads System.version() (the running BEAM) — PATH-based fake Elixir versions have no effect. The Elixir version test verifies the check produces clear output.
  • check_java ignores exit code ({out, _} pattern) — a fake java always shows ✓. The java test verifies the check is present and reports useful version info.
  • xcrun and java share /usr/bin with dirname/basename used by mise/asdf elixir launcher scripts. Filtering /usr/bin from PATH crashes the subprocess. Tests for these tools verify the success path format instead of injecting a missing-tool failure.

Common pitfalls

See common_fixes.md for a running log of diagnosed bugs and their fixes — consult it first when hitting silent crashes or unexpected BEAM behavior.

User issues log

See user_issues.md for a record of real issues encountered by beta users, their root causes, and fixes applied. Read this before working on setup, deployment, or tooling problems — the same issues recur, especially for Nix users. User alias "Nova" = macOS + Nix-managed toolchain throughout.

Key files

  • lib/dala/screen.ex — GenServer wrapper, lifecycle callbacks

  • lib/dala/socket.ex — assigns + internal dala state

  • lib/dala/renderer.ex — walks component tree, issues NIF calls

  • lib/dala/connectivity/dist.ex — platform-aware distribution startup

  • native/dala_nif/src/lib.rs — Rust NIF implementation (via Rustler)

  • ios/dala_nif.m — iOS NIF implementation (SwiftUI bridge + test harness)

  • android/jni/dala_nif.c — Android NIF implementation (JNI bridge)

  • ios/dala_beam.m — iOS BEAM launcher

  • android/jni/dala_beam.c — Android BEAM launcher

  • lib/dala/permissions.ex — Permission management

  • lib/dala/platform/native.ex — Rustler NIF declarations

  • lib/dala/platform/native_logger.ex — Native logger (NSLog/logcat)

  • lib/dala/hardware/bluetooth.ex — BLE support

  • lib/dala/connectivity/wifi.ex — WiFi network info

  • lib/dala/storage/storage.ex — App-local file storage

  • lib/dala/storage/blob.ex — Binary blob handling

  • lib/dala/hardware/wakelock.ex — Screen wakelock

  • lib/dala/ui/native_view.ex — NativeView component behaviour

  • lib/dala/ui/feedback/alert.ex — Alerts, action sheets, toasts

  • lib/dala/ui/embedded/webview.ex — WebView JS bridge

  • lib/dala/ui/sensor/motion.ex — Accelerometer/gyroscope

  • lib/dala/event/event.ex — Unified event system

  • lib/dala/plugin.ex — Plugin behaviour and DSL

  • lib/dala/ml.ex — ML public API

  • lib/dala/list.ex — List rendering

  • lib/dala/pubsub.ex — Local PubSub

External documents