ClientUtils.Harness.Onboarding (client_utils v0.1.24)

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 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.

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.