ClientUtils.Harness.Onboarding (client_utils v0.1.25)

View Source

What onboarding a working copy decides, with none of the doing.

Pure policy, so the two callers can share it without sharing anything else. Mix.Tasks.Harness.Onboard writes these results to disk for a generated application; CodeMySpec's Mix.Tasks.Cms.OnboardHarness writes the same results through its own environment abstraction. Neither this module nor that task learns what the other is.

That split is the reason onboarding lives here rather than in CodeMySpec: a generated application depends on client_utils and not on CodeMySpec, so this is the only placement where both can onboard themselves with one command.

Nothing here touches a disk or a database

Every function returns a value. The commands for creating and migrating databases are returned as strings — they are handed to whoever ran the command, never executed. That rule is not stylistic: the version of this that ran them shelled out to mix with an environment that had MIX_ENV scrubbed out of it, resolved to the shared development database, and a sibling truncate emptied it three times on 2026-08-13.

Summary

Functions

The address an agent's model turns relay through.

Whether the copy at root has been onboarded, and what to run if not.

Every database this working copy uses, each with the commands that create and migrate it.

Git settings a working copy needs.

Configure the working copy at root, and report what is left.

The database partition name for a working copy at root.

The partition recorded for the copy at root.

The recorded partition, or a raise naming what to run.

The settings a working copy needs, as a map ready to be encoded.

The file settings belong in. Untracked, deliberately — see settings/2.

Functions

anthropic_url(base_url, harness_id)

@spec anthropic_url(String.t(), String.t()) :: String.t()

The address an agent's model turns relay through.

One function, both callers, on purpose. This string was rendered twice — once here and once in CodeMySpec's own task — and the two disagreed: /api/harnesses/<id> against /harnesses/<id>/anthropic, of which only the first is a real route in CmsHarness.Web.Router. A copy onboarded by the wrong one relays every turn to a 404 and its agent's work never reaches the server.

That is the defect this whole story exists to remove — two derivations of one value, disagreeing by accident — reproduced by the refactor meant to fix it, and hidden because each half looked right on its own. Neither half should be able to render this alone.

The caller supplies the base because only it knows one: CodeMySpec reads the port the generated plugin was addressed to.

check(root, opts \\ [])

@spec check(
  String.t(),
  keyword()
) :: %{onboarded: boolean(), remedy: String.t()}

Whether the copy at root has been onboarded, and what to run if not.

Absence has to report itself. A missing thing that produces no error where it is missing surfaces somewhere else wearing another failure's costume, and stopping that is what onboarding is for.

databases(partition, app)

@spec databases(String.t(), atom() | String.t()) :: [
  %{
    name: String.t(),
    partition: String.t(),
    create: String.t(),
    migrate: String.t()
  }
]

Every database this working copy uses, each with the commands that create and migrate it.

Two, not one. An analyzer and an interactive session deliberately use different databases — running a suite and an analyzer concurrently against one produced 13 orphaned rows and 17 phantom failures — so reporting a single database hides the second, and hiding the second is how an agent migrates one and stays blocked on the other for three days.

Each command names its own database for the same reason. The migration guard printed MIX_ENV=test mix ecto.migrate while checking a partition that command never touches; an agent followed it correctly and nothing changed.

The name has no separator before the partition, because that is what config/test.exs composes: "<app>_test<partition>". An earlier version wrote _test_, so the printed name and the database its own command created differed by one character — the same defect as the hardcoded prefix before it, surviving the fix for that one because the output was only ever compared to itself (c4a2acae).

git_config()

@spec git_config() :: [{String.t(), String.t()}]

Git settings a working copy needs.

submodule.recurse because a submodule sits in detached HEAD by design and so accepts writes with no branch to carry them. Four QA briefs — including the evidence behind a story shipped with QA recorded complete — existed in exactly one working copy for a day because of it, and nothing reported a problem.

onboard(root, opts \\ [])

@spec onboard(
  String.t(),
  keyword()
) :: map()

Configure the working copy at root, and report what is left.

Writes the settings a copy needs, sets the git config it needs, then names the databases it still wants — and does not create them. Returns a report; the caller decides how to show it.

Pass io: to route the writes somewhere other than the real filesystem. The default is FileIO, which is what a generated application gets without configuring anything.

No database is created, migrated or dropped. The commands come back as strings and running them is the operator's job. The rule is not stylistic: the version that ran them shelled out to mix with an environment that had MIX_ENV scrubbed out of it, resolved to the shared development database, and a sibling truncate emptied it three times on 2026-08-13.

No worktree is created. This configures the copy it is pointed at.

partition_name(root)

@spec partition_name(String.t()) :: String.t()

The database partition name for a working copy at root.

Assigned here once, at onboarding, and recorded — which is the whole point. Two mechanisms currently derive this independently and disagree by accident: config/test.exs builds a name from the worktree path, TestDatabase digests the cwd. The digest below is not better than either; it is only computed in one place and written down, and that is what makes it answerable.

resolve_partition(root, opts \\ [])

@spec resolve_partition(
  String.t(),
  keyword()
) :: {:ok, String.t()} | {:error, :not_onboarded}

The partition recorded for the copy at root.

Read, never derived. There is one path: onboarding assigns the partition and writes it down, and everything afterwards reads that. A copy with nothing recorded is not onboarded, and this says so rather than inventing a value.

The fallback that used to live here was the second mechanism. Onboarding recorded a partition and nothing read it: CmsHarness.TestDatabase digested the cwd, partition_name/1 digested it identically, and the analyzer set MIX_TEST_PARTITION from its own copy. They agreed because one algorithm had been written twice — a coincidence maintained by hand rather than a shared value, and recording a value nobody reads turned two derivations into three (acaf8035). Leaving a derive-on-miss path in the resolver would have kept both alive, silently, for exactly the copies nobody had onboarded.

partition_name/1 still exists, and is only for assigning a name during onboarding. Nothing resolves through it.

Pass read: to resolve through something other than the filesystem — a 1-arity function taking a path relative to root.

resolve_partition!(root, opts \\ [])

@spec resolve_partition!(
  String.t(),
  keyword()
) :: String.t()

The recorded partition, or a raise naming what to run.

For callers that cannot proceed without one. The message is the remedy, because a refusal whose fix is not stated is the failure this story is about.

settings(harness_id, partition, base_url)

@spec settings(String.t(), String.t(), String.t()) :: map()

The settings a working copy needs, as a map ready to be encoded.

Goes to .claude/settings.local.json, never .claude/settings.json. The base URL carries the harness id, and settings.json is tracked — so writing it there stages one machine's identity for commit and the next clone inherits it, looking onboarded while talking to a harness that is not its own.

settings_path()

@spec settings_path() :: String.t()

The file settings belong in. Untracked, deliberately — see settings/2.