From b463e170a868152064c623a591a591dd9b4b1676 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 25 Jun 2026 22:43:48 +0200 Subject: [PATCH 01/68] fix(cli): drop stale fmt.Sprintf args from localnet up help The help text no longer references cache/instance paths, but the format call still passed those values and failed govet printf. Co-authored-by: Cursor --- internal/cli/localnet/up.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/cli/localnet/up.go b/internal/cli/localnet/up.go index c7393114..2fe1d6fb 100644 --- a/internal/cli/localnet/up.go +++ b/internal/cli/localnet/up.go @@ -5,18 +5,12 @@ import ( "strings" "github.com/bitdynamics-ab/canton-devkit/internal/localnet" - "github.com/bitdynamics-ab/canton-devkit/internal/registry" "github.com/bitdynamics-ab/canton-devkit/internal/splice" "github.com/spf13/cobra" ) func buildUp() *cobra.Command { opts := &localnet.UpOptions{} - // Source paths dynamically so the help text reflects whatever - // CANTON_DEVKIT_REGISTRY override the user has active at help time, - // and never goes stale when the on-disk layout changes. - cacheRoot := splice.CacheRoot() - instanceRoot := registry.Root() cmd := &cobra.Command{ Use: "up [name]", Aliases: []string{"start"}, @@ -32,7 +26,6 @@ Exit codes: Supported Splice versions: %s "latest" resolves to %s.`, - cacheRoot, instanceRoot, strings.Join(splice.Supported(), ", "), splice.LatestAlias), Args: cobra.MaximumNArgs(1), SilenceUsage: true, From 302e263a77dfeade1a765cd9f7aaca72b6d5a451 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 25 Jun 2026 23:04:45 +0200 Subject: [PATCH 02/68] refactor(ui): update wallet labels to include usernames for clarity Modified the labels for wallet endpoints in the orderedEndpointKeys function to include the username for each wallet type, enhancing clarity for users. This change improves the user interface by providing more descriptive labels for the Wallet options. --- internal/localnet/up.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/localnet/up.go b/internal/localnet/up.go index d8595ffe..98a73992 100644 --- a/internal/localnet/up.go +++ b/internal/localnet/up.go @@ -890,9 +890,9 @@ func PreflightCodeFromReport(r *docker.Report) string { // browsable URLs (not for sockets like postgres). func orderedEndpointKeys() []endpointDisplay { return []endpointDisplay{ - {key: "app_user_ui", label: "Wallet (app-user)", scheme: "http", category: "WEB UIs", external: true}, - {key: "app_provider_ui", label: "Wallet (app-provider)", scheme: "http", category: "WEB UIs", external: true}, - {key: "sv_ui", label: "Wallet (super-validator)", scheme: "http", category: "WEB UIs", external: true}, + {key: "app_user_ui", label: "Wallet (username: app-user)", scheme: "http", category: "WEB UIs", external: true}, + {key: "app_provider_ui", label: "Wallet (username: app-provider)", scheme: "http", category: "WEB UIs", external: true}, + {key: "sv_ui", label: "Wallet (username: super-validator)", scheme: "http", category: "WEB UIs", external: true}, {key: "swagger_ui", label: "Swagger (OpenAPI)", scheme: "http", category: "WEB UIs", external: true}, {key: "postgres", label: "Postgres", scheme: "postgresql", category: "INFRASTRUCTURE", external: false}, } From bb1c3ad6c991d62c8c44c13813662aad34b71d76 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 26 Jun 2026 09:46:50 +0200 Subject: [PATCH 03/68] fix(e2e): correct non-devkit container count in M1-DWN-001 The down test computed the non-devkit container count with `grep -cv ... || echo "0"`. On empty/no-match input, `grep -c` already prints "0" but also exits non-zero, so the `|| echo "0"` fallback fired and produced a two-line value ("0\n0"). That broke the subsequent `[ "$BEFORE" -eq "$AFTER" ]` integer comparison ("[: 0\n0: integer expected"), making the test fail with "down failed or containers remain" even though localnet down succeeded. Drop the redundant fallback so the count is a clean integer. --- scripts/e2e-milestone1.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/e2e-milestone1.sh b/scripts/e2e-milestone1.sh index 718d0f3b..a4ddf82c 100755 --- a/scripts/e2e-milestone1.sh +++ b/scripts/e2e-milestone1.sh @@ -412,8 +412,12 @@ TEST_ID="M1-DWN-001" sleep 2 fi - # Record non-devkit container count - NON_DEVKIT_BEFORE=$(docker ps --format '{{.Names}}' | grep -cv "e2e-test" || echo "0") + # Record non-devkit container count. + # grep -c already prints "0" on no match; do not append `|| echo "0"` + # because grep -c exits non-zero on an empty/no-match input, which would + # fire the fallback and produce a two-line value ("0\n0") that breaks the + # `[ ... -eq ... ]` integer comparison below. + NON_DEVKIT_BEFORE=$(docker ps --format '{{.Names}}' | grep -cv "e2e-test") # Step 1: down exits 0 cli down e2e-test-default \ @@ -431,7 +435,7 @@ TEST_ID="M1-DWN-001" fi # Step 3: non-devkit containers unaffected - NON_DEVKIT_AFTER=$(docker ps --format '{{.Names}}' | grep -cv "e2e-test" || echo "0") + NON_DEVKIT_AFTER=$(docker ps --format '{{.Names}}' | grep -cv "e2e-test") [ "$NON_DEVKIT_BEFORE" -eq "$NON_DEVKIT_AFTER" ] \ || { echo "FAIL step 3: non-devkit container count changed ($NON_DEVKIT_BEFORE → $NON_DEVKIT_AFTER)" >&2; exit 1; } ) From ba4f546a9af83083c42b7d6f0dab6b74bca66327 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 26 Jun 2026 17:08:04 +0200 Subject: [PATCH 04/68] ci(e2e): remove adopted volumes left by prior runs The E2E job runs on a persistent self-hosted runner. `docker compose down --volumes` only removes volumes Compose itself created; volumes left by an earlier run are re-adopted as external on the next `up` (Compose warns "already exists but was not created by Docker Compose") and are NOT removed by `down --volumes`. This stranded canton-e2e-test-default_postgres and _domain-upgrade-dump across runs, tripping M1-CLN-001's "volumes remain after clean" check even though `canton-devkit clean` ran correctly. The failure was an environment/harness issue, not a product bug. Explicitly remove the per-project volumes by name prefix in both the pre-run "Clean stale state" and the "Force cleanup on failure" steps so each run starts from a clean slate regardless of how prior volumes were created. --- .github/workflows/e2e.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 06c17794..d063430e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -56,6 +56,14 @@ jobs: run: | for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do docker compose -p "canton-${name}" down --volumes 2>/dev/null || true + # `compose down --volumes` only removes volumes Compose itself + # created; volumes left by a prior run get re-adopted as + # external and survive. On this persistent self-hosted runner + # that strands canton-_postgres / _domain-upgrade-dump and + # trips M1-CLN-001's "volumes remain after clean" check. Remove + # them explicitly by project-name prefix to guarantee a clean slate. + docker volume ls -q --filter "name=^canton-${name}_" \ + | xargs -r docker volume rm -f 2>/dev/null || true rm -f "$HOME/.canton-devkit/localnet/${name}/.lock" done @@ -74,5 +82,9 @@ jobs: run: | for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do docker compose -p "canton-${name}" down --volumes 2>/dev/null || true + # Also drop adopted/external volumes Compose won't remove, so the + # next run on this persistent runner starts from a clean slate. + docker volume ls -q --filter "name=^canton-${name}_" \ + | xargs -r docker volume rm -f 2>/dev/null || true done rm -f /tmp/e2e-m1-snapshot.tgz From 60995d41c80c9bc9e207f7d0d89d850e3e7f96c2 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 26 Jun 2026 16:33:34 +0200 Subject: [PATCH 05/68] docs(agents): add guidelines for creating temporary files and folders Updated AGENTS.md to include a new section on best practices for creating temporary files and directories. Emphasized the importance of using the current working directory or repository root, suggested relative paths, and outlined cleanup procedures. Noted exceptions for using system-level directories like /tmp when necessary. --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b88415a3..687c3890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,3 +107,11 @@ Before submitting: 4. Relevant documentation added/updated 5. PR title is clear and understandable 6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up ticket is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. + +## Temporary Files & Folders + +When you need to create temporary files or directories, create them inside the **current working directory** or the **repository/worktree root** — not in `/tmp` or other system-level directories. This keeps operations within the workspace and avoids triggering permission approval prompts. + +- Use relative paths like `./tmp/`, `./.tmp/`, or a descriptive name in the project root. +- Clean up temporary files and directories when they are no longer needed. +- **Exception**: Using `/tmp` is allowed only when it is the only viable option — e.g., sharing data with another program or user that expects `/tmp`, or inspecting output written there by external tools (like tmux debug output). Exhaust in-project alternatives first. \ No newline at end of file From 53bb2754b842d8ba008af3260c4f65884948e0a7 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sat, 27 Jun 2026 20:50:34 +0200 Subject: [PATCH 06/68] docs(agents): track proposal deviations in changes-from-proposal.md Add docs/changes-from-proposal.md, a maintained changelog of every deliberate deviation between the original Development Fund proposal (docs/original-devkit-proposal.md) and the shipped implementation. Seeded with 24 entries covering all deviations audited from the current CLI surface: instance name addressing, --format vs --json, command aliases, new lifecycle/inspection/token subcommands, connection flag conventions, telemetry, and up-time flag additions. Also adds a load-bearing 'Proposal deviation tracking' rule to AGENTS.md (plus PR checklist item #7) requiring contributors to update the changes file in the same PR whenever command syntax, flags, aliases, defaults, or user-facing behaviour diverges from the proposal. The rule lives in AGENTS.md rather than a .claude/skills/ skill so it fires on every agent session, not only when an agent judges the task matches. --- AGENTS.md | 24 +++ docs/changes-from-proposal.md | 321 ++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 docs/changes-from-proposal.md diff --git a/AGENTS.md b/AGENTS.md index 687c3890..4b1a80d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,29 @@ Rules: - **Service-model subcommands (`restart`/`pause`/`unpause`/`ps`):** these genuinely need the `-f` model, so they MUST replay the enabled profile set via `composeProfiles(state)` (persisted as `state.Profiles` at `up` time, with an adapter fallback for pre-fix instances). Omitting `--profile` here targets zero services. - **Explicitly-targeted single-service actions** (e.g. `docker compose -p stop `): exempt — explicitly naming a service bypasses profile filtering per the compose docs. +### Proposal deviation tracking (load-bearing) + +**Any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to `docs/original-devkit-proposal.md` MUST add or update an entry in `docs/changes-from-proposal.md` in the same PR.** + +The file exists so the committee, reviewers, and future contributors can see exactly where the shipped implementation differs from what was proposed — and why. Letting it go stale defeats the purpose. + +What triggers an update: + +- A new command or subcommand is added that the proposal did not name. +- A command or flag is renamed from the proposal's wording. +- A flag's semantics or default changes relative to the proposal's description. +- A new alias is introduced. +- A behaviour described in the proposal is intentionally not implemented, or is deferred with a `// TODO` comment. +- A behaviour not mentioned in the proposal is added that a user would notice (e.g. a confirmation prompt, a new opt-out mechanism, a different connection model). + +What does **not** trigger an update: + +- Internal refactors with no user-visible effect. +- Bug fixes that bring behaviour in line with what the proposal described. +- Docs-only changes. + +The instruction lives in `AGENTS.md` (not a `.claude/skills/` skill) because it must fire on every contributor session, not only when an agent judges a "proposal-tracking" task is active. + ### Testing Requirements - **All bug fixes must include regression tests** @@ -107,6 +130,7 @@ Before submitting: 4. Relevant documentation added/updated 5. PR title is clear and understandable 6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up ticket is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. +7. **Proposal deviation tracking:** if the change introduces or alters command syntax, flags, aliases, defaults, or user-facing behaviour relative to `docs/original-devkit-proposal.md`, `docs/changes-from-proposal.md` is updated in this PR. See "Proposal deviation tracking" rule above. ## Temporary Files & Folders diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md new file mode 100644 index 00000000..4dc874a1 --- /dev/null +++ b/docs/changes-from-proposal.md @@ -0,0 +1,321 @@ +# Changes from Original Proposal + +This document records every deliberate deviation — command syntax, flag names, behaviour, or scope — between the [original DevKit Development Fund proposal](./original-devkit-proposal.md) and the shipped implementation. + +**Maintenance rule:** any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to the proposal **must** add or update an entry here in the same PR. See the "Proposal deviation tracking" rule in [AGENTS.md](../AGENTS.md). + +--- + +## Table of contents + +- [Instance name addressing](#instance-name-addressing) +- [Machine-readable output flag](#machine-readable-output-flag) +- [Command aliases](#command-aliases) +- [Lifecycle: `pause` and `resume` (new)](#lifecycle-pause-and-resume-new) +- [Inspection: `creds` (new)](#inspection-creds-new) +- [Inspection: `versions` (new)](#inspection-versions-new) +- [Web UI: `ui` command (new)](#web-ui-ui-command-new) +- [Orchestration: `refresh` (new)](#orchestration-refresh-new) +- [Per-container operations: `container` (new)](#per-container-operations-container-new) +- [Observability: `observability` command (new)](#observability-observability-command-new) +- [Skills: installable CLI commands (new)](#skills-installable-cli-commands-new) +- [Contracts: `contracts ls` (new)](#contracts-contracts-ls-new) +- [Contracts/tx: endpoint not yet auto-discovered](#contractstx-endpoint-not-yet-auto-discovered) +- [DAR: connection flags per-command](#dar-connection-flags-per-command) +- [DAR: `--instance` flag name](#dar---instance-flag-name) +- [Token: additional subcommands (new)](#token-additional-subcommands-new) +- [Token: `transfer accept` subcommand](#token-transfer-accept-subcommand) +- [Token: `burn` requires explicit confirmation](#token-burn-requires-explicit-confirmation) +- [Token: `--instance` required flag](#token---instance-required-flag) +- [Token: `--name` collision in `token create`](#token---name-collision-in-token-create) +- [Root-level `telemetry` command (new)](#root-level-telemetry-command-new) +- [`up`: `--allow-uncurated` flag (new)](#up---allow-uncurated-flag-new) +- [`up`: `--profile` replaces a separate profiles config surface](#up---profile-replaces-a-separate-profiles-config-surface) +- [`up`: `--port-base` flag (new)](#up---port-base-flag-new) + +--- + +## Instance name addressing + +**Proposal said:** instance name is always passed as `--name ` across all commands. + +**Shipped:** +- Most lifecycle/inspection commands (`up`, `down`, `restart`, `pause`, `resume`, `status`, `logs`, `creds`, `snapshot`, `restore`) accept the name as **either** a positional argument **or** `--name` — both are equivalent. Example: `dpm localnet up dev` and `dpm localnet up --name dev` do the same thing. +- `clean`, `list`, `doctor`, `refresh`, `metrics` are `--name`-only (no positional arg). +- `dar` subcommands use `--instance` (alias `--name`). +- `token` subcommands use required `--instance`. + +**Why:** The positional form is faster to type for interactive use and matches conventions in similar tools (`kubectl`, `docker`). `--name`-only commands are those that are conceptually multi-instance by default (e.g. `list`) or where positional args would be ambiguous. + +--- + +## Machine-readable output flag + +**Proposal said:** machine-readable output is requested via `--json`. + +**Shipped:** commands use `--format ` with accepted values `json`, `text` (and sometimes `table`). Example: `dpm localnet status dev --format json`. + +**Why:** `--format` is more flexible (allows future formats such as `yaml` or `table` without adding new flags) and is consistent with the established pattern in tools like `docker` and `gh`. + +--- + +## Command aliases + +The following aliases are not in the proposal but are shipped: + +| Canonical command | Alias(es) | Notes | +|---|---|---| +| `localnet up` | `start` | More intuitive for new users | +| `localnet down` | `stop` | Pair with `start` | +| `localnet observability` | `obs` | Shorter for interactive use | +| `localnet container list` | `ls`, `ps` | Matches Docker CLI conventions | +| `localnet token party ls` | `list` | Consistency within party subcommand | +| `localnet token party rm` | `remove` | Consistency within party subcommand | + +`localnet list` has **no** `ls` alias despite the pattern above — adding it would shadow `localnet logs` with a common prefix, increasing ambiguity in tab-completion. + +--- + +## Lifecycle: `pause` and `resume` (new) + +**Proposal said:** not mentioned. + +**Shipped:** `dpm localnet pause ` and `dpm localnet resume `. + +`pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT. No readiness wait is performed on resume. + +**Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Required for Web UI parity (the UI exposes a pause/resume action on the instance card). + +--- + +## Inspection: `creds` (new) + +**Proposal said:** not mentioned as a standalone command. `env` was the credential/config export surface. + +**Shipped:** `dpm localnet creds [name]` prints the HS256 JWTs captured at `up` time, in four formats: `table` (default — JWTs omitted for safety), `env` (shell-exportable `AUTH__TOKEN=...` lines), `json` (full credential objects including JWTs), `raw` (single JWT, requires `--role`). + +**Why:** `env` covers Ledger API endpoints and wallet URLs; `creds` is the dedicated surface for auth tokens. Separating them avoids combining sensitive credential material with non-sensitive endpoint strings in one command. + +--- + +## Inspection: `versions` (new) + +**Proposal said:** `--version ` in `localnet up` selects the Splice version. Supported versions and a compatibility matrix were mentioned as documentation items, not as a CLI command. + +**Shipped:** `dpm localnet versions` is a live command that lists every Splice version in the DevKit curated catalogue plus every tag the upstream Splice GitHub repository currently exposes. Each row has a status: `supported`, `drifted` (force-pushed — security signal), `available` (upstream only, not yet catalogued), or `catalogued-only` (removed upstream). Supports `--offline` and `--format json`. + +**Why:** The catalogue cross-reference against upstream helps maintainers catch force-pushed tags early and gives users visibility into which versions are safe to pin. + +--- + +## Web UI: `ui` command (new) + +**Proposal said:** a Web UI exists, but the proposal described it as a dashboard accessible alongside the CLI, not as a separately invocable CLI command. + +**Shipped:** `dpm localnet ui` starts the embedded Vite/React HTTP server (default port 7777, loopback-only). Flags: `--port`, `--host`, `--allow-non-loopback`. Non-loopback binding is refused by default as a DNS-rebinding defence; SSH tunnelling is the recommended remote-access path. + +**Why:** Packaging the UI launch as a CLI subcommand keeps the single-binary model and lets users control when the UI server is running. + +--- + +## Orchestration: `refresh` (new) + +**Proposal said:** not mentioned. + +**Shipped:** `dpm localnet refresh [--name ]` triggers an on-demand reconciliation pass that syncs the registry's persisted status with the live `docker compose ps` state. This is the CLI mirror of the background reconciler that runs inside `localnet ui`. + +**Why:** Required for Web UI parity. Useful when a user has stopped containers externally (e.g. via `docker compose down` directly) and wants the registry to reflect that without restarting the UI. + +--- + +## Per-container operations: `container` (new) + +**Proposal said:** `dpm localnet restart [service] --name ` restarts the full LocalNet or one service. + +**Shipped:** Full-instance restart remains `dpm localnet restart`. Per-container operations are under a separate `container` parent: + +- `localnet container list ` (aliases `ls`, `ps`) — lists containers with state/health. +- `localnet container restart ` — restarts one container; verifies it belongs to the instance's compose project before acting. +- `localnet container logs ` — tails logs for one container (flags: `--tail`, `--since`). + +**Why:** Separating the `container` subtree from top-level lifecycle commands keeps the namespace clean and mirrors the Web UI's Container Health panel. Accepting both the service short name and the full container name (e.g. `splice` or `pr432-splice`) makes the CLI friendlier than the raw Docker form. + +--- + +## Observability: `observability` command (new) + +**Proposal said:** `dpm localnet metrics` prints Grafana dashboard URLs and a concise text summary. No separate toggle command was proposed; observability components were to be controlled via `--profile` flags at `up` time. + +**Shipped:** In addition to `localnet metrics`, a `localnet observability` command (alias `obs`) manages the Prometheus/Grafana sidecars **on a running instance** without restarting Canton: + +- `observability enable [--prometheus] [--grafana]` — brings sidecars up. +- `observability disable [--prometheus] [--grafana]` — stops them; Canton is untouched. +- `observability status` — read-only report of which sidecars are active and their URLs. + +Both `--prometheus` and `--grafana` flags allow controlling each sidecar independently. With neither flag, both are selected (umbrella semantics). The enabled state is persisted so a subsequent `down`/`up` re-enables it automatically. + +**Why:** Enabling observability at `up` time via `--profile` requires a full restart to change. The `observability enable/disable` path lets developers toggle the monitoring stack without disrupting a running ledger — matching the Web UI's "Enable observability now" toggle. + +--- + +## Skills: installable CLI commands (new) + +**Proposal said:** DevKit "may provide optional, editor-agnostic AI agent skill documents." The proposal described them as documentation artifacts, not as CLI commands. + +**Shipped:** `dpm localnet skills` is a full subcommand tree: + +- `skills list` — lists the embedded skill documents (name, description, filename). +- `skills install [--target claude|codex] [--dir ] [--force]` — writes the embedded skill documents into the appropriate agent skills directory (`~/.claude/skills/` for Claude, `~/.codex/skills/` for Codex). Clobber-safe by default: a destination that exists with different content is skipped unless `--force` is passed. + +The embedded skill docs are the same artifacts that back the Web UI's Agent Skills screen, ensuring CLI and UI show the same content. + +**Why:** Users need a way to install skill documents without manually copying files. The `install` command provides a one-step path consistent with how users already install DPM components. + +--- + +## Contracts: `contracts ls` (new) + +**Proposal said:** `dpm localnet contracts watch` — live tail of create/archive events. + +**Shipped:** `contracts watch` is present and matches the proposal. In addition, `contracts ls` (alias: none) lists active contracts via a one-shot query rather than a live stream. + +**Why:** A non-streaming list is often more useful than a continuous watch in CI and scripted contexts. + +--- + +## Contracts/tx: endpoint not yet auto-discovered + +**Proposal said:** commands connect to the LocalNet participants automatically (implied by the named-instance model). + +**Shipped:** `contracts` and `tx` commands require callers to pass `--endpoint host:port` explicitly. Auto-discovery of the gRPC participant port from registry state is not yet implemented. A comment in `localnet.go` documents this as pending work. + +**Why:** Auto-discovery requires resolving the participant's gRPC port from the registry state, which was deferred to avoid blocking the initial contract/tx CLI release. + +--- + +## DAR: connection flags per-command + +**Proposal said:** DAR commands connect to participants via the named instance implicitly. + +**Shipped:** Each `dar` subcommand carries its own connection flags: `--admin-host`, `--token`, `--insecure` (defaults to `true`), `--ca-cert`, `--instance` (alias `--name`), `--role` (default `app-user`). There is no standalone `dar connect` command. + +**Why:** Per-command connection flags make the DAR subcommands usable against any Ledger API endpoint, not just DevKit-managed instances, giving operators more flexibility in CI and multi-environment workflows. + +--- + +## DAR: `--instance` flag name + +**Proposal said:** instance selection is `--name ` uniformly. + +**Shipped:** `dar` subcommands use `--instance` as the primary flag name (with `--name` as an alias). + +**Why:** In `dar` contexts, `--name` is ambiguous between the instance name and the DAR/package name. Using `--instance` as the primary name eliminates that ambiguity. + +--- + +## Token: additional subcommands (new) + +**Proposal said:** `token create`, `token mint`, `token transfer`, `token burn`, `token balance`. + +**Shipped:** all five from the proposal, plus: + +| New command | Purpose | +|---|---| +| `token balances` | Portfolio-style matrix view across all instruments for one or more parties | +| `token summary` | Aggregate stats for one instrument (supply, holder count, recent activity) | +| `token activity` | Recent transaction history feed for an instrument (`--limit` defaults to 50) | +| `token party new ` | Register a named party alias for use in token commands | +| `token party ls` | List registered party aliases | +| `token party rm ` | Remove a party alias | +| `token faucet ` | Fund a party with an auto-accepted transfer (no recipient interaction needed) | +| `token demo` | One-step provision: creates a DEMO instrument and seeds a holder wallet | + +**Why:** The alias registry (`token party`) reduces repeated `--party ` flags. `faucet` and `demo` target workshop and onboarding use cases where speed matters more than full CIP-0112 flow fidelity. + +--- + +## Token: `transfer accept` subcommand + +**Proposal said:** `token transfer` as a single command. + +**Shipped:** `token transfer` initiates a transfer; `token transfer accept` accepts a pending incoming transfer. CIP-0112 transfers are two-phase (offer + accept), so both halves are exposed as CLI subcommands. + +**Why:** The two-phase model is required by the CIP-0112 protocol. Exposing both steps gives scripts and workshops full control over the accept timing. + +--- + +## Token: `burn` requires explicit confirmation + +**Proposal said:** `token burn {token-name} {amount}` as a straightforward command. + +**Shipped:** `token burn` prompts for confirmation before executing because the operation is irreversible. The prompt is bypassed with `--yes` / `-y`. + +**Why:** Guarding an irreversible ledger operation with a confirmation prompt is standard CLI practice and prevents accidental burns in interactive sessions. + +--- + +## Token: `--instance` required flag + +**Proposal said:** token commands connect to the active or `--name`-selected instance. + +**Shipped:** `--instance` is a **required** flag on all `token` subcommands (no default or auto-resolution from a single registered instance). + +**Why:** Making `--instance` explicit prevents token commands from silently targeting the wrong LocalNet when multiple instances are registered. + +--- + +## Token: `--name` collision in `token create` + +**Proposal said:** instance selection via `--name `. + +**Shipped:** In `token create`, `--name` refers to the **instrument name** (e.g. `--name "My Token"`), not the instance. The instance is selected via `--instance`. This is an intentional exception to the general `--name` = instance name convention. + +**Why:** The instrument name is the primary user-facing input in the token creation wizard. Using `--name` for it matches natural language ("name this token") even though it breaks the global `--name` = instance convention. Document when using `token create` to avoid confusion. + +--- + +## Root-level `telemetry` command (new) + +**Proposal said:** not mentioned. Adoption measurement was described as a reporting/documentation exercise. + +**Shipped:** A root-level `telemetry` command (sibling to `localnet`, not nested under it) manages privacy-preserving usage telemetry: + +- `telemetry on` / `off` — opt in or out. +- `telemetry status` — show current state and the anonymous install ID. +- `telemetry preview [--format]` — show the payload that would be sent without sending it. +- `telemetry flush` — send any buffered events immediately. +- `telemetry reset-id` — generate a new anonymous ID. + +Telemetry is **on by default** with opt-out via `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1`. An internal hidden subcommand `_record-install-surface ` is used by install scripts to record the distribution channel. + +**Why:** Provides the adoption signals described in Milestone 4 (install counts, usage trends) in a privacy-preserving, opt-out model. The opt-out via standard `DO_NOT_TRACK` honours ecosystem conventions. + +--- + +## `up`: `--allow-uncurated` flag (new) + +**Proposal said:** `--version ` pins a Splice LocalNet version from the supported set. Unsupported versions were not addressed. + +**Shipped:** `--allow-uncurated` lets users pass a Splice tag that is not in the DevKit curated catalogue. DevKit resolves the tag against the upstream Splice GitHub repo and proceeds, printing a warning that the resulting LocalNet is not tested by DevKit. + +**Why:** Gives power users and maintainers a path to test prereleases and alpha tags without waiting for a catalogue update, while keeping the default path (no flag) restricted to tested versions. + +--- + +## `up`: `--profile` replaces a separate profiles config surface + +**Proposal said:** per-component toggles for Prometheus and Grafana as a LocalNet configuration model item; the exact mechanism was not specified. + +**Shipped:** `--profile ` (repeatable) is a flag on `localnet up`. Supported values include `prometheus`, `grafana`, and `observability` (legacy umbrella that activates both). Profiles are persisted in instance state so a subsequent `up` re-enables the same set. The `localnet observability enable/disable` command can toggle sidecars on a running instance without `--profile` at `up` time. + +**Why:** Docker Compose profiles are the natural mechanism for optional service groups in the Splice LocalNet stack. Exposing them directly as `--profile` keeps the model transparent and auditable. Persisting the profile set enables reproducible restarts. + +--- + +## `up`: `--port-base` flag (new) + +**Proposal said:** named instances use explicit port configuration so two LocalNets can run on one machine, but the mechanism for specifying ports was not defined. + +**Shipped:** `--port-base ` pins host ports deterministically starting from `n` (each service gets `base+N`). With `--port-base 0` (default), ports are auto-allocated with stable reuse across restarts. Every derived port must be free or `up` fails immediately with no silent fallback. + +**Why:** Auto-allocation works for single-developer use; `--port-base` is needed for CI layouts and reproducible multi-instance setups where port assignments must be predictable and documented. From 95746ead3ef5538285256918a275ad29b344bd69 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sat, 27 Jun 2026 21:02:46 +0200 Subject: [PATCH 07/68] docs: reorganize changes-from-proposal.md by subcommand group Group all deviations by the subcommand they affect instead of a flat list. Add a top-level framing paragraph stating that every deviation is intentional (UX, performance/resource efficiency, security, correctness, or CLI \u2194 Web UI parity) rather than a mistake. Structural changes: - Add 'Cross-cutting conventions' section for name addressing, --format, and aliases (span multiple commands so not tied to one subcommand) - Group all three 'localnet up' flag additions under a single section with subsections - Merge contracts/tx entries into one section with subsections - Merge dar entries into one section with subsections - Merge all five token entries into one section with subsections - Rebuild table of contents to match new structure No content removed; all 'Proposal said / Shipped / Why' text preserved and lightly improved to reinforce intentionality. --- docs/changes-from-proposal.md | 205 ++++++++++++++++++---------------- 1 file changed, 111 insertions(+), 94 deletions(-) diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md index 4dc874a1..11f13d24 100644 --- a/docs/changes-from-proposal.md +++ b/docs/changes-from-proposal.md @@ -2,40 +2,49 @@ This document records every deliberate deviation — command syntax, flag names, behaviour, or scope — between the [original DevKit Development Fund proposal](./original-devkit-proposal.md) and the shipped implementation. +Every deviation listed here is **intentional**, not an oversight or implementation mistake. Each one was made for a concrete reason: improving developer or user experience, system performance or resource efficiency, security, correctness, or CLI ↔ Web UI parity. The per-entry **"Why"** notes record that rationale. Where the proposal's wording was a high-level intent rather than a precise spec, the shipped form is the deliberate concretization of that intent. + **Maintenance rule:** any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to the proposal **must** add or update an entry here in the same PR. See the "Proposal deviation tracking" rule in [AGENTS.md](../AGENTS.md). --- ## Table of contents -- [Instance name addressing](#instance-name-addressing) -- [Machine-readable output flag](#machine-readable-output-flag) -- [Command aliases](#command-aliases) -- [Lifecycle: `pause` and `resume` (new)](#lifecycle-pause-and-resume-new) -- [Inspection: `creds` (new)](#inspection-creds-new) -- [Inspection: `versions` (new)](#inspection-versions-new) -- [Web UI: `ui` command (new)](#web-ui-ui-command-new) -- [Orchestration: `refresh` (new)](#orchestration-refresh-new) -- [Per-container operations: `container` (new)](#per-container-operations-container-new) -- [Observability: `observability` command (new)](#observability-observability-command-new) -- [Skills: installable CLI commands (new)](#skills-installable-cli-commands-new) -- [Contracts: `contracts ls` (new)](#contracts-contracts-ls-new) -- [Contracts/tx: endpoint not yet auto-discovered](#contractstx-endpoint-not-yet-auto-discovered) -- [DAR: connection flags per-command](#dar-connection-flags-per-command) -- [DAR: `--instance` flag name](#dar---instance-flag-name) -- [Token: additional subcommands (new)](#token-additional-subcommands-new) -- [Token: `transfer accept` subcommand](#token-transfer-accept-subcommand) -- [Token: `burn` requires explicit confirmation](#token-burn-requires-explicit-confirmation) -- [Token: `--instance` required flag](#token---instance-required-flag) -- [Token: `--name` collision in `token create`](#token---name-collision-in-token-create) -- [Root-level `telemetry` command (new)](#root-level-telemetry-command-new) -- [`up`: `--allow-uncurated` flag (new)](#up---allow-uncurated-flag-new) -- [`up`: `--profile` replaces a separate profiles config surface](#up---profile-replaces-a-separate-profiles-config-surface) -- [`up`: `--port-base` flag (new)](#up---port-base-flag-new) +- [Cross-cutting conventions](#cross-cutting-conventions) + - [Instance name addressing](#instance-name-addressing) + - [Machine-readable output flag](#machine-readable-output-flag) + - [Command aliases](#command-aliases) +- [`localnet up`](#localnet-up) + - [`--allow-uncurated` flag (new)](#--allow-uncurated-flag-new) + - [`--profile` flag (new)](#--profile-flag-new) + - [`--port-base` flag (new)](#--port-base-flag-new) +- [`localnet pause` / `resume` (new)](#localnet-pause--resume-new) +- [`localnet creds` (new)](#localnet-creds-new) +- [`localnet versions` (new)](#localnet-versions-new) +- [`localnet ui` (new)](#localnet-ui-new) +- [`localnet refresh` (new)](#localnet-refresh-new) +- [`localnet container` (new)](#localnet-container-new) +- [`localnet observability` (new)](#localnet-observability-new) +- [`localnet skills` (new)](#localnet-skills-new) +- [`localnet contracts` / `tx`](#localnet-contracts--tx) + - [`contracts ls` (new)](#contracts-ls-new) + - [Endpoint not yet auto-discovered](#endpoint-not-yet-auto-discovered) +- [`localnet dar`](#localnet-dar) + - [Connection flags per-command](#connection-flags-per-command) + - [`--instance` flag name](#--instance-flag-name) +- [`localnet token`](#localnet-token) + - [Additional subcommands (new)](#additional-subcommands-new) + - [`transfer accept` subcommand](#transfer-accept-subcommand) + - [`burn` requires explicit confirmation](#burn-requires-explicit-confirmation) + - [`--instance` required flag](#--instance-required-flag) + - [`--name` collision in `token create`](#--name-collision-in-token-create) +- [`telemetry` (root-level, new)](#telemetry-root-level-new) --- -## Instance name addressing +## Cross-cutting conventions + +### Instance name addressing **Proposal said:** instance name is always passed as `--name ` across all commands. @@ -49,7 +58,7 @@ This document records every deliberate deviation — command syntax, flag names, --- -## Machine-readable output flag +### Machine-readable output flag **Proposal said:** machine-readable output is requested via `--json`. @@ -59,7 +68,7 @@ This document records every deliberate deviation — command syntax, flag names, --- -## Command aliases +### Command aliases The following aliases are not in the proposal but are shipped: @@ -76,7 +85,39 @@ The following aliases are not in the proposal but are shipped: --- -## Lifecycle: `pause` and `resume` (new) +## `localnet up` + +### `--allow-uncurated` flag (new) + +**Proposal said:** `--version ` pins a Splice LocalNet version from the supported set. Unsupported versions were not addressed. + +**Shipped:** `--allow-uncurated` lets users pass a Splice tag that is not in the DevKit curated catalogue. DevKit resolves the tag against the upstream Splice GitHub repo and proceeds, printing a warning that the resulting LocalNet is not tested by DevKit. + +**Why:** Gives power users and maintainers a path to test prereleases and alpha tags without waiting for a catalogue update, while keeping the default path (no flag) restricted to tested versions. + +--- + +### `--profile` flag (new) + +**Proposal said:** per-component toggles for Prometheus and Grafana as a LocalNet configuration model item; the exact mechanism was not specified. + +**Shipped:** `--profile ` (repeatable) is a flag on `localnet up`. Supported values include `prometheus`, `grafana`, and `observability` (legacy umbrella that activates both). Profiles are persisted in instance state so a subsequent `up` re-enables the same set. The `localnet observability enable/disable` command can toggle sidecars on a running instance without `--profile` at `up` time. + +**Why:** Docker Compose profiles are the natural mechanism for optional service groups in the Splice LocalNet stack. Exposing them directly as `--profile` keeps the model transparent and auditable. Persisting the profile set enables reproducible restarts. + +--- + +### `--port-base` flag (new) + +**Proposal said:** named instances use explicit port configuration so two LocalNets can run on one machine, but the mechanism for specifying ports was not defined. + +**Shipped:** `--port-base ` pins host ports deterministically starting from `n` (each service gets `base+N`). With `--port-base 0` (default), ports are auto-allocated with stable reuse across restarts. Every derived port must be free or `up` fails immediately with no silent fallback. + +**Why:** Auto-allocation works for single-developer use; `--port-base` is needed for CI layouts and reproducible multi-instance setups where port assignments must be predictable and documented. + +--- + +## `localnet pause` / `resume` (new) **Proposal said:** not mentioned. @@ -84,51 +125,51 @@ The following aliases are not in the proposal but are shipped: `pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT. No readiness wait is performed on resume. -**Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Required for Web UI parity (the UI exposes a pause/resume action on the instance card). +**Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Frees CPU and reduces resource consumption without discarding ledger state. Required for CLI ↔ Web UI parity (the UI exposes a pause/resume action on the instance card). --- -## Inspection: `creds` (new) +## `localnet creds` (new) **Proposal said:** not mentioned as a standalone command. `env` was the credential/config export surface. **Shipped:** `dpm localnet creds [name]` prints the HS256 JWTs captured at `up` time, in four formats: `table` (default — JWTs omitted for safety), `env` (shell-exportable `AUTH__TOKEN=...` lines), `json` (full credential objects including JWTs), `raw` (single JWT, requires `--role`). -**Why:** `env` covers Ledger API endpoints and wallet URLs; `creds` is the dedicated surface for auth tokens. Separating them avoids combining sensitive credential material with non-sensitive endpoint strings in one command. +**Why:** `env` covers Ledger API endpoints and wallet URLs; `creds` is the dedicated surface for auth tokens. Separating them avoids combining sensitive credential material with non-sensitive endpoint strings in one command, and makes it easier to handle each category differently (e.g. redact tokens in logs while freely printing URLs). --- -## Inspection: `versions` (new) +## `localnet versions` (new) **Proposal said:** `--version ` in `localnet up` selects the Splice version. Supported versions and a compatibility matrix were mentioned as documentation items, not as a CLI command. **Shipped:** `dpm localnet versions` is a live command that lists every Splice version in the DevKit curated catalogue plus every tag the upstream Splice GitHub repository currently exposes. Each row has a status: `supported`, `drifted` (force-pushed — security signal), `available` (upstream only, not yet catalogued), or `catalogued-only` (removed upstream). Supports `--offline` and `--format json`. -**Why:** The catalogue cross-reference against upstream helps maintainers catch force-pushed tags early and gives users visibility into which versions are safe to pin. +**Why:** The catalogue cross-reference against upstream helps maintainers catch force-pushed tags early (a security signal) and gives users live visibility into which versions are safe to pin, without consulting external documentation. --- -## Web UI: `ui` command (new) +## `localnet ui` (new) **Proposal said:** a Web UI exists, but the proposal described it as a dashboard accessible alongside the CLI, not as a separately invocable CLI command. **Shipped:** `dpm localnet ui` starts the embedded Vite/React HTTP server (default port 7777, loopback-only). Flags: `--port`, `--host`, `--allow-non-loopback`. Non-loopback binding is refused by default as a DNS-rebinding defence; SSH tunnelling is the recommended remote-access path. -**Why:** Packaging the UI launch as a CLI subcommand keeps the single-binary model and lets users control when the UI server is running. +**Why:** Packaging the UI launch as a CLI subcommand keeps the single-binary model and lets users control when the UI server is running. The loopback-only default and the `--allow-non-loopback` guard are a deliberate security measure — the UI handles JWTs and party identifiers and is not designed for unauthenticated LAN-wide exposure. --- -## Orchestration: `refresh` (new) +## `localnet refresh` (new) **Proposal said:** not mentioned. **Shipped:** `dpm localnet refresh [--name ]` triggers an on-demand reconciliation pass that syncs the registry's persisted status with the live `docker compose ps` state. This is the CLI mirror of the background reconciler that runs inside `localnet ui`. -**Why:** Required for Web UI parity. Useful when a user has stopped containers externally (e.g. via `docker compose down` directly) and wants the registry to reflect that without restarting the UI. +**Why:** Required for CLI ↔ Web UI parity. Useful when a user has stopped containers externally (e.g. via `docker compose down` directly) and wants the registry to reflect that without restarting the UI server. --- -## Per-container operations: `container` (new) +## `localnet container` (new) **Proposal said:** `dpm localnet restart [service] --name ` restarts the full LocalNet or one service. @@ -138,11 +179,11 @@ The following aliases are not in the proposal but are shipped: - `localnet container restart ` — restarts one container; verifies it belongs to the instance's compose project before acting. - `localnet container logs ` — tails logs for one container (flags: `--tail`, `--since`). -**Why:** Separating the `container` subtree from top-level lifecycle commands keeps the namespace clean and mirrors the Web UI's Container Health panel. Accepting both the service short name and the full container name (e.g. `splice` or `pr432-splice`) makes the CLI friendlier than the raw Docker form. +**Why:** Separating the `container` subtree from top-level lifecycle commands keeps the namespace clean and mirrors the Web UI's Container Health panel. Accepting both the service short name and the full container name (e.g. `splice` or `pr432-splice`) improves UX over the raw Docker form. The membership check before restart is a security measure that prevents a typo or hostile input from restarting an arbitrary host container. --- -## Observability: `observability` command (new) +## `localnet observability` (new) **Proposal said:** `dpm localnet metrics` prints Grafana dashboard URLs and a concise text summary. No separate toggle command was proposed; observability components were to be controlled via `--profile` flags at `up` time. @@ -154,11 +195,11 @@ The following aliases are not in the proposal but are shipped: Both `--prometheus` and `--grafana` flags allow controlling each sidecar independently. With neither flag, both are selected (umbrella semantics). The enabled state is persisted so a subsequent `down`/`up` re-enables it automatically. -**Why:** Enabling observability at `up` time via `--profile` requires a full restart to change. The `observability enable/disable` path lets developers toggle the monitoring stack without disrupting a running ledger — matching the Web UI's "Enable observability now" toggle. +**Why:** Enabling observability at `up` time via `--profile` requires a full restart to change. The `observability enable/disable` path lets developers toggle the monitoring stack without disrupting a running ledger — saving the boot cost and preserving in-flight ledger state. Matches the Web UI's "Enable observability now" toggle for CLI ↔ Web UI parity. --- -## Skills: installable CLI commands (new) +## `localnet skills` (new) **Proposal said:** DevKit "may provide optional, editor-agnostic AI agent skill documents." The proposal described them as documentation artifacts, not as CLI commands. @@ -169,51 +210,57 @@ Both `--prometheus` and `--grafana` flags allow controlling each sidecar indepen The embedded skill docs are the same artifacts that back the Web UI's Agent Skills screen, ensuring CLI and UI show the same content. -**Why:** Users need a way to install skill documents without manually copying files. The `install` command provides a one-step path consistent with how users already install DPM components. +**Why:** Users need a one-step way to install skill documents without manually locating and copying files. The clobber-safe default protects hand-edited skill docs from being silently overwritten on re-install. Required for CLI ↔ Web UI parity (the Web UI's Agent Skills screen surfaces the same embedded docs). --- -## Contracts: `contracts ls` (new) +## `localnet contracts` / `tx` + +### `contracts ls` (new) **Proposal said:** `dpm localnet contracts watch` — live tail of create/archive events. -**Shipped:** `contracts watch` is present and matches the proposal. In addition, `contracts ls` (alias: none) lists active contracts via a one-shot query rather than a live stream. +**Shipped:** `contracts watch` is present and matches the proposal. In addition, `contracts ls` lists active contracts via a one-shot query rather than a live stream. -**Why:** A non-streaming list is often more useful than a continuous watch in CI and scripted contexts. +**Why:** A non-streaming snapshot is more useful than a continuous watch in CI and scripted contexts where the caller wants to assert on current state without keeping a long-lived process open. --- -## Contracts/tx: endpoint not yet auto-discovered +### Endpoint not yet auto-discovered **Proposal said:** commands connect to the LocalNet participants automatically (implied by the named-instance model). **Shipped:** `contracts` and `tx` commands require callers to pass `--endpoint host:port` explicitly. Auto-discovery of the gRPC participant port from registry state is not yet implemented. A comment in `localnet.go` documents this as pending work. -**Why:** Auto-discovery requires resolving the participant's gRPC port from the registry state, which was deferred to avoid blocking the initial contract/tx CLI release. +**Why:** Auto-discovery was deferred to avoid blocking the initial contract/tx CLI release. The explicit `--endpoint` flag is a deliberate interim design — it keeps the commands usable against any Ledger API endpoint (not just DevKit-managed instances) until the auto-discovery path lands. --- -## DAR: connection flags per-command +## `localnet dar` + +### Connection flags per-command **Proposal said:** DAR commands connect to participants via the named instance implicitly. **Shipped:** Each `dar` subcommand carries its own connection flags: `--admin-host`, `--token`, `--insecure` (defaults to `true`), `--ca-cert`, `--instance` (alias `--name`), `--role` (default `app-user`). There is no standalone `dar connect` command. -**Why:** Per-command connection flags make the DAR subcommands usable against any Ledger API endpoint, not just DevKit-managed instances, giving operators more flexibility in CI and multi-environment workflows. +**Why:** Per-command connection flags make the DAR subcommands usable against any Ledger API endpoint, not just DevKit-managed instances. This gives operators more flexibility in CI and multi-environment workflows without requiring a running LocalNet registry. --- -## DAR: `--instance` flag name +### `--instance` flag name **Proposal said:** instance selection is `--name ` uniformly. **Shipped:** `dar` subcommands use `--instance` as the primary flag name (with `--name` as an alias). -**Why:** In `dar` contexts, `--name` is ambiguous between the instance name and the DAR/package name. Using `--instance` as the primary name eliminates that ambiguity. +**Why:** In `dar` contexts, `--name` is ambiguous between the instance name and the DAR/package name. Using `--instance` as the primary name eliminates that ambiguity and makes commands self-documenting at a glance. --- -## Token: additional subcommands (new) +## `localnet token` + +### Additional subcommands (new) **Proposal said:** `token create`, `token mint`, `token transfer`, `token burn`, `token balance`. @@ -230,51 +277,51 @@ The embedded skill docs are the same artifacts that back the Web UI's Agent Skil | `token faucet ` | Fund a party with an auto-accepted transfer (no recipient interaction needed) | | `token demo` | One-step provision: creates a DEMO instrument and seeds a holder wallet | -**Why:** The alias registry (`token party`) reduces repeated `--party ` flags. `faucet` and `demo` target workshop and onboarding use cases where speed matters more than full CIP-0112 flow fidelity. +**Why:** The alias registry (`token party`) improves UX by eliminating repeated `--party ` flags across commands. `faucet` and `demo` target workshop and onboarding use cases where speed matters more than exercising the full CIP-0112 two-phase flow. `balances`, `summary`, and `activity` provide portfolio-level and historical views that are essential for verifying token operations during testing. --- -## Token: `transfer accept` subcommand +### `transfer accept` subcommand **Proposal said:** `token transfer` as a single command. **Shipped:** `token transfer` initiates a transfer; `token transfer accept` accepts a pending incoming transfer. CIP-0112 transfers are two-phase (offer + accept), so both halves are exposed as CLI subcommands. -**Why:** The two-phase model is required by the CIP-0112 protocol. Exposing both steps gives scripts and workshops full control over the accept timing. +**Why:** The two-phase model is required by the CIP-0112 protocol — it is not a simplification but a faithful implementation of the standard. Exposing both steps gives scripts and workshops full control over the accept timing, enabling realistic multi-party test scenarios. --- -## Token: `burn` requires explicit confirmation +### `burn` requires explicit confirmation **Proposal said:** `token burn {token-name} {amount}` as a straightforward command. **Shipped:** `token burn` prompts for confirmation before executing because the operation is irreversible. The prompt is bypassed with `--yes` / `-y`. -**Why:** Guarding an irreversible ledger operation with a confirmation prompt is standard CLI practice and prevents accidental burns in interactive sessions. +**Why:** Guarding an irreversible ledger operation with a confirmation prompt is standard CLI practice and prevents accidental burns in interactive sessions. The `--yes` flag preserves full scriptability for automation. --- -## Token: `--instance` required flag +### `--instance` required flag **Proposal said:** token commands connect to the active or `--name`-selected instance. **Shipped:** `--instance` is a **required** flag on all `token` subcommands (no default or auto-resolution from a single registered instance). -**Why:** Making `--instance` explicit prevents token commands from silently targeting the wrong LocalNet when multiple instances are registered. +**Why:** Making `--instance` explicit prevents token commands from silently targeting the wrong LocalNet when multiple instances are registered — a correctness and safety measure, not an inconvenience. --- -## Token: `--name` collision in `token create` +### `--name` collision in `token create` **Proposal said:** instance selection via `--name `. **Shipped:** In `token create`, `--name` refers to the **instrument name** (e.g. `--name "My Token"`), not the instance. The instance is selected via `--instance`. This is an intentional exception to the general `--name` = instance name convention. -**Why:** The instrument name is the primary user-facing input in the token creation wizard. Using `--name` for it matches natural language ("name this token") even though it breaks the global `--name` = instance convention. Document when using `token create` to avoid confusion. +**Why:** The instrument name is the primary user-facing input in the token creation wizard. Using `--name` for it matches natural language ("name this token") and makes the interactive wizard more intuitive, even though it breaks the global `--name` = instance convention elsewhere. --- -## Root-level `telemetry` command (new) +## `telemetry` (root-level, new) **Proposal said:** not mentioned. Adoption measurement was described as a reporting/documentation exercise. @@ -288,34 +335,4 @@ The embedded skill docs are the same artifacts that back the Web UI's Agent Skil Telemetry is **on by default** with opt-out via `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1`. An internal hidden subcommand `_record-install-surface ` is used by install scripts to record the distribution channel. -**Why:** Provides the adoption signals described in Milestone 4 (install counts, usage trends) in a privacy-preserving, opt-out model. The opt-out via standard `DO_NOT_TRACK` honours ecosystem conventions. - ---- - -## `up`: `--allow-uncurated` flag (new) - -**Proposal said:** `--version ` pins a Splice LocalNet version from the supported set. Unsupported versions were not addressed. - -**Shipped:** `--allow-uncurated` lets users pass a Splice tag that is not in the DevKit curated catalogue. DevKit resolves the tag against the upstream Splice GitHub repo and proceeds, printing a warning that the resulting LocalNet is not tested by DevKit. - -**Why:** Gives power users and maintainers a path to test prereleases and alpha tags without waiting for a catalogue update, while keeping the default path (no flag) restricted to tested versions. - ---- - -## `up`: `--profile` replaces a separate profiles config surface - -**Proposal said:** per-component toggles for Prometheus and Grafana as a LocalNet configuration model item; the exact mechanism was not specified. - -**Shipped:** `--profile ` (repeatable) is a flag on `localnet up`. Supported values include `prometheus`, `grafana`, and `observability` (legacy umbrella that activates both). Profiles are persisted in instance state so a subsequent `up` re-enables the same set. The `localnet observability enable/disable` command can toggle sidecars on a running instance without `--profile` at `up` time. - -**Why:** Docker Compose profiles are the natural mechanism for optional service groups in the Splice LocalNet stack. Exposing them directly as `--profile` keeps the model transparent and auditable. Persisting the profile set enables reproducible restarts. - ---- - -## `up`: `--port-base` flag (new) - -**Proposal said:** named instances use explicit port configuration so two LocalNets can run on one machine, but the mechanism for specifying ports was not defined. - -**Shipped:** `--port-base ` pins host ports deterministically starting from `n` (each service gets `base+N`). With `--port-base 0` (default), ports are auto-allocated with stable reuse across restarts. Every derived port must be free or `up` fails immediately with no silent fallback. - -**Why:** Auto-allocation works for single-developer use; `--port-base` is needed for CI layouts and reproducible multi-instance setups where port assignments must be predictable and documented. +**Why:** Provides the adoption signals described in Milestone 4 (install counts, usage trends) in a privacy-preserving, opt-out model without requiring manual tracking. The opt-out via standard `DO_NOT_TRACK` honours widely adopted ecosystem conventions. Placing it at the root level (not under `localnet`) reflects that it is a tool-wide concern, not a LocalNet-specific one. From 1dad92096c498ee0dc8737d7c8c272b490a1e705 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sat, 27 Jun 2026 22:09:13 +0200 Subject: [PATCH 08/68] chore: move OCI publish to homebrew-canton-devkit Comment out the DPM component OCI publish steps (Install DPM CLI, Lay out component dirs, Log in to GHCR, Validate manifest, Publish) and their associated env vars (GHCR_NAMESPACE, DPM_VERSION, DPM_LINUX_SHA256). The OCI publish now lives in homebrew-canton-devkit's publish-oci.yml workflow, which triggers on GitHub Release creation and publishes to the public namespace ghcr.io/bitdynamics-ab/homebrew-canton-devkit:. The commented-out steps are preserved with a TODO for re-enabling once we have a public OCI registry we can push to directly from this repo. Also updates docs (getting-started.md, packaging.md, README.md) to reference the new public GHCR namespace. --- .github/workflows/release.yml | 200 +++++++++++++++------------------- README.md | 2 +- docs/getting-started.md | 2 +- docs/packaging.md | 4 +- 4 files changed, 94 insertions(+), 114 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f91100d..d9108ff3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,21 +10,19 @@ on: permissions: contents: write - packages: write + # packages: write — no longer needed; OCI publish moved to homebrew-canton-devkit env: - # GHCR namespace for both standalone-binary releases and the - # DPM-component OCI artifact. - GHCR_NAMESPACE: ghcr.io/${{ github.repository }} - # DPM CLI version pinned for reproducible publishes. Bump deliberately - # — see https://github.com/digital-asset/dpm/releases. The companion - # DPM_LINUX_SHA256 below pins the linux-amd64 tarball used to install - # the CLI in CI; recompute via: - # curl -sL | sha256sum - DPM_VERSION: 1.0.16 - DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 - # Build matrix: shared by both the standalone-binary archives and the - # DPM-component OCI artifact (both produced in the single release job). + # TODO: OCI publish has moved to homebrew-canton-devkit (the public + # distribution repo) so that the DPM component is published under a + # public GHCR namespace. Re-enable here once we have an OCI registry + # we can push to directly from this (private) repo. + # See: https://github.com/bitdynamics-ab/homebrew-canton-devkit + # + # GHCR_NAMESPACE: ghcr.io/${{ github.repository }} + # DPM_VERSION: 1.0.16 + # DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 + # Build matrix: shared by the standalone-binary archives. RELEASE_TARGETS: linux/amd64 darwin/arm64 windows/amd64 jobs: @@ -384,100 +382,82 @@ jobs: commit -m "chore: update APT repo for ${VERSION}" git -C "${repo}" push - - name: Install DPM CLI (sha256-verified) - run: | - set -euo pipefail - tar="/tmp/dpm-${DPM_VERSION}-linux-amd64.tar.gz" - curl -sSfL \ - "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ - -o "$tar" - echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - - bindir="${RUNNER_TEMP:-/tmp}/canton-devkit-bin" - mkdir -p "$bindir" - tar -xzf "$tar" -C "$bindir" dpm - chmod 0755 "$bindir/dpm" - echo "$bindir" >> "$GITHUB_PATH" - "$bindir/dpm" --version - - - name: Lay out per-platform component dirs - run: | - set -euo pipefail - # Mirror the layout dpm publish component expects: - # /component.yaml - # /LICENSE - # /bin/canton-devkit[.exe] - # - # DPM does NOT auto-append `.exe` on Windows — it stat()s the - # exact path in component.yaml. We therefore template the - # manifest per platform: Unix uses bin/canton-devkit; Windows - # gets bin/canton-devkit.exe. (Empirically verified against - # DPM 1.0.16.) - read -ra targets <<< "${RELEASE_TARGETS}" - for target in "${targets[@]}"; do - os="${target%/*}" - arch="${target#*/}" - dir="dist/component_${os}_${arch}" - mkdir -p "${dir}/bin" - cp LICENSE "${dir}/LICENSE" - - binary_name="canton-devkit" - [ "$os" = "windows" ] && binary_name="canton-devkit.exe" - - # Copy the binary built earlier in this same job (local disk — - # no artifact round-trip, so the executable bit is preserved). - cp "component-bin/${os}_${arch}/${binary_name}" "${dir}/bin/${binary_name}" - chmod +x "${dir}/bin/${binary_name}" - - # Substitute the binary path token in the manifest template. - sed "s|@@BINARY_PATH@@|bin/${binary_name}|" \ - packaging/component.yaml.tmpl > "${dir}/component.yaml" - done - ls -R dist - - - name: Log in to GHCR - if: steps.meta.outputs.push == 'true' - # docker/login-action@v4 - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Validate component manifest (dry-run) - env: - VERSION: ${{ steps.meta.outputs.component_version }} - run: | - set -euo pipefail - dpm publish component \ - "oci://${GHCR_NAMESPACE}:${VERSION}" \ - --dry-run \ - --platform linux/amd64=dist/component_linux_amd64 \ - --platform darwin/arm64=dist/component_darwin_arm64 \ - --platform windows/amd64=dist/component_windows_amd64 - - - name: Publish DPM component (tag pushes only) - if: steps.meta.outputs.push == 'true' - env: - VERSION: ${{ steps.meta.outputs.component_version }} - run: | - set -euo pipefail - # Only move the `latest` tag for FINAL releases. A pre-release - # (v1.2.0-rc1) or a hotfix tagged against an older line must not - # clobber `latest`. Semver pre-releases carry a `-` in the - # build metadata; gate on its absence. - extra_tags=() - case "${VERSION}" in - *-*) echo "pre-release ${VERSION} — leaving 'latest' untouched" ;; - *) extra_tags+=(--extra-tags latest) ;; - esac - # ${arr[@]+"${arr[@]}"} expands to nothing when the array is - # empty WITHOUT tripping `set -u` (portable across bash 3.2+), - # so a pre-release publishes with no extra args rather than a - # stray empty argument. - dpm publish component \ - "oci://${GHCR_NAMESPACE}:${VERSION}" \ - --include-git-info \ - ${extra_tags[@]+"${extra_tags[@]}"} \ - --platform linux/amd64=dist/component_linux_amd64 \ - --platform darwin/arm64=dist/component_darwin_arm64 \ - --platform windows/amd64=dist/component_windows_amd64 + # TODO: OCI publish has moved to homebrew-canton-devkit (the public + # distribution repo). The workflow there triggers on GitHub Release + # creation, downloads these same tarballs, and publishes the DPM + # component to ghcr.io/bitdynamics-ab/homebrew-canton-devkit:. + # Re-enable these steps once we have a public OCI registry we can + # push to directly from this repo. + # + # - name: Install DPM CLI (sha256-verified) + # run: | + # set -euo pipefail + # tar="/tmp/dpm-${DPM_VERSION}-linux-amd64.tar.gz" + # curl -sSfL \ + # "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ + # -o "$tar" + # echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - + # bindir="${RUNNER_TEMP:-/tmp}/canton-devkit-bin" + # mkdir -p "$bindir" + # tar -xzf "$tar" -C "$bindir" dpm + # chmod 0755 "$bindir/dpm" + # echo "$bindir" >> "$GITHUB_PATH" + # "$bindir/dpm" --version + # + # - name: Lay out per-platform component dirs + # run: | + # set -euo pipefail + # read -ra targets <<< "${RELEASE_TARGETS}" + # for target in "${targets[@]}"; do + # os="${target%/*}" + # arch="${target#*/}" + # dir="dist/component_${os}_${arch}" + # mkdir -p "${dir}/bin" + # cp LICENSE "${dir}/LICENSE" + # binary_name="canton-devkit" + # [ "$os" = "windows" ] && binary_name="canton-devkit.exe" + # cp "component-bin/${os}_${arch}/${binary_name}" "${dir}/bin/${binary_name}" + # chmod +x "${dir}/bin/${binary_name}" + # sed "s|@@BINARY_PATH@@|bin/${binary_name}|" \ + # packaging/component.yaml.tmpl > "${dir}/component.yaml" + # done + # ls -R dist + # + # - name: Log in to GHCR + # if: steps.meta.outputs.push == 'true' + # uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + # with: + # registry: ghcr.io + # username: ${{ github.actor }} + # password: ${{ secrets.GITHUB_TOKEN }} + # + # - name: Validate component manifest (dry-run) + # env: + # VERSION: ${{ steps.meta.outputs.component_version }} + # run: | + # set -euo pipefail + # dpm publish component \ + # "oci://${GHCR_NAMESPACE}:${VERSION}" \ + # --dry-run \ + # --platform linux/amd64=dist/component_linux_amd64 \ + # --platform darwin/arm64=dist/component_darwin_arm64 \ + # --platform windows/amd64=dist/component_windows_amd64 + # + # - name: Publish DPM component (tag pushes only) + # if: steps.meta.outputs.push == 'true' + # env: + # VERSION: ${{ steps.meta.outputs.component_version }} + # run: | + # set -euo pipefail + # extra_tags=() + # case "${VERSION}" in + # *-*) echo "pre-release ${VERSION} — leaving 'latest' untouched" ;; + # *) extra_tags+=(--extra-tags latest) ;; + # esac + # dpm publish component \ + # "oci://${GHCR_NAMESPACE}:${VERSION}" \ + # --include-git-info \ + # ${extra_tags[@]+"${extra_tags[@]}"} \ + # --platform linux/amd64=dist/component_linux_amd64 \ + # --platform darwin/arm64=dist/component_darwin_arm64 \ + # --platform windows/amd64=dist/component_windows_amd64 diff --git a/README.md b/README.md index 2c17c5fc..b36529c9 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first fo Tagged builds (`v*`) publish: - Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- Docker images to `ghcr.io/bitdynamics-ab/canton-devkit:` +- DPM component to `ghcr.io/bitdynamics-ab/homebrew-canton-devkit:` Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b1fd992..f48a537e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,7 +57,7 @@ version: 0.1.0 source: . dependencies: [] components: - - oci://ghcr.io/bitdynamics-ab/canton-devkit: + - oci://ghcr.io/bitdynamics-ab/homebrew-canton-devkit: ``` ```bash diff --git a/docs/packaging.md b/docs/packaging.md index 5dd9c1ec..4b067a5f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -44,11 +44,11 @@ tar -xzf canton-devkit_v0.1.0_linux_amd64.tar.gz ## DPM component The DPM component is published to GitHub Container Registry on every -tagged release at `ghcr.io/bitdynamics-ab/canton-devkit:`. +tagged release at `ghcr.io/bitdynamics-ab/homebrew-canton-devkit:`. Install via: ```sh -dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit: +dpm install package oci://ghcr.io/bitdynamics-ab/homebrew-canton-devkit: dpm localnet --help ``` From 24446e1b571c94809195fba227d8db3773b14297 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 09:36:39 +0200 Subject: [PATCH 09/68] Revert "chore: move OCI publish to homebrew-canton-devkit" This reverts commit 1dad92096c498ee0dc8737d7c8c272b490a1e705. --- .github/workflows/release.yml | 200 +++++++++++++++++++--------------- README.md | 2 +- docs/getting-started.md | 2 +- docs/packaging.md | 4 +- 4 files changed, 114 insertions(+), 94 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9108ff3..6f91100d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,19 +10,21 @@ on: permissions: contents: write - # packages: write — no longer needed; OCI publish moved to homebrew-canton-devkit + packages: write env: - # TODO: OCI publish has moved to homebrew-canton-devkit (the public - # distribution repo) so that the DPM component is published under a - # public GHCR namespace. Re-enable here once we have an OCI registry - # we can push to directly from this (private) repo. - # See: https://github.com/bitdynamics-ab/homebrew-canton-devkit - # - # GHCR_NAMESPACE: ghcr.io/${{ github.repository }} - # DPM_VERSION: 1.0.16 - # DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 - # Build matrix: shared by the standalone-binary archives. + # GHCR namespace for both standalone-binary releases and the + # DPM-component OCI artifact. + GHCR_NAMESPACE: ghcr.io/${{ github.repository }} + # DPM CLI version pinned for reproducible publishes. Bump deliberately + # — see https://github.com/digital-asset/dpm/releases. The companion + # DPM_LINUX_SHA256 below pins the linux-amd64 tarball used to install + # the CLI in CI; recompute via: + # curl -sL | sha256sum + DPM_VERSION: 1.0.16 + DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 + # Build matrix: shared by both the standalone-binary archives and the + # DPM-component OCI artifact (both produced in the single release job). RELEASE_TARGETS: linux/amd64 darwin/arm64 windows/amd64 jobs: @@ -382,82 +384,100 @@ jobs: commit -m "chore: update APT repo for ${VERSION}" git -C "${repo}" push - # TODO: OCI publish has moved to homebrew-canton-devkit (the public - # distribution repo). The workflow there triggers on GitHub Release - # creation, downloads these same tarballs, and publishes the DPM - # component to ghcr.io/bitdynamics-ab/homebrew-canton-devkit:. - # Re-enable these steps once we have a public OCI registry we can - # push to directly from this repo. - # - # - name: Install DPM CLI (sha256-verified) - # run: | - # set -euo pipefail - # tar="/tmp/dpm-${DPM_VERSION}-linux-amd64.tar.gz" - # curl -sSfL \ - # "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ - # -o "$tar" - # echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - - # bindir="${RUNNER_TEMP:-/tmp}/canton-devkit-bin" - # mkdir -p "$bindir" - # tar -xzf "$tar" -C "$bindir" dpm - # chmod 0755 "$bindir/dpm" - # echo "$bindir" >> "$GITHUB_PATH" - # "$bindir/dpm" --version - # - # - name: Lay out per-platform component dirs - # run: | - # set -euo pipefail - # read -ra targets <<< "${RELEASE_TARGETS}" - # for target in "${targets[@]}"; do - # os="${target%/*}" - # arch="${target#*/}" - # dir="dist/component_${os}_${arch}" - # mkdir -p "${dir}/bin" - # cp LICENSE "${dir}/LICENSE" - # binary_name="canton-devkit" - # [ "$os" = "windows" ] && binary_name="canton-devkit.exe" - # cp "component-bin/${os}_${arch}/${binary_name}" "${dir}/bin/${binary_name}" - # chmod +x "${dir}/bin/${binary_name}" - # sed "s|@@BINARY_PATH@@|bin/${binary_name}|" \ - # packaging/component.yaml.tmpl > "${dir}/component.yaml" - # done - # ls -R dist - # - # - name: Log in to GHCR - # if: steps.meta.outputs.push == 'true' - # uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 - # with: - # registry: ghcr.io - # username: ${{ github.actor }} - # password: ${{ secrets.GITHUB_TOKEN }} - # - # - name: Validate component manifest (dry-run) - # env: - # VERSION: ${{ steps.meta.outputs.component_version }} - # run: | - # set -euo pipefail - # dpm publish component \ - # "oci://${GHCR_NAMESPACE}:${VERSION}" \ - # --dry-run \ - # --platform linux/amd64=dist/component_linux_amd64 \ - # --platform darwin/arm64=dist/component_darwin_arm64 \ - # --platform windows/amd64=dist/component_windows_amd64 - # - # - name: Publish DPM component (tag pushes only) - # if: steps.meta.outputs.push == 'true' - # env: - # VERSION: ${{ steps.meta.outputs.component_version }} - # run: | - # set -euo pipefail - # extra_tags=() - # case "${VERSION}" in - # *-*) echo "pre-release ${VERSION} — leaving 'latest' untouched" ;; - # *) extra_tags+=(--extra-tags latest) ;; - # esac - # dpm publish component \ - # "oci://${GHCR_NAMESPACE}:${VERSION}" \ - # --include-git-info \ - # ${extra_tags[@]+"${extra_tags[@]}"} \ - # --platform linux/amd64=dist/component_linux_amd64 \ - # --platform darwin/arm64=dist/component_darwin_arm64 \ - # --platform windows/amd64=dist/component_windows_amd64 + - name: Install DPM CLI (sha256-verified) + run: | + set -euo pipefail + tar="/tmp/dpm-${DPM_VERSION}-linux-amd64.tar.gz" + curl -sSfL \ + "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ + -o "$tar" + echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - + bindir="${RUNNER_TEMP:-/tmp}/canton-devkit-bin" + mkdir -p "$bindir" + tar -xzf "$tar" -C "$bindir" dpm + chmod 0755 "$bindir/dpm" + echo "$bindir" >> "$GITHUB_PATH" + "$bindir/dpm" --version + + - name: Lay out per-platform component dirs + run: | + set -euo pipefail + # Mirror the layout dpm publish component expects: + # /component.yaml + # /LICENSE + # /bin/canton-devkit[.exe] + # + # DPM does NOT auto-append `.exe` on Windows — it stat()s the + # exact path in component.yaml. We therefore template the + # manifest per platform: Unix uses bin/canton-devkit; Windows + # gets bin/canton-devkit.exe. (Empirically verified against + # DPM 1.0.16.) + read -ra targets <<< "${RELEASE_TARGETS}" + for target in "${targets[@]}"; do + os="${target%/*}" + arch="${target#*/}" + dir="dist/component_${os}_${arch}" + mkdir -p "${dir}/bin" + cp LICENSE "${dir}/LICENSE" + + binary_name="canton-devkit" + [ "$os" = "windows" ] && binary_name="canton-devkit.exe" + + # Copy the binary built earlier in this same job (local disk — + # no artifact round-trip, so the executable bit is preserved). + cp "component-bin/${os}_${arch}/${binary_name}" "${dir}/bin/${binary_name}" + chmod +x "${dir}/bin/${binary_name}" + + # Substitute the binary path token in the manifest template. + sed "s|@@BINARY_PATH@@|bin/${binary_name}|" \ + packaging/component.yaml.tmpl > "${dir}/component.yaml" + done + ls -R dist + + - name: Log in to GHCR + if: steps.meta.outputs.push == 'true' + # docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate component manifest (dry-run) + env: + VERSION: ${{ steps.meta.outputs.component_version }} + run: | + set -euo pipefail + dpm publish component \ + "oci://${GHCR_NAMESPACE}:${VERSION}" \ + --dry-run \ + --platform linux/amd64=dist/component_linux_amd64 \ + --platform darwin/arm64=dist/component_darwin_arm64 \ + --platform windows/amd64=dist/component_windows_amd64 + + - name: Publish DPM component (tag pushes only) + if: steps.meta.outputs.push == 'true' + env: + VERSION: ${{ steps.meta.outputs.component_version }} + run: | + set -euo pipefail + # Only move the `latest` tag for FINAL releases. A pre-release + # (v1.2.0-rc1) or a hotfix tagged against an older line must not + # clobber `latest`. Semver pre-releases carry a `-` in the + # build metadata; gate on its absence. + extra_tags=() + case "${VERSION}" in + *-*) echo "pre-release ${VERSION} — leaving 'latest' untouched" ;; + *) extra_tags+=(--extra-tags latest) ;; + esac + # ${arr[@]+"${arr[@]}"} expands to nothing when the array is + # empty WITHOUT tripping `set -u` (portable across bash 3.2+), + # so a pre-release publishes with no extra args rather than a + # stray empty argument. + dpm publish component \ + "oci://${GHCR_NAMESPACE}:${VERSION}" \ + --include-git-info \ + ${extra_tags[@]+"${extra_tags[@]}"} \ + --platform linux/amd64=dist/component_linux_amd64 \ + --platform darwin/arm64=dist/component_darwin_arm64 \ + --platform windows/amd64=dist/component_windows_amd64 diff --git a/README.md b/README.md index b36529c9..2c17c5fc 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first fo Tagged builds (`v*`) publish: - Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- DPM component to `ghcr.io/bitdynamics-ab/homebrew-canton-devkit:` +- Docker images to `ghcr.io/bitdynamics-ab/canton-devkit:` Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. diff --git a/docs/getting-started.md b/docs/getting-started.md index f48a537e..5b1fd992 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,7 +57,7 @@ version: 0.1.0 source: . dependencies: [] components: - - oci://ghcr.io/bitdynamics-ab/homebrew-canton-devkit: + - oci://ghcr.io/bitdynamics-ab/canton-devkit: ``` ```bash diff --git a/docs/packaging.md b/docs/packaging.md index 4b067a5f..5dd9c1ec 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -44,11 +44,11 @@ tar -xzf canton-devkit_v0.1.0_linux_amd64.tar.gz ## DPM component The DPM component is published to GitHub Container Registry on every -tagged release at `ghcr.io/bitdynamics-ab/homebrew-canton-devkit:`. +tagged release at `ghcr.io/bitdynamics-ab/canton-devkit:`. Install via: ```sh -dpm install package oci://ghcr.io/bitdynamics-ab/homebrew-canton-devkit: +dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit: dpm localnet --help ``` From b8c77497586819fef67323a063d00fc824929ac4 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 09:48:21 +0200 Subject: [PATCH 10/68] =?UTF-8?q?docs:=20fix=20README=20=E2=80=94=20GHCR?= =?UTF-8?q?=20artifact=20is=20a=20DPM=20component,=20not=20a=20Docker=20im?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow has no docker build/push. Everything published to ghcr.io/bitdynamics-ab/canton-devkit: goes through 'dpm publish component'. This was leftover wording from before the DPM-component publish existed. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c17c5fc..50d3fab8 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first fo Tagged builds (`v*`) publish: - Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- Docker images to `ghcr.io/bitdynamics-ab/canton-devkit:` +- DPM component (platform binaries) to `ghcr.io/bitdynamics-ab/canton-devkit:` — install with `dpm install package` Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. From eb4b72489ead30437c06c87e0a4d0af8d3ddb9ac Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 09:50:25 +0200 Subject: [PATCH 11/68] Fix formatting in README for DPM component section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50d3fab8..84d28c93 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,7 @@ Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first fo Tagged builds (`v*`) publish: - Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- DPM component (platform binaries) to `ghcr.io/bitdynamics-ab/canton-devkit:` — install with `dpm install package` +- DPM component to `ghcr.io/bitdynamics-ab/canton-devkit:` — install with `dpm install package` Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. From 195ff51fd8cbc3b9dfc0c455888b1b3f1c7c1386 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 10:03:34 +0200 Subject: [PATCH 12/68] docs: add implementation plan for weekly OCI artifact verification workflow Describes the GitHub Actions workflow to add at .github/workflows/verify-public-oci.yml. Verifies that the public canton-devkit DPM component OCI artifact is: - Anonymously pullable from GHCR (package is Public) - Multi-arch in OCI index metadata (linux/amd64, darwin/arm64, windows/amd64) - Installable + runnable on linux/amd64 via 'dpm install package oci://...' Resolves the sdk-version open question: 'dpm install package' accepts the OCI ref as a positional argument, requiring no project file or sdk-version. --- PLAN-verify-public-oci.md | 225 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 PLAN-verify-public-oci.md diff --git a/PLAN-verify-public-oci.md b/PLAN-verify-public-oci.md new file mode 100644 index 00000000..e3322916 --- /dev/null +++ b/PLAN-verify-public-oci.md @@ -0,0 +1,225 @@ +# Plan: Weekly automated verification of the public canton-devkit DPM/OCI artifact + +Hand-off plan for implementing a scheduled GitHub Actions workflow that verifies the +`ghcr.io/bitdynamics-ab/canton-devkit` DPM component is publicly pullable and +installable. + +- **Workflow to create:** `.github/workflows/verify-public-oci.yml` in this repo. +- **Status:** plan only — nothing has been created yet. + +--- + +## 1. Goal + +A **weekly** (Mondays 05:00 UTC) + manually-dispatchable workflow on the self-hosted +Linux e2e runner that proves the published DPM component is: + +1. **Anonymously pullable** from GHCR — i.e. the package is genuinely Public. +2. **Multi-arch in metadata** — the OCI index advertises all three release platforms + (`linux/amd64`, `darwin/arm64`, `windows/amd64`). +3. **Installable and runnable (linux/amd64)** — `dpm install package oci://…` succeeds + and `dpm localnet --help` runs the installed binary. + +A failed run means something regressed: the package was flipped private, a release +broke the artifact, or a platform is missing from the index. + +--- + +## 2. Runner constraint + +The e2e runner is **Linux/amd64 only**. Therefore: + +- **Functional execution** (Steps 4–5) tests **linux/amd64** only. +- **Multi-arch** (Step 3) is verified at **index-metadata level** (reading JSON from + the OCI index manifest) — not by running the other-arch binaries. + +--- + +## 3. Resolved: no `daml.yaml` / `sdk-version` needed + +`dpm install package` accepts the OCI ref as a positional argument: + +```sh +dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit: +``` + +This requires no project file, no `sdk-version`, and no SDK download. The +`daml.yaml`-with-`components:` approach shown in `docs/getting-started.md` is the +end-user workflow; the direct-ref form works fine for CI verification. + +--- + +## 4. Triggers + +```yaml +on: + schedule: + - cron: "0 5 * * 1" # Mon 05:00 UTC — offset from e2e(04:00) / integration(03:00) / refresh-versions(Mon 06:00) + workflow_dispatch: + inputs: + version: + description: "OCI tag to verify (default: latest)" + required: false + default: "latest" +``` + +--- + +## 5. Job header + +```yaml +permissions: + contents: read # read-only detective check — no packages:write + +jobs: + verify: + name: verify-public-oci + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + env: + NS: bitdynamics-ab/canton-devkit + # Keep these in sync with release.yml (DPM_VERSION / DPM_LINUX_SHA256). + # Add a cross-reference comment in both files when bumping. + DPM_VERSION: "1.0.16" + DPM_LINUX_SHA256: "387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874" +``` + +--- + +## 6. Steps + +### Step 1 — Resolve version & ensure anonymity + +```sh +set -euo pipefail +VERSION="${{ github.event.inputs.version || 'latest' }}" +echo "VERSION=${VERSION}" >> "$GITHUB_ENV" +# Defeat any cached runner docker credentials — this test must be truly anonymous. +docker logout ghcr.io || true +``` + +### Step 2 — Anonymous registry fetch (raw v2 API, no docker pull) + +Fetch the index manifest without credentials using only a public registry token: + +```sh +token=$(curl -fsS "https://ghcr.io/token?scope=repository:${NS}:pull" | jq -r .token) +code=$(curl -sS -o manifest.json -w '%{http_code}' \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" \ + "https://ghcr.io/v2/${NS}/manifests/${VERSION}") +test "$code" = "200" || { + echo "::error::GHCR returned HTTP ${code} anonymously for ${NS}:${VERSION} — package may be private" + exit 1 +} +``` + +> Note: `jq` is used here. Confirm it is installed on the e2e runner (it is used in +> other scripts in this repo). If not available, fall back to: +> `token=$(curl -fsS "..." | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')` + +### Step 3 — Multi-arch metadata assertion (index JSON, not execution) + +Assert that all three release platforms appear in the OCI index: + +```sh +for plat in linux/amd64 darwin/arm64 windows/amd64; do + os="${plat%/*}"; arch="${plat#*/}" + jq -e --arg os "$os" --arg arch "$arch" \ + '.manifests[]?.platform | select(.os==$os and .architecture==$arch)' manifest.json > /dev/null \ + || { + echo "::error::OCI index for ${NS}:${VERSION} is missing platform ${plat}" + exit 1 + } +done +``` + +This runs fine on Linux because it only reads JSON. It fails if the manifest is a +single-platform manifest rather than an index — which is the expected failure mode +for a malformed release. + +### Step 4 — Install DPM CLI (verbatim from `release.yml`, sha256-verified) + +```sh +tar="${RUNNER_TEMP}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" +curl -sSfL \ + "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ + -o "$tar" +echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - +bindir="${RUNNER_TEMP}/dpm-bin" +mkdir -p "$bindir" +tar -xzf "$tar" -C "$bindir" dpm +chmod 0755 "$bindir/dpm" +echo "$bindir" >> "$GITHUB_PATH" +dpm --version +``` + +### Step 5 — Anonymous install + smoke test (linux/amd64) + +Install the component from the public registry using the direct-ref form (no project +file or sdk-version required): + +```sh +dpm install package "oci://ghcr.io/${NS}:${VERSION}" +dpm localnet --help # success ⇒ linux/amd64 binary resolved, downloaded, registered, executable +``` + +### Step 6 — Cleanup (always runs) + +```yaml +- name: Cleanup + if: always() + run: rm -f manifest.json +``` + +--- + +## 7. Conventions to follow + +Match the style of `e2e.yml` and `integration.yml`: + +- **SHA-pin every third-party action** with a `# owner/action@vX` comment above it. + This workflow needs only `actions/checkout` (optional — checkout is not strictly + required since Step 5 installs globally, not into the workspace). Aim for **zero** + marketplace actions and do everything in `run:` blocks to minimize supply-chain + surface. +- `set -euo pipefail` in every multi-line `run:` block. +- Header comment block: purpose, triggers, Linux-only note, cron offset rationale. +- No `packages: write` — this is a read-only detective check. + +--- + +## 8. Validation before relying on the cron + +1. Open a PR, trigger via `workflow_dispatch` with default (`latest`) — confirm green. +2. Trigger with a pinned known-good tag (`0.10.1`) — confirm green. +3. **Negative test:** dispatch with `version: 0.0.0-nope` — Step 2 must fail with a + clear `::error::` message. This proves the guard actually works. +4. Merge; Monday cron takes over. + +--- + +## 9. Maintenance notes (include as comments in the workflow) + +- `DPM_VERSION` / `DPM_LINUX_SHA256` are duplicated from `release.yml`. + Add a cross-reference comment in **both** files: `# Keep in sync with verify-public-oci.yml` + and `# Keep in sync with release.yml`. Bump them together. +- The platform list in Step 3 mirrors `RELEASE_TARGETS` in `release.yml` — keep in sync. +- Namespace hard-coded to `ghcr.io/bitdynamics-ab/canton-devkit`. Update if the org/repo moves. + +--- + +## 10. Alerting + +GitHub's default failed-scheduled-run email notifications are sufficient. +No webhook or additional secrets required. + +--- + +## 11. Audit / compliance note + +This is a **read-only detective control** monitoring intentional public exposure of +the package — appropriate ISO 27001 / Vanta evidence that public access is verified +on a recurring basis. It introduces **no credentials** and **no write scopes**. +Document alongside the "package made public" change-management entry from the +Option-A rollout. From 665806f88db5b74e17047f7562982bd8ac98629d Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 10:16:21 +0200 Subject: [PATCH 13/68] ci: add weekly verify-public-oci workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly (Mon 05:00 UTC) + manually-dispatchable workflow that proves the published ghcr.io/bitdynamics-ab/canton-devkit DPM component is: 1. Anonymously pullable from GHCR (the package is genuinely Public). 2. Multi-arch in metadata — the OCI index lists linux/amd64, darwin/arm64, and windows/amd64. 3. Installable + runnable on linux/amd64 via 'dpm install package oci://...' and 'dpm localnet --help'. The check uses only raw curl (v2 API with a self-fetched public pull token) and the sha256-pinned DPM CLI — no Docker credentials, no Docker images, no marketplace actions beyond curl/jq on the runner. Also adds a '# Keep in sync with verify-public-oci.yml' cross-reference comment in release.yml next to DPM_VERSION / DPM_LINUX_SHA256 so both files are bumped together. --- .github/workflows/release.yml | 1 + .github/workflows/verify-public-oci.yml | 124 ++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 .github/workflows/verify-public-oci.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f91100d..db5eb68f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,7 @@ env: # DPM_LINUX_SHA256 below pins the linux-amd64 tarball used to install # the CLI in CI; recompute via: # curl -sL | sha256sum + # Keep in sync with verify-public-oci.yml. DPM_VERSION: 1.0.16 DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 # Build matrix: shared by both the standalone-binary archives and the diff --git a/.github/workflows/verify-public-oci.yml b/.github/workflows/verify-public-oci.yml new file mode 100644 index 00000000..ce0a31eb --- /dev/null +++ b/.github/workflows/verify-public-oci.yml @@ -0,0 +1,124 @@ +name: Verify public OCI + +# Weekly read-only detective check that the published DPM component at +# ghcr.io/bitdynamics-ab/canton-devkit is genuinely PUBLIC and usable. +# +# It proves three things: +# 1. Anonymously pullable from GHCR (the package is really Public). +# 2. Multi-arch in metadata — the OCI index advertises all three release +# platforms (linux/amd64, darwin/arm64, windows/amd64). +# 3. Installable + runnable on linux/amd64 — `dpm install package oci://…` +# succeeds and `dpm localnet --help` runs the installed binary. +# +# A failed run means a regression: the package was flipped private, a +# release broke the artifact, or a platform is missing from the index. +# +# Triggers: +# - schedule: Mondays 05:00 UTC. Offset from e2e (04:00), +# integration (03:00), and refresh-versions (Mon 06:00). +# - workflow_dispatch: manual trigger; optional `version` input. +# +# Platform: Linux/amd64 only (self-hosted Proxmox e2e runner). Functional +# execution tests linux/amd64 only; the other two platforms are verified +# at index-metadata level (reading the OCI index JSON), not by running +# their binaries. +# +# Anonymity: the curl steps use only the self-fetched public pull token; +# dpm uses its own auth config (not Docker credentials) and has no login +# config for GHCR on this runner by default. +# +# Audit/compliance: read-only detective control monitoring intentional +# public exposure of the package (ISO 27001 / Vanta evidence). Introduces +# no credentials and no write scopes. +# +# Maintenance: +# - DPM_VERSION / DPM_LINUX_SHA256 are duplicated from release.yml. +# Bump them together. Keep in sync with release.yml. +# - The platform list in "Multi-arch metadata assertion" mirrors +# RELEASE_TARGETS in release.yml — keep in sync. +# - NS is hard-coded to bitdynamics-ab/canton-devkit. +# Update if the org/repo moves. + +on: + schedule: + - cron: "0 5 * * 1" # Mon 05:00 UTC + workflow_dispatch: + inputs: + version: + description: "OCI tag to verify (default: latest)" + required: false + default: "latest" + +permissions: + contents: read # read-only detective check — no packages:write + +jobs: + verify: + name: verify-public-oci + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + env: + NS: bitdynamics-ab/canton-devkit + # Keep DPM_VERSION / DPM_LINUX_SHA256 in sync with release.yml. + DPM_VERSION: "1.0.16" + DPM_LINUX_SHA256: "387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874" + + steps: + - name: Resolve version + run: | + set -euo pipefail + VERSION="${{ github.event.inputs.version || 'latest' }}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Anonymous registry fetch (raw v2 API) + run: | + set -euo pipefail + token=$(curl -fsS "https://ghcr.io/token?scope=repository:${NS}:pull" | jq -r .token) + code=$(curl -sS -o manifest.json -w '%{http_code}' \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" \ + "https://ghcr.io/v2/${NS}/manifests/${VERSION}") + test "$code" = "200" || { + echo "::error::GHCR returned HTTP ${code} anonymously for ${NS}:${VERSION} — package may be private" + exit 1 + } + + - name: Multi-arch metadata assertion + run: | + set -euo pipefail + # Platform list mirrors RELEASE_TARGETS in release.yml. + for plat in linux/amd64 darwin/arm64 windows/amd64; do + os="${plat%/*}"; arch="${plat#*/}" + jq -e --arg os "$os" --arg arch "$arch" \ + '.manifests[]?.platform | select(.os==$os and .architecture==$arch)' manifest.json > /dev/null \ + || { + echo "::error::OCI index for ${NS}:${VERSION} is missing platform ${plat}" + exit 1 + } + done + + - name: Install DPM CLI (sha256-verified) + run: | + set -euo pipefail + tar="${RUNNER_TEMP}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" + curl -sSfL \ + "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ + -o "$tar" + echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - + bindir="${RUNNER_TEMP}/dpm-bin" + mkdir -p "$bindir" + tar -xzf "$tar" -C "$bindir" dpm + chmod 0755 "$bindir/dpm" + echo "$bindir" >> "$GITHUB_PATH" + dpm --version + + - name: Anonymous install + smoke test (linux/amd64) + run: | + set -euo pipefail + dpm install package "oci://ghcr.io/${NS}:${VERSION}" + # success ⇒ linux/amd64 binary resolved, downloaded, registered, executable + dpm localnet --help + + - name: Cleanup + if: always() + run: rm -f manifest.json From dcc2f7102ca19fad4dd348ca8ff125bcafcfb95b Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 10:56:08 +0200 Subject: [PATCH 14/68] Delete PLAN-verify-public-oci.md --- PLAN-verify-public-oci.md | 225 -------------------------------------- 1 file changed, 225 deletions(-) delete mode 100644 PLAN-verify-public-oci.md diff --git a/PLAN-verify-public-oci.md b/PLAN-verify-public-oci.md deleted file mode 100644 index e3322916..00000000 --- a/PLAN-verify-public-oci.md +++ /dev/null @@ -1,225 +0,0 @@ -# Plan: Weekly automated verification of the public canton-devkit DPM/OCI artifact - -Hand-off plan for implementing a scheduled GitHub Actions workflow that verifies the -`ghcr.io/bitdynamics-ab/canton-devkit` DPM component is publicly pullable and -installable. - -- **Workflow to create:** `.github/workflows/verify-public-oci.yml` in this repo. -- **Status:** plan only — nothing has been created yet. - ---- - -## 1. Goal - -A **weekly** (Mondays 05:00 UTC) + manually-dispatchable workflow on the self-hosted -Linux e2e runner that proves the published DPM component is: - -1. **Anonymously pullable** from GHCR — i.e. the package is genuinely Public. -2. **Multi-arch in metadata** — the OCI index advertises all three release platforms - (`linux/amd64`, `darwin/arm64`, `windows/amd64`). -3. **Installable and runnable (linux/amd64)** — `dpm install package oci://…` succeeds - and `dpm localnet --help` runs the installed binary. - -A failed run means something regressed: the package was flipped private, a release -broke the artifact, or a platform is missing from the index. - ---- - -## 2. Runner constraint - -The e2e runner is **Linux/amd64 only**. Therefore: - -- **Functional execution** (Steps 4–5) tests **linux/amd64** only. -- **Multi-arch** (Step 3) is verified at **index-metadata level** (reading JSON from - the OCI index manifest) — not by running the other-arch binaries. - ---- - -## 3. Resolved: no `daml.yaml` / `sdk-version` needed - -`dpm install package` accepts the OCI ref as a positional argument: - -```sh -dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit: -``` - -This requires no project file, no `sdk-version`, and no SDK download. The -`daml.yaml`-with-`components:` approach shown in `docs/getting-started.md` is the -end-user workflow; the direct-ref form works fine for CI verification. - ---- - -## 4. Triggers - -```yaml -on: - schedule: - - cron: "0 5 * * 1" # Mon 05:00 UTC — offset from e2e(04:00) / integration(03:00) / refresh-versions(Mon 06:00) - workflow_dispatch: - inputs: - version: - description: "OCI tag to verify (default: latest)" - required: false - default: "latest" -``` - ---- - -## 5. Job header - -```yaml -permissions: - contents: read # read-only detective check — no packages:write - -jobs: - verify: - name: verify-public-oci - runs-on: [self-hosted, Linux, X64, proxmox, e2e] - timeout-minutes: 20 - env: - NS: bitdynamics-ab/canton-devkit - # Keep these in sync with release.yml (DPM_VERSION / DPM_LINUX_SHA256). - # Add a cross-reference comment in both files when bumping. - DPM_VERSION: "1.0.16" - DPM_LINUX_SHA256: "387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874" -``` - ---- - -## 6. Steps - -### Step 1 — Resolve version & ensure anonymity - -```sh -set -euo pipefail -VERSION="${{ github.event.inputs.version || 'latest' }}" -echo "VERSION=${VERSION}" >> "$GITHUB_ENV" -# Defeat any cached runner docker credentials — this test must be truly anonymous. -docker logout ghcr.io || true -``` - -### Step 2 — Anonymous registry fetch (raw v2 API, no docker pull) - -Fetch the index manifest without credentials using only a public registry token: - -```sh -token=$(curl -fsS "https://ghcr.io/token?scope=repository:${NS}:pull" | jq -r .token) -code=$(curl -sS -o manifest.json -w '%{http_code}' \ - -H "Authorization: Bearer ${token}" \ - -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" \ - "https://ghcr.io/v2/${NS}/manifests/${VERSION}") -test "$code" = "200" || { - echo "::error::GHCR returned HTTP ${code} anonymously for ${NS}:${VERSION} — package may be private" - exit 1 -} -``` - -> Note: `jq` is used here. Confirm it is installed on the e2e runner (it is used in -> other scripts in this repo). If not available, fall back to: -> `token=$(curl -fsS "..." | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')` - -### Step 3 — Multi-arch metadata assertion (index JSON, not execution) - -Assert that all three release platforms appear in the OCI index: - -```sh -for plat in linux/amd64 darwin/arm64 windows/amd64; do - os="${plat%/*}"; arch="${plat#*/}" - jq -e --arg os "$os" --arg arch "$arch" \ - '.manifests[]?.platform | select(.os==$os and .architecture==$arch)' manifest.json > /dev/null \ - || { - echo "::error::OCI index for ${NS}:${VERSION} is missing platform ${plat}" - exit 1 - } -done -``` - -This runs fine on Linux because it only reads JSON. It fails if the manifest is a -single-platform manifest rather than an index — which is the expected failure mode -for a malformed release. - -### Step 4 — Install DPM CLI (verbatim from `release.yml`, sha256-verified) - -```sh -tar="${RUNNER_TEMP}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" -curl -sSfL \ - "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ - -o "$tar" -echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - -bindir="${RUNNER_TEMP}/dpm-bin" -mkdir -p "$bindir" -tar -xzf "$tar" -C "$bindir" dpm -chmod 0755 "$bindir/dpm" -echo "$bindir" >> "$GITHUB_PATH" -dpm --version -``` - -### Step 5 — Anonymous install + smoke test (linux/amd64) - -Install the component from the public registry using the direct-ref form (no project -file or sdk-version required): - -```sh -dpm install package "oci://ghcr.io/${NS}:${VERSION}" -dpm localnet --help # success ⇒ linux/amd64 binary resolved, downloaded, registered, executable -``` - -### Step 6 — Cleanup (always runs) - -```yaml -- name: Cleanup - if: always() - run: rm -f manifest.json -``` - ---- - -## 7. Conventions to follow - -Match the style of `e2e.yml` and `integration.yml`: - -- **SHA-pin every third-party action** with a `# owner/action@vX` comment above it. - This workflow needs only `actions/checkout` (optional — checkout is not strictly - required since Step 5 installs globally, not into the workspace). Aim for **zero** - marketplace actions and do everything in `run:` blocks to minimize supply-chain - surface. -- `set -euo pipefail` in every multi-line `run:` block. -- Header comment block: purpose, triggers, Linux-only note, cron offset rationale. -- No `packages: write` — this is a read-only detective check. - ---- - -## 8. Validation before relying on the cron - -1. Open a PR, trigger via `workflow_dispatch` with default (`latest`) — confirm green. -2. Trigger with a pinned known-good tag (`0.10.1`) — confirm green. -3. **Negative test:** dispatch with `version: 0.0.0-nope` — Step 2 must fail with a - clear `::error::` message. This proves the guard actually works. -4. Merge; Monday cron takes over. - ---- - -## 9. Maintenance notes (include as comments in the workflow) - -- `DPM_VERSION` / `DPM_LINUX_SHA256` are duplicated from `release.yml`. - Add a cross-reference comment in **both** files: `# Keep in sync with verify-public-oci.yml` - and `# Keep in sync with release.yml`. Bump them together. -- The platform list in Step 3 mirrors `RELEASE_TARGETS` in `release.yml` — keep in sync. -- Namespace hard-coded to `ghcr.io/bitdynamics-ab/canton-devkit`. Update if the org/repo moves. - ---- - -## 10. Alerting - -GitHub's default failed-scheduled-run email notifications are sufficient. -No webhook or additional secrets required. - ---- - -## 11. Audit / compliance note - -This is a **read-only detective control** monitoring intentional public exposure of -the package — appropriate ISO 27001 / Vanta evidence that public access is verified -on a recurring basis. It introduces **no credentials** and **no write scopes**. -Document alongside the "package made public" change-management entry from the -Option-A rollout. From a458cf5d4183bb895decf6622baf9e28ca07cb73 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 11:19:58 +0200 Subject: [PATCH 15/68] fix(ci): call dpm by absolute path in Install step $GITHUB_PATH additions only take effect in subsequent steps, not in the same step where the echo is done. Bare 'dpm --version' therefore failed with exit 127. Use "$bindir/dpm" --version (matching release.yml:401). --- .github/workflows/verify-public-oci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify-public-oci.yml b/.github/workflows/verify-public-oci.yml index ce0a31eb..1d812078 100644 --- a/.github/workflows/verify-public-oci.yml +++ b/.github/workflows/verify-public-oci.yml @@ -110,7 +110,7 @@ jobs: tar -xzf "$tar" -C "$bindir" dpm chmod 0755 "$bindir/dpm" echo "$bindir" >> "$GITHUB_PATH" - dpm --version + "$bindir/dpm" --version - name: Anonymous install + smoke test (linux/amd64) run: | From 9ec575c661e2259ae8b56ea917ceaf6e062b93cf Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 11:26:18 +0200 Subject: [PATCH 16/68] fix(ci): fix two more failures in verify-public-oci smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem 1: 'dpm install package' requires a daml.yaml with a components: entry in the current directory — there is no argument form. The step was passing the OCI ref as a positional arg, which dpm does not support. Problem 2: 'dpm install package' requires a strict semver OCI tag; symbolic tags like 'latest' are rejected with 'invalid semantic version'. The resolved version is extracted from the manifest's org.opencontainers.image.version annotation (written by dpm publish) and stored as INSTALL_VERSION for use in the install step. Fix: after the anonymous fetch step, extract INSTALL_VERSION from manifest.json. The smoke test creates a minimal daml.yaml (no sdk-version to avoid the 'opt-in components + SDK bundle' conflict) in a RUNNER_TEMP workdir, then runs 'dpm install package' and 'dpm localnet --help' from that directory. Verified locally against latest (resolved to 0.10.1). --- .github/workflows/verify-public-oci.yml | 27 +++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/verify-public-oci.yml b/.github/workflows/verify-public-oci.yml index 1d812078..710321d8 100644 --- a/.github/workflows/verify-public-oci.yml +++ b/.github/workflows/verify-public-oci.yml @@ -7,7 +7,7 @@ name: Verify public OCI # 1. Anonymously pullable from GHCR (the package is really Public). # 2. Multi-arch in metadata — the OCI index advertises all three release # platforms (linux/amd64, darwin/arm64, windows/amd64). -# 3. Installable + runnable on linux/amd64 — `dpm install package oci://…` +# 3. Installable + runnable on linux/amd64 — `dpm install package` # succeeds and `dpm localnet --help` runs the installed binary. # # A failed run means a regression: the package was flipped private, a @@ -82,6 +82,12 @@ jobs: echo "::error::GHCR returned HTTP ${code} anonymously for ${NS}:${VERSION} — package may be private" exit 1 } + # Extract the strict semver from the manifest annotations. + # dpm install package requires a strict semver OCI tag; symbolic + # tags like "latest" are rejected. org.opencontainers.image.version + # is always present in our published manifests (set by dpm publish). + INSTALL_VERSION=$(jq -r '.annotations["org.opencontainers.image.version"]' manifest.json) + echo "INSTALL_VERSION=${INSTALL_VERSION}" >> "$GITHUB_ENV" - name: Multi-arch metadata assertion run: | @@ -110,12 +116,29 @@ jobs: tar -xzf "$tar" -C "$bindir" dpm chmod 0755 "$bindir/dpm" echo "$bindir" >> "$GITHUB_PATH" + # Use absolute path — $GITHUB_PATH additions only take effect in + # subsequent steps, not in the same step where the echo is done. "$bindir/dpm" --version - name: Anonymous install + smoke test (linux/amd64) run: | set -euo pipefail - dpm install package "oci://ghcr.io/${NS}:${VERSION}" + # dpm install package reads daml.yaml from the current directory. + # Create a minimal project file — no sdk-version (which would pull + # in the SDK bundle and conflict with opt-in components), just the + # component reference with the resolved strict semver tag. + workdir="${RUNNER_TEMP}/dpm-verify" + mkdir -p "$workdir" + cat > "$workdir/daml.yaml" < Date: Sun, 28 Jun 2026 16:36:37 +0000 Subject: [PATCH 17/68] chore(deps): bump undici from 7.27.0 to 7.28.0 in /frontend Bumps [undici](https://github.com/nodejs/undici) from 7.27.0 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.0...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 436108b2..4e427a07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2007,9 +2007,9 @@ } }, "node_modules/undici": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", - "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { From 5bd1be3579aae53c98875063db102f5dfd3d215b Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 19:16:50 +0200 Subject: [PATCH 18/68] chore(ci): rename e2e.yml to e2e-test-devkit-functions.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename for clarity — the workflow name now describes what it tests (canton-devkit end-to-end functions / Milestone 1 lifecycle) rather than just 'E2E'. Updates the display name to match. --- .github/workflows/{e2e.yml => e2e-test-devkit-functions.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{e2e.yml => e2e-test-devkit-functions.yml} (98%) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e-test-devkit-functions.yml similarity index 98% rename from .github/workflows/e2e.yml rename to .github/workflows/e2e-test-devkit-functions.yml index d063430e..1e3af7ff 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e-test-devkit-functions.yml @@ -1,4 +1,4 @@ -name: E2E +name: E2E: canton-devkit Functions # Shell-based end-to-end tests. Each milestone adds a job to this # workflow. Currently: Milestone 1 (LocalNet CLI lifecycle). From 73d3a315c6836b87768bba1d4eb674fcd92505ac Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 19:36:22 +0200 Subject: [PATCH 19/68] chore(ci): rename verify-public-oci.yml to e2e-test-dpm-installation.yml Updates display name to 'E2E: DPM Installation', job name to e2e-test-dpm-installation, and the cross-reference comment in release.yml. No logic changes. --- .../{verify-public-oci.yml => e2e-test-dpm-installation.yml} | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename .github/workflows/{verify-public-oci.yml => e2e-test-dpm-installation.yml} (98%) diff --git a/.github/workflows/verify-public-oci.yml b/.github/workflows/e2e-test-dpm-installation.yml similarity index 98% rename from .github/workflows/verify-public-oci.yml rename to .github/workflows/e2e-test-dpm-installation.yml index 710321d8..2a188ab9 100644 --- a/.github/workflows/verify-public-oci.yml +++ b/.github/workflows/e2e-test-dpm-installation.yml @@ -1,4 +1,4 @@ -name: Verify public OCI +name: E2E: DPM Installation # Weekly read-only detective check that the published DPM component at # ghcr.io/bitdynamics-ab/canton-devkit is genuinely PUBLIC and usable. @@ -54,7 +54,7 @@ permissions: jobs: verify: - name: verify-public-oci + name: e2e-test-dpm-installation runs-on: [self-hosted, Linux, X64, proxmox, e2e] timeout-minutes: 20 env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db5eb68f..6c89010a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ env: # DPM_LINUX_SHA256 below pins the linux-amd64 tarball used to install # the CLI in CI; recompute via: # curl -sL | sha256sum - # Keep in sync with verify-public-oci.yml. + # Keep in sync with e2e-test-dpm-installation.yml. DPM_VERSION: 1.0.16 DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 # Build matrix: shared by both the standalone-binary archives and the From cdb72c73e6b07ed7c6a3649f72c9fd5257242beb Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 19:54:04 +0200 Subject: [PATCH 20/68] fix(webui): show usable JWTs in Developer Setup for LocalNet The App config panel and JWT generator rendered tokens as , so copy-pasted env/json/yaml config and generated JWTs were unusable against the running ledger. LocalNet is loopback-only and signs with a shared dev secret (the dev-secret warning already renders on the JWT panel), so surface the raw token directly: - api.ts: app-config fetchers request ?include_jwt=true - DeveloperSetup: JWT panel issues with include_jwt=true on mount and shows the token immediately; drop the reveal/hide toggle in favor of a Copy-only button Backend and CLI keep their redacted-by-default behavior; only the LocalNet Web UI opts into raw tokens. --- frontend/src/api.ts | 9 +- frontend/src/screens/DeveloperSetup.test.tsx | 107 ++++++------------- frontend/src/screens/DeveloperSetup.tsx | 86 +++++---------- 3 files changed, 65 insertions(+), 137 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d1d6050b..c8966a08 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -275,12 +275,17 @@ export type AppConfigFormat = "env" | "json" | "yaml"; // text — the env / yaml endpoints emit text/plain so apiFetch's // JSON-decode path would error. Inline a small fetch here that // returns the body verbatim. +// +// include_jwt=true: the app config is meant to be copy-pasted into a +// dApp's environment, so a redacted token makes it unusable. LocalNet +// is loopback-only with dev-secret tokens (the UI shows the dev-secret +// warning), so the raw token is surfaced here on purpose. export async function fetchAppConfigText( name: string, format: "env" | "yaml", ): Promise { const resp = await fetch( - `/api/instances/${encodeURIComponent(name)}/app-config?format=${format}`, + `/api/instances/${encodeURIComponent(name)}/app-config?format=${format}&include_jwt=true`, ); if (!resp.ok) { const body = await resp.text(); @@ -303,7 +308,7 @@ export interface AppConfigPayload { export const fetchAppConfigJSON = (name: string) => apiFetch( - `/api/instances/${encodeURIComponent(name)}/app-config?format=json`, + `/api/instances/${encodeURIComponent(name)}/app-config?format=json&include_jwt=true`, ); // ── create-instance flow ────────────────────────────────── diff --git a/frontend/src/screens/DeveloperSetup.test.tsx b/frontend/src/screens/DeveloperSetup.test.tsx index 00bd9e6e..dc751f73 100644 --- a/frontend/src/screens/DeveloperSetup.test.tsx +++ b/frontend/src/screens/DeveloperSetup.test.tsx @@ -3,17 +3,17 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DeveloperSetup } from "./DeveloperSetup"; -// DeveloperSetup tests — the JWT redacted-by-default + reveal -// contract is security-relevant; pin it in tests so a refactor -// that breaks it can't ship silently. +// DeveloperSetup tests — LocalNet surfaces usable (raw) tokens by +// default. Pin that contract so a refactor that re-redacts the UI +// (making copy-pasted config unusable) can't ship silently. // // What matters: -// 1. mount fetches WITHOUT include_jwt=true (redacted-default) -// 2. "Show token" re-fetches WITH include_jwt=true -// 3. Copy on the revealed token hits navigator.clipboard.writeText -// 4. Hide re-redacts (state goes back to the redacted view) -// 5. AppConfigPanel switches transport based on format -// (env/yaml → text endpoint, json → apiFetch JSON path) +// 1. JWT panel fetches WITH include_jwt=true on mount and renders +// the real token split into header.payload.signature +// 2. Copy writes the full token to navigator.clipboard.writeText +// 3. AppConfigPanel fetches WITH include_jwt=true and switches +// transport based on format (env/yaml → text endpoint, json → +// apiFetch JSON path) // // Other surface (chip-row interactions, audience input) is // trivial and covered by the build/typecheck step; testing it @@ -63,10 +63,10 @@ describe("DeveloperSetup — JwtPanel", () => { }); afterEach(() => vi.unstubAllGlobals()); - it("fetches redacted JWT on mount (no include_jwt query)", async () => { + it("fetches a usable JWT on mount with include_jwt=true and renders it", async () => { const { calls } = recordingFetch(({ url }) => { if (url.includes("/jwt")) { - return jwtResponse({ redacted: true, token: "" }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } // app-config text fetch from AppConfigPanel return new Response("KEY=value\n", { status: 200 }); @@ -79,34 +79,11 @@ describe("DeveloperSetup — JwtPanel", () => { expect(jwtCall).toBeDefined(); }); const jwtCall = calls.find((c) => c.url.includes("/jwt"))!; - // The mount fetch MUST NOT include the reveal query — the - // redacted-by-default contract lives here. - expect(jwtCall.url).toBe("/api/instances/demo/jwt"); - expect(jwtCall.url).not.toContain("include_jwt"); - }); + // LocalNet surfaces a usable token — the mount fetch opts into + // the raw token so the generated JWT is copy-pasteable. + expect(jwtCall.url).toContain("include_jwt=true"); - it("Show token re-fetches with include_jwt=true and displays the real token", async () => { - let callIdx = 0; - recordingFetch(({ url }) => { - if (url.includes("/jwt")) { - callIdx++; - // Second JWT call is the reveal — return the real token. - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); - } - return new Response("KEY=value\n", { status: 200 }); - }); - - render(); - - const showBtn = await screen.findByRole("button", { name: /show token/i }); - await userEvent.click(showBtn); - - // The revealed token appears in the TokenBox split into parts. + // The real token renders in the TokenBox split into parts. await waitFor(() => { expect(screen.getByText("header")).toBeInTheDocument(); expect(screen.getByText("payload")).toBeInTheDocument(); @@ -114,24 +91,18 @@ describe("DeveloperSetup — JwtPanel", () => { }); }); - it("Copy on a revealed token writes the full token to clipboard", async () => { - let callIdx = 0; + it("Copy writes the full token to clipboard", async () => { recordingFetch(({ url }) => { if (url.includes("/jwt")) { - callIdx++; - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } return new Response("KEY=value\n", { status: 200 }); }); render(); - await userEvent.click(await screen.findByRole("button", { name: /show token/i })); + // Wait for the token to render before copying. + await screen.findByText("signature"); // Two Copy buttons exist (JwtPanel + AppConfigPanel). Scope // to the JWT card so we click the right one. const jwtCard = screen.getByText("JWT generator").closest("section")!; @@ -141,32 +112,6 @@ describe("DeveloperSetup — JwtPanel", () => { "header.payload.signature", ); }); - - it("Hide re-redacts the token view (back to the redacted UI)", async () => { - let callIdx = 0; - recordingFetch(({ url }) => { - if (url.includes("/jwt")) { - callIdx++; - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); - } - return new Response("KEY=value\n", { status: 200 }); - }); - - render(); - - await userEvent.click(await screen.findByRole("button", { name: /show token/i })); - await userEvent.click(await screen.findByRole("button", { name: /hide/i })); - - // After Hide, the Show token button is back; the split-out - // header/payload/signature spans should be gone. - expect(await screen.findByRole("button", { name: /show token/i })).toBeInTheDocument(); - expect(screen.queryByText("signature")).not.toBeInTheDocument(); - }); }); describe("DeveloperSetup — AppConfigPanel", () => { @@ -175,7 +120,7 @@ describe("DeveloperSetup — AppConfigPanel", () => { it("uses ?format=env on mount and switches to ?format=json on tab click", async () => { const { calls } = recordingFetch(({ url }) => { if (url.includes("/jwt")) { - return jwtResponse({ redacted: true, token: "" }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } if (url.includes("format=json")) { return new Response( @@ -197,7 +142,11 @@ describe("DeveloperSetup — AppConfigPanel", () => { await waitFor(() => { expect( - calls.find((c) => c.url.includes("app-config?format=env")), + calls.find( + (c) => + c.url.includes("app-config?format=env") && + c.url.includes("include_jwt=true"), + ), ).toBeDefined(); }); @@ -210,7 +159,11 @@ describe("DeveloperSetup — AppConfigPanel", () => { await waitFor(() => { expect( - calls.find((c) => c.url.includes("app-config?format=json")), + calls.find( + (c) => + c.url.includes("app-config?format=json") && + c.url.includes("include_jwt=true"), + ), ).toBeDefined(); }); diff --git a/frontend/src/screens/DeveloperSetup.tsx b/frontend/src/screens/DeveloperSetup.tsx index 1b2470d8..d0b00b2e 100644 --- a/frontend/src/screens/DeveloperSetup.tsx +++ b/frontend/src/screens/DeveloperSetup.tsx @@ -12,10 +12,12 @@ import { W, wMono } from "../tokens"; // DeveloperSetup — the "Developer setup" card from the 2026-05-25 // webui-dashboard.jsx refresh. Two sub-panels: // -// 1. JWT generator: role/audience picker + redacted-by-default -// token preview + "show token" toggle that re-issues with -// ?include_jwt=true. Mirrors the mockup's chip-row controls -// and the colored token-segment display. +// 1. JWT generator: role/audience picker + a usable token preview +// + copy button. LocalNet is loopback-only with dev-secret +// tokens (the dev-secret warning renders below), so the raw +// token is surfaced directly — no redaction toggle. Mirrors the +// mockup's chip-row controls and the colored token-segment +// display. // // 2. App config exporter: format tabs (env / json / yaml) + // monospace preview + copy button. Each format hits the @@ -29,10 +31,9 @@ import { W, wMono } from "../tokens"; const ROLES = ["app-provider", "app-user", "sv"] as const; type Role = (typeof ROLES)[number]; -// Default-redact is enforced server-side; this UI surfaces it -// explicitly. "Show token" triggers a one-shot re-fetch with -// ?include_jwt=true rather than persisting the raw value in -// component state for long — every render re-checks `revealed`. +// The backend redacts JWTs by default; this LocalNet-only UI opts +// into the raw token (?include_jwt=true) so the generated token is +// usable as-is. The dev-secret warning makes the trade-off explicit. export function DeveloperSetup({ name }: { name: string }) { return (
("app-provider"); const [audience, setAudience] = useState("https://canton.network.global"); - const [redacted, setRedacted] = useState(null); - const [revealed, setRevealed] = useState(null); + const [jwt, setJwt] = useState(null); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); - // Fetch a redacted JWT on mount + whenever role/audience/name - // changes. The redacted form gives us the party + warning - // metadata without ever surfacing the raw token by default. + // Issue a usable JWT on mount + whenever role/audience/name changes. + // include_jwt=true so the raw token is returned — LocalNet only. useEffect(() => { let cancelled = false; setBusy(true); - setRevealed(null); // reveal is one-shot per (role, audience) - issueJwt(name, { role, audience }, false) + issueJwt(name, { role, audience }, true) .then((r) => { - if (!cancelled) setRedacted(r); + if (!cancelled) setJwt(r); setErr(null); }) .catch((e) => { @@ -81,17 +79,7 @@ function JwtPanel({ name }: { name: string }) { }; }, [name, role, audience]); - const reveal = async () => { - setBusy(true); - try { - const r = await issueJwt(name, { role, audience }, true); - setRevealed(r.token); - } catch (e) { - setErr(e instanceof ApiError ? e.message : "failed to reveal token"); - } finally { - setBusy(false); - } - }; + const token = jwt?.token ?? null; return ( @@ -120,11 +108,11 @@ function JwtPanel({ name }: { name: string }) { - {redacted?.party ?? "—"} + {jwt?.party ?? "—"}
- +
- {!revealed && ( - - )} - {revealed && ( - - )} - {revealed && ( - - )} +
- {redacted?.warning_dev_secret && ( + {jwt?.warning_dev_secret && (

- {redacted.warning_dev_secret} + {jwt.warning_dev_secret}

)} {err && } @@ -353,8 +323,8 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { function TokenBox({ token, revealed }: { token: string; revealed: boolean }) { // Split the JWT into header.payload.signature for the colored - // preview from the mockup. If the token is the redacted - // placeholder, render it without splitting. + // preview from the mockup. Placeholders ("—", "…") aren't 3-part + // tokens, so they render as plain text. const parts = token.split("."); const isJwt = parts.length === 3 && revealed; return ( From ed6f4a5f231d1815b69aa08d3d1c87d070dfdca7 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 28 Jun 2026 22:44:59 +0200 Subject: [PATCH 21/68] fix(ci): quote E2E workflow names to fix YAML parse error The unquoted 'name: E2E: ...' value was parsed as a nested mapping (colon-space), making both e2e workflows invalid YAML so GitHub Actions never ran them. Quote the value to fix parsing. --- .github/workflows/e2e-test-devkit-functions.yml | 2 +- .github/workflows/e2e-test-dpm-installation.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-test-devkit-functions.yml b/.github/workflows/e2e-test-devkit-functions.yml index 1e3af7ff..1e3f03c4 100644 --- a/.github/workflows/e2e-test-devkit-functions.yml +++ b/.github/workflows/e2e-test-devkit-functions.yml @@ -1,4 +1,4 @@ -name: E2E: canton-devkit Functions +name: "E2E: canton-devkit Functions" # Shell-based end-to-end tests. Each milestone adds a job to this # workflow. Currently: Milestone 1 (LocalNet CLI lifecycle). diff --git a/.github/workflows/e2e-test-dpm-installation.yml b/.github/workflows/e2e-test-dpm-installation.yml index 2a188ab9..285fe0d2 100644 --- a/.github/workflows/e2e-test-dpm-installation.yml +++ b/.github/workflows/e2e-test-dpm-installation.yml @@ -1,4 +1,4 @@ -name: E2E: DPM Installation +name: "E2E: DPM Installation" # Weekly read-only detective check that the published DPM component at # ghcr.io/bitdynamics-ab/canton-devkit is genuinely PUBLIC and usable. From 12b7a4092f3e181c1d6fc525be8731214eb40ed3 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics Date: Tue, 30 Jun 2026 15:18:34 +0530 Subject: [PATCH 22/68] docs: refresh README milestone roadmap (#194) --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 84d28c93..94aadb48 100644 --- a/README.md +++ b/README.md @@ -357,9 +357,10 @@ The `splice` container runs **one Java process** (`SpliceApp daemon`) that hosts | Milestone | Status | Highlights | |---|---|---| -| **M1 — LocalNet CLI** | ✅ Shipped | `up` / `down` / `status` / `list` / `logs` / `env` / `doctor` / `snapshot` / `restore` + friendly errors | -| **M2 — Web UI + Observability + DAR + Agent skills** | 🚧 In progress | Dashboard, container health, JWT issuer, app-config exporter, snapshot/restore UI | -| **M3 — Canton Token Standard** | 📅 Planned | `token create` / `mint` / `transfer` / `balance` — CLI + Web UI. Tracks [CIP-0056](https://github.com/canton-foundation/cips/blob/main/cip-0056/cip-0056.md) (finalised) and incorporates [CIP-0112](https://github.com/canton-foundation/cips) (V2 draft — privacy, performance, accounting improvements) as it stabilises | +| **M1 — LocalNet CLI + packaging** | ✅ Shipped | Lifecycle commands (`up` / `down` / `restart` / `clean` / `status` / `logs`), named instances, version pinning, explicit ports, snapshot/restore, doctor/preflight, deterministic automation output, DPM component, Homebrew/APT, and standalone release artifacts | +| **M2 — Web UI + observability + DAR + Explorer** | ✅ Shipped | Web UI parity for LocalNet lifecycle, logs, env export, snapshots, and preflight; Prometheus/Grafana with Canton dashboard presets; `metrics`; DAR upload/list/info/download/diff/remove/build-upload/watch; ACS + transaction Explorer; optional agent skill docs | +| **M3 — CIP-0112 token tooling** | ✅ Shipped | Token workspace for LocalNet: party aliases, `token create`, `mint`, `transfer`, `burn`, `faucet`, `balance(s)`, `summary`, and `activity`, targeting the Token Standard V2 / CIP-0112 path via the `token-standard-v2` catalogue entry and `tokens-v2` profile | +| **M4 — Ecosystem outreach** | 🚧 Evidence package in progress | Measurement stack is shipped: external smoke-cron kit, privacy-preserving telemetry collector/dashboard, GitHub release-download snapshots, Homebrew/APT install-surface signals, and reviewer kit. Acceptance still depends on documented external usage: 5 apps/projects, 250 cumulative installs/downloads, 2 workshops, and 1 case study/blog post | Follow progress in [open PRs](https://github.com/bitdynamics-ab/canton-devkit/pulls), or [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new) to weigh in on direction. From 1690b134856b1f9bc7bdfa9bd54db06933ec6e35 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 2 Jul 2026 15:59:33 +0000 Subject: [PATCH 23/68] docs: clean up outdated/duplicated content and fix cross-links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reconcile platform-support claim in faq.md with the tested matrix (macOS arm64, Linux amd64, Windows amd64). - Drop the "Homebrew not yet published" hedge in getting-started; point to docs/homebrew.md (tap + formula automation already ship). - Rewrite the limitations.md observability section: the host-level shared stack has shipped (observability.md is authoritative); the remaining limitation is the transitional per-instance dual stack. - Remove brittle "proposal line 188" citations. - Fix a broken link to a nonexistent docs/issues/*.md in the M1 e2e transcript. - Align install-snippet version placeholders in packaging.md. - Add observability.md to the README docs index. - Move two stale internal docs out of the tree (content preserved in Linear BIT-234 and BIT-235): design/localnet-token-workspace.md (shipped; now covered by docs/tokens.md) and ux-improvement-followup.md (residual --name→positional TODO). - Remove obsolete .claude/launch.json. --- .claude/launch.json | 14 --- README.md | 1 + docs/design/localnet-token-workspace.md | 160 ------------------------ docs/faq.md | 8 +- docs/getting-started.md | 9 +- docs/limitations.md | 52 ++++---- docs/observability.md | 3 +- docs/packaging.md | 8 +- docs/tests/e2e-test-milestone-1.md | 1 - docs/ux-improvement-followup.md | 75 ----------- 10 files changed, 42 insertions(+), 289 deletions(-) delete mode 100644 .claude/launch.json delete mode 100644 docs/design/localnet-token-workspace.md delete mode 100644 docs/ux-improvement-followup.md diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 0aa4d6ee..00000000 --- a/.claude/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "devkit-ui", - "runtimeExecutable": "./bin/canton-devkit", - "runtimeArgs": ["localnet", "ui", "--port", "7777"], - "port": 7777, - "env": { - "CANTON_DEVKIT_REGISTRY": "/tmp/devkit-preview-registry" - } - } - ] -} diff --git a/README.md b/README.md index 94aadb48..03ee5a50 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Optional `--profile observability` adds **Prometheus + Grafana** with a curated > **Docs index:** [Getting started](docs/getting-started.md) · > [Tokens (CIP-0112 / V2)](docs/tokens.md) · > [Explorer](docs/explorer.md) · +> [Observability](docs/observability.md) · > [Dashboard customization](docs/dashboard-customization.md) · > [FAQ](docs/faq.md) · > [Troubleshooting](docs/troubleshooting.md) · diff --git a/docs/design/localnet-token-workspace.md b/docs/design/localnet-token-workspace.md deleted file mode 100644 index 047c5961..00000000 --- a/docs/design/localnet-token-workspace.md +++ /dev/null @@ -1,160 +0,0 @@ -# LocalNet token workspace — "god-mode" token management - -## Problem - -Doing anything with tokens across parties on a LocalNet instance is -currently raw-protocol work. To verify one cross-party transfer in -testing we had to: - -1. Allocate a party via raw `grpcurl` against PartyManagementService. -2. Grant `CanActAs`/`CanReadAs` via raw `grpcurl` against - UserManagementService. -3. Look up the participant ledger port by hand. -4. Look up the DSO admin party by hand. -5. Capture a 130-char `TransferInstruction` contract id and paste it - into a second command. -6. Hand-inject the Amulet instrument into `state.json` so the UI list - would show it. - -None of that is something a DevKit user should ever touch. The current -token surface treats each party as if it were an independent -custodian — which is the right model for a *production* wallet, but the -wrong model for a LocalNet sandbox. - -## Core insight - -**On LocalNet there is no trust boundary between parties — you own all -of them.** The `unsafe` dev secret signs for every role; the operator -can allocate parties, grant rights, and read any ACS at will. A LocalNet -token tool should be built around that fact, not fight it. - -That reframes the whole surface from "a wallet per party" to "a single -god-mode workspace over the instance" where the developer can: - -- refer to parties by short alias, never by fingerprinted id, -- see every party's balance of every instrument at once, -- move tokens between any two parties in one step, -- fund a fresh party instantly, - -without ever thinking about JWTs, ports, rights, or contract ids. - -## Proposal - -Five pieces. (1) and (2) are the foundation; the rest build on the -alias registry. - -### 1. Party registry with aliases - -`registry.State` gains `Parties map[string]PartyRef` (alias → party id + -participant role + created_at). Populated two ways: - -- **Auto-seed on `up`**: the bootstrap local parties get aliases - `app-user`, `app-provider`, `sv` (mirroring the roles) — discovered - via `ListKnownParties` + the role-prefix match we already do in - `localPartiesForRole`. -- **`localnet party new `**: allocates a party - (PartyManagementService), auto-grants the role JWT `CanActAs` + - `CanReadAs` for it (the manual grpcurl step #2 we hit), and records - the alias. - -Everywhere a party id is accepted — `--from`, `--to`, `--party`, -`balance --party` — an alias resolves transparently via the registry. -A 90-char id still works; an alias is just sugar. - -New CLI: - -``` -localnet party ls # alias → id table -localnet party new bob # allocate + grant + record -localnet party rm bob # forget alias (party stays on ledger) -``` - -### 2. Multi-party balance matrix - -Because the operator can read as every registered party, the natural -view is one table — **instruments × parties → amount** — not a -single-party wallet. - -``` -localnet token balances # the whole matrix -INSTRUMENT app-user app-provider sv bob -Amulet 10985.16 4220.16 9301.5 75.0 -``` - -Implementation: iterate the registry's parties, ACS-query each with the -HoldingV2 filter (we already have `runBalanceLive` per party), pivot -into a matrix. The existing single-party `balance` stays for scripting. - -Web UI: replace the per-instrument "Holdings" sub-table with this -matrix as the default Tokens view; party columns come from the alias -registry, instruments from on-chain discovery (#4). - -### 3. One-shot auto-accept transfer - -Two-step offer→accept is correct V2 semantics, but on LocalNet the -operator controls the receiver, so the ceremony is pure friction for -iteration. Add `--auto-accept` (default true on LocalNet, override -with `--no-auto-accept` to exercise the real two-step flow): - -``` -localnet token transfer alice bob 75 --instrument Amulet -# offer → capture instruction id → accept as bob, in one command -``` - -Builds directly on the offer/accept orchestration already shipped; just -chains them when the receiver alias is locally hosted. Falls back to the -two-step flow (print the instruction id) when the receiver isn't a -locally-controlled party. - -### 4. On-chain instrument discovery - -Stop relying on `registry.State.Tokens` for the instrument list. Scan -the ACS for every contract implementing `HoldingV2`, collect distinct -`instrumentId` values, and present that as the instrument set (Amulet + -anything `token create` produced). `state.Tokens` stays as the source -of human metadata (name, symbol, decimals) but no longer gates -visibility. Removes the manual-seed hack and makes the UI reflect the -ledger. - -### 5. Faucet - -Funding a fresh test party is the single most common dev need and is -currently impossible (mint is unsupported on Amulet). A faucet taps an -already-funded party (the SV or validator operator wallet, which holds -Amulet from LocalNet bootstrap) and transfers to the target: - -``` -localnet token faucet bob 100 # sv → bob, 100 Amulet, auto-accepted -``` - -Implemented as a transfer (#3) from a well-known funded party — no new -ledger primitive, just a convenience wrapper. - -## What stays the same - -- The low-level live transfer/accept/balance orchestration is the - engine; this is all sugar + discovery on top. -- Production-shaped single-party commands remain for users who want to - exercise real wallet semantics. -- No change to the auth model — still the `unsafe` dev secret, - loopback-only, never reused against a real network. - -## Sequencing - -1. **Party registry + aliases (#1)** — foundation, unblocks everything. -2. **Balance matrix (#2)** — highest day-to-day value once aliases exist. -3. **Auto-accept (#3)** + **faucet (#5)** — small wrappers on the above. -4. **Instrument discovery (#4)** — independent; can land any time, fixes - the UI cosmetic gap. - -## Open questions - -- Alias collisions / reserved names (`sv`, `app-user`) — reject or - shadow? -- Should `party new` auto-grant on *all three* participants or just the - role that allocated it? (Cross-participant parties need explicit - hosting; LocalNet's default topology hosts each party on one - participant.) -- Faucet source selection: always SV, or pick the richest funded party - automatically? -- CLI ↔ UI parity (AGENTS.md): every piece here lands on both surfaces. diff --git a/docs/faq.md b/docs/faq.md index 4a342866..f5d57f67 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -23,9 +23,11 @@ immutable commit SHA and verified by SHA-256 after extraction. See [versions.md](versions.md). **Which platforms are supported?** -macOS (arm64) and Linux (amd64) are the primary, CI-tested targets. -Windows (amd64) binaries are published; cross-platform coverage is -tracked under the release matrix. +macOS (arm64), Linux (amd64), and Windows (amd64) are the released, +tested targets. Other OS/arch combinations may work (DevKit only +orchestrates Docker) but are untested — `localnet doctor` warns on +unsupported platforms. See the compatibility matrix in +[getting-started.md](getting-started.md#5-compatibility-matrix). ## Versions diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b1fd992..9801f266 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -184,14 +184,15 @@ Move-Item canton-devkit-dist\canton-devkit.exe "$env:USERPROFILE\bin\canton-devk canton-devkit version ``` -### Homebrew (when published) +### Homebrew (macOS arm64 / Linux amd64) ```bash -brew install bitdynamics-ab/tap/canton-devkit +brew tap bitdynamics-ab/canton-devkit +brew install canton-devkit ``` -> Homebrew availability is tracked separately; until the tap is -> published, use the standalone download above. +> See [homebrew.md](./homebrew.md) for the direct-formula install, +> the tap layout, and how the formula is kept in sync on each release. ### From source (Go toolchain) diff --git a/docs/limitations.md b/docs/limitations.md index 449a4721..e7a96c51 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -120,30 +120,28 @@ and links to follow-up tickets where applicable. Updated as we ship. rather than DPM until the Windows `.exe` path through DPM is verified. -## Shared observability stack - -- **Observability is per-instance, not a single host-level stack - (yet).** Each LocalNet started (or runtime-toggled) with observability - gets its own Prometheus **and** Grafana container, joined to that - instance's docker network. Two observability-enabled instances - therefore run two of each — roughly **~250–350 MiB each** for - Prometheus and Grafana respectively (so ~600 MiB of duplicated - overhead per extra environment). -- **Trade-off.** The per-instance model keeps scraping trivial: - Prometheus resolves `canton:10013` / `splice:10013` over the - instance's own docker network DNS, so no metrics port is published to - the host and instances never contend on one scrape config. The cost - is the duplicated RAM above when several environments run at once. -- **Planned follow-up — host-level shared stack.** The original - proposal (docs/original-devkit-proposal.md line 188) envisioned ONE - host-level Prometheus + Grafana serving every instance via Prometheus - file-based service discovery (`file_sd_configs`) regenerated as - instances come and go. That requires the shared Prometheus to reach - each instance's metrics endpoint across docker networks (joining every - instance network, or publishing the metrics port to loopback) plus - refcounted teardown when the last instance referencing it goes away — - a larger, networking-sensitive change deferred to keep this pass - coherent. The runtime toggle already funnels through a single neutral - function (`internal/localnet.SetObservability`), so the migration is - additive rather than a rewrite of both surfaces. Tracked as a - `// TODO: shared observability stack` follow-up. +## Observability: transitional dual stack + +The host-level shared Prometheus + Grafana stack has shipped — one +stack serves every running LocalNet via file-based service discovery, +refcounted by target file. See +[docs/observability.md](observability.md#stack-topology--host-shared-with-a-transitional-per-instance-overlay) +for the topology. + +- **Each observability-enabled instance still *also* runs a + per-instance Prometheus + Grafana overlay** alongside the shared + stack, so while running it has **two** Prometheus and **two** Grafana + containers — roughly **~600 MiB** of duplicated overhead per extra + environment. +- **Why it's kept (for now).** The per-instance overlay is a deliberate + fallback: both the CLI and the Web UI read shared-first and fall back + to the per-instance Prometheus when the shared stack isn't up, and the + per-instance scrape uses in-network service DNS (`canton:10013`) + rather than `host.docker.internal`, so it works on any platform + regardless of the Linux `host-gateway` mapping. +- **Follow-up.** Gating the per-instance overlay off (to drop the + duplication) is deferred until the shared-only path is end-to-end + validated on a native Linux Docker host. The runtime toggle funnels + through a single neutral function + (`internal/localnet.SetObservability`), so removing the overlay is + additive rather than a rewrite of both surfaces. diff --git a/docs/observability.md b/docs/observability.md index b3cd7772..69c697f5 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -124,7 +124,8 @@ the stable-port contract kept the bookmarked Grafana URL alive. ## Stack topology — host-shared, with a transitional per-instance overlay A single **host-level** Prometheus + Grafana (#39) serves every running -LocalNet, fulfilling the original proposal (line 188). It runs as its own +LocalNet, fulfilling the original proposal's shared-observability goal. +It runs as its own compose project (`canton-devkit-observability`), independent of any instance's lifecycle. Each observability-enabled instance publishes its canton/splice `:10013` metrics ports on `127.0.0.1:` and writes diff --git a/docs/packaging.md b/docs/packaging.md index 5dd9c1ec..ffde4358 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -31,13 +31,13 @@ paths. Verify before unpacking/installing: ```sh sha256sum --check SHA256SUMS -tar -xzf canton-devkit_v0.1.0_linux_amd64.tar.gz +tar -xzf canton-devkit_v0.7.0_linux_amd64.tar.gz ./canton-devkit localnet --help ``` > **Version-string asymmetry:** the standalone archive filenames keep the -> `v` prefix (`canton-devkit_v0.1.0_…`), matching the git tag, while the -> DPM/OCI tag strips it (`…:0.1.0`) because DPM requires a bare-semver +> `v` prefix (`canton-devkit_v0.7.0_…`), matching the git tag, while the +> DPM/OCI tag strips it (`…:0.7.0`) because DPM requires a bare-semver > tag. Same release, two conventions — chosen to match each ecosystem's > norm. @@ -138,7 +138,7 @@ apt policy canton-devkit Direct artifact install remains available: ```sh -sudo apt install ./canton-devkit_0.9.0_amd64.deb +sudo apt install ./canton-devkit_0.7.0_amd64.deb canton-devkit version ``` diff --git a/docs/tests/e2e-test-milestone-1.md b/docs/tests/e2e-test-milestone-1.md index 6834ece6..e535ff65 100644 --- a/docs/tests/e2e-test-milestone-1.md +++ b/docs/tests/e2e-test-milestone-1.md @@ -819,7 +819,6 @@ The test plan assumes command syntax that differs from the actual CLI implementa **Severity:** Medium **Test:** M1-CLN-001 -**Issue:** [`docs/issues/down-clean-orphaned-volumes.md`](../issues/down-clean-orphaned-volumes.md) `localnet down` (default) deregisters the instance from the registry on success. A subsequent `localnet clean --name X --force` then reports "Nothing to clean" but Docker volumes remain on disk. This is a design gap — both commands work correctly individually but don't compose in the `down` → `clean` sequence. diff --git a/docs/ux-improvement-followup.md b/docs/ux-improvement-followup.md deleted file mode 100644 index 34bc6f83..00000000 --- a/docs/ux-improvement-followup.md +++ /dev/null @@ -1,75 +0,0 @@ -# UX Improvement Followup: Positional Instance Name + Aliases - -This tracks the remaining docs and code surfaces that still use the -`--name ` flag form after the CLI was updated to accept the -instance name as a positional argument (e.g., `localnet up dev` -instead of `localnet up --name dev`). The `--name` flag still works -(backward compatible); these updates are cosmetic — switching -examples and suggestions to the shorter positional form. - -Also tracks surfaces that should mention the `start`/`stop` aliases -for `up`/`down`. - -## Context - -- **PR**: initial implementation of positional name + aliases -- **What changed**: 11 lifecycle commands (`up`, `down`, `restart`, - `pause`, `resume`, `env`, `logs`, `creds`, `snapshot`, `restore`, - `status`) accept the instance name as an optional positional arg -- **Aliases**: `start` → `up`, `stop` → `down` - -## Remaining work - -### 1. UI handler error strings - -User-facing error messages in the Web UI handlers still suggest -`--name` form. Update to positional form. - -- [ ] `internal/ui/handlers/instances.go` — `dpm localnet down --name …` - and `dpm localnet up --name …` suggestion strings -- [ ] `internal/ui/handlers/dar.go` — restart suggestion: - `dpm localnet down --name … followed by dpm localnet up --name …` -- [ ] `internal/ui/handlers/dar_inspect.go` — same pattern as dar.go -- [ ] `internal/ui/handlers/contracts.go` — same pattern as dar.go -- [ ] `internal/ui/handlers/metrics.go` — - `dpm localnet up --profile observability --name …` - -### 2. Internal doc-comment examples - -- [ ] `internal/ui/term/box.go` — doc-comment example: - `dpm localnet env --name hubble` -- [ ] `internal/ui/term/step.go` — doc-comment example: - `dpm localnet up --name hubble` - -### 3. User-facing docs guides - -Update lifecycle command examples from `--name` to positional form: - -- [ ] `docs/getting-started.md` — walkthrough commands (~15 instances) -- [ ] `docs/troubleshooting.md` — suggested commands (~10 instances) -- [ ] `docs/observability.md` — example commands (~4 instances) -- [ ] `docs/dashboard-customization.md` — example commands (~5 instances) -- [ ] `docs/tokens.md` — lifecycle examples (~3 instances; skip - `token create --name` which is a token name, not instance name) -- [ ] `docs/explorer.md` — lifecycle examples (~6 instances) -- [ ] `docs/limitations.md` — CLI usage notes (~2 instances) -- [ ] `docs/validation-checklist.md` — validation commands (~3 instances) -- [ ] `docs/faq.md` — prose reference to `--name` (~1 instance) - -### 4. E2E test transcript docs - -These are verbose test-case transcripts. The `--name` form still works, -so these are low priority but should eventually reflect the preferred -form. - -- [ ] `docs/tests/e2e-test-milestone-1.md` — ~100+ `--name` instances - across lifecycle commands; also update the conventions section - (lines ~779-782) to document positional form and aliases -- [ ] `docs/tests/e2e-test-milestone-2.md` — ~50+ `--name` instances -- [ ] `docs/tests/e2e-test-milestone-3.md` — ~20+ `--name` instances - -## Explicitly out of scope - -- `docs/original-devkit-proposal.md` — historical proposal, left as-is -- `docs/proposals/*` — design proposals, left as-is -- `AGENTS.md` — contributor conventions, not user-facing examples From 8797be007af7c6e6be1835d5e6b9d5d017271cf2 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:36:04 +0530 Subject: [PATCH 24/68] docs: reframe for open source; remove internal process docs Remove documents that only made sense inside the development-fund process: the original proposal, the proposal-deviation log, the reviewer kit, the internal telemetry design proposal, and the per-milestone e2e test scripts. Reframe the remaining docs in neutral OSS voice: no milestone/acceptance framing, no internal reviewer process, working links only. Replace AGENTS.md with CONTRIBUTING.md: same engineering rules (build, test, lint, testing requirements, the CLI/Web-UI parity convention, commit and PR guidance) minus the internal process framing. Drop CLAUDE.md and local tooling entries from .gitignore. --- .gitignore | 3 - CLAUDE.md | 1 - AGENTS.md => CONTRIBUTING.md | 59 +- README.md | 25 +- docs/adoption/reviewer-kit.md | 76 - docs/changes-from-proposal.md | 338 ----- docs/dashboard-customization.md | 5 +- docs/explorer.md | 6 +- docs/faq.md | 7 +- docs/getting-started.md | 9 +- docs/homebrew.md | 23 +- docs/limitations.md | 86 +- docs/observability.md | 14 +- docs/original-devkit-proposal.md | 409 ------ docs/packaging.md | 23 +- docs/proposals/telemetry.md | 113 -- docs/telemetry.md | 15 +- docs/tests/e2e-test-milestone-1.html | 1431 ------------------- docs/tests/e2e-test-milestone-1.md | 843 ----------- docs/tests/e2e-test-milestone-2.html | 1942 -------------------------- docs/tests/e2e-test-milestone-2.md | 971 ------------- docs/tests/e2e-test-milestone-3.html | 1323 ------------------ docs/tests/e2e-test-milestone-3.md | 526 ------- docs/tokens.md | 4 +- docs/troubleshooting.md | 3 +- docs/validation-checklist.md | 21 +- docs/versions.md | 51 +- telemetry-collector/DEPLOY.md | 59 +- telemetry-collector/README.md | 53 +- 29 files changed, 219 insertions(+), 8220 deletions(-) delete mode 100644 CLAUDE.md rename AGENTS.md => CONTRIBUTING.md (65%) delete mode 100644 docs/adoption/reviewer-kit.md delete mode 100644 docs/changes-from-proposal.md delete mode 100644 docs/original-devkit-proposal.md delete mode 100644 docs/proposals/telemetry.md delete mode 100644 docs/tests/e2e-test-milestone-1.html delete mode 100644 docs/tests/e2e-test-milestone-1.md delete mode 100644 docs/tests/e2e-test-milestone-2.html delete mode 100644 docs/tests/e2e-test-milestone-2.md delete mode 100644 docs/tests/e2e-test-milestone-3.html delete mode 100644 docs/tests/e2e-test-milestone-3.md diff --git a/.gitignore b/.gitignore index ada4c161..c7c9eef3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,6 @@ dist/ .idea/ .vscode/ -# Agent runtime state -.claude/scheduled_tasks.lock - # Vite/React build output for the embedded Web UI. The placeholder # index.html is tracked so go:embed has at least one match on a # fresh clone; `make frontend` overwrites it with the real bundle. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/AGENTS.md b/CONTRIBUTING.md similarity index 65% rename from AGENTS.md rename to CONTRIBUTING.md index 4b1a80d5..15f48cae 100644 --- a/AGENTS.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Agent Guidelines for canton-devkit +# Contributing to canton-devkit -This file provides guidelines for AI agents contributing to canton-devkit. +Thanks for your interest in contributing! This document describes the conventions the project follows and what we look for in a pull request. ## Project Overview @@ -10,11 +10,24 @@ canton-devkit is a CLI tool for managing Canton LocalNet developer environments. - **CLI Framework:** Cobra - **Module path:** `github.com/bitdynamics-ab/canton-devkit` +## Getting Started + +```sh +git clone https://github.com/bitdynamics-ab/canton-devkit.git +cd canton-devkit +make build # → ./bin/canton-devkit +make test # run Go tests +make lint # golangci-lint +make frontend # build the Web UI bundle (optional) +``` + +For anything non-trivial, please [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first to discuss the change. + ## Code Change Rules -### CLI ↔ Web UI parity (load-bearing) +### CLI ↔ Web UI parity -**Any user-facing feature must land on BOTH the CLI and the Web UI surface when it applies to both.** Single-surface features are a long-term debt: an operator who learns the feature in one place can't find it in the other, and the surfaces drift in subtle ways (different validation, different error shapes, different timeouts). +**Any user-facing feature must land on BOTH the CLI and the Web UI surface when it applies to both.** This is a core project convention. Single-surface features are a long-term debt: an operator who learns the feature in one place can't find it in the other, and the surfaces drift in subtle ways (different validation, different error shapes, different timeouts). When adding or changing a feature, ask: @@ -31,9 +44,9 @@ When the work spans both: - **Mirror the verbs.** If the UI gets `POST /api/instances/{name}/containers/{c}/restart`, the CLI should get `dpm localnet container restart `. The CLI name is a wrapper around the same handler logic; both pass through the same shared function. - **Mirror the guards.** If the Web UI's pre-flight gate refuses to start a Splice 0.6.4 instance on a 4 GiB host, `dpm localnet up --version 0.6.4` must refuse it for the same reason. Don't let one surface be lenient where the other is strict. -**When you can't reach parity in the same PR**, file a follow-up ticket and add a `// TODO(#issue): CLI parity — ` comment at the divergence point so reviewers can see it. Never close out a feature as "done" while one surface is silently missing it. +**When you can't reach parity in the same PR**, file a follow-up issue and add a `// TODO(#issue): CLI parity — ` comment at the divergence point so reviewers can see it. Never close out a feature as "done" while one surface is silently missing it. -### Docker Compose teardown must be `-p`-only (load-bearing) +### Docker Compose teardown must be `-p`-only **Teardown verbs (`docker compose down` / `stop` without an explicit service argument) MUST tear down by Docker project label — `-p ` — and MUST NOT pass `-f` compose files, `--env-file`, or `--profile`.** @@ -45,29 +58,6 @@ Rules: - **Service-model subcommands (`restart`/`pause`/`unpause`/`ps`):** these genuinely need the `-f` model, so they MUST replay the enabled profile set via `composeProfiles(state)` (persisted as `state.Profiles` at `up` time, with an adapter fallback for pre-fix instances). Omitting `--profile` here targets zero services. - **Explicitly-targeted single-service actions** (e.g. `docker compose -p stop `): exempt — explicitly naming a service bypasses profile filtering per the compose docs. -### Proposal deviation tracking (load-bearing) - -**Any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to `docs/original-devkit-proposal.md` MUST add or update an entry in `docs/changes-from-proposal.md` in the same PR.** - -The file exists so the committee, reviewers, and future contributors can see exactly where the shipped implementation differs from what was proposed — and why. Letting it go stale defeats the purpose. - -What triggers an update: - -- A new command or subcommand is added that the proposal did not name. -- A command or flag is renamed from the proposal's wording. -- A flag's semantics or default changes relative to the proposal's description. -- A new alias is introduced. -- A behaviour described in the proposal is intentionally not implemented, or is deferred with a `// TODO` comment. -- A behaviour not mentioned in the proposal is added that a user would notice (e.g. a confirmation prompt, a new opt-out mechanism, a different connection model). - -What does **not** trigger an update: - -- Internal refactors with no user-visible effect. -- Bug fixes that bring behaviour in line with what the proposal described. -- Docs-only changes. - -The instruction lives in `AGENTS.md` (not a `.claude/skills/` skill) because it must fire on every contributor session, not only when an agent judges a "proposal-tracking" task is active. - ### Testing Requirements - **All bug fixes must include regression tests** @@ -129,13 +119,4 @@ Before submitting: 3. No test coverage regression (check with `go tool cover`) 4. Relevant documentation added/updated 5. PR title is clear and understandable -6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up ticket is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. -7. **Proposal deviation tracking:** if the change introduces or alters command syntax, flags, aliases, defaults, or user-facing behaviour relative to `docs/original-devkit-proposal.md`, `docs/changes-from-proposal.md` is updated in this PR. See "Proposal deviation tracking" rule above. - -## Temporary Files & Folders - -When you need to create temporary files or directories, create them inside the **current working directory** or the **repository/worktree root** — not in `/tmp` or other system-level directories. This keeps operations within the workspace and avoids triggering permission approval prompts. - -- Use relative paths like `./tmp/`, `./.tmp/`, or a descriptive name in the project root. -- Clean up temporary files and directories when they are no longer needed. -- **Exception**: Using `/tmp` is allowed only when it is the only viable option — e.g., sharing data with another program or user that expects `/tmp`, or inspecting output written there by external tools (like tmux debug output). Exhaust in-project alternatives first. \ No newline at end of file +6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up issue is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. diff --git a/README.md b/README.md index 03ee5a50..d1ad8627 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Optional `--profile observability` adds **Prometheus + Grafana** with a curated > [Telemetry](docs/telemetry.md) > > Demo: [`scripts/demo.sh`](scripts/demo.sh) (guided tour) · -> [`scripts/validate-zero-to-localnet.sh`](scripts/validate-zero-to-localnet.sh) (timed M1 check) +> [`scripts/validate-zero-to-localnet.sh`](scripts/validate-zero-to-localnet.sh) (timed zero-to-LocalNet check) ### 1 · Install @@ -350,18 +350,21 @@ The `splice` container runs **one Java process** (`SpliceApp daemon`) that hosts | **Splice integration** | We download `cluster/compose/localnet/` from upstream [`canton-network/splice`](https://github.com/canton-network/splice) — described by the project as *"reference applications for operating Validators and Super-Validators on the Canton Network"* — pinned by commit SHA (immutable) and verified by SHA-256 post-extract. No forks, no patches. Maintainer flow: [`docs/versions.md`](docs/versions.md) | | **Registry** | Every instance has a `state.json` (ports, JWTs, party IDs, compose project name). Single source of truth for CLI + Web UI. Atomic writes + index lock for concurrent ups | | **JWT signing** | Splice LocalNet authenticates ledger and app traffic with a **fixed dev secret** — the literal string `unsafe` — applied to HS-256 JWTs (Splice config labels: `unsafe-jwt-hmac-256` / `hs-256-unsafe`). The DevKit signs JWTs locally with that same secret so client code can `Bearer ` against the local participant. **Never reuse against MainNet or any non-LocalNet deployment** — warning reprinted on every signing path | -| **CLI ↔ Web UI parity** | Every user-facing operation lands on both surfaces. Codified in [`AGENTS.md`](AGENTS.md). No UI-only or CLI-only features | +| **CLI ↔ Web UI parity** | Every user-facing operation lands on both surfaces. Codified in [`CONTRIBUTING.md`](CONTRIBUTING.md). No UI-only or CLI-only features | --- ## 🗺️ Roadmap -| Milestone | Status | Highlights | -|---|---|---| -| **M1 — LocalNet CLI + packaging** | ✅ Shipped | Lifecycle commands (`up` / `down` / `restart` / `clean` / `status` / `logs`), named instances, version pinning, explicit ports, snapshot/restore, doctor/preflight, deterministic automation output, DPM component, Homebrew/APT, and standalone release artifacts | -| **M2 — Web UI + observability + DAR + Explorer** | ✅ Shipped | Web UI parity for LocalNet lifecycle, logs, env export, snapshots, and preflight; Prometheus/Grafana with Canton dashboard presets; `metrics`; DAR upload/list/info/download/diff/remove/build-upload/watch; ACS + transaction Explorer; optional agent skill docs | -| **M3 — CIP-0112 token tooling** | ✅ Shipped | Token workspace for LocalNet: party aliases, `token create`, `mint`, `transfer`, `burn`, `faucet`, `balance(s)`, `summary`, and `activity`, targeting the Token Standard V2 / CIP-0112 path via the `token-standard-v2` catalogue entry and `tokens-v2` profile | -| **M4 — Ecosystem outreach** | 🚧 Evidence package in progress | Measurement stack is shipped: external smoke-cron kit, privacy-preserving telemetry collector/dashboard, GitHub release-download snapshots, Homebrew/APT install-surface signals, and reviewer kit. Acceptance still depends on documented external usage: 5 apps/projects, 250 cumulative installs/downloads, 2 workshops, and 1 case study/blog post | +**Shipped today** + +- **LocalNet lifecycle CLI + packaging** — lifecycle commands (`up` / `down` / `restart` / `clean` / `status` / `logs`), named instances, version pinning, explicit ports, snapshot/restore, doctor/preflight, deterministic automation output, DPM component, Homebrew/APT, and standalone release artifacts +- **Web UI + observability + DAR + Explorer** — Web UI parity for LocalNet lifecycle, logs, env export, snapshots, and preflight; Prometheus/Grafana with Canton dashboard presets; `metrics`; DAR upload/list/info/download/diff/remove/build-upload/watch; ACS + transaction Explorer; optional agent skill docs +- **CIP-0112 token tooling** — token workspace for LocalNet: party aliases, `token create`, `mint`, `transfer`, `burn`, `faucet`, `balance(s)`, `summary`, and `activity`, targeting the Token Standard V2 / CIP-0112 path via the `token-standard-v2` catalogue entry and `tokens-v2` profile + +**Planned** + +- Intel Mac (`darwin_amd64`) and Linux ARM (`linux_arm64`) release artifacts Follow progress in [open PRs](https://github.com/bitdynamics-ab/canton-devkit/pulls), or [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new) to weigh in on direction. @@ -372,7 +375,7 @@ Follow progress in [open PRs](https://github.com/bitdynamics-ab/canton-devkit/pu
Is this an official Canton or Digital Asset project? -No. It's a community tool built by [Bit Dynamics AB](https://bitdynamics.me/) under a [Canton Foundation grant](https://github.com/canton-foundation/canton-dev-fund/pull/18). The upstream Splice repo it wraps is governed by the [Canton Network](https://canton.network/). +No. It's a community tool built by [Bit Dynamics AB](https://bitdynamics.me/). The upstream Splice repo it wraps is governed by the [Canton Network](https://canton.network/).
@@ -449,7 +452,7 @@ For fixtures, snapshot once and check in the `.tgz` (or stash on object storage) ## 🤝 Contributing -Contributions welcome — see [`AGENTS.md`](AGENTS.md) for the full set of conventions. +Contributions welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full set of conventions. **Quick rules** @@ -484,7 +487,7 @@ Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](. ## 💛 Acknowledgements -`canton-devkit` wraps the [Splice LocalNet](https://github.com/canton-network/splice) compose project published by the Canton Network community. Splice is [Digital Asset](https://www.digitalasset.com/)'s open-source reference implementation of the Canton Network validator and super-validator apps. The Global Synchronizer that underpins Canton Network is governed by the [Canton Foundation](https://canton.foundation/), which also funds this project via a [developer grant](https://github.com/canton-foundation/canton-dev-fund/pull/18). Daml — the smart-contract language Canton uses — is developed by Digital Asset. +`canton-devkit` wraps the [Splice LocalNet](https://github.com/canton-network/splice) compose project published by the Canton Network community. Splice is [Digital Asset](https://www.digitalasset.com/)'s open-source reference implementation of the Canton Network validator and super-validator apps. The Global Synchronizer that underpins Canton Network is governed by the [Canton Foundation](https://canton.foundation/). Daml — the smart-contract language Canton uses — is developed by Digital Asset.
diff --git a/docs/adoption/reviewer-kit.md b/docs/adoption/reviewer-kit.md deleted file mode 100644 index 7b30d938..00000000 --- a/docs/adoption/reviewer-kit.md +++ /dev/null @@ -1,76 +0,0 @@ -# DevKit reviewer kit (M1 adoption) - -The M1 adoption metric is: **≥3 external companies/teams have reviewed -canton-devkit and tested LocalNet setup + lifecycle.** This kit is -everything you need to recruit and run those reviews. The recruiting -itself — identifying and contacting teams — is a human step; this page -makes it turnkey once you have a contact. - -> **Status of the metric is people, not code.** Securing 3 external teams -> is outreach work. Track the actual reviewers in the table at the bottom. - -## Who to approach - -Good first reviewers are teams who already touch Canton/Daml and feel the -LocalNet pain canton-devkit removes: - -- Daml app developers who currently hand-roll `docker compose` against - Splice. -- Teams on the CIP-0112 token path. -- Canton Foundation ecosystem contacts (co-marketing). -- Internal teams at partner orgs already piloting Canton. - -## The ask (copy-paste outreach template) - -> Subject: 15-minute LocalNet review — canton-devkit -> -> Hi — we built **canton-devkit**, a single-binary tool that brings -> up a full Canton LocalNet (sequencers, mediators, participants, Splice -> apps) in one command, with a CLI + Web UI for the whole lifecycle and -> CIP-0112 token tooling. -> -> Would you spend ~15 minutes taking it zero-to-running and telling us -> where it's rough? Everything you need is one page: -> `docs/getting-started.md`, and there's a self-timing harness -> (`scripts/validate-zero-to-localnet.sh`) if you want it. -> -> We're specifically validating "new user → running LocalNet in under 10 -> minutes." Your friction notes are the whole point — no prep needed. - -## What to send them - -1. **Install + first run** — [getting-started.md](../getting-started.md). -2. **The checklist** — [validation-checklist.md](../validation-checklist.md) - (manual boxes + the timed harness). -3. **A token walkthrough** (optional, for CIP-0112 teams) — - [tokens.md](../tokens.md) or `scripts/demo.sh --with-tokens`. -4. **Where to file feedback** — a GitHub issue with the `doctor` output + - their platform, or the structured form below. - -## Feedback form (what to collect per reviewer) - -``` -Company / team: -Reviewer: -Platform (OS + arch): -Docker memory: -Cache: cold | warm -zero-to-LocalNet wall-clock: -Result: pass | fail (which step) -Top 3 friction points: -Would they use it again? (y/n + why) -CIP-0112 token flow tried? (y/n) -OK to attribute publicly? (y/n) -``` - -## Tracking - -| # | Company / team | Contact | Date | Result | Friction notes | Public-OK | -|---|---|---|---|---|---|---| -| 1 | _TBD_ | | | | | | -| 2 | _TBD_ | | | | | | -| 3 | _TBD_ | | | | | | - -Three rows filled with a `pass` (or a fixed `fail`) closes the M1 -adoption metric. Feed the friction notes into the UX polish backlog -and the aggregate into the M4 adoption transparency update. diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md deleted file mode 100644 index 11f13d24..00000000 --- a/docs/changes-from-proposal.md +++ /dev/null @@ -1,338 +0,0 @@ -# Changes from Original Proposal - -This document records every deliberate deviation — command syntax, flag names, behaviour, or scope — between the [original DevKit Development Fund proposal](./original-devkit-proposal.md) and the shipped implementation. - -Every deviation listed here is **intentional**, not an oversight or implementation mistake. Each one was made for a concrete reason: improving developer or user experience, system performance or resource efficiency, security, correctness, or CLI ↔ Web UI parity. The per-entry **"Why"** notes record that rationale. Where the proposal's wording was a high-level intent rather than a precise spec, the shipped form is the deliberate concretization of that intent. - -**Maintenance rule:** any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to the proposal **must** add or update an entry here in the same PR. See the "Proposal deviation tracking" rule in [AGENTS.md](../AGENTS.md). - ---- - -## Table of contents - -- [Cross-cutting conventions](#cross-cutting-conventions) - - [Instance name addressing](#instance-name-addressing) - - [Machine-readable output flag](#machine-readable-output-flag) - - [Command aliases](#command-aliases) -- [`localnet up`](#localnet-up) - - [`--allow-uncurated` flag (new)](#--allow-uncurated-flag-new) - - [`--profile` flag (new)](#--profile-flag-new) - - [`--port-base` flag (new)](#--port-base-flag-new) -- [`localnet pause` / `resume` (new)](#localnet-pause--resume-new) -- [`localnet creds` (new)](#localnet-creds-new) -- [`localnet versions` (new)](#localnet-versions-new) -- [`localnet ui` (new)](#localnet-ui-new) -- [`localnet refresh` (new)](#localnet-refresh-new) -- [`localnet container` (new)](#localnet-container-new) -- [`localnet observability` (new)](#localnet-observability-new) -- [`localnet skills` (new)](#localnet-skills-new) -- [`localnet contracts` / `tx`](#localnet-contracts--tx) - - [`contracts ls` (new)](#contracts-ls-new) - - [Endpoint not yet auto-discovered](#endpoint-not-yet-auto-discovered) -- [`localnet dar`](#localnet-dar) - - [Connection flags per-command](#connection-flags-per-command) - - [`--instance` flag name](#--instance-flag-name) -- [`localnet token`](#localnet-token) - - [Additional subcommands (new)](#additional-subcommands-new) - - [`transfer accept` subcommand](#transfer-accept-subcommand) - - [`burn` requires explicit confirmation](#burn-requires-explicit-confirmation) - - [`--instance` required flag](#--instance-required-flag) - - [`--name` collision in `token create`](#--name-collision-in-token-create) -- [`telemetry` (root-level, new)](#telemetry-root-level-new) - ---- - -## Cross-cutting conventions - -### Instance name addressing - -**Proposal said:** instance name is always passed as `--name ` across all commands. - -**Shipped:** -- Most lifecycle/inspection commands (`up`, `down`, `restart`, `pause`, `resume`, `status`, `logs`, `creds`, `snapshot`, `restore`) accept the name as **either** a positional argument **or** `--name` — both are equivalent. Example: `dpm localnet up dev` and `dpm localnet up --name dev` do the same thing. -- `clean`, `list`, `doctor`, `refresh`, `metrics` are `--name`-only (no positional arg). -- `dar` subcommands use `--instance` (alias `--name`). -- `token` subcommands use required `--instance`. - -**Why:** The positional form is faster to type for interactive use and matches conventions in similar tools (`kubectl`, `docker`). `--name`-only commands are those that are conceptually multi-instance by default (e.g. `list`) or where positional args would be ambiguous. - ---- - -### Machine-readable output flag - -**Proposal said:** machine-readable output is requested via `--json`. - -**Shipped:** commands use `--format ` with accepted values `json`, `text` (and sometimes `table`). Example: `dpm localnet status dev --format json`. - -**Why:** `--format` is more flexible (allows future formats such as `yaml` or `table` without adding new flags) and is consistent with the established pattern in tools like `docker` and `gh`. - ---- - -### Command aliases - -The following aliases are not in the proposal but are shipped: - -| Canonical command | Alias(es) | Notes | -|---|---|---| -| `localnet up` | `start` | More intuitive for new users | -| `localnet down` | `stop` | Pair with `start` | -| `localnet observability` | `obs` | Shorter for interactive use | -| `localnet container list` | `ls`, `ps` | Matches Docker CLI conventions | -| `localnet token party ls` | `list` | Consistency within party subcommand | -| `localnet token party rm` | `remove` | Consistency within party subcommand | - -`localnet list` has **no** `ls` alias despite the pattern above — adding it would shadow `localnet logs` with a common prefix, increasing ambiguity in tab-completion. - ---- - -## `localnet up` - -### `--allow-uncurated` flag (new) - -**Proposal said:** `--version ` pins a Splice LocalNet version from the supported set. Unsupported versions were not addressed. - -**Shipped:** `--allow-uncurated` lets users pass a Splice tag that is not in the DevKit curated catalogue. DevKit resolves the tag against the upstream Splice GitHub repo and proceeds, printing a warning that the resulting LocalNet is not tested by DevKit. - -**Why:** Gives power users and maintainers a path to test prereleases and alpha tags without waiting for a catalogue update, while keeping the default path (no flag) restricted to tested versions. - ---- - -### `--profile` flag (new) - -**Proposal said:** per-component toggles for Prometheus and Grafana as a LocalNet configuration model item; the exact mechanism was not specified. - -**Shipped:** `--profile ` (repeatable) is a flag on `localnet up`. Supported values include `prometheus`, `grafana`, and `observability` (legacy umbrella that activates both). Profiles are persisted in instance state so a subsequent `up` re-enables the same set. The `localnet observability enable/disable` command can toggle sidecars on a running instance without `--profile` at `up` time. - -**Why:** Docker Compose profiles are the natural mechanism for optional service groups in the Splice LocalNet stack. Exposing them directly as `--profile` keeps the model transparent and auditable. Persisting the profile set enables reproducible restarts. - ---- - -### `--port-base` flag (new) - -**Proposal said:** named instances use explicit port configuration so two LocalNets can run on one machine, but the mechanism for specifying ports was not defined. - -**Shipped:** `--port-base ` pins host ports deterministically starting from `n` (each service gets `base+N`). With `--port-base 0` (default), ports are auto-allocated with stable reuse across restarts. Every derived port must be free or `up` fails immediately with no silent fallback. - -**Why:** Auto-allocation works for single-developer use; `--port-base` is needed for CI layouts and reproducible multi-instance setups where port assignments must be predictable and documented. - ---- - -## `localnet pause` / `resume` (new) - -**Proposal said:** not mentioned. - -**Shipped:** `dpm localnet pause ` and `dpm localnet resume `. - -`pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT. No readiness wait is performed on resume. - -**Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Frees CPU and reduces resource consumption without discarding ledger state. Required for CLI ↔ Web UI parity (the UI exposes a pause/resume action on the instance card). - ---- - -## `localnet creds` (new) - -**Proposal said:** not mentioned as a standalone command. `env` was the credential/config export surface. - -**Shipped:** `dpm localnet creds [name]` prints the HS256 JWTs captured at `up` time, in four formats: `table` (default — JWTs omitted for safety), `env` (shell-exportable `AUTH__TOKEN=...` lines), `json` (full credential objects including JWTs), `raw` (single JWT, requires `--role`). - -**Why:** `env` covers Ledger API endpoints and wallet URLs; `creds` is the dedicated surface for auth tokens. Separating them avoids combining sensitive credential material with non-sensitive endpoint strings in one command, and makes it easier to handle each category differently (e.g. redact tokens in logs while freely printing URLs). - ---- - -## `localnet versions` (new) - -**Proposal said:** `--version ` in `localnet up` selects the Splice version. Supported versions and a compatibility matrix were mentioned as documentation items, not as a CLI command. - -**Shipped:** `dpm localnet versions` is a live command that lists every Splice version in the DevKit curated catalogue plus every tag the upstream Splice GitHub repository currently exposes. Each row has a status: `supported`, `drifted` (force-pushed — security signal), `available` (upstream only, not yet catalogued), or `catalogued-only` (removed upstream). Supports `--offline` and `--format json`. - -**Why:** The catalogue cross-reference against upstream helps maintainers catch force-pushed tags early (a security signal) and gives users live visibility into which versions are safe to pin, without consulting external documentation. - ---- - -## `localnet ui` (new) - -**Proposal said:** a Web UI exists, but the proposal described it as a dashboard accessible alongside the CLI, not as a separately invocable CLI command. - -**Shipped:** `dpm localnet ui` starts the embedded Vite/React HTTP server (default port 7777, loopback-only). Flags: `--port`, `--host`, `--allow-non-loopback`. Non-loopback binding is refused by default as a DNS-rebinding defence; SSH tunnelling is the recommended remote-access path. - -**Why:** Packaging the UI launch as a CLI subcommand keeps the single-binary model and lets users control when the UI server is running. The loopback-only default and the `--allow-non-loopback` guard are a deliberate security measure — the UI handles JWTs and party identifiers and is not designed for unauthenticated LAN-wide exposure. - ---- - -## `localnet refresh` (new) - -**Proposal said:** not mentioned. - -**Shipped:** `dpm localnet refresh [--name ]` triggers an on-demand reconciliation pass that syncs the registry's persisted status with the live `docker compose ps` state. This is the CLI mirror of the background reconciler that runs inside `localnet ui`. - -**Why:** Required for CLI ↔ Web UI parity. Useful when a user has stopped containers externally (e.g. via `docker compose down` directly) and wants the registry to reflect that without restarting the UI server. - ---- - -## `localnet container` (new) - -**Proposal said:** `dpm localnet restart [service] --name ` restarts the full LocalNet or one service. - -**Shipped:** Full-instance restart remains `dpm localnet restart`. Per-container operations are under a separate `container` parent: - -- `localnet container list ` (aliases `ls`, `ps`) — lists containers with state/health. -- `localnet container restart ` — restarts one container; verifies it belongs to the instance's compose project before acting. -- `localnet container logs ` — tails logs for one container (flags: `--tail`, `--since`). - -**Why:** Separating the `container` subtree from top-level lifecycle commands keeps the namespace clean and mirrors the Web UI's Container Health panel. Accepting both the service short name and the full container name (e.g. `splice` or `pr432-splice`) improves UX over the raw Docker form. The membership check before restart is a security measure that prevents a typo or hostile input from restarting an arbitrary host container. - ---- - -## `localnet observability` (new) - -**Proposal said:** `dpm localnet metrics` prints Grafana dashboard URLs and a concise text summary. No separate toggle command was proposed; observability components were to be controlled via `--profile` flags at `up` time. - -**Shipped:** In addition to `localnet metrics`, a `localnet observability` command (alias `obs`) manages the Prometheus/Grafana sidecars **on a running instance** without restarting Canton: - -- `observability enable [--prometheus] [--grafana]` — brings sidecars up. -- `observability disable [--prometheus] [--grafana]` — stops them; Canton is untouched. -- `observability status` — read-only report of which sidecars are active and their URLs. - -Both `--prometheus` and `--grafana` flags allow controlling each sidecar independently. With neither flag, both are selected (umbrella semantics). The enabled state is persisted so a subsequent `down`/`up` re-enables it automatically. - -**Why:** Enabling observability at `up` time via `--profile` requires a full restart to change. The `observability enable/disable` path lets developers toggle the monitoring stack without disrupting a running ledger — saving the boot cost and preserving in-flight ledger state. Matches the Web UI's "Enable observability now" toggle for CLI ↔ Web UI parity. - ---- - -## `localnet skills` (new) - -**Proposal said:** DevKit "may provide optional, editor-agnostic AI agent skill documents." The proposal described them as documentation artifacts, not as CLI commands. - -**Shipped:** `dpm localnet skills` is a full subcommand tree: - -- `skills list` — lists the embedded skill documents (name, description, filename). -- `skills install [--target claude|codex] [--dir ] [--force]` — writes the embedded skill documents into the appropriate agent skills directory (`~/.claude/skills/` for Claude, `~/.codex/skills/` for Codex). Clobber-safe by default: a destination that exists with different content is skipped unless `--force` is passed. - -The embedded skill docs are the same artifacts that back the Web UI's Agent Skills screen, ensuring CLI and UI show the same content. - -**Why:** Users need a one-step way to install skill documents without manually locating and copying files. The clobber-safe default protects hand-edited skill docs from being silently overwritten on re-install. Required for CLI ↔ Web UI parity (the Web UI's Agent Skills screen surfaces the same embedded docs). - ---- - -## `localnet contracts` / `tx` - -### `contracts ls` (new) - -**Proposal said:** `dpm localnet contracts watch` — live tail of create/archive events. - -**Shipped:** `contracts watch` is present and matches the proposal. In addition, `contracts ls` lists active contracts via a one-shot query rather than a live stream. - -**Why:** A non-streaming snapshot is more useful than a continuous watch in CI and scripted contexts where the caller wants to assert on current state without keeping a long-lived process open. - ---- - -### Endpoint not yet auto-discovered - -**Proposal said:** commands connect to the LocalNet participants automatically (implied by the named-instance model). - -**Shipped:** `contracts` and `tx` commands require callers to pass `--endpoint host:port` explicitly. Auto-discovery of the gRPC participant port from registry state is not yet implemented. A comment in `localnet.go` documents this as pending work. - -**Why:** Auto-discovery was deferred to avoid blocking the initial contract/tx CLI release. The explicit `--endpoint` flag is a deliberate interim design — it keeps the commands usable against any Ledger API endpoint (not just DevKit-managed instances) until the auto-discovery path lands. - ---- - -## `localnet dar` - -### Connection flags per-command - -**Proposal said:** DAR commands connect to participants via the named instance implicitly. - -**Shipped:** Each `dar` subcommand carries its own connection flags: `--admin-host`, `--token`, `--insecure` (defaults to `true`), `--ca-cert`, `--instance` (alias `--name`), `--role` (default `app-user`). There is no standalone `dar connect` command. - -**Why:** Per-command connection flags make the DAR subcommands usable against any Ledger API endpoint, not just DevKit-managed instances. This gives operators more flexibility in CI and multi-environment workflows without requiring a running LocalNet registry. - ---- - -### `--instance` flag name - -**Proposal said:** instance selection is `--name ` uniformly. - -**Shipped:** `dar` subcommands use `--instance` as the primary flag name (with `--name` as an alias). - -**Why:** In `dar` contexts, `--name` is ambiguous between the instance name and the DAR/package name. Using `--instance` as the primary name eliminates that ambiguity and makes commands self-documenting at a glance. - ---- - -## `localnet token` - -### Additional subcommands (new) - -**Proposal said:** `token create`, `token mint`, `token transfer`, `token burn`, `token balance`. - -**Shipped:** all five from the proposal, plus: - -| New command | Purpose | -|---|---| -| `token balances` | Portfolio-style matrix view across all instruments for one or more parties | -| `token summary` | Aggregate stats for one instrument (supply, holder count, recent activity) | -| `token activity` | Recent transaction history feed for an instrument (`--limit` defaults to 50) | -| `token party new ` | Register a named party alias for use in token commands | -| `token party ls` | List registered party aliases | -| `token party rm ` | Remove a party alias | -| `token faucet ` | Fund a party with an auto-accepted transfer (no recipient interaction needed) | -| `token demo` | One-step provision: creates a DEMO instrument and seeds a holder wallet | - -**Why:** The alias registry (`token party`) improves UX by eliminating repeated `--party ` flags across commands. `faucet` and `demo` target workshop and onboarding use cases where speed matters more than exercising the full CIP-0112 two-phase flow. `balances`, `summary`, and `activity` provide portfolio-level and historical views that are essential for verifying token operations during testing. - ---- - -### `transfer accept` subcommand - -**Proposal said:** `token transfer` as a single command. - -**Shipped:** `token transfer` initiates a transfer; `token transfer accept` accepts a pending incoming transfer. CIP-0112 transfers are two-phase (offer + accept), so both halves are exposed as CLI subcommands. - -**Why:** The two-phase model is required by the CIP-0112 protocol — it is not a simplification but a faithful implementation of the standard. Exposing both steps gives scripts and workshops full control over the accept timing, enabling realistic multi-party test scenarios. - ---- - -### `burn` requires explicit confirmation - -**Proposal said:** `token burn {token-name} {amount}` as a straightforward command. - -**Shipped:** `token burn` prompts for confirmation before executing because the operation is irreversible. The prompt is bypassed with `--yes` / `-y`. - -**Why:** Guarding an irreversible ledger operation with a confirmation prompt is standard CLI practice and prevents accidental burns in interactive sessions. The `--yes` flag preserves full scriptability for automation. - ---- - -### `--instance` required flag - -**Proposal said:** token commands connect to the active or `--name`-selected instance. - -**Shipped:** `--instance` is a **required** flag on all `token` subcommands (no default or auto-resolution from a single registered instance). - -**Why:** Making `--instance` explicit prevents token commands from silently targeting the wrong LocalNet when multiple instances are registered — a correctness and safety measure, not an inconvenience. - ---- - -### `--name` collision in `token create` - -**Proposal said:** instance selection via `--name `. - -**Shipped:** In `token create`, `--name` refers to the **instrument name** (e.g. `--name "My Token"`), not the instance. The instance is selected via `--instance`. This is an intentional exception to the general `--name` = instance name convention. - -**Why:** The instrument name is the primary user-facing input in the token creation wizard. Using `--name` for it matches natural language ("name this token") and makes the interactive wizard more intuitive, even though it breaks the global `--name` = instance convention elsewhere. - ---- - -## `telemetry` (root-level, new) - -**Proposal said:** not mentioned. Adoption measurement was described as a reporting/documentation exercise. - -**Shipped:** A root-level `telemetry` command (sibling to `localnet`, not nested under it) manages privacy-preserving usage telemetry: - -- `telemetry on` / `off` — opt in or out. -- `telemetry status` — show current state and the anonymous install ID. -- `telemetry preview [--format]` — show the payload that would be sent without sending it. -- `telemetry flush` — send any buffered events immediately. -- `telemetry reset-id` — generate a new anonymous ID. - -Telemetry is **on by default** with opt-out via `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1`. An internal hidden subcommand `_record-install-surface ` is used by install scripts to record the distribution channel. - -**Why:** Provides the adoption signals described in Milestone 4 (install counts, usage trends) in a privacy-preserving, opt-out model without requiring manual tracking. The opt-out via standard `DO_NOT_TRACK` honours widely adopted ecosystem conventions. Placing it at the root level (not under `localnet`) reflects that it is a tool-wide concern, not a LocalNet-specific one. diff --git a/docs/dashboard-customization.md b/docs/dashboard-customization.md index a9c5709b..4231e055 100644 --- a/docs/dashboard-customization.md +++ b/docs/dashboard-customization.md @@ -293,7 +293,8 @@ pool rather than the ledger itself. and starting LocalNet with the observability overlay. - [docs/observability.md](observability.md) — audited metric families and the `canton_*` → `daml_*` substitution table. -- [docs/telemetry.md](telemetry.md) — what metrics DevKit itself - emits (separate from Canton's metrics). +- [docs/telemetry.md](telemetry.md) — the anonymous usage counters + the DevKit CLI itself records (separate from Canton's Prometheus + metrics). - [docs/troubleshooting.md](troubleshooting.md) — common Grafana / Prometheus startup issues. diff --git a/docs/explorer.md b/docs/explorer.md index d7d33d42..4b35d8cf 100644 --- a/docs/explorer.md +++ b/docs/explorer.md @@ -194,8 +194,8 @@ The **Contracts** view is live: The stream-status pill in the top bar and the table sub-header report the real connection state — `live`, `reconnecting`, `truncated` (the backend capped the stream; reconciliation takes -over), or `idle`. The wording is honest: it tracks the stream, not -a hard-coded label. +over), or `idle`. The label tracks the actual stream state, not a +hard-coded value. The **Transactions** and **Timeline** views are still snapshots — they call `UpdateService` for the most recent N updates. Re-apply @@ -238,7 +238,7 @@ canton-devkit localnet tx replay \ --party alice ``` -The `contracts ls --format json` output now includes the decoded +The `contracts ls --format json` output includes the decoded contract `payload` (the same field the Web UI drawer shows), so a `jq` consumer can read field values, not just contract IDs. diff --git a/docs/faq.md b/docs/faq.md index f5d57f67..025f022f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -13,9 +13,10 @@ participants, Splice apps) in Docker. It gives you a CLI embedded Web UI for the same operations. **CLI or Web UI — which should I use?** -Both expose the same operations (CLI ↔ UI parity is a project rule). Use -the CLI for scripting/CI; `canton-devkit localnet ui` for a dashboard, -the contract explorer, DAR management, metrics, and the token workspace. +Both expose the same operations — the two surfaces are kept in parity +by design. Use the CLI for scripting/CI; `canton-devkit localnet ui` +for a dashboard, the contract explorer, DAR management, metrics, and +the token workspace. **Does it fork or patch Splice?** No. It downloads the upstream `cluster/compose/localnet/` tree pinned by diff --git a/docs/getting-started.md b/docs/getting-started.md index 9801f266..ad4b738a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -144,9 +144,10 @@ sudo apt install canton-devkit=0.7.0 ``` The APT repo is currently unsigned and therefore uses `trusted=yes`; -the release still publishes SHA-256 metadata, and package installation -records a best-effort anonymous `apt` install-surface telemetry ping. -Adding a signed repository key is a follow-up hardening step. +the release still publishes SHA-256 metadata. A signed repository key +is planned. Package installation records a best-effort anonymous `apt` +install-surface telemetry ping — see [telemetry.md](./telemetry.md) +for what is sent and how to opt out before installing. Direct `.deb` install also works: @@ -222,7 +223,7 @@ canton-devkit localnet dar upload ./my-app.dar --instance demo # 6. Watch live contracts. The participant gRPC endpoint isn't # host-published by default, so pass --endpoint host:port -# (auto-discovery from --name is a pending follow-up). Find the +# (auto-discovery from --name is not yet supported). Find the # port under "participant_ledger_app-user" in `status` output. canton-devkit localnet contracts watch --name demo --endpoint localhost: diff --git a/docs/homebrew.md b/docs/homebrew.md index 6a18ab84..51c4550e 100644 --- a/docs/homebrew.md +++ b/docs/homebrew.md @@ -1,30 +1,27 @@ # Homebrew install `canton-devkit` ships a Homebrew formula for macOS (Apple Silicon) and -Linux (x86_64). The formula and downloadable build artifacts live in the public -[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit) -repository so users can download release artifacts without access to the -private source repository. +Linux (x86_64). The formula and downloadable build artifacts live in the +dedicated tap repository +[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit), +following the standard Homebrew tap layout. -This private source repository does not keep a `Formula/` directory. Homebrew +This source repository does not keep a `Formula/` directory. Homebrew distribution files are maintained in `homebrew-canton-devkit`; this repository only keeps the release helper script and docs that describe the process. ## Install (direct, no tap) -After a public release is published and the formula is updated with real -checksums: - ```sh brew install --formula \ https://raw.githubusercontent.com/bitdynamics-ab/homebrew-canton-devkit/main/Formula/canton-devkit.rb ``` -> Note: the formula's stable `url` + `sha256` start as placeholders -> (`version "0.0.0"`, all-zero SHA) until the first release tag is cut; -> the release workflow then rewrites them automatically (see below). -> There is no public `--HEAD` install path because the source repository -> is private. +> Note: the formula's `url` + `sha256` are rewritten automatically by +> the release workflow on every release tag (see below), so the direct +> formula always points at the latest published release. There is no +> `--HEAD` install path — the formula installs prebuilt release +> artifacts only. ## Install (via tap) diff --git a/docs/limitations.md b/docs/limitations.md index e7a96c51..b543e78e 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,49 +1,42 @@ # Known limitations -Living list of things DevKit does not (yet) do well, with the rationale -and links to follow-up tickets where applicable. Updated as we ship. +Things DevKit does not (yet) do well, with the rationale and +workarounds where applicable. This list is updated as limitations are +resolved. ## Instance naming - **`--name` must be a DNS label.** Names are validated against RFC 1123: 1-63 chars of lowercase `[a-z0-9-]`, must start and end with `[a-z0-9]`. Uppercase, underscores, and leading/trailing hyphens are rejected. - We chose DNS-label form so the same name is safe to embed as a - hostname in the future `{service}.{instance}.localhost` routing model - without a second translation step. Single source of truth lives in + DNS-label form was chosen so the same name is safe to embed as a + hostname in a future `{service}.{instance}.localhost` routing model + without a second translation step. The single source of truth lives in `internal/registry/state.go` (`ValidateName`); the CLI layer delegates. - *Migration:* pre-PR-#20 instances created with uppercase or underscore - names (e.g. `MyStack`, `my_stack`) must be torn down with the old - binary and re-created under a DNS-label name. + *Migration:* instances created with an older release that still + allowed uppercase or underscore names (e.g. `MyStack`, `my_stack`) + must be torn down with that older binary and re-created under a + DNS-label name. ## Concurrency / locking -- **(resolved)** *Earlier the Windows registry lock was a no-op and - `withIndexLock` was a process-local `sync.Mutex`, so two concurrent - `localnet up --name foo` invocations on Windows could race past the - lock.* Both now take real cross-process locks via - `windows.LockFileEx` (`internal/registry/lock_windows.go` uses - `LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY` for the - fail-fast per-instance lock; `internal/registry/index_lock_windows.go` - uses the blocking `LOCKFILE_EXCLUSIVE_LOCK` for the index - read-modify-write). The OS releases the lock when the handle closes - or the process exits, so there is no stale lock file to recover. This - uses `golang.org/x/sys/windows`, already a direct dependency (e.g. - `internal/localnet/snapshot/diskspace_windows.go`). - *Linux/macOS use `syscall.Flock`; behaviour is now equivalent across - platforms.* +- **(resolved)** Registry locking is now a real cross-process lock on + every platform. On Windows both the fail-fast per-instance lock and + the blocking index read-modify-write lock go through + `windows.LockFileEx` (`internal/registry/lock_windows.go`, + `internal/registry/index_lock_windows.go`); Linux/macOS use + `syscall.Flock`. The OS releases the lock when the handle closes or + the process exits, so there is no stale lock file to recover. ## Splice version pinning -- **(resolved)** *Earlier the catalogue pinned the raw gzip SHA, which - could drift if GitHub regenerated the source-tarball.* The catalogue - now pins (a) the git commit SHA (immutable, content-addressable — - `internal/splice/versions.json`'s `commit` field) and (b) the - ContentSHA of the extracted `cluster/compose/localnet/` subtree - (`content_sha` field). The tarball-by-commit URL is byte-stable - enough; we hash the extracted tree, not the gzip envelope, so a - gzip-level rewrite (compression-level change, mtime drift) has no - effect. See `docs/versions.md`. +- **(resolved)** The catalogue pins (a) the git commit SHA (immutable, + content-addressable — `internal/splice/versions.json`'s `commit` + field) and (b) the ContentSHA of the extracted + `cluster/compose/localnet/` subtree (`content_sha` field). The hash + covers the extracted tree, not the gzip envelope, so a gzip-level + rewrite by GitHub (compression-level change, mtime drift) has no + effect. See [versions.md](./versions.md). ## Container image pinning @@ -53,9 +46,9 @@ and links to follow-up tickets where applicable. Updated as we ship. through a single shared `IMAGE_TAG` variable (`image: "${IMAGE_REPO}canton:${IMAGE_TAG}"`, `${IMAGE_REPO}splice-app:${IMAGE_TAG}`, the web UIs, …). Because one - variable addresses ~6 distinct images, we can't inject per-image - `@sha256:` digests via the compose env — a single digest can't pin six - different images. + variable addresses ~6 distinct images, per-image `@sha256:` digests + cannot be injected via the compose env — a single digest can't pin + six different images. Instead DevKit VERIFIES post-up: after services are healthy it records each running image's content digest (image ID) in `state.json` @@ -70,19 +63,19 @@ and links to follow-up tickets where applicable. Updated as we ship. ## Compose env reconstruction - **`composeContext` rebuilds env from registry state.** - `down` / `logs` / `creds` need the env that was passed to `up`. We - reconstruct it from `state.json` so a fresh shell can still operate - the instance. Any new env var Splice adds in a future release that - we don't capture in state will silently break operations from a - fresh shell. Mitigation: integration tests in CI (follow-up ticket). + `down` / `logs` / `creds` need the env that was passed to `up`. + DevKit reconstructs it from `state.json` so a fresh shell can still + operate the instance. Any new env var a future Splice release adds + that is not captured in state will silently break operations from a + fresh shell. ## Integration testing - **No CI integration test for `localnet up` against real Splice.** Unit tests cover parsers and orchestration well, but the actual - bring-up flow is never exercised end-to-end in CI. The first - upstream-contract drift will be found by a user, not by us. Filed - separately as a follow-up. + bring-up flow is not yet exercised end-to-end in CI, so drift in the + upstream Splice compose contract may first surface at runtime rather + than in CI. ## Memory requirements @@ -99,7 +92,8 @@ and links to follow-up tickets where applicable. Updated as we ship. `WaitForHealthy` times out at 15 min. - **GitHub `ubuntu-latest` runners have 7 GB RAM** — enough for `up` to start but Splice's onboarding may not complete. Use a - larger runner class or self-hosted for the integration job. + larger runner class or a self-hosted runner for CI jobs that + bring up LocalNet. - **Docker Desktop default on macOS is 8 GB.** Bump via Settings → Resources before running multi-instance scenarios. @@ -120,9 +114,9 @@ and links to follow-up tickets where applicable. Updated as we ship. rather than DPM until the Windows `.exe` path through DPM is verified. -## Observability: transitional dual stack +## Observability: transitional dual stack -The host-level shared Prometheus + Grafana stack has shipped — one +DevKit runs a host-level shared Prometheus + Grafana stack — one stack serves every running LocalNet via file-based service discovery, refcounted by target file. See [docs/observability.md](observability.md#stack-topology--host-shared-with-a-transitional-per-instance-overlay) @@ -139,7 +133,7 @@ for the topology. per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any platform regardless of the Linux `host-gateway` mapping. -- **Follow-up.** Gating the per-instance overlay off (to drop the +- **Planned.** Gating the per-instance overlay off (to drop the duplication) is deferred until the shared-only path is end-to-end validated on a native Linux Docker host. The runtime toggle funnels through a single neutral function diff --git a/docs/observability.md b/docs/observability.md index 69c697f5..d29ca089 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -117,15 +117,15 @@ persisted in the registry (`state.json`'s `profiles` field). A later `down` + `up` (or the Web UI **Restart**) **re-enables the same profiles automatically**; you do not have to re-pass `--profile`. An explicit `--profile` on the re-up still wins (replaces, doesn't merge), -so you can deliberately drop observability. This closes the prior gap -where Prometheus/Grafana silently vanished on every restart even though -the stable-port contract kept the bookmarked Grafana URL alive. +so you can deliberately drop observability. (Earlier releases did not +persist profiles, so Prometheus/Grafana silently vanished on every +restart even though the stable-port contract kept the bookmarked Grafana +URL alive.) ## Stack topology — host-shared, with a transitional per-instance overlay -A single **host-level** Prometheus + Grafana (#39) serves every running -LocalNet, fulfilling the original proposal's shared-observability goal. -It runs as its own +A single **host-level** Prometheus + Grafana serves every running +LocalNet. It runs as its own compose project (`canton-devkit-observability`), independent of any instance's lifecycle. Each observability-enabled instance publishes its canton/splice `:10013` metrics ports on `127.0.0.1:` and writes @@ -153,6 +153,6 @@ up, and the per-instance scrape uses in-network service DNS platform regardless of the Linux `host-gateway` mapping. Gating the per-instance overlay off (to drop the duplication) is deferred until the shared-only path can be end-to-end validated on a native Linux Docker host -— see [docs/limitations.md](limitations.md#shared-observability-stack). The +— see [docs/limitations.md](limitations.md#observability-transitional-dual-stack). The extra resource cost (a second Prometheus+Grafana per instance) is the price of that fallback on a dev machine; it carries no correctness impact. diff --git a/docs/original-devkit-proposal.md b/docs/original-devkit-proposal.md deleted file mode 100644 index a169fed8..00000000 --- a/docs/original-devkit-proposal.md +++ /dev/null @@ -1,409 +0,0 @@ -## Development Fund Proposal - -**Author:** Zhe Li (BitDynamics) - -**Status:** Submitted - -**Created:** 2026-02-22 - ---- - -## Abstract - -Canton DevKit is a native DPM component and standalone CLI for LocalNet operations, debugging, observability, and CIP-0112 (token standard V2) testing for the Canton network. Distributed primarily as a DPM component that registers a single "localnet" top-level command, DevKit integrates directly into the existing DPM toolchain — developers install it via dpm install package and access all features as "dpm localnet ". It is also available as a standalone CLI ("canton-devkit") for users who do not use DPM. -DevKit packages common LocalNet workflows into the dpm localnet command tree and an embedded Web UI: starting and managing named LocalNets, inspecting services and endpoints, uploading and inspecting DARs, exploring live contracts and transactions, viewing developer-focused observability dashboards, and testing CIP-0112 token flows locally. It builds on the existing Splice LocalNet and DPM toolchains rather than replacing them. - ---- - -## Specification - -### 1. Objective - -According to the [Canton Network Developer Experience and Tooling Survey](https://forum.canton.network/t/canton-network-developer-experience-and-tooling-survey-analysis-2026/8412), 41% of respondents cited Environment Setup & Node Operations as the task that took the longest to "get right." Developers are currently forced to be infrastructure engineers before they can be product builders. - -The current official LocalNet stack creates significant friction for onboarding, workshops, hackathons, and automated development workflows because it requires users to manually manage Docker containers, configuration files, environment variables, observability setup, and ad-hoc scripts for inspection and token operations. The survey also rated Local Development Frameworks as the most critical need, with specific mentions of tools like Hardhat, Foundry, and Anchor — a unified CLI toolchain that helps with orchestrating local node environments and automating testing and deployment pipelines without complex manual configuration. - -DevKit targets the following use cases: - -* Local app development, particularly with multiple participants -* Integration and end-to-end testing -* CI/CD flows -* Demos, workshops, and other repeatable/controlled environments - -The goal is to deliver a complementary DevKit for local Canton development. This maintained tooling will enable any developer or automation workflow to manage the complete lifecycle of one or more LocalNets using simple commands or a UI, monitor and explore activity, and experiment with CantonCoin and CIP-0112 flows locally. - -### 2. Implementation Mechanics -(Explain how the solution will be implemented. Include technologies, components, workflows, and operational approach.) - -The solution is delivered primarily as a **native DPM component** that registers a single top-level `localnet` command, and additionally as a **standalone CLI application** (`canton-devkit`). It will be implemented in **Go** and the same binary serves both distribution paths. DPM users install DevKit via `dpm install package` and invoke commands as `dpm localnet ...`; standalone users install a native binary and invoke commands as `canton-devkit localnet ...`. End users will not need Go, Node.js, Python, Rust, or a source checkout to run it. DevKit uses Docker containers to run LocalNet, and packages the developer experience into a single binary that requires no git clone, no Makefile knowledge, and no manual environment variable setup. It will also include other optional helper services that developers can enable or disable as needed. - -Throughout this document, commands are shown in their DPM form (`dpm localnet ...`). Standalone users invoke the same commands by replacing `dpm` with `canton-devkit` (e.g. `canton-devkit localnet up`). Both forms execute the same code path. - -DevKit will support all platforms: macOS (apple silicon), Linux, and Windows from the start. - -#### Distribution and Runtime Requirements - -DevKit's primary distribution path is a native DPM component published to an OCI registry. Users add a reference (e.g. `oci:///canton-devkit:`) to the `components` section of their `daml.yaml` or `multi-package.yaml` and run `dpm install package`. DPM then exposes DevKit as a single top-level `localnet` command (e.g. `dpm localnet up`, `dpm localnet dar upload`). Nesting all DevKit features under one top-level command keeps the DPM integration surface minimal and avoids naming conflicts with existing or future DPM builtins. - -Additionally, DevKit will be published as a standalone Go binary through GitHub Releases with checksums. The initial artifact set will target macOS (apple silicon), Linux, and Windows. Optional convenience install paths such as Homebrew where appropriate and/or an install script may be provided. The standalone path serves users who do not have or want DPM installed (for example DevOps engineers, CI pipelines, and workshop facilitators) and exposes the same command tree under the `canton-devkit` binary name. - -DevKit will not install or bundle Docker. A working Docker runtime is the only required local system dependency because DevKit orchestrates the existing Splice LocalNet container stack rather than replacing it. DevKit will not modify Docker daemon configuration, install system packages, or change user permissions on the host. - -#### Docker Handling - -`dpm localnet up` (or `canton-devkit localnet up`) will run Docker preflight checks before starting LocalNet, including Docker CLI availability, daemon connectivity, Docker Compose v2, required ports, disk space, memory suitable for the Splice LocalNet stack, and host-specific prerequisites such as Linux Docker permissions or Docker Desktop availability on macOS/Windows. If a check fails, DevKit will provide platform-specific remediation instructions instead of modifying the host system. - -DevKit will manage LocalNet resources through deterministic Docker Compose project names and labels, so named LocalNets can be started, inspected, logged, stopped, snapshotted, and cleaned without affecting unrelated Docker containers, networks, or volumes. It will also make port allocation explicit for named instances and print the actual endpoints selected for each LocalNet. - -#### Relationship to Existing Tooling - -Canton already ships several developer tools. The DevKit is designed to complement them, not to replace them: - -| Existing Tool | DevKit Relationship | -|---|---| -| **DPM** (`dpm`) | DevKit is distributed as a **native DPM component** that registers a single `localnet` top-level command, so DPM users access all DevKit features as `dpm localnet ` (e.g. `dpm localnet up`, `dpm localnet dar upload`). Command naming will be coordinated with the DPM maintainers to avoid conflicts with future builtins. | -| **Existing LocalNet setup in Splice codebase** | Splice LocalNet remains the underlying runtime. DevKit selects and version-pins known Splice LocalNet artifacts, generates local configuration, manages Docker lifecycle, exposes endpoints, health, logs, snapshots, and explorer workflows, while still allowing developers to use raw Splice LocalNet directly. | -| **`cn-quickstart` and official getting-started flows** | DevKit does not decide what official docs should recommend or replace quickstart content. It can provide a repeatable LocalNet lifecycle and inspection layer for quickstart-style development, workshops, and demos. | -| **Daml Shell** (`dpm daml-shell`) | DevKit does **not** replace Daml Shell, and intentionally does **not** duplicate its commands. One-shot single-contract lookup (`contract `), single-transaction inspection (`transaction `), per-template `active/creates/archives` listings, and CSV ACS export (`\| csv \| export`) remain the Daml Shell REPL's responsibility. DevKit adds capabilities Daml Shell does not provide: live `contracts watch` (streaming creates/archives), multi-filter `tx ls` (party + offset range + template in one query), per-party `tx replay` (visibility projection), and a visual Web UI explorer that spans **multiple participants of a named LocalNet**. | - -#### Canton DevKit Features - -##### LocalNet Management - -The existing LocalNet setup requires manually downloading Splice bundles, exporting environment variables, composing multi-flag Docker commands, and understanding Docker Compose profiles. DevKit collapses this into: - -###### LocalNet Scope and Boundaries - -DevKit's LocalNet scope starts with the core CLI lifecycle: starting, stopping, restarting, cleaning, checking status, viewing logs, selecting a Splice LocalNet version, running preflight checks, and using basic named-instance isolation. Richer automation conveniences such as machine-readable output, environment export, instance discovery, deeper diagnostics, and Web UI views are treated as incremental additions rather than requirements for the first usable LocalNet release. - -###### CI and Automation Support - -DevKit will support headless automation workflows without making CI the only design target. For the core CLI lifecycle, commands will return deterministic exit codes and `localnet up` will wait for LocalNet readiness or fail with a clear timeout/error. Additional automation conveniences will include machine-readable `--json` output, `.env`-style endpoint export for tests, and example CI workflows that start LocalNet, wait for readiness, run application tests, and tear the instance down safely. - -###### Multiple LocalNets on One Machine - -Basic named-instance support belongs in the core orchestration model because Docker resource naming and port isolation should be designed in from the beginning. DevKit will support `--name ` with deterministic Docker Compose project names, labels, and explicit port configuration so two LocalNets can run on one machine when sufficient resources and non-conflicting ports are available. Advanced instance discovery and dashboards, such as `localnet list`, `localnet env`, and Web UI views across named instances, are higher-level conveniences rather than requirements for the first usable LocalNet release. - -###### Splice Version Compatibility - -`--version ` in `localnet up` selects the Splice LocalNet version to run. DevKit documentation will include a compatibility matrix for supported Splice versions and platforms. The initial release will validate the initially supported version, while maintenance releases will cover smoke testing, compatibility updates, and patch releases for newer Splice releases. - -Compatibility with breaking Splice releases follows a best-effort model: the implementing team owns compatibility patches within a documented support window (or explicit cutoff) for each major Splice line, and will communicate timelines early when upstream breaking changes land so teams can plan upgrades. If ecosystem demand justifies it, stricter turnaround commitments (for example an SLA-style support tier) could be introduced by mutual agreement with the Committee without changing the default grant expectations. - -###### LocalNet Configuration Model - -DevKit will make the important LocalNet inputs explicit: instance name, Splice version, port settings, enabled optional services, observability settings, startup DAR uploads, and LocalNet-only token test setup. The initial scope does not require a full topology language; the priority is a predictable, documented configuration surface for common local development, workshop, and CI workflows. - -###### New dpm Commands - -The core lifecycle commands are part of the first usable CLI release. Automation and diagnostic conveniences such as environment export, instance discovery, richer status formats, and host diagnostics are added incrementally as the CLI matures. - -| Command | Purpose | Expected Output / Behavior | -|---|---|---| -| `dpm localnet up --name [--version ]` (or `canton-devkit localnet up ...` standalone) | Start a named LocalNet | Runs Docker preflight checks, selects the requested Splice LocalNet version, starts services, waits for readiness, and prints endpoints and credential locations. | -| `dpm localnet down --name ` | Stop a named LocalNet | Stops DevKit-managed services for that instance without touching unrelated Docker resources. | -| `dpm localnet restart [service] --name ` | Restart an instance or service | Restarts the full LocalNet or one service and re-runs readiness checks. | -| `dpm localnet clean --name ` | Remove LocalNet resources | Removes DevKit-managed containers, networks, and volumes for the named instance after confirmation. | -| `dpm localnet status --name ` | Inspect health | Shows service health, selected Splice version, ports, participant readiness, wallet/scan URLs, and next troubleshooting steps when unhealthy. | -| `dpm localnet logs [service] --name ` | Debug services | Streams or tails logs with optional service filtering. | -| `dpm localnet snapshot/restore --name ` | Save or replay state | Captures or restores LocalNet state for demos, workshops, and repeatable testing. | -| `dpm localnet env --name ` | Export app/test config | Prints `.env`-style values for Ledger API, JSON API, admin API, wallet UI, scan UI, parties, and users. | -| `dpm localnet list` | Discover instances | Lists DevKit-managed LocalNets and their state without touching unrelated Docker resources. | -| `dpm localnet doctor` | Diagnose host readiness | Checks Docker, Compose v2, permissions, ports, memory, disk, and supported platform assumptions. | - -The **standalone** binary exposes the same command tree; invoke it with `canton-devkit` instead of `dpm`, for example: - -``` -canton-devkit localnet up -``` - -###### Web UI Features - -The Web UI will provide a LocalNet dashboard showing named instances, service health, selected Splice version, endpoints, ports, credential locations, participant readiness, and recent logs. It will include service-level log views, participant/party/package views, links into Grafana dashboards, and quick actions for common LocalNet lifecycle operations such as start, stop, restart, status, and cleanup. - -##### DAR Management - -Today developers upload DARs to each LocalNet participant manually (via `daml ledger upload-dar`, the JSON API, or the Canton Console), and there is no built-in way to inspect, diff, or hot-redeploy packages across a multi-participant LocalNet. DevKit closes that gap without replicating `dpm build` / `daml build` — it offers a `build-upload` convenience shortcut that delegates compilation to `dpm` and then uploads the resulting DAR to LocalNet participants in a single step. - -Initially, DevKit consumes package metadata via DevKit's own DAR parser to extract module, template, choice, field, interface, and dependency information. Once the Canton core team's enriched package metadata endpoints become available, DevKit will prefer the upstream endpoints over local DAR parser. - -###### New dpm Commands -* `dpm localnet dar upload [--participant | --all-participants] [--vet] [--dry-run]` (or `canton-devkit localnet dar upload ...` standalone) — upload a DAR to one or all participants of the active (or `--name`-selected) LocalNet, optionally vetting for Smart Contract Upgrade (SCU). -* `dpm localnet dar list [--participant ]` — list uploaded packages with package ID, name, version, Daml-LF version, module count, upload time, and vetting status. -* `dpm localnet dar info ` — show modules, templates, interfaces, choices, fields, dependencies, and hash for a package. -* `dpm localnet dar download [--out ]` — fetch a DAR back from a participant. -* `dpm localnet dar diff ` — human-readable diff of templates/choices/fields between two package versions, with SCU-compatibility signals (name/version/LF-version/field deltas). -* `dpm localnet dar remove ` — unvet / remove where supported by the participant admin API. -* `dpm localnet dar build-upload [--project ]` — convenience shortcut that invokes `dpm build` (or `daml build`) and uploads the resulting DAR to LocalNet participants in a single step; skipped with a clear message if `dpm` is not available. -* `dpm localnet dar watch ` — watch mode: rebuild via `dpm build` and re-upload to selected participants on source change for a hot-deploy loop. - -###### Web UI Features -* Drag-and-drop DAR upload with per-participant vetting toggles. -* Package explorer tree: modules → templates → choices → fields (with types), interfaces, dependencies, and hashes. -* SCU-aware diff viewer between any two package versions. -* Hot-deploy indicator showing the last watch-mode upload and its status per participant. - -###### Scope Boundaries -* DevKit is **not** a Daml compiler. It delegates to `dpm build` / `daml build` and will not duplicate DPM functionality. -* SCU-compatibility output is best-effort based on package metadata and structural comparison — authoritative upgrade validation remains the responsibility of the Ledger API and `daml` tooling. - -##### Contract Tracking & Exploration - -The proposal already notes that developers "often build ad-hoc tools for exploring transactions, contract state, and token operations." DevKit ships a shared, privacy-aware explorer for the Active Contract Set (ACS) and transaction history across one or more named LocalNets, so teams stop rebuilding the same inspector. - -The first-pass scope is the **live** view: ACS table, transaction list, and detail views backed by Ledger API v2. Historical / archived-contract search via PQS is explicitly deferred. - -###### New dpm Commands -* `dpm localnet contracts watch [filters]` — live tail of create/archive events, similar to `kubectl get -w`. (Not provided by `daml-shell`, which reads PQS snapshots rather than streaming live updates.) -* `dpm localnet tx ls [--party

] [--from ] [--to ] [--template ]` (or `canton-devkit localnet tx ls ...` standalone) — list transactions with multi-dimensional filters (party + offset range + template). (`daml-shell` exposes per-template `creates`/`archives` listings bounded by session offsets but has no unified transactions-list with party filtering.) -* `dpm localnet tx replay ` — reconstruct the per-party visibility projection ("what this party sees") for debugging privacy and authorization issues. (Not provided by `daml-shell` or any other shipped DPM component.) - -One-shot contract and transaction lookups (e.g. fetching a single contract by ID, rendering a single transaction tree, or exporting the ACS as CSV) are already covered by `dpm daml-shell` (`contract `, `transaction `, `active | csv | export `). DevKit does not duplicate those commands at the CLI level; the Web UI surfaces the same data visually. - -###### Web UI Features -* **Explorer** section with a live ACS table filterable by template, party, and participant; payload previews, age, signatories/observers, and a detail drawer. -* **Transaction timeline** with expandable trees, party visibility badges, and links from exercise/create nodes to the affected contracts. -* **Contract detail view**: full payload (JSON and typed), lifecycle (created-at tx → exercises → archived-at tx), interface views, and related contracts by key or referenced contract ID. -* **Per-party projection** selector that always displays which participant + party the current view is projected through, to avoid a misleading "global ledger" impression. -* **Saved queries / bookmarks** shareable via URL, and an ad-hoc **event subscription panel** that updates in real time. - -(The mockup below makes the proposed Web UI scope more concrete by showing the LocalNet overview shell that would host the explorer, transaction views, service status, endpoints, and quick actions.) - -![DevKit LocalNet overview mockup showing the LocalNet dashboard, services, endpoints, parties, and recent activity.](./devkit-mock-overview.png) - -###### Implementation Notes -* Backend uses Ledger API v2: `StateService.GetActiveContracts`, `UpdateService.GetUpdates`, and `EventQueryService`, with `PackageService` + DAR metadata (from the DAR Management feature) to decode payloads into typed form. -* Multi-LocalNet aware via Milestone 1's named instances (`--name`); participant selector is present in every command and UI view. -* Privacy is not cosmetic: visibility is always projected through an explicit (participant, party) pair. - -###### Scope Boundaries -* No PQS dependency in the first pass; archived-contract history beyond what the live Ledger API exposes, and SQL-style historical queries, are out of scope. -* DevKit does not re-implement Daml Shell's REPL or duplicate its commands. The DevKit CLI focuses on capabilities `daml-shell` does not offer (live `contracts watch`, multi-filter `tx ls`, and per-party `tx replay`); the Web UI provides the visual counterpart for the same data. - -##### Observability and Monitoring - -DevKit does not rebuild the observability stack from scratch. Instead, it bundles and configures a Prometheus/Grafana stack tailored for LocalNet, with ongoing optimization of that stack where practical. - -* Per-component toggles for Prometheus, Grafana so developers enable only what they need. -* A single observability stack can serve multiple LocalNet instances on the host, reducing duplicated overhead when several environments are in use. -* Ships Canton-specific Grafana dashboard presets focused on DApp developers (as opposed to operator-level dashboards): transactions/sec, command completion latency, active contract counts, and per-template throughput. -* Adds a `dpm localnet metrics` subcommand (or `canton-devkit localnet metrics` standalone) that prints Grafana dashboard URLs and a concise text summary of key metrics (throughput, latency p50/p99, resource usage) for quick terminal-based checks. -* Documents how teams can extend or customize dashboards for their own services. - -##### Optional AI Agent Skill Documents - -DevKit may provide optional, editor-agnostic AI agent skill documents that describe safe workflows for invoking documented `dpm localnet` commands (or the equivalent `canton-devkit localnet` commands for standalone users). These documents are auxiliary documentation artifacts layered on top of the stable CLI; they are not part of the core runtime and do not prescribe how developers write code or which editor or agent they use. - -Example workflows include starting or stopping a named LocalNet, checking readiness with `dpm localnet status`, tailing logs with `dpm localnet logs [service]`, uploading a pre-built DAR, listing deployed packages, inspecting active contracts, and reporting LocalNet readiness. Where compilation is needed, the workflow delegates to existing Daml tooling such as `dpm build` and then uses DevKit only for LocalNet deployment and inspection. - -Initial examples may be provided for Claude and Codex-style agent formats, but the supported integration surface is the stable `dpm localnet` (and equivalent `canton-devkit localnet`) CLI rather than any specific editor or AI platform. - -##### Local Token Faucets & Token Standard Toolkit (CIP-0112) - -LocalNet already ships wallet UIs and a Registry API for token transfers, but developers still lack a CLI-driven faucet and a guided token-creation flow for everyday token operations. DevKit closes those gaps for LocalNet testing: it helps developers exercise token registration and common token flows before integrating with production-grade wallet, registry, custody, or compliance infrastructure. - -The token wizard and convenience commands (Milestone 3) target CIP-0112 first, so new projects align with the expected direction. CIP-56 (V1) compatibility and V1→V2 migration helpers remain optional and may be scoped to a later milestone or post-grant workstream depending on ecosystem demand and feedback during implementation. - -DevKit will use the LocalNet Ledger API, wallet UI/API, and registry APIs where available, but it will not act as a production issuer, custodian, wallet provider, or dApp connectivity layer. The committed token scope for this grant is Canton token-standard testing on LocalNet centered on CIP-0112 (V2) as primary; support for other token standards or a broad dual-V1/V2 product surface would require explicit scope renegotiation. - -* `dpm localnet token create` (or `canton-devkit localnet token create` standalone) — interactive "token wizard" to define new tokens (name, symbol, decimals, initial supply) and mint to test wallets, aligned with CIP-0112 semantics as the default path. -* `dpm localnet token [mint | transfer | burn | balance] {token-name} {amount} [--to wallet]` — convenience commands wrapping the Ledger API / Registry API for common token operations on that default path. - -(The mockup below shows the proposed token toolkit / faucet surface for CIP-0112-oriented LocalNet testing, including token cards, mint/transfer actions, and recent token activity) - -![DevKit token toolkit mockup showing CIP-0112 token cards, mint and transfer actions, and recent token activity.](./devkit-mock-token-faucet.png) - -### 3. Architectural Alignment - -The Canton DevKit removes the friction of managing local test environments so developers can focus on building their applications. It aligns with the Development Fund's remit to support developer tooling and critical infrastructure as common goods, and is consistent with the milestone‑based, CC‑denominated funding and governance model formalized under CIP‑100. Token tooling is designed to follow the CIP-0112 (Token Standard V2) direction (evolving CIP-56), making it easier for developers to test tokenized applications and integrations in a way that reflects Mainnet patterns. - -### 4. Backward Compatibility - -The Canton DevKit primarily targets LocalNet developer environments and does not change Canton protocol behavior, Daml semantics, or existing production deployments. Developers can continue using the Splice LocalNet Docker stack. - -No backward compatibility impact. - ---- - -## Milestones and Deliverables - -### Milestone 1: LocalNet Management — CLI - -- **Estimated Delivery:** Month 3 -- **Focus:** Single-command LocalNet lifecycle management via CLI. -- **Deliverables / Metrics:** - - `dpm localnet up/down/restart/clean/status/logs` CLI commands (and equivalent `canton-devkit localnet ...` standalone commands) with auto-generated configs, keys, identities, and printed endpoints and credentials. - - Version pinning (`--version`) and basic named-instance isolation (`--name`) using deterministic Docker Compose project names, labels, and explicit port configuration. - - Snapshot and restore (`dpm localnet snapshot/restore`) for saving and replaying LocalNet state. - - **Native DPM component packaging** (`component.yaml` plus OCI publishing in the release CI) so DevKit is installable via `dpm install package` from Milestone 1 onward. - - Standalone Go binary release artifacts for macOS arm64, Linux amd64, and Windows amd64, published with checksums (same binary as the DPM component). - - Installation and "Getting Started" guide for both DPM-component and standalone install paths on macOS, Linux, and Windows, including Docker prerequisite checks and troubleshooting. - - Docker preflight checks in `dpm localnet up` for Docker CLI availability, daemon connectivity, Docker Compose v2, required ports, disk space, memory, and host-specific prerequisites such as Linux Docker permissions or Docker Desktop availability on macOS/Windows. - - Basic `dpm localnet doctor` diagnostics covering Docker CLI availability, daemon connectivity, Docker Compose v2, platform support, required ports, disk space, memory, and host-specific prerequisites. - - Deterministic exit codes and readiness wait behavior suitable for basic headless automation. - - Compatibility matrix documenting the initially supported Splice LocalNet version and supported macOS/Linux/Windows platforms. - - Demo script showing startup, readiness, status, logs, teardown, and one two-instance run using explicit non-conflicting ports. - - Internal testing plus at least one external tester validating that a new developer can go from zero to running LocalNet in under 10 minutes. -- **Adoption Metrics:** at least 3 companies/teams have reviewed the tool and tested it for LocalNet setup and lifecycle usage. - -### Milestone 2: Web UI, Observability, Monitoring, DAR & Contract Tooling, Optional AI Agent Skill Documents - -- **Estimated Delivery:** Month 6 -- **Focus:** Web UI for LocalNet management, integrated observability, DAR package management, live contract and transaction exploration, and optional AI agent skill documents. -- **Deliverables / Value Metrics:** - - Web UI covering all CLI features from Milestone 1 with a user-friendly interface. - - Richer LocalNet automation conveniences, such as machine-readable status output, environment export for app/test configuration, named-instance discovery, enriched `doctor` diagnostics, and deeper troubleshooting guidance. - - Example CI workflow demonstrating LocalNet startup, readiness wait, optional DAR upload, test execution, and teardown. - - Bundled Prometheus/Grafana stack with per-component enable/disable, sensible lightweight defaults, and documentation of minimum practical resources when the full stack is enabled. - - Canton-specific Grafana dashboard presets focused on DApp developers: transactions/sec, command completion latency, active contract counts, and per-template throughput. - - `dpm localnet metrics` subcommand printing Grafana dashboard URLs and a concise text summary of key metrics (throughput, latency p50/p99, resource usage). - - DAR management CLI (`dpm localnet dar upload/list/info/download/diff/remove/build-upload/watch`) with multi-participant support, optional `dpm build` integration, and SCU-aware diff signals. - - DAR Web UI with drag-and-drop upload, per-participant vetting toggles, package explorer tree, diff viewer, and hot-deploy indicator. - - Contract tracking CLI (`dpm localnet contracts watch` and `dpm localnet tx ls/replay`) backed by Ledger API v2, scoped to capabilities not already provided by `dpm daml-shell` (live watch, multi-filter transaction listing, per-party visibility projection). - - Contract tracking Web UI "Explorer" with live ACS table, transaction timeline, contract detail drawer, and explicit per-party visibility projection. - - Optional AI agent skill documents demonstrating safe `dpm localnet` workflows for LocalNet lifecycle, DAR upload, package inspection, contract queries, and log/status checks. - - Documentation on recommended usage, dashboard customization, DAR workflows, contract explorer usage, and optional AI agent skill documents. -- **Adoption Metrics:** at least 5 companies/teams have started using it in their daily Canton development workflow. - -### Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) - -- **Estimated Delivery:** Month 9 -- **Focus:** CantonCoin / Token Standard tooling and UX polish, CIP-0112. -- **Deliverables / Value Metrics:** - - `dpm localnet token mint` CLI and Web UI minting for tokens on LocalNet on the CIP-0112 path. - - `dpm localnet token create` interactive token wizard defining new tokens (name, symbol, decimals, initial supply) aligned with CIP-0112 as the default. - - `dpm localnet token transfer / burn / balance` convenience commands wrapping the Ledger API / Registry API for that path. - - Expanded regression coverage across the supported macOS, Linux, and Windows targets, UX polish across CLI and Web UI, and consolidated documentation, FAQs, and troubleshooting guides (including explicit note of CIP-0112 scope and optional future CIP-56 support per ecosystem demand). -- **Adoption Metrics:** at least 7 external projects/teams demonstrate a LocalNet workflow on the CIP-0112 path. - -### Milestone 4: Adoption Validation and Ecosystem Outreach - -- **Estimated Delivery:** Month 12 -- **Focus:** Demonstrate meaningful external adoption of DevKit and publish ecosystem-facing validation artifacts. -- **Deliverables / Value Metrics:** - - Document at least 5 external apps/projects using DevKit in real development or testing workflows, evidenced by issue reports, demos, written feedback, case studies, or maintainer attestations. - - Publish a short adoption transparency update in release notes or changelog entries, including release/download/install trends, stars/forks/watchers (labeled as visibility), and telemetry aggregates if enabled. - - Report progress toward a composite floor of at least 250 cumulative installs/downloads across supported distribution channels (for example: GitHub Releases, Homebrew, install script). - - Track external feedback through issues, release notes, or documented changelog entries. - - Host 2 online/offline workshops about the Canton DevKit. - - Publish 1 case study or blog post. -- **Adoption Metrics:** at least 5 external apps/projects are actively using DevKit in real development or testing workflows by Milestone 4 acceptance. - -### Adoption Measurement - -Meaningful external adoption is evaluated using a composite view rather than any single KPI, no single public metric is treated as definitive proof on its own. - -DevKit adoption reporting will combine: - -* Installation-oriented signals (GitHub release downloads, package-manager installs such as Homebrew where available, and install-script usage counts where applicable). -* Visibility signals (GitHub stars, forks, watchers) used as discoverability indicators rather than direct usage proof. -* Privacy-preserving telemetry aggregates (if implemented), with clear documentation and user opt-out controls. -* Qualitative usage evidence such as issue reports, demos, case studies, feedback notes, or maintainer attestations from external teams/projects. - -Milestone 4 targets documented adoption across at least 5 external apps/projects, supported by a composite floor of at least 250 cumulative installs/downloads across supported channels and the qualitative evidence above. - ---- - -## Acceptance Criteria - -The Tech & Ops Committee will evaluate completion based on: - -* Delivery of the Canton DevKit capabilities specified for each milestone. -* **Milestone-specific adoption criteria:** - * **Milestone 1:** 3 external companies/teams have installed DevKit (via the DPM component, the standalone binary, or both) and successfully run `localnet up/status/down` across the supported macOS, Linux, and Windows environments, including at least one validated Windows installation/run, with at least one tester validating named-instance isolation using explicit non-conflicting ports. - * **Milestone 2:** 5 external companies/teams or representative Canton deployments have used the Web UI, DAR workflow, contract explorer, transaction explorer, or observability workflow against their own DAR/application and provided feedback artifacts. - * **Milestone 3:** At least 7 external projects/teams demonstrate a LocalNet workflow on the CIP-0112 path such as `create -> mint -> transfer` or `mint -> transfer -> burn` and provide feedback or demo artifacts. - * **Milestone 4:** Meaningful external adoption is demonstrated through at least 2 public workshops, 1 case study/blog post, documented usage by at least 5 external apps/projects in real development or testing workflows, and at least 250 cumulative installs/downloads across supported distribution channels; this is evaluated with composite evidence (downloads/installs + optional telemetry + visibility signals + direct feedback/case-study evidence), not any single metric in isolation. -* If the optional Maintenance & Compatibility Extension is approved, completion of that extension would be evaluated based on a maintained compatibility matrix for supported Splice releases/platforms, smoke tests against newer Splice releases, published compatibility notes, patch releases for compatibility fixes and high-priority bugs, and documented incorporation of user feedback during the extension term. -* Acceptable adoption and feedback evidence includes GitHub issues, pull requests, release notes, written feedback, demo recordings, workshop materials, case studies, Committee acceptance notes, release/download/install statistics, documented telemetry summaries (if enabled), and repository visibility metrics when reported as trends. -* Demonstrated functionality via scripts, demos, and documentation showing: - * Installation via the **native DPM component** (`dpm install package`) as the primary path, and via the standalone Go binary on macOS, Linux, and Windows as the additional path; neither requires users to install a programming language runtime. - * Single-command LocalNet startup and teardown, including named-instance isolation, explicit port configuration, and snapshot/restore workflows. - * Docker prerequisite handling with clear failures when Docker is missing, unreachable, lacks Compose v2, has insufficient resources, or has port conflicts. - * Web UI covering the same LocalNet management features as the CLI. - * Working Grafana dashboards for throughput, latency, and resource usage on a sample DApp. - * Upload, list, inspect, and diff DAR packages across multiple participants of a named LocalNet, including the `dpm`-backed `dar build-upload` convenience command and watch-mode hot redeploy. - * Live-watch ACS changes and list transactions with multi-dimensional filters via CLI (`contracts watch`, `tx ls`), reconstruct per-party visibility projection via `tx replay`, and browse the Active Contract Set and transaction history via the Web UI. - * Optional AI agent skill documents demonstrating use of documented `dpm localnet` commands (or equivalent `canton-devkit localnet` commands) to manage a named LocalNet, upload a DAR, and inspect resulting packages/contracts without requiring editor-specific integration. - * Token creation wizard and token flows (mint, transfer, burn, balance) on LocalNet targeting CIP-0112 as the default; optional CIP-56 support is out of scope for the committed acceptance bar unless later agreed. -* Documentation and knowledge transfer sufficient for developers to install, run, and extend DevKit. -* Evidence that feedback loops from external users are incorporated into releases (bug fixes, UX improvements, and docs updates). - ---- - -## Funding - -**Total Funding Request:** - -Base proposal total: **1,900,000 CC** over **12 months**. - -### Payment Breakdown by Milestone - -* Milestone 1 (LocalNet Management — CLI): 400,000 CC upon committee acceptance. -* Milestone 2 (Web UI, Observability, Monitoring, DAR & Contract Tooling, Optional AI Agent Skill Documents): 400,000 CC upon committee acceptance. -* Milestone 3 (Token Faucets & Token Standard Tooling, CIP-0112): 500,000 CC upon final release and acceptance. -* Milestone 4 (Adoption Validation and Ecosystem Outreach): 600,000 CC upon committee acceptance. - -### Optional Maintenance & Compatibility Extension - -An additional **600,000 CC** is proposed as a separate optional extension covering **12 months** of post-grant maintenance and Splice upgrade support after completion of the base proposal. - -If approved, this optional extension would cover: - -* Maintaining a documented compatibility matrix for supported Splice releases and platforms, consistent with the support-window / best-effort policy described under Splice Version Compatibility. -* Running smoke tests against newer Splice releases and publishing compatibility notes. -* Shipping patch releases for compatibility fixes and high-priority user-reported bugs. -* Ongoing incorporation of external feedback into maintenance releases. - -This optional extension is not included in the **1,900,000 CC** base proposal total above. If approved in addition to the base proposal, the combined total would be **2,500,000 CC**. - -Funding is requested in Canton Coin, consistent with the Development Fund's CC‑denominated, milestone‑based grants model under CIP‑100. - -### Volatility Stipulation - -The proposed base project duration is 12 months, with Months 1-9 focused on core delivery and Months 10-12 focused on adoption validation and ecosystem outreach. - -* The base grant is denominated in a fixed amount of Canton Coin (**1,900,000 CC**) with milestone allocations as above, and will be subject to re‑evaluation at the 6‑month mark to account for material CC/USD volatility, in line with the Fund's governance guidelines. -* If scope changes or delays requested by the Committee extend timelines beyond the original plan, remaining milestones and CC amounts can be renegotiated by mutual agreement. -* If the optional Maintenance & Compatibility Extension is approved, its support term, checkpoints, and CC allocation can be finalized by mutual agreement through the Committee process. -* The committed token tooling targets CIP-0112 as the default path; optional CIP-56 compatibility or other token standards would require renegotiation of milestone scope and funding if pursued within the grant period. - -### Post-grant sustainability - -The base twelve-month grant is structured to deliver the product and validate adoption using the milestone adoption metrics above. Beyond that window, sustainable operation may take the form of the optional Maintenance & Compatibility Extension described above, continued open-source/community-led maintenance, and/or handover or closer alignment with Digital Asset or the Canton Foundation—whichever the Committee judges best, informed by adoption evidence from the milestones. Nothing in this proposal binds the Foundation or Digital Asset to take ownership absent mutual agreement through the Fund’s governance process. - ---- - -## Team Background - -### BitDynamics - -BitDynamics brings deep experience in building and operating blockchain infrastructure. The team has worked across Ethereum client infrastructure, validator operations, and production-grade hosting systems supporting validator infrastructure securing more than 2 billion USD in assets. This background is directly relevant to building reliable, auditable, and security-conscious public infrastructure for a grants program. Team is also building actively on Canton. - ---- - -## Co-Marketing - -Upon release of major components (e.g., first public DevKit release, explorer, token tooling), the implementing entity will collaborate with the Canton Foundation on: - -* Coordinated announcements highlighting DevKit as shared developer tooling for the ecosystem. -* A case study or technical blog post explaining how DevKit simplifies LocalNet workflows and token experimentation. -* Participation in developer‑focused promotion such as workshops, hackathons, office hours, or webinars showcasing DevKit usage. - ---- - -## Motivation - -The Splice source code for Canton already provides a LocalNet environment, but developers must manually manage Docker, configs, and observability and often build ad‑hoc tools for exploring transactions, contract state, and token operations. This slows down onboarding for new teams, workshops, and hackathons, and leads to fragmented, privately maintained tooling rather than shared public goods. - -By consolidating LocalNet lifecycle management, observability, and token testing into a single CLI and Web UI tool suite, the proposal significantly lowers the barrier to entry for building on Canton. It directly supports the Fund's aim to back developer tooling and critical infrastructure that act as common goods and deliver long‑term value across the ecosystem. - ---- - -## Rationale - -Reducing the operational overhead of local development is a prerequisite for sustainable ecosystem growth; developer time reclaimed from infrastructure management translates directly into faster application delivery and broader adoption. Delivering functionality in three incremental, self‑contained milestones enables early value (single-command LocalNet lifecycle management) and iterative refinement (metrics, tokens) with clear checkpoints for the Committee. - -Success is measured through sustained, multi-signal adoption trends over time that combine team usage, installation-oriented indicators, and external feedback evidence. - -Separate, uncoordinated tools for observability, explorers, and token testing would increase maintenance burden and fragment the developer experience. A unified Canton DevKit CLI and Web UI tool suite offers a complementary local workflow layer over existing Canton tooling, while remaining extensible so the community can adapt it to evolving needs and future CIPs. diff --git a/docs/packaging.md b/docs/packaging.md index ffde4358..2324c0be 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -77,15 +77,15 @@ the path doesn't include the extension. ### Why a single top-level command? DPM components register top-level commands into a flat namespace shared -with DPM builtins and every other component. We deliberately register -only `localnet` to: +with DPM builtins and every other component. DevKit deliberately +registers only `localnet` to: - Avoid collisions with DPM builtins (`install`, `publish`, `versions`, `bootstrap`, …) or with future first-party components. - Keep the DPM surface minimal — `dpm localnet up`, `dpm localnet dar upload`, `dpm localnet contracts ls`, etc. nest naturally. -All DevKit subcommands live inside our binary's own Cobra tree, not in +All DevKit subcommands live inside the binary's own Cobra tree, not in the DPM manifest. ## Local validation @@ -167,10 +167,10 @@ The hosted repo is generated on every release by preserving all existing rewriting `Packages`, `Packages.gz`, and `Release` metadata under `apt/dists/stable/main/binary-amd64/`. -**Current hardening gap:** the APT repo is unsigned and documented with -`trusted=yes`. This is acceptable for an initial static repository backed -by HTTPS and release checksums, but a production-grade repo should add a -GPG-signed `InRelease` file and install instructions using `signed-by=`. +**Known limitation:** the APT repo is unsigned and documented with +`trusted=yes`. The repository is backed by HTTPS and release checksums, +but a GPG-signed `InRelease` file and install instructions using +`signed-by=` are planned hardening steps. ## Supply-chain integrity @@ -179,11 +179,10 @@ with `sha256sum --check`) plus the immutability of the GHCR OCI digest. The CI pipeline also pins every GitHub Action and the DPM CLI tarball by SHA. -**Known gap (follow-up):** the release artifacts are **not yet +**Known limitation:** the release artifacts are **not yet cryptographically signed**. There are no [cosign](https://github.com/sigstore/cosign)/Sigstore signatures on `SHA256SUMS` or on the OCI artifact, so consumers can verify *integrity* (the bytes match the checksum) but not *provenance* -(the bytes were produced by our pipeline). Adding keyless cosign signing -+ a published verification step is tracked as a post-v1 hardening item — -not blocking the initial release, but required before the artifacts are -promoted as a trusted distribution channel. +(the bytes were produced by the project's release pipeline). Keyless +cosign signing plus a published verification step is a planned +hardening item. diff --git a/docs/proposals/telemetry.md b/docs/proposals/telemetry.md deleted file mode 100644 index 26372cf1..00000000 --- a/docs/proposals/telemetry.md +++ /dev/null @@ -1,113 +0,0 @@ -# Telemetry — privacy-first usage counters - -**Status:** Implemented (v1.0, ship-dark) · **Scope:** v1 - -> v1.0 ships the CLI-side: counter package, allow-list, opt-out notice, -> precedence + `DPM_TELEMETRY` + `DPM_TELEMETRY_DEBUG`, `App.Run` wiring, -> the root `telemetry` command, and the golden tests. No production -> collector is deployed yet — with no endpoint baked in, counters stay -> local. **Consent model: opt-out** — telemetry is **on by default** and -> users disable it anytime (`telemetry off` / `DPM_TELEMETRY=off` / -> `DO_NOT_TRACK=1`). - -## Goal - -Lightweight, anonymous usage telemetry that shows **what's used** and -**what breaks** without compromising the privacy posture (loopback-only -UI, JWT redaction, no PII in commits). - -## Non-goals - -Identifying users or machines (no hardware-derived id, no IP) · capturing -what a command ran against (no instance/party/contract ids, paths, -hostnames, ports) · error content · sessionizing/sequencing invocations · -any flow enabling a behavioral profile. *One* exception, scoped tightly: -a single random, hardware-independent **install token** is sent solely to -de-duplicate install counts (Design #2) — it links to nothing else. - -## Design - -1. **Opt-out — telemetry is ON by default; users opt out anytime.** - On the first operational command a one-time TTY-gated notice states - it plainly: *"Telemetry is ON by default. Turn it off anytime: - `canton-devkit telemetry off` (or `DPM_TELEMETRY=off` / - `DO_NOT_TRACK=1`)."* All three switches disable it, and the choice - persists. Non-interactive runs never prompt and never block. -2. **One anonymous install token, nothing else.** No machine id, no - hashed hardware id, no IP retention. The single exception is a random - UUIDv4 (`install_id`) minted client-side and stored in the telemetry - config — *not* derived from any hardware attribute. It rides alongside - counter uploads so the collector can count DISTINCT installs (the one - adoption number additive counters can't yield), and is stored there - ALONE as `(token, active-date)`, never joined to a counter. It is - per-config-file (a fresh container/VM/reinstall mints a new one), - suppressed in CI, and rotatable via `telemetry reset-id`. Counters - themselves still merge into a daily aggregate with no per-invocation - row. -3. **Counter taxonomy (10 slots).** Closed, compile-time-enforced - allow-list (`internal/telemetry/allowlist.go`): `dpm/command`, - `dpm/command_exit`, `dpm/channel`, `dpm/os`, `dpm/arch`, `dpm/ci`, - `dpm/llm_agent`, `dpm/docker_engine`, `dpm/compose_version_bucket`, - `dpm/doctor_fail`. See [docs/telemetry.md](../telemetry.md) for buckets. -4. **Never collected.** instance/project/compose names · party/contract - ids · JWT fields · DAR/package/module names · file paths · hostnames · - IP/MAC · args beyond the verb · error messages · stack traces · ports · - env names/values · sub-week timestamps. -5. **Transport.** No event queue. Counters live in a local weekly file. - A completed past week uploads once (single POST, 2 s timeout, no - inner retries); on failure → mark deferred, retry next window; after 2 - misses → drop. Retrying an aggregate is privacy-safe; events are not. -6. **Collector.** Custom minimal endpoint `POST /v1/counters` with body - `{schema_version, period, granularity, counters, install_id?}` — not a - SaaS events API. The optional `install_id` is recorded only in a - separate `seen_install (token, active-date)` table for unique-install - counts; it is never stored beside a counter. -7. **Retention.** Local file: **current week + 3 prior weeks** (rolling - 4-week window — useful for offline debug, still no per-event row, no - sub-week timestamp, no id). Server raw intake: 24 h. Server aggregates: - 180 days. Dashboard: aggregated weeks only. (v1.1, server-side.) -8. **Small-cell suppression.** Start at **k = 3** for v1; ratchet upward - (5 → 10) as the install base grows. Encoded as a config knob, not a - structural change. (v1.1, server-side.) -9. **Disclosure UX.** TTY-gated one-time notice on the first *operational* - localnet verb; never on `version`/help/`telemetry …`/non-TTY. -10. **Precedence.** `DO_NOT_TRACK` → `DPM_TELEMETRY` → config file → - default on. `DPM_TELEMETRY_DEBUG=1` → print the would-send JSON to - stderr, skip the network. -11. **CLI surface.** Root-level `canton-devkit telemetry on|off|status|preview`. -12. **Web UI parity.** Settings toggle + `/api/telemetry` GET/POST + - `/api/telemetry/preview`, loopback-only. Optional — only build if a - real user surface motivates it; the CLI surface plus - `DPM_TELEMETRY_DEBUG=1` is the audit path operators actually need. -13. **Code shape.** `internal/telemetry/{allowlist,counter,store,config, - uploader,notice,context}.go`. -14. **Hook point.** `internal/cli/app.go` `App.Run`, after Cobra's - `root.ExecuteC()` returns. The verb derives from the executed - `*cobra.Command` via the `localnetVerb(cmd)` helper (NOT `args[0]`, - which would always be `"localnet"` for `canton-devkit localnet up`). - The sink is installed via `App.WithTelemetry()`; tests leave the - package's no-op sink so they never write or send. -15. **Channel detection.** `-ldflags -X main.channel=stable|nightly`; - defaults to `dev` for local `go build`. -16. **Domain.** `telemetry.canton-devkit.dev` subdomain. (v1.1.) -17. **Tests — defense in depth.** Two-layer enforcement of the allow-list: - (a) compile-time `go/ast` walk over every `telemetry.Inc(chart, bucket)` - literal in the tree; (b) **runtime allow-list check inside `Inc()`** - that silently drops unknown `(chart, bucket)` pairs — closes the gap - where buckets are concatenated at runtime (e.g. - `Inc("dpm/command_exit", verb+"/"+outcome)`) which the AST can't - enumerate. Plus `DO_NOT_TRACK`/precedence tests, weekly-merge no-id - /no-timestamp guard, 2-attempt-drop upload, `TestRunIsArgvOnly` - still green. -18. **Public artifacts.** This doc; allow-list + sender code in the public - repo; schema changes bump `schema_version` + update this doc. - -## Pending (not in v1.0) - -- **v1.1** — collector endpoint + nginx (no IP/UA/cookies) + weekly rollup - + k = 3-anonymized public dashboard at `telemetry.canton-devkit.dev`. -- **v1.2** — Web UI parity (`/api/telemetry` + Settings panel), per the - AGENTS.md CLI ↔ UI rule. Optional; only build if a real user surface - motivates it. -- Bake a `nightly` channel build (`[nightly]` commit trigger) when - nightly releases start. diff --git a/docs/telemetry.md b/docs/telemetry.md index 311bd3e0..0cf4ceaa 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,12 +1,12 @@ # Telemetry canton-devkit records **anonymous, aggregate usage counters** — merged -into a daily total with **no per-invocation rows** — to help the team see -what's used and what breaks. The only identifier sent is a single +into a daily total with **no per-invocation rows** — to help maintainers +see what's used and what breaks. The only identifier sent is a single **anonymous random install token** (a UUID, not derived from any hardware -detail) used purely so we can count *distinct* installs; it never tags an -individual counter. See the full design at -[docs/proposals/telemetry.md](proposals/telemetry.md). +detail) used purely to count *distinct* installs; it never tags an +individual counter. This page is the complete reference for what is +collected, what is never collected, and how to inspect or disable it. Inspect exactly what's queued any time: @@ -80,7 +80,7 @@ nothing else. One value is sent that *can* distinguish installs: a random **UUIDv4** minted on first upload and stored in your telemetry config. It exists for exactly one reason — so the collector can answer *"how many distinct -installs?"* (the one adoption number pure counters can't give). What it is +installs?"* (the one number pure counters can't give). What it is **not**: - **Not derived from your machine** — no hostname, MAC, serial, or @@ -137,5 +137,4 @@ canton-devkit telemetry flush # send all queued counters now (skip DPM_TELEMETRY_DEBUG=1 canton-devkit localnet status # print the would-send JSON to stderr, send nothing ``` -See also: [proposals/telemetry.md](proposals/telemetry.md) (full design) · -[FAQ](faq.md) · [getting-started](getting-started.md). +See also: [FAQ](faq.md) · [Getting started](getting-started.md). diff --git a/docs/tests/e2e-test-milestone-1.html b/docs/tests/e2e-test-milestone-1.html deleted file mode 100644 index 97f834fa..00000000 --- a/docs/tests/e2e-test-milestone-1.html +++ /dev/null @@ -1,1431 +0,0 @@ - - - - - -E2E Test Plan -- Milestone 1: LocalNet Management CLI - - - - - - - -

- -
-

E2E Test Plan — Milestone 1: LocalNet Management CLI 18 Tests

-
- Proposal: original-devkit-proposal.md, Milestone 1 - Delivery: Month 3 - Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) -
-
-
-
0 / 0 steps completed
-
-
- - -
- Scope. 18 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, down, restart, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised. -
- - -

Conventions & Environment Setup

- -
-

CLI modes: Commands use $CLI as a placeholder. Set to dpm localnet (DPM component) or canton-devkit localnet (standalone). Run the full suite once per mode.

-
    -
  • Exit code 0 = success. Non-zero = failure (specific codes noted where relevant).
  • -
  • Output verification uses grep -qE patterns. A step passes if the grep matches.
  • -
  • $PLATFORM is one of macos, linux, windows.
  • -
  • Default step timeout: 30 seconds unless noted.
  • -
-
- -
# Set CLI mode (run full suite twice -- once per mode)
-export CLI="dpm localnet"       # DPM component mode
-# OR
-export CLI="canton-devkit localnet"  # standalone mode
-
-# Ensure Docker is running
-docker info > /dev/null 2>&1 || { echo "FAIL: Docker not running"; exit 1; }
-
-# Ensure clean state before test suite
-$CLI clean --name e2e-test-default --force 2>/dev/null || true
-$CLI clean --name e2e-test-a --force 2>/dev/null || true
-$CLI clean --name e2e-test-b --force 2>/dev/null || true
- - -

Test Cases

- - -
- - - M1-INST-001 - Install via DPM component - Installation - -
-
- Preconditions: DPM CLI installed, network access to OCI registry - Platforms: All -
- -
- -
-

Step 1. Install the DevKit DPM component:

-
dpm install package canton-devkit
-
Expected: Exit code 0.
-

Verify dpm localnet --help exits 0 and output matches:

-
dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
-
-
- -
- -
-

Step 2. Confirm the localnet top-level command is registered:

-
dpm --help 2>&1 | grep -qE "localnet"
-
Expected: Match found (exit 0).
-
-
- -
Cleanup: None.
-
-
- - -
- - - M1-INST-002 - Install standalone binary - Installation - -
-
- Preconditions: Network access to GitHub Releases - Platforms: All (platform-specific binary) -
- -
- -
-

Step 1. Download the correct binary for the current platform:

-
# macOS (Apple Silicon)
-curl -L -o canton-devkit https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-darwin-arm64
-chmod +x canton-devkit
-
-# Linux (amd64)
-curl -L -o canton-devkit https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-linux-amd64
-chmod +x canton-devkit
-
-# Windows (amd64) -- PowerShell
-# Invoke-WebRequest -Uri https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-windows-amd64.exe -OutFile canton-devkit.exe
-
Expected: File downloaded, non-zero size.
-
-
- -
- -
-

Step 2. Verify the binary runs:

-
./canton-devkit localnet --help
-
Expected: Exit code 0, output matches:
-
./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
-
-
- -
- -
-

Step 3. Verify checksum (if published):

-
curl -L -o checksums.txt https://github.com/<org>/canton-devkit/releases/latest/download/checksums.txt
-sha256sum -c checksums.txt 2>&1 | grep -qE "canton-devkit.*OK"
-
Expected: Checksum matches.
-
-
- -
Cleanup: rm -f canton-devkit checksums.txt
-
-
- - -
- - - M1-INST-003 - Verify binary on all platforms - Installation - -
-
- Preconditions: Binary installed (M1-INST-001 or M1-INST-002) - Platforms: All (run once per platform) -
- -
- -
-

Step 1. Check version output:

-
$CLI --version
-
Expected: Exit code 0, output matches a semver pattern:
-
$CLI --version 2>&1 | grep -qE "[0-9]+\.[0-9]+\.[0-9]+"
-
-
- -
- -
-

Step 2. Check help output includes all Milestone 1 commands:

-
$CLI --help 2>&1 | grep -qE "up"
-$CLI --help 2>&1 | grep -qE "down"
-$CLI --help 2>&1 | grep -qE "restart"
-$CLI --help 2>&1 | grep -qE "clean"
-$CLI --help 2>&1 | grep -qE "status"
-$CLI --help 2>&1 | grep -qE "logs"
-$CLI --help 2>&1 | grep -qE "snapshot"
-$CLI --help 2>&1 | grep -qE "restore"
-$CLI --help 2>&1 | grep -qE "doctor"
-
Expected: All grep commands exit 0.
-
-
- -
- -
-

Step 3. Verify no runtime dependencies required (no Go, Node, Python, Rust):

-
# Binary should be statically linked / self-contained
-file $(which canton-devkit) 2>/dev/null || file $(which dpm) 2>/dev/null
-
Expected: Output indicates a compiled binary (e.g., "Mach-O", "ELF", "PE32").
-
-
- -
Cleanup: None.
-
-
- - -
- - - M1-DOC-001 - Doctor — all checks pass - Preflight - -
-
- Preconditions: Docker running, Compose v2 available, sufficient resources - Platforms: All -
- -
- -
-

Step 1. Run doctor:

-
$CLI doctor
-
Expected: Exit code 0.
-

Verify output includes pass indicators for all checks:

-
$CLI doctor 2>&1 | grep -qiE "(docker cli|docker daemon|compose v2|ports|disk|memory)"
-

Verify no failures reported:

-
$CLI doctor 2>&1 | grep -qiE "(fail|error|missing)" && echo "FAIL: doctor reports issues" || echo "PASS"
-
-
- -
Cleanup: None.
-
-
- - -
- - - M1-DOC-002 - Doctor — Docker not installed - Preflight - -
-
- Preconditions: Docker CLI removed from PATH or Docker daemon stopped - Platforms: All -
- -
- -
-

Step 1. Temporarily hide Docker from PATH:

-
PATH_BACKUP="$PATH"
-export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v docker | tr '\n' ':')
-
-
- -
- -
-

Step 2. Run doctor:

-
$CLI doctor
-
Expected: Non-zero exit code.
-

Verify remediation instructions in output:

-
$CLI doctor 2>&1 | grep -qiE "(install docker|docker not found|docker desktop)"
-
-
- -
- -
-

Step 3. Restore PATH:

-
export PATH="$PATH_BACKUP"
-
-
- -
Cleanup: PATH restored in step 3.
-
-
- - -
- - - M1-DOC-003 - Doctor — insufficient resources - Preflight - -
-
- Preconditions: Docker running but with known resource constraints (e.g., low memory limit on Docker Desktop) - Platforms: macOS, Windows (Docker Desktop with configurable resource limits) -
- -
- -
-

Step 1. Run doctor with constrained Docker resources:

-
$CLI doctor
-
Expected: Non-zero exit code OR exit 0 with warnings.
-

Verify resource warnings in output:

-
$CLI doctor 2>&1 | grep -qiE "(memory|disk|insufficient|warning)"
-
-
- -
-

Note: This test may require manual Docker Desktop resource configuration. On Linux with native Docker, simulate by setting --memory limits on the daemon. If the environment has sufficient resources, verify that doctor reports adequate resources instead.

-
- -
Cleanup: Restore Docker resource settings to original values.
-
-
- - -
- - - M1-UP-001 - LocalNet up (default) - Lifecycle - -
-
- Preconditions: Docker running, no existing LocalNet named e2e-test-default - Platforms: All - Timeout: 300s -
- -
- -
-

Step 1. Start a default LocalNet:

-
$CLI up --name e2e-test-default
-
Expected: Exit code 0.
-

Verify endpoints printed:

-
$CLI up --name e2e-test-default 2>&1 | grep -qiE "(endpoint|port|url|ledger|json.api)"
-

Verify readiness wait completed:

-
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
-
-
- -
- -
-

Step 2. Verify Docker resources are labeled correctly:

-
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default"
-
Expected: At least one container matches.
-
-
- -
- -
-

Step 3. Verify deterministic Docker Compose project name:

-
docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-default"
-
Expected: Project listed.
-
-
- -
Cleanup: $CLI down --name e2e-test-default
-
-
- - -
- - - M1-UP-002 - LocalNet up with --name - Lifecycle - -
-
- Preconditions: Docker running - Platforms: All - Timeout: 300s -
- -
- -
-

Step 1. Start a named LocalNet:

-
$CLI up --name e2e-named-test
-
Expected: Exit code 0.
-
-
- -
- -
-

Step 2. Verify the instance uses the specified name:

-
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-named-test"
-
Expected: Match found.
-
-
- -
- -
-

Step 3. Verify status references the correct name:

-
$CLI status --name e2e-named-test 2>&1 | grep -qiE "e2e-named-test"
-
Expected: Exit code 0, name appears in output.
-
-
- -
Cleanup: $CLI down --name e2e-named-test && $CLI clean --name e2e-named-test --force
-
-
- - -
- - - M1-UP-003 - LocalNet up with --version - Lifecycle - -
-
- Preconditions: Docker running, known valid Splice version from compatibility matrix - Platforms: All - Timeout: 300s -
- -
- -
-

Step 1. Start a LocalNet with explicit version:

-
SPLICE_VERSION="<known-valid-version>"  # from compatibility matrix
-$CLI up --name e2e-version-test --version "$SPLICE_VERSION"
-
Expected: Exit code 0.
-
-
- -
- -
-

Step 2. Verify the selected version is reflected in status:

-
$CLI status --name e2e-version-test 2>&1 | grep -qE "$SPLICE_VERSION"
-
Expected: Version string appears in output.
-
-
- -
- -
-

Step 3. Test with invalid version:

-
$CLI up --name e2e-bad-version --version "0.0.0-nonexistent"
-
Expected: Non-zero exit code, error message about invalid/unavailable version.
-
-
- -
Cleanup: $CLI down --name e2e-version-test && $CLI clean --name e2e-version-test --force
-
-
- - -
- - - M1-STS-001 - Status shows healthy services - Status - -
-
- Preconditions: LocalNet e2e-test-default running (depends on M1-UP-001 setup) - Platforms: All -
- -
- -
-

Step 1. Start LocalNet if not running:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Check status:

-
$CLI status --name e2e-test-default
-
Expected: Exit code 0.
-

Verify output includes required information:

-
OUTPUT=$($CLI status --name e2e-test-default 2>&1)
-echo "$OUTPUT" | grep -qiE "(healthy|running|ready)"          # service health
-echo "$OUTPUT" | grep -qiE "(port|endpoint)"                   # ports/endpoints
-echo "$OUTPUT" | grep -qiE "(participant)"                     # participant readiness
-echo "$OUTPUT" | grep -qiE "(version|splice)"                  # selected version
-
-
- -
- -
-

Step 3. Check status for non-existent LocalNet:

-
$CLI status --name nonexistent-localnet-xyz
-
Expected: Non-zero exit code, clear error message.
-
-
- -
Cleanup: $CLI down --name e2e-test-default
-
-
- - -
- - - M1-LOG-001 - Logs — full and service-filtered - Logs - -
-
- Preconditions: LocalNet e2e-test-default running - Platforms: All -
- -
- -
-

Step 1. Start LocalNet if not running:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Tail full logs (non-blocking with timeout):

-
timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -50
-
Expected: Output is non-empty (logs are streaming).
-

Verify:

-
timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -5 | wc -l | grep -qE "[1-9]"
-
-
- -
- -
-

Step 3. Tail logs for a specific service:

-
timeout 10 $CLI logs participant --name e2e-test-default 2>&1 | head -20
-
Expected: Output is non-empty, logs come from the specified service only.
-
-
- -
- -
-

Step 4. Tail logs for non-existent service:

-
$CLI logs nonexistent-service --name e2e-test-default
-
Expected: Non-zero exit code or clear error message about unknown service.
-
-
- -
Cleanup: $CLI down --name e2e-test-default
-
-
- - -
- - - M1-RST-001 - Restart full + single service - Lifecycle - -
-
- Preconditions: LocalNet e2e-test-default running - Platforms: All - Timeout: 300s -
- -
- -
-

Step 1. Start LocalNet if not running:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Restart the full LocalNet:

-
$CLI restart --name e2e-test-default
-
Expected: Exit code 0.
-

Verify readiness after restart:

-
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
-
-
- -
- -
-

Step 3. Restart a single service:

-
$CLI restart participant --name e2e-test-default
-
Expected: Exit code 0.
-

Verify the restarted service is healthy:

-
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
-
-
- -
Cleanup: $CLI down --name e2e-test-default
-
-
- - -
- - - M1-DWN-001 - Down stops instance cleanly - Lifecycle - -
-
- Preconditions: LocalNet e2e-test-default running - Platforms: All -
- -
- -
-

Step 1. Start LocalNet:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Verify it is running:

-
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default"
-
-
- -
- -
-

Step 3. Stop it:

-
$CLI down --name e2e-test-default
-
Expected: Exit code 0.
-
-
- -
- -
-

Step 4. Verify containers stopped:

-
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS"
-
-
- -
- -
-

Step 5. Verify unrelated Docker resources are not affected:

-
# If other non-DevKit containers were running before, they should still be running
-docker ps --format '{{.Names}}' | grep -v "canton-devkit" | wc -l
-
Expected: Count unchanged from before test.
-
-
- -
Cleanup: $CLI clean --name e2e-test-default --force 2>/dev/null || true
-
-
- - -
- - - M1-CLN-001 - Clean removes all resources - Lifecycle - -
-
- Preconditions: LocalNet e2e-test-default has been started and stopped - Platforms: All -
- -
- -
-

Step 1. Start and stop a LocalNet:

-
$CLI up --name e2e-test-default
-$CLI down --name e2e-test-default
-
-
- -
- -
-

Step 2. Verify resources exist (volumes, networks):

-
docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default"
-
Expected: Volumes exist from the stopped instance.
-
-
- -
- -
-

Step 3. Clean the instance:

-
$CLI clean --name e2e-test-default
-
Expected: Exit code 0. May prompt for confirmation (use --force if non-interactive).
-

If confirmation is required:

-
echo "y" | $CLI clean --name e2e-test-default
-# OR
-$CLI clean --name e2e-test-default --force
-
-
- -
- -
-

Step 4. Verify all DevKit-managed resources removed:

-
docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: volumes remain" || echo "PASS"
-docker network ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: networks remain" || echo "PASS"
-docker ps -a --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers remain" || echo "PASS"
-
-
- -
Cleanup: None (test is self-cleaning).
-
-
- - -
- - - M1-SNP-001 - Snapshot and restore - State - -
-
- Preconditions: LocalNet e2e-test-default running - Platforms: All - Timeout: 300s -
- -
- -
-

Step 1. Start LocalNet and wait for readiness:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Create a snapshot:

-
$CLI snapshot --name e2e-test-default
-
Expected: Exit code 0.
-

Verify snapshot reference is output:

-
$CLI snapshot --name e2e-test-default 2>&1 | grep -qiE "(snapshot|saved|created)"
-
-
- -
- -
-

Step 3. Stop and clean the LocalNet:

-
$CLI down --name e2e-test-default
-$CLI clean --name e2e-test-default --force
-
-
- -
- -
-

Step 4. Restore from snapshot:

-
$CLI restore --name e2e-test-default
-
Expected: Exit code 0.
-

Verify LocalNet is running and healthy after restore:

-
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
-
-
- -
Cleanup: $CLI down --name e2e-test-default && $CLI clean --name e2e-test-default --force
-
-
- - -
- - - M1-ISO-001 - Two named instances, non-conflicting ports - Isolation - -
-
- Preconditions: Docker running, sufficient resources for two LocalNets - Platforms: All - Timeout: 600s -
- -
- -
-

Step 1. Start first instance with explicit ports:

-
$CLI up --name e2e-test-a
-
Expected: Exit code 0.
-
-
- -
- -
-

Step 2. Start second instance with non-conflicting ports:

-
$CLI up --name e2e-test-b
-
Expected: Exit code 0.
-
-
- -
- -
-

Step 3. Verify both instances are running and isolated:

-
$CLI status --name e2e-test-a 2>&1 | grep -qiE "(healthy|ready|running)"
-$CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)"
-
-
- -
- -
-

Step 4. Verify port isolation (no port conflicts):

-
PORTS_A=$($CLI status --name e2e-test-a 2>&1 | grep -oE "[0-9]{4,5}" | sort)
-PORTS_B=$($CLI status --name e2e-test-b 2>&1 | grep -oE "[0-9]{4,5}" | sort)
-OVERLAP=$(comm -12 <(echo "$PORTS_A") <(echo "$PORTS_B"))
-[ -z "$OVERLAP" ] && echo "PASS: no port overlap" || echo "FAIL: overlapping ports: $OVERLAP"
-
-
- -
- -
-

Step 5. Verify Docker resource isolation (separate project names):

-
docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-a"
-docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-b"
-
-
- -
- -
-

Step 6. Stop one instance and verify the other is unaffected:

-
$CLI down --name e2e-test-a
-$CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)"
-
Expected: Instance B still healthy.
-
-
- -
Cleanup: -
$CLI down --name e2e-test-a 2>/dev/null || true
-$CLI down --name e2e-test-b 2>/dev/null || true
-$CLI clean --name e2e-test-a --force 2>/dev/null || true
-$CLI clean --name e2e-test-b --force 2>/dev/null || true
-
-
-
- - -
- - - M1-ENV-001 - Env export outputs valid config - Automation - -
-
- Preconditions: LocalNet e2e-test-default running - Platforms: All -
- -
- -
-

Step 1. Start LocalNet if not running:

-
$CLI up --name e2e-test-default
-
-
- -
- -
-

Step 2. Export environment:

-
$CLI env --name e2e-test-default
-
Expected: Exit code 0.
-

Verify .env-style output:

-
OUTPUT=$($CLI env --name e2e-test-default 2>&1)
-echo "$OUTPUT" | grep -qE "^[A-Z_]+=.+"                        # KEY=value format
-echo "$OUTPUT" | grep -qiE "(LEDGER|JSON.API|ADMIN|PARTICIPANT)" # expected keys
-
-
- -
- -
-

Step 3. Verify exported values are usable (source and test a variable):

-
eval "$($CLI env --name e2e-test-default)"
-# Verify at least one URL/port is reachable
-curl -sf "http://${LEDGER_API_HOST:-localhost}:${LEDGER_API_PORT:-6865}/health" > /dev/null 2>&1 || \
-curl -sf "http://${JSON_API_HOST:-localhost}:${JSON_API_PORT:-7575}/health" > /dev/null 2>&1 || \
-echo "WARN: Could not reach exported endpoints (may require different health check path)"
-
-
- -
Cleanup: $CLI down --name e2e-test-default
-
-
- - -
- - - M1-LST-001 - List discovers running instances - Automation - -
-
- Preconditions: At least one named LocalNet running - Platforms: All -
- -
- -
-

Step 1. Start two instances:

-
$CLI up --name e2e-test-a
-$CLI up --name e2e-test-b
-
-
- -
- -
-

Step 2. List instances:

-
$CLI list
-
Expected: Exit code 0.
-

Verify both instances appear:

-
OUTPUT=$($CLI list 2>&1)
-echo "$OUTPUT" | grep -qE "e2e-test-a"
-echo "$OUTPUT" | grep -qE "e2e-test-b"
-
-
- -
- -
-

Step 3. Stop one instance and re-list:

-
$CLI down --name e2e-test-a
-OUTPUT=$($CLI list 2>&1)
-echo "$OUTPUT" | grep -qE "e2e-test-b"
-
Expected: Instance B still listed, instance A either removed or shown as stopped.
-
-
- -
- -
-

Step 4. Verify no non-DevKit containers appear in the list:

-
$CLI list 2>&1 | grep -qiE "(canton-devkit|localnet|e2e-test)" || echo "WARN: list output format unclear"
-
-
- -
Cleanup: -
$CLI down --name e2e-test-a 2>/dev/null || true
-$CLI down --name e2e-test-b 2>/dev/null || true
-$CLI clean --name e2e-test-a --force 2>/dev/null || true
-$CLI clean --name e2e-test-b --force 2>/dev/null || true
-
-
-
- - - -

Exit Code Contract

-
- - - - - - - - - - - -
Exit CodeMeaning
0Success
1General error
2Docker not available or preflight check failed
3LocalNet instance not found
4Port conflict
5Resource insufficient (memory/disk)
Non-zeroAny failure (agent should capture stderr for diagnostics)
-
-

Exact exit codes are subject to implementation. The key contract is: 0 = success, non-zero = failure with diagnostic output on stderr.

- -

Cross-Platform Notes

-
- - - - - - - -
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. file command shows "Mach-O 64-bit executable arm64". Ports bind to localhost by default.
Linux (amd64)Native Docker or Docker Desktop. Doctor should check Linux Docker permissions (user in docker group or rootless Docker). file command shows "ELF 64-bit LSB executable, x86-64".
Windows (amd64)Docker Desktop with WSL 2 backend. Commands use PowerShell or WSL. file equivalent: Get-Command canton-devkit.exe. Path separators differ.
-
- -

Test Execution Summary

-
- - - - - - - - - - - - - - - - - - - - - - -
IDTest NameCategoryDepends On
M1-INST-001Install via DPM componentInstallation
M1-INST-002Install standalone binaryInstallation
M1-INST-003Verify binary on all platformsInstallationM1-INST-001 or M1-INST-002
M1-DOC-001Doctor — all checks passPreflightM1-INST-003
M1-DOC-002Doctor — Docker not installedPreflightM1-INST-003
M1-DOC-003Doctor — insufficient resourcesPreflightM1-INST-003
M1-UP-001LocalNet up (default)LifecycleM1-DOC-001
M1-UP-002LocalNet up with --nameLifecycleM1-DOC-001
M1-UP-003LocalNet up with --versionLifecycleM1-DOC-001
M1-STS-001Status shows healthy servicesStatusM1-UP-001
M1-LOG-001Logs — full and service-filteredLogsM1-UP-001
M1-RST-001Restart full + single serviceLifecycleM1-UP-001
M1-DWN-001Down stops instance cleanlyLifecycleM1-UP-001
M1-CLN-001Clean removes all resourcesLifecycleM1-DWN-001
M1-SNP-001Snapshot and restoreStateM1-UP-001
M1-ISO-001Two named instancesIsolationM1-DOC-001
M1-ENV-001Env export outputs valid configAutomationM1-UP-001
M1-LST-001List discovers running instancesAutomationM1-UP-001
-
- -
-

Source: e2e-test-milestone-1.md

-

This page is a companion artifact generated from the source markdown. Checkbox state is saved in your browser's localStorage.

-
- -
- - - - - diff --git a/docs/tests/e2e-test-milestone-1.md b/docs/tests/e2e-test-milestone-1.md deleted file mode 100644 index e535ff65..00000000 --- a/docs/tests/e2e-test-milestone-1.md +++ /dev/null @@ -1,843 +0,0 @@ -# E2E Test Plan — Milestone 1: LocalNet Management CLI - -> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 1 (Lines 230–247) -> **Estimated Delivery:** Month 3 -> **Total Tests:** 18 -> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) - ---- - -## Overview - -This test plan validates the core LocalNet lifecycle management CLI delivered in Milestone 1. Every test is designed for mechanical execution by an AI agent or CI pipeline. - -### Conventions - -- Commands are shown in both forms: `dpm localnet ...` (DPM component) and `canton-devkit localnet ...` (standalone). Both must be tested. -- `$CLI` is used as a placeholder — set it to either `dpm localnet` or `canton-devkit localnet` before running. -- Exit code `0` = success. Non-zero = failure (specific codes noted where relevant). -- Output verification uses `grep -qE` patterns. A test step passes if the grep matches. -- `$PLATFORM` is one of `macos`, `linux`, `windows`. -- Timeouts are specified per-step where relevant. Default step timeout: 30 seconds unless noted. - -### Environment Setup - -```bash -# Set CLI mode (run full suite twice — once per mode) -export CLI="dpm localnet" # DPM component mode -# OR -export CLI="canton-devkit localnet" # standalone mode - -# Ensure Docker is running -docker info > /dev/null 2>&1 || { echo "FAIL: Docker not running"; exit 1; } - -# Ensure clean state before test suite -$CLI clean --name e2e-test-default --force 2>/dev/null || true -$CLI clean --name e2e-test-a --force 2>/dev/null || true -$CLI clean --name e2e-test-b --force 2>/dev/null || true -``` - ---- - -## Test Cases - ---- - -### M1-INST-001: Install via DPM component - -**Preconditions:** DPM CLI installed, network access to OCI registry. -**Platforms:** All - -**Steps:** - -1. Install the DevKit DPM component: - ```bash - dpm install package canton-devkit - ``` - - **Expected:** Exit code `0`. - - **Verify:** `dpm localnet --help` exits `0` and output matches: - ```bash - dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)" - ``` - -2. Confirm the `localnet` top-level command is registered: - ```bash - dpm --help 2>&1 | grep -qE "localnet" - ``` - - **Expected:** Match found (exit `0`). - -**Cleanup:** None. - ---- - -### M1-INST-002: Install standalone binary - -**Preconditions:** Network access to GitHub Releases. -**Platforms:** All (platform-specific binary) - -**Steps:** - -1. Download the correct binary for the current platform: - ```bash - # macOS (Apple Silicon) - curl -L -o canton-devkit https://github.com//canton-devkit/releases/latest/download/canton-devkit-darwin-arm64 - chmod +x canton-devkit - - # Linux (amd64) - curl -L -o canton-devkit https://github.com//canton-devkit/releases/latest/download/canton-devkit-linux-amd64 - chmod +x canton-devkit - - # Windows (amd64) — PowerShell - # Invoke-WebRequest -Uri https://github.com//canton-devkit/releases/latest/download/canton-devkit-windows-amd64.exe -OutFile canton-devkit.exe - ``` - - **Expected:** File downloaded, non-zero size. - -2. Verify the binary runs: - ```bash - ./canton-devkit localnet --help - ``` - - **Expected:** Exit code `0`, output matches: - ```bash - ./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)" - ``` - -3. Verify checksum (if published): - ```bash - curl -L -o checksums.txt https://github.com//canton-devkit/releases/latest/download/checksums.txt - sha256sum -c checksums.txt 2>&1 | grep -qE "canton-devkit.*OK" - ``` - - **Expected:** Checksum matches. - -**Cleanup:** `rm -f canton-devkit checksums.txt` - ---- - -### M1-INST-003: Verify binary on all platforms - -**Preconditions:** Binary installed (M1-INST-001 or M1-INST-002). -**Platforms:** All (run once per platform) - -**Steps:** - -1. Check version output: - ```bash - $CLI --version - ``` - - **Expected:** Exit code `0`, output matches a semver pattern: - ```bash - $CLI --version 2>&1 | grep -qE "[0-9]+\.[0-9]+\.[0-9]+" - ``` - -2. Check help output includes all Milestone 1 commands: - ```bash - $CLI --help 2>&1 | grep -qE "up" - $CLI --help 2>&1 | grep -qE "down" - $CLI --help 2>&1 | grep -qE "restart" - $CLI --help 2>&1 | grep -qE "clean" - $CLI --help 2>&1 | grep -qE "status" - $CLI --help 2>&1 | grep -qE "logs" - $CLI --help 2>&1 | grep -qE "snapshot" - $CLI --help 2>&1 | grep -qE "restore" - $CLI --help 2>&1 | grep -qE "doctor" - ``` - - **Expected:** All grep commands exit `0`. - -3. Verify no runtime dependencies required (no Go, Node, Python, Rust): - ```bash - # Binary should be statically linked / self-contained - file $(which canton-devkit) 2>/dev/null || file $(which dpm) 2>/dev/null - ``` - - **Expected:** Output indicates a compiled binary (e.g., "Mach-O", "ELF", "PE32"). - -**Cleanup:** None. - ---- - -### M1-DOC-001: Doctor — all checks pass - -**Preconditions:** Docker running, Compose v2 available, sufficient resources. -**Platforms:** All - -**Steps:** - -1. Run doctor: - ```bash - $CLI doctor - ``` - - **Expected:** Exit code `0`. - - **Verify output includes pass indicators for all checks:** - ```bash - $CLI doctor 2>&1 | grep -qiE "(docker cli|docker daemon|compose v2|ports|disk|memory)" - ``` - - **Verify no failures reported:** - ```bash - $CLI doctor 2>&1 | grep -qiE "(fail|error|missing)" && echo "FAIL: doctor reports issues" || echo "PASS" - ``` - -**Cleanup:** None. - ---- - -### M1-DOC-002: Doctor — Docker not installed - -**Preconditions:** Docker CLI removed from PATH or Docker daemon stopped. -**Platforms:** All - -**Steps:** - -1. Temporarily hide Docker from PATH: - ```bash - PATH_BACKUP="$PATH" - export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v docker | tr '\n' ':') - ``` - -2. Run doctor: - ```bash - $CLI doctor - ``` - - **Expected:** Non-zero exit code. - - **Verify remediation instructions in output:** - ```bash - $CLI doctor 2>&1 | grep -qiE "(install docker|docker not found|docker desktop)" - ``` - -3. Restore PATH: - ```bash - export PATH="$PATH_BACKUP" - ``` - -**Cleanup:** PATH restored in step 3. - ---- - -### M1-DOC-003: Doctor — insufficient resources - -**Preconditions:** Docker running but with known resource constraints (e.g., low memory limit on Docker Desktop). -**Platforms:** macOS, Windows (Docker Desktop with configurable resource limits) - -**Steps:** - -1. Run doctor with constrained Docker resources: - ```bash - $CLI doctor - ``` - - **Expected:** Non-zero exit code OR exit `0` with warnings. - - **Verify resource warnings in output:** - ```bash - $CLI doctor 2>&1 | grep -qiE "(memory|disk|insufficient|warning)" - ``` - -**Note:** This test may require manual Docker Desktop resource configuration. On Linux with native Docker, simulate by setting `--memory` limits on the daemon. If the environment has sufficient resources, verify that doctor reports adequate resources instead. - -**Cleanup:** Restore Docker resource settings to original values. - ---- - -### M1-UP-001: LocalNet up (default) - -**Preconditions:** Docker running, no existing LocalNet named `e2e-test-default`. -**Platforms:** All -**Timeout:** 300 seconds (5 minutes for full startup + readiness) - -**Steps:** - -1. Start a default LocalNet: - ```bash - $CLI up --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify endpoints printed:** - ```bash - $CLI up --name e2e-test-default 2>&1 | grep -qiE "(endpoint|port|url|ledger|json.api)" - ``` - - **Verify readiness wait completed (command did not return until services ready):** - ```bash - $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - -2. Verify Docker resources are labeled correctly: - ```bash - docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" - ``` - - **Expected:** At least one container matches. - -3. Verify deterministic Docker Compose project name: - ```bash - docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-default" - ``` - - **Expected:** Project listed. - -**Cleanup:** `$CLI down --name e2e-test-default` - ---- - -### M1-UP-002: LocalNet up with --name - -**Preconditions:** Docker running. -**Platforms:** All -**Timeout:** 300 seconds - -**Steps:** - -1. Start a named LocalNet: - ```bash - $CLI up --name e2e-named-test - ``` - - **Expected:** Exit code `0`. - -2. Verify the instance uses the specified name: - ```bash - docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-named-test" - ``` - - **Expected:** Match found. - -3. Verify status references the correct name: - ```bash - $CLI status --name e2e-named-test 2>&1 | grep -qiE "e2e-named-test" - ``` - - **Expected:** Exit code `0`, name appears in output. - -**Cleanup:** `$CLI down --name e2e-named-test && $CLI clean --name e2e-named-test --force` - ---- - -### M1-UP-003: LocalNet up with --version - -**Preconditions:** Docker running, known valid Splice version from compatibility matrix. -**Platforms:** All -**Timeout:** 300 seconds - -**Steps:** - -1. Start a LocalNet with explicit version: - ```bash - SPLICE_VERSION="" # from compatibility matrix - $CLI up --name e2e-version-test --version "$SPLICE_VERSION" - ``` - - **Expected:** Exit code `0`. - -2. Verify the selected version is reflected in status: - ```bash - $CLI status --name e2e-version-test 2>&1 | grep -qE "$SPLICE_VERSION" - ``` - - **Expected:** Version string appears in output. - -3. Test with invalid version: - ```bash - $CLI up --name e2e-bad-version --version "0.0.0-nonexistent" - ``` - - **Expected:** Non-zero exit code, error message about invalid/unavailable version. - -**Cleanup:** `$CLI down --name e2e-version-test && $CLI clean --name e2e-version-test --force` - ---- - -### M1-STS-001: Status shows healthy services - -**Preconditions:** LocalNet `e2e-test-default` running (depends on M1-UP-001 setup). -**Platforms:** All - -**Steps:** - -1. Start LocalNet if not running: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Check status: - ```bash - $CLI status --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify output includes required information:** - ```bash - OUTPUT=$($CLI status --name e2e-test-default 2>&1) - echo "$OUTPUT" | grep -qiE "(healthy|running|ready)" # service health - echo "$OUTPUT" | grep -qiE "(port|endpoint)" # ports/endpoints - echo "$OUTPUT" | grep -qiE "(participant)" # participant readiness - echo "$OUTPUT" | grep -qiE "(version|splice)" # selected version - ``` - -3. Check status for non-existent LocalNet: - ```bash - $CLI status --name nonexistent-localnet-xyz - ``` - - **Expected:** Non-zero exit code, clear error message. - -**Cleanup:** `$CLI down --name e2e-test-default` - ---- - -### M1-LOG-001: Logs — full and service-filtered - -**Preconditions:** LocalNet `e2e-test-default` running. -**Platforms:** All - -**Steps:** - -1. Start LocalNet if not running: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Tail full logs (non-blocking with timeout): - ```bash - timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -50 - ``` - - **Expected:** Output is non-empty (logs are streaming). - - **Verify:** - ```bash - timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -5 | wc -l | grep -qE "[1-9]" - ``` - -3. Tail logs for a specific service: - ```bash - timeout 10 $CLI logs participant --name e2e-test-default 2>&1 | head -20 - ``` - - **Expected:** Output is non-empty, logs come from the specified service only. - -4. Tail logs for non-existent service: - ```bash - $CLI logs nonexistent-service --name e2e-test-default - ``` - - **Expected:** Non-zero exit code or clear error message about unknown service. - -**Cleanup:** `$CLI down --name e2e-test-default` - ---- - -### M1-RST-001: Restart full + single service - -**Preconditions:** LocalNet `e2e-test-default` running. -**Platforms:** All -**Timeout:** 300 seconds - -**Steps:** - -1. Start LocalNet if not running: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Restart the full LocalNet: - ```bash - $CLI restart --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify readiness after restart:** - ```bash - $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - -3. Restart a single service: - ```bash - $CLI restart participant --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify the restarted service is healthy:** - ```bash - $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - -**Cleanup:** `$CLI down --name e2e-test-default` - ---- - -### M1-DWN-001: Down stops instance cleanly - -**Preconditions:** LocalNet `e2e-test-default` running. -**Platforms:** All - -**Steps:** - -1. Start LocalNet: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Verify it is running: - ```bash - docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" - ``` - -3. Stop it: - ```bash - $CLI down --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - -4. Verify containers stopped: - ```bash - docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS" - ``` - -5. Verify unrelated Docker resources are not affected: - ```bash - # If other non-DevKit containers were running before, they should still be running - docker ps --format '{{.Names}}' | grep -v "canton-devkit" | wc -l - ``` - - **Expected:** Count unchanged from before test. - -**Cleanup:** `$CLI clean --name e2e-test-default --force 2>/dev/null || true` - ---- - -### M1-CLN-001: Clean removes all resources - -**Preconditions:** LocalNet `e2e-test-default` has been started and stopped. -**Platforms:** All - -**Steps:** - -1. Start and stop a LocalNet: - ```bash - $CLI up --name e2e-test-default - $CLI down --name e2e-test-default - ``` - -2. Verify resources exist (volumes, networks): - ```bash - docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" - ``` - - **Expected:** Volumes exist from the stopped instance. - -3. Clean the instance: - ```bash - $CLI clean --name e2e-test-default - ``` - - **Expected:** Exit code `0`. May prompt for confirmation (use `--force` if non-interactive). - - If confirmation is required: - ```bash - echo "y" | $CLI clean --name e2e-test-default - # OR - $CLI clean --name e2e-test-default --force - ``` - -4. Verify all DevKit-managed resources removed: - ```bash - docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: volumes remain" || echo "PASS" - docker network ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: networks remain" || echo "PASS" - docker ps -a --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers remain" || echo "PASS" - ``` - -**Cleanup:** None (test is self-cleaning). - ---- - -### M1-SNP-001: Snapshot and restore - -**Preconditions:** LocalNet `e2e-test-default` running. -**Platforms:** All -**Timeout:** 300 seconds - -**Steps:** - -1. Start LocalNet and wait for readiness: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Create a snapshot: - ```bash - $CLI snapshot --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify snapshot reference is output:** - ```bash - $CLI snapshot --name e2e-test-default 2>&1 | grep -qiE "(snapshot|saved|created)" - ``` - -3. Stop and clean the LocalNet: - ```bash - $CLI down --name e2e-test-default - $CLI clean --name e2e-test-default --force - ``` - -4. Restore from snapshot: - ```bash - $CLI restore --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify LocalNet is running and healthy after restore:** - ```bash - $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - -**Cleanup:** `$CLI down --name e2e-test-default && $CLI clean --name e2e-test-default --force` - ---- - -### M1-ISO-001: Two named instances, non-conflicting ports - -**Preconditions:** Docker running, sufficient resources for two LocalNets. -**Platforms:** All -**Timeout:** 600 seconds (10 minutes for two full startups) - -**Steps:** - -1. Start first instance with explicit ports: - ```bash - $CLI up --name e2e-test-a - ``` - - **Expected:** Exit code `0`. - -2. Start second instance with non-conflicting ports: - ```bash - $CLI up --name e2e-test-b - ``` - - **Expected:** Exit code `0`. - -3. Verify both instances are running and isolated: - ```bash - $CLI status --name e2e-test-a 2>&1 | grep -qiE "(healthy|ready|running)" - $CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - -4. Verify port isolation (no port conflicts): - ```bash - PORTS_A=$($CLI status --name e2e-test-a 2>&1 | grep -oE "[0-9]{4,5}" | sort) - PORTS_B=$($CLI status --name e2e-test-b 2>&1 | grep -oE "[0-9]{4,5}" | sort) - OVERLAP=$(comm -12 <(echo "$PORTS_A") <(echo "$PORTS_B")) - [ -z "$OVERLAP" ] && echo "PASS: no port overlap" || echo "FAIL: overlapping ports: $OVERLAP" - ``` - -5. Verify Docker resource isolation (separate project names): - ```bash - docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-a" - docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-b" - ``` - -6. Stop one instance and verify the other is unaffected: - ```bash - $CLI down --name e2e-test-a - $CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)" - ``` - - **Expected:** Instance B still healthy. - -**Cleanup:** -```bash -$CLI down --name e2e-test-a 2>/dev/null || true -$CLI down --name e2e-test-b 2>/dev/null || true -$CLI clean --name e2e-test-a --force 2>/dev/null || true -$CLI clean --name e2e-test-b --force 2>/dev/null || true -``` - ---- - -### M1-ENV-001: Env export outputs valid config - -**Preconditions:** LocalNet `e2e-test-default` running. -**Platforms:** All - -**Steps:** - -1. Start LocalNet if not running: - ```bash - $CLI up --name e2e-test-default - ``` - -2. Export environment: - ```bash - $CLI env --name e2e-test-default - ``` - - **Expected:** Exit code `0`. - - **Verify `.env`-style output:** - ```bash - OUTPUT=$($CLI env --name e2e-test-default 2>&1) - echo "$OUTPUT" | grep -qE "^[A-Z_]+=.+" # KEY=value format - echo "$OUTPUT" | grep -qiE "(LEDGER|JSON.API|ADMIN|PARTICIPANT)" # expected keys - ``` - -3. Verify exported values are usable (source and test a variable): - ```bash - eval "$($CLI env --name e2e-test-default)" - # Verify at least one URL/port is reachable - curl -sf "http://${LEDGER_API_HOST:-localhost}:${LEDGER_API_PORT:-6865}/health" > /dev/null 2>&1 || \ - curl -sf "http://${JSON_API_HOST:-localhost}:${JSON_API_PORT:-7575}/health" > /dev/null 2>&1 || \ - echo "WARN: Could not reach exported endpoints (may require different health check path)" - ``` - -**Cleanup:** `$CLI down --name e2e-test-default` - ---- - -### M1-LST-001: List discovers running instances - -**Preconditions:** At least one named LocalNet running. -**Platforms:** All - -**Steps:** - -1. Start two instances: - ```bash - $CLI up --name e2e-test-a - $CLI up --name e2e-test-b - ``` - -2. List instances: - ```bash - $CLI list - ``` - - **Expected:** Exit code `0`. - - **Verify both instances appear:** - ```bash - OUTPUT=$($CLI list 2>&1) - echo "$OUTPUT" | grep -qE "e2e-test-a" - echo "$OUTPUT" | grep -qE "e2e-test-b" - ``` - -3. Stop one instance and re-list: - ```bash - $CLI down --name e2e-test-a - OUTPUT=$($CLI list 2>&1) - echo "$OUTPUT" | grep -qE "e2e-test-b" - ``` - - **Expected:** Instance B still listed, instance A either removed or shown as stopped. - -4. Verify no non-DevKit containers appear in the list: - ```bash - $CLI list 2>&1 | grep -qiE "(canton-devkit|localnet|e2e-test)" || echo "WARN: list output format unclear" - ``` - -**Cleanup:** -```bash -$CLI down --name e2e-test-a 2>/dev/null || true -$CLI down --name e2e-test-b 2>/dev/null || true -$CLI clean --name e2e-test-a --force 2>/dev/null || true -$CLI clean --name e2e-test-b --force 2>/dev/null || true -``` - ---- - -## Exit Code Contract - -| Exit Code | Meaning | -|---|---| -| `0` | Success | -| `1` | General error | -| `2` | Docker not available or preflight check failed | -| `3` | LocalNet instance not found | -| `4` | Port conflict | -| `5` | Resource insufficient (memory/disk) | -| Non-zero | Any failure (agent should capture stderr for diagnostics) | - -*Note: Exact exit codes are subject to implementation. The key contract is: `0` = success, non-zero = failure with diagnostic output on stderr.* - ---- - -## Cross-Platform Notes - -| Platform | Special Considerations | -|---|---| -| **macOS (Apple Silicon)** | Docker Desktop required. `file` command shows "Mach-O 64-bit executable arm64". Ports bind to `localhost` by default. | -| **Linux (amd64)** | Native Docker or Docker Desktop. Doctor should check Linux Docker permissions (user in `docker` group or rootless Docker). `file` command shows "ELF 64-bit LSB executable, x86-64". | -| **Windows (amd64)** | Docker Desktop with WSL 2 backend. Commands use PowerShell or WSL. `file` equivalent: `Get-Command canton-devkit.exe`. Path separators differ. | - ---- - -## Test Execution Summary - -| ID | Test Name | Category | Depends On | -|---|---|---|---| -| M1-INST-001 | Install via DPM component | Installation | — | -| M1-INST-002 | Install standalone binary | Installation | — | -| M1-INST-003 | Verify binary on all platforms | Installation | M1-INST-001 or M1-INST-002 | -| M1-DOC-001 | Doctor — all checks pass | Preflight | M1-INST-003 | -| M1-DOC-002 | Doctor — Docker not installed | Preflight | M1-INST-003 | -| M1-DOC-003 | Doctor — insufficient resources | Preflight | M1-INST-003 | -| M1-UP-001 | LocalNet up (default) | Lifecycle | M1-DOC-001 | -| M1-UP-002 | LocalNet up with --name | Lifecycle | M1-DOC-001 | -| M1-UP-003 | LocalNet up with --version | Lifecycle | M1-DOC-001 | -| M1-STS-001 | Status shows healthy services | Status | M1-UP-001 | -| M1-LOG-001 | Logs — full and service-filtered | Logs | M1-UP-001 | -| M1-RST-001 | Restart full + single service | Lifecycle | M1-UP-001 | -| M1-DWN-001 | Down stops instance cleanly | Lifecycle | M1-UP-001 | -| M1-CLN-001 | Clean removes all resources | Lifecycle | M1-DWN-001 | -| M1-SNP-001 | Snapshot and restore | State | M1-UP-001 | -| M1-ISO-001 | Two named instances | Isolation | M1-DOC-001 | -| M1-ENV-001 | Env export outputs valid config | Automation | M1-UP-001 | -| M1-LST-001 | List discovers running instances | Automation | M1-UP-001 | - ---- - -## Execution Results — macOS (standalone mode, no DPM) - -**Date:** 2026-06-06 -**Platform:** macOS (Apple Silicon / arm64), Docker Desktop 29.5.2 -**CLI mode:** `canton-devkit localnet` (standalone — no DPM) -**Binary version:** dev -**Splice version (default/latest):** 0.6.4 -**Splice version (explicit):** 0.6.3 -**Script:** `scripts/e2e-milestone1.sh` - -### CLI Syntax Adaptations - -The test plan assumes command syntax that differs from the actual CLI implementation. The following adaptations were applied: - -| Test Plan Syntax | Actual CLI Syntax | Notes | -|---|---|---| -| `$CLI restart participant --name X` | `$CLI restart --name X --service participant` | Service is a `--service` flag, not positional | -| `$CLI logs participant --name X` | `$CLI logs --name X --service participant` | Service is a `--service` flag, not positional | -| `$CLI snapshot --name X` | `$CLI snapshot --name X --to ` | `--to` is required — output path | -| `$CLI restore --name X` | `$CLI restore --name X --from ` | `--from` is required — input path | -| `$CLI --version` → semver | `$CDK --version` → `canton-devkit version dev` | Version is top-level, may be `dev` in local builds | -| `$CLI --help` shows `clean`, `restart` | Hidden commands; not in `--help` output | Exist and work via `--help` on each subcommand | -| Docker label `canton-devkit` | `com.docker.compose.project=canton-` | Docker compose project label, not a custom label | -| `$CLI down` then `$CLI clean` | `$CLI clean --force` on running instance | `down` deregisters the instance; `clean` can't find it after. Use `clean --force` directly | - -### Skipped Tests - -| Test | Reason | -|---|---| -| M1-INST-001 | DPM mode excluded from this run | -| M1-INST-002 | Binary already built locally; no release URL to download from | -| M1-DOC-003 | Requires manually changing Docker Desktop resource limits — destructive to dev environment | -| M1-ISO-001 | Requires two concurrent LocalNets (~16 GB Docker memory); machine has 8.84 GB available | - -### Results - -| ID | Result | Duration | Notes | -|---|---|---|---| -| M1-INST-003 | **PASS** | <1s | Version (`dev`), help (10 visible + 2 hidden commands), Mach-O arm64 | -| M1-DOC-001 | **PASS** | <2s | 0 issues, 1 warning (memory 8.84/12 GB). Exit 0. | -| M1-DOC-002 | **PASS** | <2s | Exit 2 when Docker hidden from PATH. Remediation: "Install Docker Desktop for Mac" | -| M1-UP-001 | **PASS** | ~2-4 min | Splice 0.6.4, cached images. Status: healthy. Docker compose project verified. | -| M1-STS-001 | **PASS** | <2s | Status includes health, endpoints, participant info. Non-existent instance → exit 1. | -| M1-LOG-001 | **PASS** | <10s | Full logs: 308 lines. Service-filtered (`canton`): 20 lines. | -| M1-ENV-001 | **PASS** | <1s | `export CANTON_*` format. Contains JWT (redacted), audience, port variables. | -| M1-RST-001 | **PASS** | ~5-8 min | Full restart + single-service (`--service canton`) restart. Readiness wait is slow post-restart. | -| M1-SNP-001 | **PASS*** | ~10 min | Snapshot: 78 MB .tgz. Restore + re-up works but splice re-sync can exceed 5 min (crash-consistent, not app-consistent). | -| M1-DWN-001 | **PASS** | ~5s | Containers stopped, non-devkit containers unaffected. | -| M1-CLN-001 | **PASS*** | ~10 min | See finding below. `clean --force` on running instance removes all resources (containers, volumes, networks). | -| M1-UP-002 | **PASS** | ~2-4 min | Named instance `e2e-named-test` created, Docker containers + status verified. | -| M1-UP-003 | **PASS** | ~2-4 min | Splice 0.6.3 (explicit `--version`). Status shows version. Invalid version `0.0.0-nonexistent` → exit 1 with clear error. | -| M1-LST-001 | **PASS** | <2s | List shows running instance with name, splice version, status, ports. Adapted to single-instance (resource constraint). | - -### Findings - -#### Finding 1: `down` + `clean` workflow leaves orphaned volumes - -**Severity:** Medium -**Test:** M1-CLN-001 - -`localnet down` (default) deregisters the instance from the registry on success. A subsequent `localnet clean --name X --force` then reports "Nothing to clean" but Docker volumes remain on disk. This is a design gap — both commands work correctly individually but don't compose in the `down` → `clean` sequence. - -**Workaround:** Use `localnet clean --name X --force` directly on a running instance (it runs `down` internally before removing volumes). Do not call `down` before `clean`. - -#### Finding 2: Post-restore `up` may exceed readiness timeout - -**Severity:** Low -**Test:** M1-SNP-001 - -After `snapshot` → `down` → `clean` → `restore` → `up`, the Splice service must re-sync from scratch. On a machine with 8.84 GB Docker memory, this consistently exceeds the 5-minute default readiness wait, causing `up` to exit with a timeout. The services are actually healthy — they just need more time. - -**Recommendation:** Document that post-restore bring-up may take longer than a fresh `up`, especially on resource-constrained hosts. Consider a `--timeout` flag on `up`. - -#### Finding 3: `restart` readiness wait is very slow - -**Severity:** Low -**Test:** M1-RST-001 - -Full `localnet restart` and single-service `restart --service canton` both work correctly, but the post-restart readiness wait can take 5+ minutes. The services come back healthy; the wait just takes time. - -**Recommendation:** Consider `--no-wait` as a practical default for CI scripts, with a separate `localnet status --wait` for blocking on readiness. diff --git a/docs/tests/e2e-test-milestone-2.html b/docs/tests/e2e-test-milestone-2.html deleted file mode 100644 index a56ff3b2..00000000 --- a/docs/tests/e2e-test-milestone-2.html +++ /dev/null @@ -1,1942 +0,0 @@ - - - - - -E2E Test Plan -- Milestone 2: Web UI, Observability, DAR & Contract Tooling - - - - - - - -
- -
-

E2E Test Plan — Milestone 2 26 TESTS

-
- Scope: Web UI, Observability, DAR & Contract Tooling - Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) - Prerequisite: Milestone 1 passing - Delivery: Month 6 -
-
- 0 / 0 steps completed -
-
-
- - -
- TL;DR. 26 end-to-end tests covering the Web UI dashboard and lifecycle controls, DAR package management (upload, list, info, download, diff, remove, watch, build-upload), live contract tracking and transaction exploration, Prometheus/Grafana observability toggle and dashboards, CI automation with --json output, and AI agent skill document validation. -
- - -

Conventions & Environment Setup

- -
-
    -
  • $CLI = dpm localnet or canton-devkit localnet (run full suite twice).
  • -
  • Test DAR: daml-intro-contracts project (Token template, Daml SDK 3.5.1).
  • -
  • $DAR_PATH = path to the built .dar file.
  • -
  • $WEB_UI_URL = URL of the Web UI (from $CLI up or $CLI status).
  • -
  • Web UI tests use curl for HTTP-level validation.
  • -
  • Default step timeout: 30 seconds unless noted.
  • -
-
- -
- - ENV - Environment Setup - Setup - -
-
# Set CLI mode
-export CLI="dpm localnet"       # or "canton-devkit localnet"
-
-# Build the test DAR
-cd daml-intro-contracts
-daml build
-export DAR_PATH="$(pwd)/.daml/dist/daml-intro-contracts-1.0.0.dar"
-cd ..
-
-# Ensure clean state
-$CLI clean --name e2e-m2-test --force 2>/dev/null || true
-
-# Start LocalNet for Milestone 2 tests
-$CLI up --name e2e-m2-test
-
-# Capture Web UI URL from status output
-export WEB_UI_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1)
-
-
- - - - -

Web UI

- - -
- - M2-WEB-001 - Web UI launches and is accessible - Web UI - -
-
- Preconditions: LocalNet e2e-m2-test running. - Platforms: All -
- -
-
- - Step 1. Verify the Web UI URL is printed during startup: -
-
$CLI status --name e2e-m2-test 2>&1 | grep -qiE "(web.ui|dashboard|http.*ui)"
-
Expected: URL found in output.
-
- -
-
- - Step 2. Verify the Web UI is reachable via HTTP: -
-
curl -sf -o /dev/null -w "%{http_code}" "$WEB_UI_URL"
-
Expected: HTTP 200.
-
- -
-
- - Step 3. Verify the Web UI serves HTML: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "<html|<!DOCTYPE"
-
Expected: Valid HTML response.
-
- -
Cleanup: None (LocalNet stays running for subsequent tests).
-
-
- - -
- - M2-WEB-002 - Web UI lifecycle actions (start/stop/restart) - Web UI - -
-
- Preconditions: Web UI accessible (M2-WEB-001). - Platforms: All -
- -
-
- - Step 1. Verify the Web UI exposes lifecycle action endpoints or renders action buttons: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(start|stop|restart|status|clean)"
-
Expected: Lifecycle actions are present in the UI HTML.
-
- -
-
- - Step 2. Test the status view via Web UI (API endpoint if available): -
-
curl -sf "$WEB_UI_URL/api/status" 2>/dev/null || \
-curl -sf "$WEB_UI_URL/status" 2>/dev/null
-
Expected: JSON or HTML response showing LocalNet health.
-
- -
- Note: Full interactive testing of start/stop/restart via the Web UI requires browser automation (e.g., Playwright, Puppeteer). The above steps validate endpoint availability. An AI agent should verify that clicking "Restart" in the UI triggers $CLI restart behavior and the UI updates to reflect the new state. -
- -
Cleanup: None.
-
-
- - -
- - M2-WEB-003 - Web UI LocalNet dashboard content - Web UI - -
-
- Preconditions: Web UI accessible, LocalNet running. - Platforms: All -
- -
-
- - Step 1. Verify dashboard shows named instances: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "e2e-m2-test"
-
- -
-
- - Step 2. Verify dashboard shows service health indicators: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(healthy|running|ready|status)"
-
- -
-
- - Step 3. Verify dashboard shows endpoints and ports: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(endpoint|port|localhost|[0-9]{4,5})"
-
- -
-
- - Step 4. Verify dashboard shows participant information: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(participant|party)"
-
- -
-
- - Step 5. Verify dashboard shows Splice version: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(version|splice)"
-
- -
Cleanup: None.
-
-
- - - - -

DAR Management

- - -
- - M2-DAR-001 - DAR upload to single participant - DAR - -
-
- Preconditions: LocalNet running, $DAR_PATH exists. - Platforms: All -
- -
-
- - Step 1. Upload DAR to a single participant: -
-
$CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify upload confirmation:

-
$CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "(uploaded|success|package)"
-
- -
-
- - Step 2. Verify the package appears in the list: -
-
$CLI dar list --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts"
-
- -
Cleanup: None (package remains for subsequent tests).
-
-
- - -
- - M2-DAR-002 - DAR upload to all participants - DAR - -
-
- Preconditions: LocalNet running, $DAR_PATH exists. - Platforms: All -
- -
-
- - Step 1. Upload DAR to all participants: -
-
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test
-
Expected: Exit code 0.
-
- -
-
- - Step 2. Verify package is listed on multiple participants: -
-
$CLI dar list --name e2e-m2-test 2>&1 | grep -ciE "daml-intro-contracts"
-
Expected: Count >= 2 (one entry per participant).
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-003 - DAR upload with --vet and --dry-run - DAR - -
-
- Preconditions: LocalNet running, $DAR_PATH exists. - Platforms: All -
- -
-
- - Step 1. Dry-run upload (should not actually upload): -
-
$CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test
-
Expected: Exit code 0, output shows what would happen without executing.
-

Verify:

-
$CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test 2>&1 | grep -qiE "(dry.run|would|simulate)"
-
- -
-
- - Step 2. Upload with vetting for SCU: -
-
$CLI dar upload "$DAR_PATH" --all-participants --vet --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify vetting status:

-
$CLI dar list --name e2e-m2-test 2>&1 | grep -iE "daml-intro-contracts" | grep -qiE "(vetted|vet)"
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-004 - DAR list packages - DAR - -
-
- Preconditions: DAR uploaded (M2-DAR-001 or M2-DAR-002). - Platforms: All -
- -
-
- - Step 1. List packages: -
-
$CLI dar list --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output includes required fields:

-
OUTPUT=$($CLI dar list --name e2e-m2-test 2>&1)
-echo "$OUTPUT" | grep -qiE "(package.id|name|version)"  # identifiers
-echo "$OUTPUT" | grep -qiE "(daml.lf|module)"            # metadata
-echo "$OUTPUT" | grep -qiE "daml-intro-contracts"        # our package
-
- -
-
- - Step 2. List packages filtered by participant: -
-
$CLI dar list --participant participant1 --name e2e-m2-test
-
Expected: Exit code 0, list is scoped to that participant.
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-005 - DAR info (modules, templates, choices) - DAR - -
-
- Preconditions: DAR uploaded. - Platforms: All -
- -
-
- - Step 1. Get package info by name: -
-
$CLI dar info daml-intro-contracts --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output includes structural details:

-
OUTPUT=$($CLI dar info daml-intro-contracts --name e2e-m2-test 2>&1)
-echo "$OUTPUT" | grep -qiE "Token"           # template name
-echo "$OUTPUT" | grep -qiE "owner"            # field name
-echo "$OUTPUT" | grep -qiE "(module|Token)"   # module listing
-echo "$OUTPUT" | grep -qiE "(dependency|hash)" # metadata
-
- -
-
- - Step 2. Get package info by package ID: -
-
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
-$CLI dar info "$PKG_ID" --name e2e-m2-test
-
Expected: Exit code 0, same info as by name.
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-006 - DAR download - DAR - -
-
- Preconditions: DAR uploaded. - Platforms: All -
- -
-
- - Step 1. Download a DAR by package ID: -
-
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
-$CLI dar download "$PKG_ID" --out /tmp/downloaded.dar --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify file exists and is non-empty:

-
[ -s /tmp/downloaded.dar ] && echo "PASS" || echo "FAIL: downloaded DAR is empty or missing"
-
- -
-
- - Step 2. Verify downloaded DAR is a valid archive: -
-
file /tmp/downloaded.dar | grep -qiE "(zip|archive|data)"
-
- -
Cleanup: rm -f /tmp/downloaded.dar
-
-
- - -
- - M2-DAR-007 - DAR diff between two versions - DAR - -
-
- Preconditions: Two different DAR versions uploaded (or same DAR can be diffed against itself). - Platforms: All -
- -
-
- - Step 1. Build a second version of the DAR (modify version in daml.yaml): -
-
cd daml-intro-contracts
-cp daml.yaml daml.yaml.bak
-sed -i.tmp 's/version: 1.0.0/version: 2.0.0/' daml.yaml
-daml build
-export DAR_PATH_V2="$(pwd)/.daml/dist/daml-intro-contracts-2.0.0.dar"
-mv daml.yaml.bak daml.yaml
-rm -f daml.yaml.tmp
-cd ..
-$CLI dar upload "$DAR_PATH_V2" --all-participants --name e2e-m2-test
-
- -
-
- - Step 2. Diff the two versions: -
-
$CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output shows diff information:

-
$CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test 2>&1 | grep -qiE "(template|choice|field|change|diff|identical|scu|compatible)"
-
- -
Cleanup: rm -f "$DAR_PATH_V2"
-
-
- - -
- - M2-DAR-008 - DAR remove / unvet - DAR - -
-
- Preconditions: DAR uploaded. - Platforms: All -
- -
-
- - Step 1. Get the package ID to remove: -
-
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
-
- -
-
- - Step 2. Remove / unvet the package: -
-
$CLI dar remove "$PKG_ID" --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify package is no longer listed (or marked as unvetted):

-
$CLI dar list --name e2e-m2-test 2>&1 | grep -i "$PKG_ID" | grep -qiE "(unvetted|removed)" || \
-! $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "$PKG_ID"
-
- -
-
- - Step 3. Re-upload for subsequent tests: -
-
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-009 - DAR build-upload (dpm build integration) - DAR - -
-
- Preconditions: dpm available (skip if standalone mode and dpm not installed), daml-intro-contracts project. - Platforms: All -
- -
-
- - Step 1. Run build-upload from the project directory: -
-
$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify both build and upload occurred:

-
$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(build|compil)" 
-$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(upload|deploy)"
-
- -
-
- - Step 2. If dpm is not available (standalone mode), verify graceful skip: -
-
# Only if dpm is not on PATH:
-which dpm > /dev/null 2>&1 || {
-  $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(skip|not available|dpm not found)"
-  echo "PASS: graceful skip when dpm unavailable"
-}
-
- -
Cleanup: None.
-
-
- - -
- - M2-DAR-010 - DAR watch mode (hot-deploy) - DAR - -
-
- Preconditions: LocalNet running, daml-intro-contracts project. - Platforms: All - Timeout: 60 seconds -
- -
-
- - Step 1. Start watch mode in the background: -
-
$CLI dar watch ./daml-intro-contracts --name e2e-m2-test &
-WATCH_PID=$!
-sleep 5  # let watch mode initialize
-
- -
-
- - Step 2. Trigger a rebuild by touching a source file: -
-
touch daml-intro-contracts/daml/Token.daml
-sleep 15  # wait for watch to detect change, rebuild, and re-upload
-
- -
-
- - Step 3. Verify re-upload occurred: -
-
$CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts"
-
Expected: Package is listed (re-uploaded).
-
- -
-
- - Step 4. Stop watch mode: -
-
kill $WATCH_PID 2>/dev/null || true
-wait $WATCH_PID 2>/dev/null || true
-
- -
Cleanup: Watch process killed in step 4.
-
-
- - -
- - M2-DAR-011 - Web UI DAR drag-and-drop + package explorer - DAR - Web UI - -
-
- Preconditions: Web UI accessible, DAR uploaded. - Platforms: All -
- -
-
- - Step 1. Verify DAR upload UI is present in the Web UI: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(upload|drag.*drop|dar)"
-
- -
-
- - Step 2. Verify package explorer tree is present: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(package|module|template|explorer)"
-
- -
-
- - Step 3. Verify uploaded packages appear in the Web UI: -
-
curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(daml-intro-contracts|Token)"
-
- -
- Note: Drag-and-drop upload and package tree navigation require browser automation for full interactive testing. The above steps validate that the UI elements are rendered. -
- -
Cleanup: None.
-
-
- - - - -

Contract Tracking & Exploration

- - -
- - M2-CTR-001 - Contracts watch (live streaming) - Contracts - -
-
- Preconditions: LocalNet running, DAR uploaded with Token template. - Platforms: All - Timeout: 60 seconds -
- -
-
- - Step 1. Start contracts watch in the background: -
-
timeout 30 $CLI contracts watch --name e2e-m2-test > /tmp/watch-output.txt 2>&1 &
-WATCH_PID=$!
-sleep 3
-
- -
-
- - Step 2. Create a contract via the Ledger API or Daml Script to trigger a create event: -
-
# Use daml script or ledger API to create a Token contract
-# This step depends on the available parties from the LocalNet
-PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2)
-# Trigger contract creation via available means (daml script, JSON API, etc.)
-
- -
-
- - Step 3. Wait and check watch output: -
-
sleep 10
-kill $WATCH_PID 2>/dev/null || true
-wait $WATCH_PID 2>/dev/null || true
-cat /tmp/watch-output.txt | grep -qiE "(create|archive|contract|event)" && echo "PASS" || echo "FAIL: no events in watch output"
-
- -
Cleanup: rm -f /tmp/watch-output.txt
-
-
- - -
- - M2-CTR-002 - TX ls with multi-dimensional filters - Contracts - -
-
- Preconditions: LocalNet running, at least one transaction exists. - Platforms: All -
- -
-
- - Step 1. List all transactions: -
-
$CLI tx ls --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output has transaction entries:

-
$CLI tx ls --name e2e-m2-test 2>&1 | grep -qiE "(transaction|tx|offset)"
-
- -
-
- - Step 2. Filter by party: -
-
PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2)
-$CLI tx ls --party "$PARTY" --name e2e-m2-test
-
Expected: Exit code 0, only transactions visible to that party.
-
- -
-
- - Step 3. Filter by template: -
-
$CLI tx ls --template "Token:Token" --name e2e-m2-test
-
Expected: Exit code 0, only Token-related transactions.
-
- -
-
- - Step 4. Filter by offset range: -
-
$CLI tx ls --from 0 --to 100 --name e2e-m2-test
-
Expected: Exit code 0, transactions within offset range.
-
- -
-
- - Step 5. Combined multi-dimensional filter: -
-
$CLI tx ls --party "$PARTY" --template "Token:Token" --from 0 --name e2e-m2-test
-
Expected: Exit code 0, results satisfy all filters.
-
- -
Cleanup: None.
-
-
- - -
- - M2-CTR-003 - TX replay per-party projection - Contracts - -
-
- Preconditions: LocalNet running, at least one transaction exists. - Platforms: All -
- -
-
- - Step 1. Get a transaction ID from the listing: -
-
TX_ID=$($CLI tx ls --name e2e-m2-test 2>&1 | grep -oE "[a-f0-9-]{36,}" | head -1)
-
- -
-
- - Step 2. Replay the transaction showing per-party visibility: -
-
$CLI tx replay "$TX_ID" --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output shows party visibility projection:

-
$CLI tx replay "$TX_ID" --name e2e-m2-test 2>&1 | grep -qiE "(party|visible|projection|signatory|observer)"
-
- -
-
- - Step 3. Verify different parties see different projections: -
-
PARTY_A=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '1p' | cut -d= -f2)
-PARTY_B=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '2p' | cut -d= -f2)
-OUTPUT_A=$($CLI tx replay "$TX_ID" --party "$PARTY_A" --name e2e-m2-test 2>&1)
-OUTPUT_B=$($CLI tx replay "$TX_ID" --party "$PARTY_B" --name e2e-m2-test 2>&1)
-# At minimum, both should return successfully
-echo "$OUTPUT_A" | grep -qiE "(party|visible|projection)" && echo "PASS: Party A projection" || echo "WARN"
-echo "$OUTPUT_B" | grep -qiE "(party|visible|projection)" && echo "PASS: Party B projection" || echo "WARN"
-
- -
Cleanup: None.
-
-
- - -
- - M2-CTR-004 - Web UI ACS explorer table - Contracts - Web UI - -
-
- Preconditions: Web UI accessible, contracts exist. - Platforms: All -
- -
-
- - Step 1. Verify explorer section exists in Web UI: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(explorer|active.contract|acs)"
-
- -
-
- - Step 2. Verify ACS data is rendered (contracts visible): -
-
curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(contract|template|Token|signatory|observer)"
-
- -
-
- - Step 3. Verify party/template filter controls exist: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(filter|party|template|participant)"
-
- -
- Note: Full interactive filtering requires browser automation. The above validates the UI structure is present. -
- -
Cleanup: None.
-
-
- - -
- - M2-CTR-005 - Web UI transaction timeline - Contracts - Web UI - -
-
- Preconditions: Web UI accessible, transactions exist. - Platforms: All -
- -
-
- - Step 1. Verify transaction timeline section exists: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(transaction|timeline|history|tx)"
-
- -
-
- - Step 2. Verify transaction entries are rendered: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(create|exercise|archive|offset)"
-
- -
-
- - Step 3. Verify party visibility badges are present: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(party|visibility|badge)"
-
- -
Cleanup: None.
-
-
- - -
- - M2-CTR-006 - Web UI contract detail view - Contracts - Web UI - -
-
- Preconditions: Web UI accessible, contracts exist. - Platforms: All -
- -
-
- - Step 1. Verify contract detail view/drawer is accessible: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(detail|drawer|payload|lifecycle)"
-
- -
-
- - Step 2. Verify the detail view includes payload, lifecycle, and interface information: -
-
curl -sf "$WEB_UI_URL" | grep -qiE "(payload|json|lifecycle|created|signatory|observer)"
-
- -
Cleanup: None.
-
-
- - - - -

Observability and Monitoring

- - -
- - M2-OBS-001 - Prometheus/Grafana toggle enable/disable - Observability - -
-
- Preconditions: LocalNet running. - Platforms: All -
- -
-
- - Step 1. Verify observability components can be enabled: -
-
# If observability is not already running, restart with it enabled
-$CLI down --name e2e-m2-test
-$CLI up --name e2e-m2-test --enable prometheus --enable grafana
-
Expected: Exit code 0.
-
- -
-
- - Step 2. Verify Prometheus is running: -
-
PROM_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*prometheus[^ ]*" | head -1)
-# Or use default port
-PROM_URL="${PROM_URL:-http://localhost:9090}"
-curl -sf "$PROM_URL/-/healthy" > /dev/null && echo "PASS: Prometheus healthy" || echo "FAIL"
-
- -
-
- - Step 3. Verify Grafana is running: -
-
GRAFANA_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*grafana[^ ]*" | head -1)
-GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}"
-curl -sf "$GRAFANA_URL/api/health" > /dev/null && echo "PASS: Grafana healthy" || echo "FAIL"
-
- -
-
- - Step 4. Verify selective disable works: -
-
$CLI down --name e2e-m2-test
-$CLI up --name e2e-m2-test --disable prometheus
-$CLI status --name e2e-m2-test 2>&1 | grep -qiE "prometheus" && echo "WARN: Prometheus should be disabled" || echo "PASS"
-
- -
Cleanup: $CLI down --name e2e-m2-test && $CLI up --name e2e-m2-test
-
-
- - -
- - M2-OBS-002 - Grafana dashboards accessible with presets - Observability - -
-
- Preconditions: LocalNet running with Grafana enabled. - Platforms: All -
- -
-
- - Step 1. Verify Grafana is accessible: -
-
GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}"
-curl -sf "$GRAFANA_URL/api/health" | grep -qiE "ok" && echo "PASS" || echo "FAIL"
-
- -
-
- - Step 2. Verify Canton-specific dashboard presets exist: -
-
curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -qiE "(canton|transaction|latency|throughput|contract)"
-
Expected: At least one Canton-specific dashboard preset is found.
-
- -
-
- - Step 3. Verify dashboards contain expected panels: -
-
DASHBOARD_UID=$(curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -oE '"uid":"[^"]*"' | head -1 | cut -d'"' -f4)
-curl -sf "$GRAFANA_URL/api/dashboards/uid/$DASHBOARD_UID" | grep -qiE "(transactions.sec|latency|active.contract|throughput)"
-
Expected: Dashboard includes DApp developer-focused panels.
-
- -
Cleanup: None.
-
-
- - -
- - M2-OBS-003 - Metrics CLI summary output - Observability - -
-
- Preconditions: LocalNet running with Grafana enabled. - Platforms: All -
- -
-
- - Step 1. Run metrics command: -
-
$CLI metrics --name e2e-m2-test
-
Expected: Exit code 0.
-

Verify output includes key metrics:

-
OUTPUT=$($CLI metrics --name e2e-m2-test 2>&1)
-echo "$OUTPUT" | grep -qiE "(throughput|transactions)"
-echo "$OUTPUT" | grep -qiE "(latency|p50|p99)"
-echo "$OUTPUT" | grep -qiE "(resource|cpu|memory)"
-
- -
-
- - Step 2. Verify Grafana dashboard URLs are printed: -
-
$CLI metrics --name e2e-m2-test 2>&1 | grep -qiE "https?://.*grafana"
-
Expected: At least one Grafana URL in output.
-
- -
Cleanup: None.
-
-
- - - - -

Automation Conveniences

- - -
- - M2-AUT-001 - Machine-readable --json output - Automation - -
-
- Preconditions: LocalNet running. - Platforms: All -
- -
-
- - Step 1. Status with JSON output: -
-
$CLI status --name e2e-m2-test --json
-
Expected: Exit code 0.
-

Verify valid JSON:

-
$CLI status --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null
-

Verify JSON contains expected keys:

-
$CLI status --name e2e-m2-test --json 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'name' in d or 'status' in d or 'services' in d, 'Missing expected keys'"
-
- -
-
- - Step 2. DAR list with JSON output: -
-
$CLI dar list --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null
-
Expected: Valid JSON.
-
- -
-
- - Step 3. List with JSON output: -
-
$CLI list --json 2>&1 | python3 -m json.tool > /dev/null
-
Expected: Valid JSON.
-
- -
Cleanup: None.
-
-
- - -
- - M2-AUT-002 - CI workflow: up → DAR upload → test → down - Automation - -
-
- Preconditions: Docker running, daml-intro-contracts project available. - Platforms: All - Timeout: 600 seconds -
-

This test simulates a complete CI pipeline.

- -
-
- - Step 1. Start LocalNet: -
-
$CLI up --name e2e-ci-test
-EXIT_CODE=$?
-[ "$EXIT_CODE" -eq 0 ] && echo "PASS: up" || { echo "FAIL: up exited $EXIT_CODE"; exit 1; }
-
- -
-
- - Step 2. Wait for readiness (already handled by up, but verify): -
-
$CLI status --name e2e-ci-test 2>&1 | grep -qiE "(healthy|ready|running)"
-[ $? -eq 0 ] && echo "PASS: ready" || { echo "FAIL: not ready"; exit 1; }
-
- -
-
- - Step 3. Upload DAR: -
-
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-ci-test
-[ $? -eq 0 ] && echo "PASS: dar upload" || { echo "FAIL: dar upload"; exit 1; }
-
- -
-
- - Step 4. Run application tests (simulate with a health check): -
-
# In a real CI pipeline, this would be: daml test, or integration tests
-$CLI dar list --name e2e-ci-test 2>&1 | grep -qiE "daml-intro-contracts"
-[ $? -eq 0 ] && echo "PASS: test verification" || { echo "FAIL: test verification"; exit 1; }
-
- -
-
- - Step 5. Teardown: -
-
$CLI down --name e2e-ci-test
-[ $? -eq 0 ] && echo "PASS: down" || { echo "FAIL: down"; exit 1; }
-$CLI clean --name e2e-ci-test --force
-[ $? -eq 0 ] && echo "PASS: clean" || { echo "FAIL: clean"; exit 1; }
-
- -
-
- - Step 6. Verify full cleanup: -
-
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-ci-test" && echo "FAIL: containers remain" || echo "PASS: full cleanup"
-
- -
Cleanup: Handled in steps 5-6.
-
-
- - - - -

AI Agent Skill Documents

- - -
- - M2-SKL-001 - AI agent skill document validation - AI Skills - -
-
- Preconditions: Skill documents exist in the DevKit distribution. - Platforms: All -
- -
-
- - Step 1. Verify skill documents are included in the distribution: -
-
# Check for skill docs in the installed package or binary directory
-find $(dirname $(which canton-devkit 2>/dev/null || echo ".")) -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -5
-# Or check a known documentation path
-ls -la docs/skills/ 2>/dev/null || ls -la skills/ 2>/dev/null || echo "Check skill document location"
-
- -
-
- - Step 2. Verify a skill document contains executable workflow steps: -
-
# Read a skill document and verify it contains dpm localnet commands
-SKILL_DOC=$(find . -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -1)
-if [ -n "$SKILL_DOC" ]; then
-  grep -qiE "dpm localnet|canton-devkit localnet" "$SKILL_DOC" && echo "PASS: contains CLI commands" || echo "FAIL: no CLI commands found"
-  grep -qiE "(up|down|status|dar upload|logs)" "$SKILL_DOC" && echo "PASS: contains lifecycle commands" || echo "FAIL: no lifecycle commands"
-else
-  echo "WARN: Skill document not found -- check distribution packaging"
-fi
-
- -
-
- - Step 3. Execute the basic workflow described in a skill document: -
-
# The skill document should describe a workflow like:
-# 1. Start LocalNet
-# 2. Check status
-# 3. Upload a DAR
-# 4. List packages
-# 5. Check logs
-# 6. Stop LocalNet
-# Execute each step and verify:
-$CLI up --name e2e-skill-test
-$CLI status --name e2e-skill-test
-$CLI dar upload "$DAR_PATH" --all-participants --name e2e-skill-test
-$CLI dar list --name e2e-skill-test
-timeout 5 $CLI logs --name e2e-skill-test 2>&1 | head -10
-$CLI down --name e2e-skill-test
-echo "PASS: skill workflow executed successfully"
-
- -
Cleanup: $CLI clean --name e2e-skill-test --force 2>/dev/null || true
-
-
- - - - -

Cross-Platform Notes

- -
- - - - - - - - - - - - - - - - - - -
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. Web UI accessible at localhost. Grafana/Prometheus default ports may conflict with local dev tools.
Linux (amd64)Native Docker. Ensure firewall allows localhost port access for Web UI and observability stack.
Windows (amd64)Docker Desktop with WSL 2. Web UI URL may differ (localhost vs WSL IP). curl available via WSL or PowerShell Invoke-WebRequest. timeout command replaced with PowerShell equivalent.
-
- - - - -

Test Execution Summary

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTest NameCategoryDepends On
M2-WEB-001Web UI launches and is accessibleWeb UIM1 suite
M2-WEB-002Web UI lifecycle actionsWeb UIM2-WEB-001
M2-WEB-003Web UI dashboard contentWeb UIM2-WEB-001
M2-DAR-001DAR upload to single participantDARM1 suite
M2-DAR-002DAR upload to all participantsDARM1 suite
M2-DAR-003DAR upload with --vet and --dry-runDARM1 suite
M2-DAR-004DAR list packagesDARM2-DAR-001
M2-DAR-005DAR infoDARM2-DAR-001
M2-DAR-006DAR downloadDARM2-DAR-001
M2-DAR-007DAR diff between two versionsDARM2-DAR-001
M2-DAR-008DAR remove / unvetDARM2-DAR-001
M2-DAR-009DAR build-uploadDARM1 suite
M2-DAR-010DAR watch modeDARM1 suite
M2-DAR-011Web UI DAR + package explorerDAR / Web UIM2-WEB-001, M2-DAR-001
M2-CTR-001Contracts watch (live)ContractsM2-DAR-001
M2-CTR-002TX ls multi-filterContractsM2-DAR-001
M2-CTR-003TX replay per-partyContractsM2-CTR-002
M2-CTR-004Web UI ACS explorerContracts / Web UIM2-WEB-001
M2-CTR-005Web UI transaction timelineContracts / Web UIM2-WEB-001
M2-CTR-006Web UI contract detailContracts / Web UIM2-WEB-001
M2-OBS-001Prometheus/Grafana toggleObservabilityM1 suite
M2-OBS-002Grafana dashboards with presetsObservabilityM2-OBS-001
M2-OBS-003Metrics CLI summaryObservabilityM2-OBS-001
M2-AUT-001Machine-readable --json outputAutomationM1 suite
M2-AUT-002CI workflow E2EAutomationM1 suite
M2-SKL-001AI agent skill document validationAI SkillsM1 suite
-
- -
-

Source: e2e-test-milestone-2.md

-

Proposal reference: original-devkit-proposal.md, Milestone 2 (Lines 249-266). Estimated delivery: Month 6.

-

This page is a self-contained companion artifact generated from the source markdown.

-
- -
- - - - - diff --git a/docs/tests/e2e-test-milestone-2.md b/docs/tests/e2e-test-milestone-2.md deleted file mode 100644 index c4c7afa3..00000000 --- a/docs/tests/e2e-test-milestone-2.md +++ /dev/null @@ -1,971 +0,0 @@ -# E2E Test Plan — Milestone 2: Web UI, Observability, DAR & Contract Tooling - -> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 2 (Lines 249–266) -> **Estimated Delivery:** Month 6 -> **Total Tests:** 26 -> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) -> **Prerequisite:** All Milestone 1 tests passing. - ---- - -## Overview - -This test plan validates the Web UI, observability/monitoring stack, DAR package management, live contract/transaction exploration, automation conveniences, and optional AI agent skill documents delivered in Milestone 2. - -### Conventions - -- `$CLI` = `dpm localnet` or `canton-devkit localnet` (run full suite twice — once per mode). -- The test DAR is built from the `daml-intro-contracts` project (`Token` template, Daml SDK 3.5.1). -- `$DAR_PATH` = path to the built `.dar` file from `daml-intro-contracts`. -- `$WEB_UI_URL` = URL of the Web UI (printed by `$CLI up` or `$CLI status`). -- Web UI tests use `curl` for HTTP-level validation. Visual/interactive tests note what to verify manually or via browser automation. -- Default step timeout: 30 seconds unless noted. - -### Environment Setup - -```bash -# Set CLI mode -export CLI="dpm localnet" # or "canton-devkit localnet" - -# Build the test DAR -cd daml-intro-contracts -daml build -export DAR_PATH="$(pwd)/.daml/dist/daml-intro-contracts-1.0.0.dar" -cd .. - -# Ensure clean state -$CLI clean --name e2e-m2-test --force 2>/dev/null || true - -# Start LocalNet for Milestone 2 tests -$CLI up --name e2e-m2-test - -# Capture Web UI URL from status output -export WEB_UI_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1) -``` - ---- - -## Test Cases - ---- - -## Web UI - ---- - -### M2-WEB-001: Web UI launches and is accessible - -**Preconditions:** LocalNet `e2e-m2-test` running. -**Platforms:** All - -**Steps:** - -1. Verify the Web UI URL is printed during startup: - ```bash - $CLI status --name e2e-m2-test 2>&1 | grep -qiE "(web.ui|dashboard|http.*ui)" - ``` - - **Expected:** URL found in output. - -2. Verify the Web UI is reachable via HTTP: - ```bash - curl -sf -o /dev/null -w "%{http_code}" "$WEB_UI_URL" - ``` - - **Expected:** HTTP `200`. - -3. Verify the Web UI serves HTML: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "/dev/null || \ - curl -sf "$WEB_UI_URL/status" 2>/dev/null - ``` - - **Expected:** JSON or HTML response showing LocalNet health. - -**Note:** Full interactive testing of start/stop/restart via the Web UI requires browser automation (e.g., Playwright, Puppeteer). The above steps validate endpoint availability. An AI agent should verify that clicking "Restart" in the UI triggers `$CLI restart` behavior and the UI updates to reflect the new state. - -**Cleanup:** None. - ---- - -### M2-WEB-003: Web UI LocalNet dashboard content - -**Preconditions:** Web UI accessible, LocalNet running. -**Platforms:** All - -**Steps:** - -1. Verify dashboard shows named instances: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "e2e-m2-test" - ``` - -2. Verify dashboard shows service health indicators: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(healthy|running|ready|status)" - ``` - -3. Verify dashboard shows endpoints and ports: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(endpoint|port|localhost|[0-9]{4,5})" - ``` - -4. Verify dashboard shows participant information: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(participant|party)" - ``` - -5. Verify dashboard shows Splice version: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(version|splice)" - ``` - -**Cleanup:** None. - ---- - -## DAR Management - ---- - -### M2-DAR-001: DAR upload to single participant - -**Preconditions:** LocalNet running, `$DAR_PATH` exists. -**Platforms:** All - -**Steps:** - -1. Upload DAR to a single participant: - ```bash - $CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify upload confirmation:** - ```bash - $CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "(uploaded|success|package)" - ``` - -2. Verify the package appears in the list: - ```bash - $CLI dar list --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts" - ``` - -**Cleanup:** None (package remains for subsequent tests). - ---- - -### M2-DAR-002: DAR upload to all participants - -**Preconditions:** LocalNet running, `$DAR_PATH` exists. -**Platforms:** All - -**Steps:** - -1. Upload DAR to all participants: - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - -2. Verify package is listed on multiple participants: - ```bash - $CLI dar list --name e2e-m2-test 2>&1 | grep -ciE "daml-intro-contracts" - ``` - - **Expected:** Count >= 2 (one entry per participant). - -**Cleanup:** None. - ---- - -### M2-DAR-003: DAR upload with --vet and --dry-run - -**Preconditions:** LocalNet running, `$DAR_PATH` exists. -**Platforms:** All - -**Steps:** - -1. Dry-run upload (should not actually upload): - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, output shows what would happen without executing. - - **Verify:** - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test 2>&1 | grep -qiE "(dry.run|would|simulate)" - ``` - -2. Upload with vetting for SCU: - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --vet --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify vetting status:** - ```bash - $CLI dar list --name e2e-m2-test 2>&1 | grep -iE "daml-intro-contracts" | grep -qiE "(vetted|vet)" - ``` - -**Cleanup:** None. - ---- - -### M2-DAR-004: DAR list packages - -**Preconditions:** DAR uploaded (M2-DAR-001 or M2-DAR-002). -**Platforms:** All - -**Steps:** - -1. List packages: - ```bash - $CLI dar list --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output includes required fields:** - ```bash - OUTPUT=$($CLI dar list --name e2e-m2-test 2>&1) - echo "$OUTPUT" | grep -qiE "(package.id|name|version)" # identifiers - echo "$OUTPUT" | grep -qiE "(daml.lf|module)" # metadata - echo "$OUTPUT" | grep -qiE "daml-intro-contracts" # our package - ``` - -2. List packages filtered by participant: - ```bash - $CLI dar list --participant participant1 --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, list is scoped to that participant. - -**Cleanup:** None. - ---- - -### M2-DAR-005: DAR info (modules, templates, choices) - -**Preconditions:** DAR uploaded. -**Platforms:** All - -**Steps:** - -1. Get package info by name: - ```bash - $CLI dar info daml-intro-contracts --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output includes structural details:** - ```bash - OUTPUT=$($CLI dar info daml-intro-contracts --name e2e-m2-test 2>&1) - echo "$OUTPUT" | grep -qiE "Token" # template name - echo "$OUTPUT" | grep -qiE "owner" # field name - echo "$OUTPUT" | grep -qiE "(module|Token)" # module listing - echo "$OUTPUT" | grep -qiE "(dependency|hash)" # metadata - ``` - -2. Get package info by package ID: - ```bash - PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) - $CLI dar info "$PKG_ID" --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, same info as by name. - -**Cleanup:** None. - ---- - -### M2-DAR-006: DAR download - -**Preconditions:** DAR uploaded. -**Platforms:** All - -**Steps:** - -1. Download a DAR by package ID: - ```bash - PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) - $CLI dar download "$PKG_ID" --out /tmp/downloaded.dar --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify file exists and is non-empty:** - ```bash - [ -s /tmp/downloaded.dar ] && echo "PASS" || echo "FAIL: downloaded DAR is empty or missing" - ``` - -2. Verify downloaded DAR is a valid archive: - ```bash - file /tmp/downloaded.dar | grep -qiE "(zip|archive|data)" - ``` - -**Cleanup:** `rm -f /tmp/downloaded.dar` - ---- - -### M2-DAR-007: DAR diff between two versions - -**Preconditions:** Two different DAR versions uploaded (or same DAR can be diffed against itself). -**Platforms:** All - -**Steps:** - -1. Build a second version of the DAR (modify version in daml.yaml): - ```bash - cd daml-intro-contracts - cp daml.yaml daml.yaml.bak - sed -i.tmp 's/version: 1.0.0/version: 2.0.0/' daml.yaml - daml build - export DAR_PATH_V2="$(pwd)/.daml/dist/daml-intro-contracts-2.0.0.dar" - mv daml.yaml.bak daml.yaml - rm -f daml.yaml.tmp - cd .. - $CLI dar upload "$DAR_PATH_V2" --all-participants --name e2e-m2-test - ``` - -2. Diff the two versions: - ```bash - $CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output shows diff information:** - ```bash - $CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test 2>&1 | grep -qiE "(template|choice|field|change|diff|identical|scu|compatible)" - ``` - -**Cleanup:** `rm -f "$DAR_PATH_V2"` - ---- - -### M2-DAR-008: DAR remove / unvet - -**Preconditions:** DAR uploaded. -**Platforms:** All - -**Steps:** - -1. Get the package ID to remove: - ```bash - PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) - ``` - -2. Remove / unvet the package: - ```bash - $CLI dar remove "$PKG_ID" --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify package is no longer listed (or marked as unvetted):** - ```bash - $CLI dar list --name e2e-m2-test 2>&1 | grep -i "$PKG_ID" | grep -qiE "(unvetted|removed)" || \ - ! $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "$PKG_ID" - ``` - -3. Re-upload for subsequent tests: - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test - ``` - -**Cleanup:** None. - ---- - -### M2-DAR-009: DAR build-upload (dpm build integration) - -**Preconditions:** `dpm` available (skip if standalone mode and `dpm` not installed), `daml-intro-contracts` project. -**Platforms:** All - -**Steps:** - -1. Run build-upload from the project directory: - ```bash - $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify both build and upload occurred:** - ```bash - $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(build|compil)" - $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(upload|deploy)" - ``` - -2. If `dpm` is not available (standalone mode), verify graceful skip: - ```bash - # Only if dpm is not on PATH: - which dpm > /dev/null 2>&1 || { - $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(skip|not available|dpm not found)" - echo "PASS: graceful skip when dpm unavailable" - } - ``` - -**Cleanup:** None. - ---- - -### M2-DAR-010: DAR watch mode (hot-deploy) - -**Preconditions:** LocalNet running, `daml-intro-contracts` project. -**Platforms:** All -**Timeout:** 60 seconds - -**Steps:** - -1. Start watch mode in the background: - ```bash - $CLI dar watch ./daml-intro-contracts --name e2e-m2-test & - WATCH_PID=$! - sleep 5 # let watch mode initialize - ``` - -2. Trigger a rebuild by touching a source file: - ```bash - touch daml-intro-contracts/daml/Token.daml - sleep 15 # wait for watch to detect change, rebuild, and re-upload - ``` - -3. Verify re-upload occurred: - ```bash - $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts" - ``` - - **Expected:** Package is listed (re-uploaded). - -4. Stop watch mode: - ```bash - kill $WATCH_PID 2>/dev/null || true - wait $WATCH_PID 2>/dev/null || true - ``` - -**Cleanup:** Watch process killed in step 4. - ---- - -### M2-DAR-011: Web UI DAR drag-and-drop + package explorer - -**Preconditions:** Web UI accessible, DAR uploaded. -**Platforms:** All - -**Steps:** - -1. Verify DAR upload UI is present in the Web UI: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(upload|drag.*drop|dar)" - ``` - -2. Verify package explorer tree is present: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(package|module|template|explorer)" - ``` - -3. Verify uploaded packages appear in the Web UI: - ```bash - curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(daml-intro-contracts|Token)" - ``` - -**Note:** Drag-and-drop upload and package tree navigation require browser automation for full interactive testing. The above steps validate that the UI elements are rendered. - -**Cleanup:** None. - ---- - -## Contract Tracking & Exploration - ---- - -### M2-CTR-001: Contracts watch (live streaming) - -**Preconditions:** LocalNet running, DAR uploaded with `Token` template. -**Platforms:** All -**Timeout:** 60 seconds - -**Steps:** - -1. Start contracts watch in the background: - ```bash - timeout 30 $CLI contracts watch --name e2e-m2-test > /tmp/watch-output.txt 2>&1 & - WATCH_PID=$! - sleep 3 - ``` - -2. Create a contract via the Ledger API or Daml Script to trigger a create event: - ```bash - # Use daml script or ledger API to create a Token contract - # This step depends on the available parties from the LocalNet - PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2) - # Trigger contract creation via available means (daml script, JSON API, etc.) - ``` - -3. Wait and check watch output: - ```bash - sleep 10 - kill $WATCH_PID 2>/dev/null || true - wait $WATCH_PID 2>/dev/null || true - cat /tmp/watch-output.txt | grep -qiE "(create|archive|contract|event)" && echo "PASS" || echo "FAIL: no events in watch output" - ``` - -**Cleanup:** `rm -f /tmp/watch-output.txt` - ---- - -### M2-CTR-002: TX ls with multi-dimensional filters - -**Preconditions:** LocalNet running, at least one transaction exists. -**Platforms:** All - -**Steps:** - -1. List all transactions: - ```bash - $CLI tx ls --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output has transaction entries:** - ```bash - $CLI tx ls --name e2e-m2-test 2>&1 | grep -qiE "(transaction|tx|offset)" - ``` - -2. Filter by party: - ```bash - PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2) - $CLI tx ls --party "$PARTY" --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, only transactions visible to that party. - -3. Filter by template: - ```bash - $CLI tx ls --template "Token:Token" --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, only Token-related transactions. - -4. Filter by offset range: - ```bash - $CLI tx ls --from 0 --to 100 --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, transactions within offset range. - -5. Combined multi-dimensional filter: - ```bash - $CLI tx ls --party "$PARTY" --template "Token:Token" --from 0 --name e2e-m2-test - ``` - - **Expected:** Exit code `0`, results satisfy all filters. - -**Cleanup:** None. - ---- - -### M2-CTR-003: TX replay per-party projection - -**Preconditions:** LocalNet running, at least one transaction exists. -**Platforms:** All - -**Steps:** - -1. Get a transaction ID from the listing: - ```bash - TX_ID=$($CLI tx ls --name e2e-m2-test 2>&1 | grep -oE "[a-f0-9-]{36,}" | head -1) - ``` - -2. Replay the transaction showing per-party visibility: - ```bash - $CLI tx replay "$TX_ID" --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output shows party visibility projection:** - ```bash - $CLI tx replay "$TX_ID" --name e2e-m2-test 2>&1 | grep -qiE "(party|visible|projection|signatory|observer)" - ``` - -3. Verify different parties see different projections: - ```bash - PARTY_A=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '1p' | cut -d= -f2) - PARTY_B=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '2p' | cut -d= -f2) - OUTPUT_A=$($CLI tx replay "$TX_ID" --party "$PARTY_A" --name e2e-m2-test 2>&1) - OUTPUT_B=$($CLI tx replay "$TX_ID" --party "$PARTY_B" --name e2e-m2-test 2>&1) - # At minimum, both should return successfully - echo "$OUTPUT_A" | grep -qiE "(party|visible|projection)" && echo "PASS: Party A projection" || echo "WARN" - echo "$OUTPUT_B" | grep -qiE "(party|visible|projection)" && echo "PASS: Party B projection" || echo "WARN" - ``` - -**Cleanup:** None. - ---- - -### M2-CTR-004: Web UI ACS explorer table - -**Preconditions:** Web UI accessible, contracts exist. -**Platforms:** All - -**Steps:** - -1. Verify explorer section exists in Web UI: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(explorer|active.contract|acs)" - ``` - -2. Verify ACS data is rendered (contracts visible): - ```bash - curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(contract|template|Token|signatory|observer)" - ``` - -3. Verify party/template filter controls exist: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(filter|party|template|participant)" - ``` - -**Note:** Full interactive filtering requires browser automation. The above validates the UI structure is present. - -**Cleanup:** None. - ---- - -### M2-CTR-005: Web UI transaction timeline - -**Preconditions:** Web UI accessible, transactions exist. -**Platforms:** All - -**Steps:** - -1. Verify transaction timeline section exists: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(transaction|timeline|history|tx)" - ``` - -2. Verify transaction entries are rendered: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(create|exercise|archive|offset)" - ``` - -3. Verify party visibility badges are present: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(party|visibility|badge)" - ``` - -**Cleanup:** None. - ---- - -### M2-CTR-006: Web UI contract detail view - -**Preconditions:** Web UI accessible, contracts exist. -**Platforms:** All - -**Steps:** - -1. Verify contract detail view/drawer is accessible: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(detail|drawer|payload|lifecycle)" - ``` - -2. Verify the detail view includes payload, lifecycle, and interface information: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(payload|json|lifecycle|created|signatory|observer)" - ``` - -**Cleanup:** None. - ---- - -## Observability and Monitoring - ---- - -### M2-OBS-001: Prometheus/Grafana toggle enable/disable - -**Preconditions:** LocalNet running. -**Platforms:** All - -**Steps:** - -1. Verify observability components can be enabled: - ```bash - # If observability is not already running, restart with it enabled - $CLI down --name e2e-m2-test - $CLI up --name e2e-m2-test --enable prometheus --enable grafana - ``` - - **Expected:** Exit code `0`. - -2. Verify Prometheus is running: - ```bash - PROM_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*prometheus[^ ]*" | head -1) - # Or use default port - PROM_URL="${PROM_URL:-http://localhost:9090}" - curl -sf "$PROM_URL/-/healthy" > /dev/null && echo "PASS: Prometheus healthy" || echo "FAIL" - ``` - -3. Verify Grafana is running: - ```bash - GRAFANA_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*grafana[^ ]*" | head -1) - GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}" - curl -sf "$GRAFANA_URL/api/health" > /dev/null && echo "PASS: Grafana healthy" || echo "FAIL" - ``` - -4. Verify selective disable works: - ```bash - $CLI down --name e2e-m2-test - $CLI up --name e2e-m2-test --disable prometheus - $CLI status --name e2e-m2-test 2>&1 | grep -qiE "prometheus" && echo "WARN: Prometheus should be disabled" || echo "PASS" - ``` - -**Cleanup:** `$CLI down --name e2e-m2-test && $CLI up --name e2e-m2-test` - ---- - -### M2-OBS-002: Grafana dashboards accessible with presets - -**Preconditions:** LocalNet running with Grafana enabled. -**Platforms:** All - -**Steps:** - -1. Verify Grafana is accessible: - ```bash - GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}" - curl -sf "$GRAFANA_URL/api/health" | grep -qiE "ok" && echo "PASS" || echo "FAIL" - ``` - -2. Verify Canton-specific dashboard presets exist: - ```bash - curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -qiE "(canton|transaction|latency|throughput|contract)" - ``` - - **Expected:** At least one Canton-specific dashboard preset is found. - -3. Verify dashboards contain expected panels: - ```bash - DASHBOARD_UID=$(curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -oE '"uid":"[^"]*"' | head -1 | cut -d'"' -f4) - curl -sf "$GRAFANA_URL/api/dashboards/uid/$DASHBOARD_UID" | grep -qiE "(transactions.sec|latency|active.contract|throughput)" - ``` - - **Expected:** Dashboard includes DApp developer-focused panels. - -**Cleanup:** None. - ---- - -### M2-OBS-003: Metrics CLI summary output - -**Preconditions:** LocalNet running with Grafana enabled. -**Platforms:** All - -**Steps:** - -1. Run metrics command: - ```bash - $CLI metrics --name e2e-m2-test - ``` - - **Expected:** Exit code `0`. - - **Verify output includes key metrics:** - ```bash - OUTPUT=$($CLI metrics --name e2e-m2-test 2>&1) - echo "$OUTPUT" | grep -qiE "(throughput|transactions)" - echo "$OUTPUT" | grep -qiE "(latency|p50|p99)" - echo "$OUTPUT" | grep -qiE "(resource|cpu|memory)" - ``` - -2. Verify Grafana dashboard URLs are printed: - ```bash - $CLI metrics --name e2e-m2-test 2>&1 | grep -qiE "https?://.*grafana" - ``` - - **Expected:** At least one Grafana URL in output. - -**Cleanup:** None. - ---- - -## Automation Conveniences - ---- - -### M2-AUT-001: Machine-readable --json output - -**Preconditions:** LocalNet running. -**Platforms:** All - -**Steps:** - -1. Status with JSON output: - ```bash - $CLI status --name e2e-m2-test --json - ``` - - **Expected:** Exit code `0`. - - **Verify valid JSON:** - ```bash - $CLI status --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null - ``` - - **Verify JSON contains expected keys:** - ```bash - $CLI status --name e2e-m2-test --json 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'name' in d or 'status' in d or 'services' in d, 'Missing expected keys'" - ``` - -2. DAR list with JSON output: - ```bash - $CLI dar list --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null - ``` - - **Expected:** Valid JSON. - -3. List with JSON output: - ```bash - $CLI list --json 2>&1 | python3 -m json.tool > /dev/null - ``` - - **Expected:** Valid JSON. - -**Cleanup:** None. - ---- - -### M2-AUT-002: CI workflow: up → DAR upload → test → down - -**Preconditions:** Docker running, `daml-intro-contracts` project available. -**Platforms:** All -**Timeout:** 600 seconds - -This test simulates a complete CI pipeline. - -**Steps:** - -1. Start LocalNet: - ```bash - $CLI up --name e2e-ci-test - EXIT_CODE=$? - [ "$EXIT_CODE" -eq 0 ] && echo "PASS: up" || { echo "FAIL: up exited $EXIT_CODE"; exit 1; } - ``` - -2. Wait for readiness (already handled by `up`, but verify): - ```bash - $CLI status --name e2e-ci-test 2>&1 | grep -qiE "(healthy|ready|running)" - [ $? -eq 0 ] && echo "PASS: ready" || { echo "FAIL: not ready"; exit 1; } - ``` - -3. Upload DAR: - ```bash - $CLI dar upload "$DAR_PATH" --all-participants --name e2e-ci-test - [ $? -eq 0 ] && echo "PASS: dar upload" || { echo "FAIL: dar upload"; exit 1; } - ``` - -4. Run application tests (simulate with a health check): - ```bash - # In a real CI pipeline, this would be: daml test, or integration tests - $CLI dar list --name e2e-ci-test 2>&1 | grep -qiE "daml-intro-contracts" - [ $? -eq 0 ] && echo "PASS: test verification" || { echo "FAIL: test verification"; exit 1; } - ``` - -5. Teardown: - ```bash - $CLI down --name e2e-ci-test - [ $? -eq 0 ] && echo "PASS: down" || { echo "FAIL: down"; exit 1; } - $CLI clean --name e2e-ci-test --force - [ $? -eq 0 ] && echo "PASS: clean" || { echo "FAIL: clean"; exit 1; } - ``` - -6. Verify full cleanup: - ```bash - docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-ci-test" && echo "FAIL: containers remain" || echo "PASS: full cleanup" - ``` - -**Cleanup:** Handled in step 5-6. - ---- - -## AI Agent Skill Documents - ---- - -### M2-SKL-001: AI agent skill document validation - -**Preconditions:** Skill documents exist in the DevKit distribution. -**Platforms:** All - -**Steps:** - -1. Verify skill documents are included in the distribution: - ```bash - # Check for skill docs in the installed package or binary directory - find $(dirname $(which canton-devkit 2>/dev/null || echo ".")) -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -5 - # Or check a known documentation path - ls -la docs/skills/ 2>/dev/null || ls -la skills/ 2>/dev/null || echo "Check skill document location" - ``` - -2. Verify a skill document contains executable workflow steps: - ```bash - # Read a skill document and verify it contains dpm localnet commands - SKILL_DOC=$(find . -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -1) - if [ -n "$SKILL_DOC" ]; then - grep -qiE "dpm localnet|canton-devkit localnet" "$SKILL_DOC" && echo "PASS: contains CLI commands" || echo "FAIL: no CLI commands found" - grep -qiE "(up|down|status|dar upload|logs)" "$SKILL_DOC" && echo "PASS: contains lifecycle commands" || echo "FAIL: no lifecycle commands" - else - echo "WARN: Skill document not found — check distribution packaging" - fi - ``` - -3. Execute the basic workflow described in a skill document: - ```bash - # The skill document should describe a workflow like: - # 1. Start LocalNet - # 2. Check status - # 3. Upload a DAR - # 4. List packages - # 5. Check logs - # 6. Stop LocalNet - # Execute each step and verify: - $CLI up --name e2e-skill-test - $CLI status --name e2e-skill-test - $CLI dar upload "$DAR_PATH" --all-participants --name e2e-skill-test - $CLI dar list --name e2e-skill-test - timeout 5 $CLI logs --name e2e-skill-test 2>&1 | head -10 - $CLI down --name e2e-skill-test - echo "PASS: skill workflow executed successfully" - ``` - -**Cleanup:** `$CLI clean --name e2e-skill-test --force 2>/dev/null || true` - ---- - -## Cross-Platform Notes - -| Platform | Special Considerations | -|---|---| -| **macOS (Apple Silicon)** | Docker Desktop required. Web UI accessible at `localhost`. Grafana/Prometheus default ports may conflict with local dev tools. | -| **Linux (amd64)** | Native Docker. Ensure firewall allows localhost port access for Web UI and observability stack. | -| **Windows (amd64)** | Docker Desktop with WSL 2. Web UI URL may differ (`localhost` vs WSL IP). `curl` available via WSL or PowerShell `Invoke-WebRequest`. `timeout` command replaced with PowerShell equivalent. | - ---- - -## Test Execution Summary - -| ID | Test Name | Category | Depends On | -|---|---|---|---| -| M2-WEB-001 | Web UI launches and is accessible | Web UI | M1 suite | -| M2-WEB-002 | Web UI lifecycle actions | Web UI | M2-WEB-001 | -| M2-WEB-003 | Web UI dashboard content | Web UI | M2-WEB-001 | -| M2-DAR-001 | DAR upload to single participant | DAR | M1 suite | -| M2-DAR-002 | DAR upload to all participants | DAR | M1 suite | -| M2-DAR-003 | DAR upload with --vet and --dry-run | DAR | M1 suite | -| M2-DAR-004 | DAR list packages | DAR | M2-DAR-001 | -| M2-DAR-005 | DAR info | DAR | M2-DAR-001 | -| M2-DAR-006 | DAR download | DAR | M2-DAR-001 | -| M2-DAR-007 | DAR diff between two versions | DAR | M2-DAR-001 | -| M2-DAR-008 | DAR remove / unvet | DAR | M2-DAR-001 | -| M2-DAR-009 | DAR build-upload | DAR | M1 suite | -| M2-DAR-010 | DAR watch mode | DAR | M1 suite | -| M2-DAR-011 | Web UI DAR + package explorer | DAR Web UI | M2-WEB-001, M2-DAR-001 | -| M2-CTR-001 | Contracts watch (live) | Contracts | M2-DAR-001 | -| M2-CTR-002 | TX ls multi-filter | Contracts | M2-DAR-001 | -| M2-CTR-003 | TX replay per-party | Contracts | M2-CTR-002 | -| M2-CTR-004 | Web UI ACS explorer | Contracts Web UI | M2-WEB-001 | -| M2-CTR-005 | Web UI transaction timeline | Contracts Web UI | M2-WEB-001 | -| M2-CTR-006 | Web UI contract detail | Contracts Web UI | M2-WEB-001 | -| M2-OBS-001 | Prometheus/Grafana toggle | Observability | M1 suite | -| M2-OBS-002 | Grafana dashboards with presets | Observability | M2-OBS-001 | -| M2-OBS-003 | Metrics CLI summary | Observability | M2-OBS-001 | -| M2-AUT-001 | Machine-readable --json output | Automation | M1 suite | -| M2-AUT-002 | CI workflow E2E | Automation | M1 suite | -| M2-SKL-001 | AI agent skill document validation | AI Skills | M1 suite | diff --git a/docs/tests/e2e-test-milestone-3.html b/docs/tests/e2e-test-milestone-3.html deleted file mode 100644 index f7e8fd1d..00000000 --- a/docs/tests/e2e-test-milestone-3.html +++ /dev/null @@ -1,1323 +0,0 @@ - - - - - -E2E Test Plan -- Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) - - - - - - - -
- -
-

E2E Test Plan — Milestone 3 10 Tests

-

Token Faucets & Token Standard Tooling (CIP-0112)

-
- Proposal: original-devkit-proposal.md, Milestone 3 - Delivery: Month 9 - Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) - Prerequisite: Milestones 1 + 2 passing -
-
-
0 / 0 steps completed
-
-
-
- - -
- TL;DR. This test plan validates CIP-0112 token standard tooling: the token creation wizard, minting, transfer, burn, and balance CLI commands, a full lifecycle E2E flow, edge cases (partial burn to zero), Web UI token toolkit, and cross-platform regression across macOS, Linux, and Windows. -
- - -

Overview

-

This plan covers 10 end-to-end test cases for Milestone 3 of the Canton DevKit project. All token commands target the CIP-0112 (V2) path as the default. Non-interactive flags are used for wizard-style commands to enable AI agent execution.

- -

Conventions

-
    -
  • $CLI = dpm localnet or canton-devkit localnet (run the full suite twice, once per mode).
  • -
  • Token commands target the CIP-0112 (V2) path as the default.
  • -
  • $WEB_UI_URL = URL of the Web UI (from $CLI status).
  • -
  • Default step timeout: 30 seconds unless noted.
  • -
- -

Environment Setup

-
# Set CLI mode
-export CLI="dpm localnet"       # or "canton-devkit localnet"
-
-# Ensure clean state
-$CLI clean --name e2e-m3-test --force 2>/dev/null || true
-
-# Start LocalNet for Milestone 3 tests
-$CLI up --name e2e-m3-test
-
-# Capture Web UI URL
-export WEB_UI_URL=$($CLI status --name e2e-m3-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1)
-
-# Capture available wallet/party info
-export WALLET_A=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|ALICE" | head -1 | cut -d= -f2)
-export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | head -1 | cut -d= -f2)
- - -

Test Cases

- - -
- - M3-TOK-001 - Token create wizard (non-interactive) - Token Create - -
-
- Preconditions: LocalNet e2e-m3-test running. - Platforms: All -
- -
- -
-

Step 1. Create a new token using non-interactive flags (CIP-0112 path):

-
$CLI token create \
-  --token-name "TestCoin" \
-  --symbol "TST" \
-  --decimals 8 \
-  --initial-supply 1000000 \
-  --name e2e-m3-test
-

Expected: Exit code 0.

-

Verify creation confirmation:

-
$CLI token create \
-  --token-name "TestCoin" \
-  --symbol "TST" \
-  --decimals 8 \
-  --initial-supply 1000000 \
-  --name e2e-m3-test 2>&1 | grep -qiE "(created|success|TestCoin|TST)"
-
-
- -
- -
-

Step 2. Verify the token exists by checking balance:

-
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(1000000|TestCoin|TST)"
-

Expected: Balance shows the initial supply.

-
-
- -
- -
-

Step 3. Verify CIP-0112 alignment:

-
$CLI token create \
-  --token-name "TestCoin" \
-  --symbol "TST" \
-  --decimals 8 \
-  --initial-supply 1000000 \
-  --name e2e-m3-test 2>&1 | grep -qiE "(cip.0112|v2|token.standard)"
-

Expected: Output references CIP-0112 / V2 path (or no V1 warnings).

-
-
- -

Cleanup: None (token persists for subsequent tests).

-
-
- - -
- - M3-TOK-002 - Token mint - Token Ops - -
-
- Preconditions: Token "TestCoin" created (M3-TOK-001). - Platforms: All -
- -
- -
-

Step 1. Mint additional tokens:

-
$CLI token mint TestCoin 500000 --name e2e-m3-test
-

Expected: Exit code 0.

-

Verify mint confirmation:

-
$CLI token mint TestCoin 500000 --name e2e-m3-test 2>&1 | grep -qiE "(minted|success|500000)"
-
-
- -
- -
-

Step 2. Verify updated balance:

-
$CLI token balance TestCoin --name e2e-m3-test
-

Expected: Balance is now 1500000 (initial 1000000 + minted 500000).

-

Verify:

-
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "1500000"
-
-
- -
- -
-

Step 3. Mint to a specific wallet:

-
$CLI token mint TestCoin 100000 --to "$WALLET_B" --name e2e-m3-test
-

Expected: Exit code 0.

-
-
- -

Cleanup: None.

-
-
- - -
- - M3-TOK-003 - Token transfer - Token Ops - -
-
- Preconditions: Token "TestCoin" minted (M3-TOK-002), multiple wallets available. - Platforms: All -
- -
- -
-

Step 1. Transfer tokens between wallets:

-
$CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test
-

Expected: Exit code 0.

-

Verify transfer confirmation:

-
$CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test 2>&1 | grep -qiE "(transferred|success|250000)"
-
-
- -
- -
-

Step 2. Verify sender balance decreased:

-
SENDER_BALANCE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
-# Sender balance should be 1500000 - 250000 = 1250000 (or adjusted based on previous mints)
-echo "Sender balance: $SENDER_BALANCE"
-
-
- -
- -
-

Step 3. Verify receiver balance increased:

-
$CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test 2>&1
-

Expected: Receiver has tokens from transfer + any direct mints.

-
-
- -
- -
-

Step 4. Attempt transfer with insufficient balance:

-
$CLI token transfer TestCoin 999999999999 --to "$WALLET_B" --name e2e-m3-test
-

Expected: Non-zero exit code, error message about insufficient balance.

-
-
- -

Cleanup: None.

-
-
- - -
- - M3-TOK-004 - Token burn - Token Ops - -
-
- Preconditions: Token "TestCoin" exists with balance > 0. - Platforms: All -
- -
- -
-

Step 1. Burn tokens:

-
$CLI token burn TestCoin 100000 --name e2e-m3-test
-

Expected: Exit code 0.

-

Verify burn confirmation:

-
$CLI token burn TestCoin 100000 --name e2e-m3-test 2>&1 | grep -qiE "(burned|burnt|success|100000)"
-
-
- -
- -
-

Step 2. Verify balance decreased after burn:

-
$CLI token balance TestCoin --name e2e-m3-test
-

Expected: Balance reduced by 100000 from pre-burn value.

-
-
- -
- -
-

Step 3. Attempt to burn more than available balance:

-
$CLI token burn TestCoin 999999999999 --name e2e-m3-test
-

Expected: Non-zero exit code, error message about insufficient balance.

-
-
- -

Cleanup: None.

-
-
- - -
- - M3-TOK-005 - Token balance query - Token Ops - -
-
- Preconditions: Token "TestCoin" exists. - Platforms: All -
- -
- -
-

Step 1. Query balance for default wallet:

-
$CLI token balance TestCoin --name e2e-m3-test
-

Expected: Exit code 0.

-

Verify output format:

-
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(TestCoin|TST|balance|[0-9]+)"
-
-
- -
- -
-

Step 2. Query balance for a specific wallet:

-
$CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test
-

Expected: Exit code 0, shows balance for wallet B.

-
-
- -
- -
-

Step 3. Query balance for non-existent token:

-
$CLI token balance NonExistentToken --name e2e-m3-test
-

Expected: Non-zero exit code or zero balance, with clear message.

-
-
- -
- -
-

Step 4. Query all token balances (if supported):

-
$CLI token balance --name e2e-m3-test
-

Expected: Exit code 0, lists all tokens and their balances.

-
-
- -

Cleanup: None.

-
-
- - -
- - M3-TOK-006 - Full flow: create, mint, transfer, burn, balance - Token E2E - -
-
- Preconditions: LocalNet e2e-m3-test running, clean token state preferred. - Platforms: All -
-

This test executes the complete token lifecycle in a single sequential flow, validating state after each step.

- -
- -
-

Step 1. Create a new token:

-
$CLI token create \
-  --token-name "E2ECoin" \
-  --symbol "E2E" \
-  --decimals 6 \
-  --initial-supply 0 \
-  --name e2e-m3-test
-

Verify: Exit code 0, creation confirmed.

-

Assert: $CLI token balance E2ECoin --name e2e-m3-test shows 0.

-
-
- -
- -
-

Step 2. Mint initial supply:

-
$CLI token mint E2ECoin 1000000 --name e2e-m3-test
-

Verify: Exit code 0.

-

Assert: $CLI token balance E2ECoin --name e2e-m3-test shows 1000000.

-
-
- -
- -
-

Step 3. Transfer to another wallet:

-
$CLI token transfer E2ECoin 400000 --to "$WALLET_B" --name e2e-m3-test
-

Verify: Exit code 0.

-

Assert sender: Balance = 600000.

-

Assert receiver: Balance = 400000.

-
-
- -
- -
-

Step 4. Burn from sender:

-
$CLI token burn E2ECoin 100000 --name e2e-m3-test
-

Verify: Exit code 0.

-

Assert sender: Balance = 500000.

-
-
- -
- -
-

Step 5. Final balance check:

-
$CLI token balance E2ECoin --name e2e-m3-test
-

Assert sender: 500000.

-
$CLI token balance E2ECoin --to "$WALLET_B" --name e2e-m3-test
-

Assert receiver: 400000.

-

Assert total supply: 900000 (1000000 minted - 100000 burned).

-
-
- -
- -
-

Step 6. Ledger verification — verify token operations created transactions:

-
$CLI tx ls --template "E2ECoin" --name e2e-m3-test 2>&1 | wc -l
-

Expected: At least 4 transactions (create, mint, transfer, burn).

-
-
- -

Cleanup: None (E2ECoin persists for reference).

-
-
- - -
- - M3-TOK-007 - Token balance after partial burn - Token Edge - -
-
- Preconditions: Token "TestCoin" exists with known balance. - Platforms: All -
- -
- -
-

Step 1. Record current balance:

-
BEFORE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
-echo "Balance before: $BEFORE"
-
-
- -
- -
-

Step 2. Burn a small amount:

-
BURN_AMOUNT=1
-$CLI token burn TestCoin $BURN_AMOUNT --name e2e-m3-test
-

Expected: Exit code 0.

-
-
- -
- -
-

Step 3. Verify exact balance after partial burn:

-
AFTER=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
-EXPECTED=$((BEFORE - BURN_AMOUNT))
-[ "$AFTER" -eq "$EXPECTED" ] && echo "PASS: balance is $AFTER (expected $EXPECTED)" || echo "FAIL: balance is $AFTER, expected $EXPECTED"
-
-
- -
- -
-

Step 4. Burn all remaining balance:

-
$CLI token burn TestCoin "$AFTER" --name e2e-m3-test
-

Expected: Exit code 0.

-
-
- -
- -
-

Step 5. Verify zero balance:

-
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "^0$\|: 0\|balance.*0"
-

Expected: Balance is exactly 0.

-
-
- -

Cleanup: None.

-
-
- - -
- - M3-TOK-008 - Web UI token toolkit: create + mint - Token Web UI - -
-
- Preconditions: Web UI accessible, LocalNet running. - Platforms: All -
- -
- -
-

Step 1. Verify token toolkit section exists in Web UI:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(token|faucet|mint|create.*token)"
-

Expected: Token section found.

-
-
- -
- -
-

Step 2. Verify token cards are rendered:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(token.*card|TestCoin|E2ECoin|TST|E2E)"
-

Expected: Previously created tokens appear as cards.

-
-
- -
- -
-

Step 3. Verify mint action UI elements:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(mint|amount|supply)"
-

Expected: Mint controls present.

-
-
- -
- -
-

Step 4. Verify create token form/wizard UI elements:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(create|wizard|name|symbol|decimals)"
-

Expected: Token creation form present.

-
-
- -

Note: Full interactive token creation and minting via the Web UI requires browser automation. The above validates UI structure and that existing tokens are reflected.

-

Cleanup: None.

-
-
- - -
- - M3-TOK-009 - Web UI token transfer + activity feed - Token Web UI - -
-
- Preconditions: Web UI accessible, tokens with balance exist. - Platforms: All -
- -
- -
-

Step 1. Verify transfer action UI elements:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(transfer|send|recipient|to.*wallet)"
-

Expected: Transfer controls present.

-
-
- -
- -
-

Step 2. Verify recent token activity feed:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(activity|recent|history|transaction|event)"
-

Expected: Activity feed section present.

-
-
- -
- -
-

Step 3. Verify token activity includes operations from CLI tests:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(mint|transfer|burn|create)"
-

Expected: Token operations from earlier tests appear in the activity feed.

-
-
- -
- -
-

Step 4. Verify burn action UI elements:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(burn|destroy)"
-

Expected: Burn controls present.

-
-
- -
- -
-

Step 5. Verify balance display:

-
curl -sf "$WEB_UI_URL" | grep -qiE "(balance|supply|[0-9]+)"
-

Expected: Token balances displayed.

-
-
- -

Note: Full interactive transfer and activity feed verification requires browser automation.

-

Cleanup: None.

-
-
- - -
- - M3-TOK-010 - Cross-platform regression - Regression - -
-
- Preconditions: This is a meta-test — run the full M3-TOK-001 through M3-TOK-009 suite on each platform. - Platforms: All (run once per platform) -
- -
- -
-

Step 1. Per-platform execution:

-
echo "Running on platform: $(uname -s) $(uname -m)"
-
-
- -
- -
-

Step 2. Execute the full token test suite on the current platform:

-
# Run M3-TOK-001 through M3-TOK-009 and record results
-PASS_COUNT=0
-FAIL_COUNT=0
-
-# M3-TOK-001: Token create
-$CLI token create --token-name "PlatformCoin" --symbol "PLT" --decimals 6 --initial-supply 1000 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
-
-# M3-TOK-002: Token mint
-$CLI token mint PlatformCoin 500 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
-
-# M3-TOK-003: Token transfer
-$CLI token transfer PlatformCoin 200 --to "$WALLET_B" --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
-
-# M3-TOK-004: Token burn
-$CLI token burn PlatformCoin 100 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
-
-# M3-TOK-005: Token balance
-$CLI token balance PlatformCoin --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
-
-echo "Platform regression results: PASS=$PASS_COUNT FAIL=$FAIL_COUNT"
-[ "$FAIL_COUNT" -eq 0 ] && echo "PASS: all platform tests passed" || echo "FAIL: $FAIL_COUNT tests failed"
-
-
- -
- -
-

Step 3. Verify platform-specific binary integrity:

-
case "$(uname -s)" in
-  Darwin) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "Mach-O" && echo "PASS: macOS binary" || echo "WARN" ;;
-  Linux)  file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "ELF" && echo "PASS: Linux binary" || echo "WARN" ;;
-  *)      echo "Windows: verify .exe manually" ;;
-esac
-
-
- -
- -
-

Step 4. Record platform and Docker environment:

-
echo "=== Platform Info ==="
-uname -a
-docker version --format '{{.Server.Version}}'
-docker compose version
-$CLI --version
-echo "===================="
-
-
- -

Cleanup:

-
$CLI down --name e2e-m3-test 2>/dev/null || true
-$CLI clean --name e2e-m3-test --force 2>/dev/null || true
-
-
- - -
-

CIP-0112 Scope Note

-

All token tests in this plan target the CIP-0112 (Token Standard V2) path as the default, consistent with the proposal's committed scope. CIP-56 (V1) compatibility and V1-to-V2 migration helpers are explicitly out of scope for this test plan. If CIP-56 support is added later, a supplementary test plan should be created.

-
- - -

Test Execution Summary

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTest NameCategoryDepends On
M3-TOK-001Token create wizard (non-interactive)Token CreateM1 + M2 suites
M3-TOK-002Token mintToken OpsM3-TOK-001
M3-TOK-003Token transferToken OpsM3-TOK-002
M3-TOK-004Token burnToken OpsM3-TOK-002
M3-TOK-005Token balance queryToken OpsM3-TOK-001
M3-TOK-006Full flow: create, mint, transfer, burn, balanceToken E2EM1 + M2 suites
M3-TOK-007Token balance after partial burnToken EdgeM3-TOK-001
M3-TOK-008Web UI token toolkit: create + mintToken Web UIM2-WEB-001
M3-TOK-009Web UI token transfer + activity feedToken Web UIM2-WEB-001
M3-TOK-010Cross-platform regressionRegressionAll M3 tests
-
- - -

Cross-Platform Notes

-
- - - - - - - - - - - - - - - - - - - - - -
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. Token operations go through Ledger API on localhost. No known arm64-specific token issues expected.
Linux (amd64)Native Docker. Token operations may be faster due to native container performance. Ensure user is in docker group.
Windows (amd64)Docker Desktop with WSL 2. Token CLI commands work via PowerShell or WSL bash. grep and cut available in WSL; use PowerShell equivalents (Select-String, ConvertFrom-Json) for native Windows testing.
-
- - - -
- - - - - diff --git a/docs/tests/e2e-test-milestone-3.md b/docs/tests/e2e-test-milestone-3.md deleted file mode 100644 index b9b49311..00000000 --- a/docs/tests/e2e-test-milestone-3.md +++ /dev/null @@ -1,526 +0,0 @@ -# E2E Test Plan — Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) - -> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 3 (Lines 268–277) -> **Estimated Delivery:** Month 9 -> **Total Tests:** 10 -> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) -> **Prerequisites:** All Milestone 1 and Milestone 2 tests passing. - ---- - -## Overview - -This test plan validates the CIP-0112 token standard tooling delivered in Milestone 3: the token creation wizard, minting, transfer, burn, and balance commands, the full token lifecycle E2E flow, edge cases, Web UI token toolkit, and cross-platform regression. - -### Conventions - -- `$CLI` = `dpm localnet` or `canton-devkit localnet` (run full suite twice — once per mode). -- Token commands target the CIP-0112 (V2) path as the default. -- Non-interactive flags are used for wizard-style commands to enable AI agent execution. -- `$WEB_UI_URL` = URL of the Web UI (from `$CLI status`). -- Default step timeout: 30 seconds unless noted. - -### Environment Setup - -```bash -# Set CLI mode -export CLI="dpm localnet" # or "canton-devkit localnet" - -# Ensure clean state -$CLI clean --name e2e-m3-test --force 2>/dev/null || true - -# Start LocalNet for Milestone 3 tests -$CLI up --name e2e-m3-test - -# Capture Web UI URL -export WEB_UI_URL=$($CLI status --name e2e-m3-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1) - -# Capture available wallet/party info -export WALLET_A=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|ALICE" | head -1 | cut -d= -f2) -export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | head -1 | cut -d= -f2) -``` - ---- - -## Test Cases - ---- - -### M3-TOK-001: Token create wizard (non-interactive) - -**Preconditions:** LocalNet `e2e-m3-test` running. -**Platforms:** All - -**Steps:** - -1. Create a new token using non-interactive flags (CIP-0112 path): - ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - - **Verify creation confirmation:** - ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test 2>&1 | grep -qiE "(created|success|TestCoin|TST)" - ``` - -2. Verify the token exists by checking balance: - ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(1000000|TestCoin|TST)" - ``` - - **Expected:** Balance shows the initial supply. - -3. Verify CIP-0112 alignment: - ```bash - $CLI token create \ - --token-name "TestCoin" \ - --symbol "TST" \ - --decimals 8 \ - --initial-supply 1000000 \ - --name e2e-m3-test 2>&1 | grep -qiE "(cip.0112|v2|token.standard)" - ``` - - **Expected:** Output references CIP-0112 / V2 path (or no V1 warnings). - -**Cleanup:** None (token persists for subsequent tests). - ---- - -### M3-TOK-002: Token mint - -**Preconditions:** Token "TestCoin" created (M3-TOK-001). -**Platforms:** All - -**Steps:** - -1. Mint additional tokens: - ```bash - $CLI token mint TestCoin 500000 --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - - **Verify mint confirmation:** - ```bash - $CLI token mint TestCoin 500000 --name e2e-m3-test 2>&1 | grep -qiE "(minted|success|500000)" - ``` - -2. Verify updated balance: - ```bash - $CLI token balance TestCoin --name e2e-m3-test - ``` - - **Expected:** Balance is now `1500000` (initial 1000000 + minted 500000). - - **Verify:** - ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "1500000" - ``` - -3. Mint to a specific wallet: - ```bash - $CLI token mint TestCoin 100000 --to "$WALLET_B" --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - -**Cleanup:** None. - ---- - -### M3-TOK-003: Token transfer - -**Preconditions:** Token "TestCoin" minted (M3-TOK-002), multiple wallets available. -**Platforms:** All - -**Steps:** - -1. Transfer tokens between wallets: - ```bash - $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - - **Verify transfer confirmation:** - ```bash - $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test 2>&1 | grep -qiE "(transferred|success|250000)" - ``` - -2. Verify sender balance decreased: - ```bash - SENDER_BALANCE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") - # Sender balance should be 1500000 - 250000 = 1250000 (or adjusted based on previous mints) - echo "Sender balance: $SENDER_BALANCE" - ``` - -3. Verify receiver balance increased: - ```bash - $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test 2>&1 - ``` - - **Expected:** Receiver has tokens from transfer + any direct mints. - -4. Attempt transfer with insufficient balance: - ```bash - $CLI token transfer TestCoin 999999999999 --to "$WALLET_B" --name e2e-m3-test - ``` - - **Expected:** Non-zero exit code, error message about insufficient balance. - -**Cleanup:** None. - ---- - -### M3-TOK-004: Token burn - -**Preconditions:** Token "TestCoin" exists with balance > 0. -**Platforms:** All - -**Steps:** - -1. Burn tokens: - ```bash - $CLI token burn TestCoin 100000 --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - - **Verify burn confirmation:** - ```bash - $CLI token burn TestCoin 100000 --name e2e-m3-test 2>&1 | grep -qiE "(burned|burnt|success|100000)" - ``` - -2. Verify balance decreased after burn: - ```bash - $CLI token balance TestCoin --name e2e-m3-test - ``` - - **Expected:** Balance reduced by 100000 from pre-burn value. - -3. Attempt to burn more than available balance: - ```bash - $CLI token burn TestCoin 999999999999 --name e2e-m3-test - ``` - - **Expected:** Non-zero exit code, error message about insufficient balance. - -**Cleanup:** None. - ---- - -### M3-TOK-005: Token balance query - -**Preconditions:** Token "TestCoin" exists. -**Platforms:** All - -**Steps:** - -1. Query balance for default wallet: - ```bash - $CLI token balance TestCoin --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - - **Verify output format:** - ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(TestCoin|TST|balance|[0-9]+)" - ``` - -2. Query balance for a specific wallet: - ```bash - $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test - ``` - - **Expected:** Exit code `0`, shows balance for wallet B. - -3. Query balance for non-existent token: - ```bash - $CLI token balance NonExistentToken --name e2e-m3-test - ``` - - **Expected:** Non-zero exit code or zero balance, with clear message. - -4. Query all token balances (if supported): - ```bash - $CLI token balance --name e2e-m3-test - ``` - - **Expected:** Exit code `0`, lists all tokens and their balances. - -**Cleanup:** None. - ---- - -### M3-TOK-006: Full flow — create, mint, transfer, burn, balance - -**Preconditions:** LocalNet `e2e-m3-test` running, clean token state preferred. -**Platforms:** All - -This test executes the complete token lifecycle in a single sequential flow, validating state after each step. - -**Steps:** - -1. **Create** a new token: - ```bash - $CLI token create \ - --token-name "E2ECoin" \ - --symbol "E2E" \ - --decimals 6 \ - --initial-supply 0 \ - --name e2e-m3-test - ``` - - **Verify:** Exit code `0`, creation confirmed. - - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `0`. - -2. **Mint** initial supply: - ```bash - $CLI token mint E2ECoin 1000000 --name e2e-m3-test - ``` - - **Verify:** Exit code `0`. - - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `1000000`. - -3. **Transfer** to another wallet: - ```bash - $CLI token transfer E2ECoin 400000 --to "$WALLET_B" --name e2e-m3-test - ``` - - **Verify:** Exit code `0`. - - **Assert sender:** Balance = `600000`. - - **Assert receiver:** Balance = `400000`. - -4. **Burn** from sender: - ```bash - $CLI token burn E2ECoin 100000 --name e2e-m3-test - ``` - - **Verify:** Exit code `0`. - - **Assert sender:** Balance = `500000`. - -5. **Final balance** check: - ```bash - $CLI token balance E2ECoin --name e2e-m3-test - ``` - - **Assert sender:** `500000`. - ```bash - $CLI token balance E2ECoin --to "$WALLET_B" --name e2e-m3-test - ``` - - **Assert receiver:** `400000`. - - **Assert total supply:** `900000` (1000000 minted - 100000 burned). - -6. **Ledger verification** — verify token operations created transactions: - ```bash - $CLI tx ls --template "E2ECoin" --name e2e-m3-test 2>&1 | wc -l - ``` - - **Expected:** At least 4 transactions (create, mint, transfer, burn). - -**Cleanup:** None (E2ECoin persists for reference). - ---- - -### M3-TOK-007: Token balance after partial burn - -**Preconditions:** Token "TestCoin" exists with known balance. -**Platforms:** All - -**Steps:** - -1. Record current balance: - ```bash - BEFORE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") - echo "Balance before: $BEFORE" - ``` - -2. Burn a small amount: - ```bash - BURN_AMOUNT=1 - $CLI token burn TestCoin $BURN_AMOUNT --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - -3. Verify exact balance after partial burn: - ```bash - AFTER=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") - EXPECTED=$((BEFORE - BURN_AMOUNT)) - [ "$AFTER" -eq "$EXPECTED" ] && echo "PASS: balance is $AFTER (expected $EXPECTED)" || echo "FAIL: balance is $AFTER, expected $EXPECTED" - ``` - -4. Burn all remaining balance: - ```bash - $CLI token burn TestCoin "$AFTER" --name e2e-m3-test - ``` - - **Expected:** Exit code `0`. - -5. Verify zero balance: - ```bash - $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "^0$\|: 0\|balance.*0" - ``` - - **Expected:** Balance is exactly `0`. - -**Cleanup:** None. - ---- - -### M3-TOK-008: Web UI token toolkit — create + mint - -**Preconditions:** Web UI accessible, LocalNet running. -**Platforms:** All - -**Steps:** - -1. Verify token toolkit section exists in Web UI: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(token|faucet|mint|create.*token)" - ``` - - **Expected:** Token section found. - -2. Verify token cards are rendered: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(token.*card|TestCoin|E2ECoin|TST|E2E)" - ``` - - **Expected:** Previously created tokens appear as cards. - -3. Verify mint action UI elements: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(mint|amount|supply)" - ``` - - **Expected:** Mint controls present. - -4. Verify create token form/wizard UI elements: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(create|wizard|name|symbol|decimals)" - ``` - - **Expected:** Token creation form present. - -**Note:** Full interactive token creation and minting via the Web UI requires browser automation. The above validates UI structure and that existing tokens are reflected. - -**Cleanup:** None. - ---- - -### M3-TOK-009: Web UI token transfer + activity feed - -**Preconditions:** Web UI accessible, tokens with balance exist. -**Platforms:** All - -**Steps:** - -1. Verify transfer action UI elements: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(transfer|send|recipient|to.*wallet)" - ``` - - **Expected:** Transfer controls present. - -2. Verify recent token activity feed: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(activity|recent|history|transaction|event)" - ``` - - **Expected:** Activity feed section present. - -3. Verify token activity includes operations from CLI tests: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(mint|transfer|burn|create)" - ``` - - **Expected:** Token operations from earlier tests appear in the activity feed. - -4. Verify burn action UI elements: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(burn|destroy)" - ``` - - **Expected:** Burn controls present. - -5. Verify balance display: - ```bash - curl -sf "$WEB_UI_URL" | grep -qiE "(balance|supply|[0-9]+)" - ``` - - **Expected:** Token balances displayed. - -**Note:** Full interactive transfer and activity feed verification requires browser automation. - -**Cleanup:** None. - ---- - -### M3-TOK-010: Cross-platform regression (macOS/Linux/Windows) - -**Preconditions:** This test is a meta-test — run the full M3-TOK-001 through M3-TOK-009 suite on each platform. -**Platforms:** All (run once per platform) - -**Steps:** - -1. **Per-platform execution:** - ```bash - echo "Running on platform: $(uname -s) $(uname -m)" - ``` - -2. **Execute the full token test suite on the current platform:** - ```bash - # Run M3-TOK-001 through M3-TOK-009 and record results - PASS_COUNT=0 - FAIL_COUNT=0 - - # M3-TOK-001: Token create - $CLI token create --token-name "PlatformCoin" --symbol "PLT" --decimals 6 --initial-supply 1000 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) - - # M3-TOK-002: Token mint - $CLI token mint PlatformCoin 500 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) - - # M3-TOK-003: Token transfer - $CLI token transfer PlatformCoin 200 --to "$WALLET_B" --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) - - # M3-TOK-004: Token burn - $CLI token burn PlatformCoin 100 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) - - # M3-TOK-005: Token balance - $CLI token balance PlatformCoin --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) - - echo "Platform regression results: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" - [ "$FAIL_COUNT" -eq 0 ] && echo "PASS: all platform tests passed" || echo "FAIL: $FAIL_COUNT tests failed" - ``` - -3. **Verify platform-specific binary integrity:** - ```bash - case "$(uname -s)" in - Darwin) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "Mach-O" && echo "PASS: macOS binary" || echo "WARN" ;; - Linux) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "ELF" && echo "PASS: Linux binary" || echo "WARN" ;; - *) echo "Windows: verify .exe manually" ;; - esac - ``` - -4. **Record platform and Docker environment:** - ```bash - echo "=== Platform Info ===" - uname -a - docker version --format '{{.Server.Version}}' - docker compose version - $CLI --version - echo "====================" - ``` - -**Cleanup:** -```bash -$CLI down --name e2e-m3-test 2>/dev/null || true -$CLI clean --name e2e-m3-test --force 2>/dev/null || true -``` - ---- - -## Cross-Platform Notes - -| Platform | Special Considerations | -|---|---| -| **macOS (Apple Silicon)** | Docker Desktop required. Token operations go through Ledger API on localhost. No known arm64-specific token issues expected. | -| **Linux (amd64)** | Native Docker. Token operations may be faster due to native container performance. Ensure user is in `docker` group. | -| **Windows (amd64)** | Docker Desktop with WSL 2. Token CLI commands work via PowerShell or WSL bash. `grep` and `cut` available in WSL; use PowerShell equivalents (`Select-String`, `ConvertFrom-Json`) for native Windows testing. | - ---- - -## Test Execution Summary - -| ID | Test Name | Category | Depends On | -|---|---|---|---| -| M3-TOK-001 | Token create wizard (non-interactive) | Token Create | M1 + M2 suites | -| M3-TOK-002 | Token mint | Token Ops | M3-TOK-001 | -| M3-TOK-003 | Token transfer | Token Ops | M3-TOK-002 | -| M3-TOK-004 | Token burn | Token Ops | M3-TOK-002 | -| M3-TOK-005 | Token balance query | Token Ops | M3-TOK-001 | -| M3-TOK-006 | Full flow: create, mint, transfer, burn, balance | Token E2E | M1 + M2 suites | -| M3-TOK-007 | Token balance after partial burn | Token Edge | M3-TOK-001 | -| M3-TOK-008 | Web UI token toolkit: create + mint | Token Web UI | M2-WEB-001 | -| M3-TOK-009 | Web UI token transfer + activity feed | Token Web UI | M2-WEB-001 | -| M3-TOK-010 | Cross-platform regression | Regression | All M3 tests | - ---- - -## CIP-0112 Scope Note - -All token tests in this plan target the **CIP-0112 (Token Standard V2)** path as the default, consistent with the proposal's committed scope. CIP-56 (V1) compatibility and V1-to-V2 migration helpers are explicitly out of scope for this test plan. If CIP-56 support is added later, a supplementary test plan should be created. diff --git a/docs/tokens.md b/docs/tokens.md index da7b0e6e..a4cb0f49 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -9,7 +9,7 @@ no JWTs, ports, or 130-char contract ids in your face. > **Scope: V2 / CIP-0112 only.** This tooling targets the Token Standard > V2 (CIP-0112) surface. V1 / CIP-0056 is **not** supported. V2 is > currently an opt-in *alpha* track (see [the alpha caveat](#the-v2-alpha-caveat)); -> it is promoted to the default channel once V2 lands in mainline Splice. +> it will be promoted to the default channel once V2 lands in mainline Splice. --- @@ -77,7 +77,7 @@ canton-devkit localnet token mint --instance $INST --endpoint $EP \ # 4. See everyone's balances at a glance canton-devkit localnet token balances --instance $INST --endpoint $EP -# 5. Transfer (─-auto-accept settles in one step on LocalNet) +# 5. Transfer (--auto-accept settles in one step on LocalNet) canton-devkit localnet token transfer --instance $INST --endpoint $EP \ --instrument RTK --from bob --to alice --amount 250 --auto-accept diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9892580..8cf84bf7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -84,7 +84,8 @@ warns when run against a running instance. - `localnet logs --name [service]` — tail container logs. - `localnet doctor --name ` — host + instance diagnostics. -- File an issue with the `doctor` output and the failing command. +- File a [GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues) + with the `doctor` output and the failing command. ## Log lookup implementation note diff --git a/docs/validation-checklist.md b/docs/validation-checklist.md index 283a44b1..14d51c81 100644 --- a/docs/validation-checklist.md +++ b/docs/validation-checklist.md @@ -1,9 +1,9 @@ # Zero-to-LocalNet validation checklist -The M1 adoption bar is: **a new developer reaches a running LocalNet in -under 10 minutes.** This page is the reviewer-facing checklist behind that -metric. Run it yourself before a release, or hand it to an external -reviewer (see [adoption/reviewer-kit.md](adoption/reviewer-kit.md)). +The goal: **a new developer reaches a running LocalNet in under 10 +minutes.** Use this checklist to validate your installation after +installing canton-devkit, or to sanity-check a release candidate on a +fresh machine. ## Automated harness @@ -22,9 +22,9 @@ Exit `0` = passed within budget · `1` = a step failed · `2` = over budget. The harness times: binary present → `doctor` → `up` (the long pole) → `status` healthy → teardown. -## Manual reviewer checklist +## Manual checklist -A first-time reviewer with Docker installed should be able to tick every +A first-time user with Docker installed should be able to tick every box without reading source: - [ ] **Install** — one command from [getting-started.md](getting-started.md) @@ -42,9 +42,10 @@ box without reading source: - [ ] **No surprises** — no manual Docker commands, no editing config files, no hunting for ports. -## What to record +## Reporting results -For each reviewer / run, capture: +If a step fails or blows the budget, please open an issue. These details +make a run reproducible: | Field | Example | |---|---| @@ -55,5 +56,5 @@ For each reviewer / run, capture: | Result | pass / fail (step) | | Friction notes | "doctor memory hint was clear"; "didn't know which port was the UI" | -Aggregate these in the adoption transparency update (M4). Three external -reviewers passing the manual checklist satisfies the M1 adoption metric. +Successful timings are welcome too — they help track how the +zero-to-LocalNet experience holds up across platforms. diff --git a/docs/versions.md b/docs/versions.md index 2d3f7853..c31690f6 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -4,7 +4,7 @@ DevKit pins to a **curated** list of Splice versions in [`internal/splice/versions.json`](../internal/splice/versions.json) so `localnet up` never composes-up an untested upstream tag. -## What we fetch, and from where +## What DevKit fetches, and from where > **Upstream repo:** [`canton-network/splice`](https://github.com/canton-network/splice) > **Subtree extracted:** `cluster/compose/localnet/` @@ -23,9 +23,9 @@ is a separate repo that *builds on top of* the same Splice LocalNet to provide an App-Provider quickstart with a backend service, frontend, Daml workflows, etc. — see its README for context. DevKit deliberately fetches the bare LocalNet base from `canton-network/splice` rather than -the App-Provider layer from cn-quickstart, because we want the minimal -infrastructure surface for our lifecycle (`up` / `down` / `status` / -`creds` / `logs`). App-Provider workflows are out of scope for DevKit; +the App-Provider layer from cn-quickstart, because the lifecycle +commands (`up` / `down` / `status` / `creds` / `logs`) only need the +minimal infrastructure surface. App-Provider workflows are out of scope for DevKit; users who want them can run cn-quickstart's `make start` on top of a DevKit-managed LocalNet. @@ -34,8 +34,8 @@ DevKit-managed LocalNet. GitHub may surface this repo as `hyperledger-labs/splice` in older documentation (e.g. cn-quickstart's README still uses that name). That URL redirects to `canton-network/splice` — GitHub's API resolves -both to the same canonical `full_name`, and tag SHAs match. We use the -canonical name in code and docs. +both to the same canonical `full_name`, and tag SHAs match. DevKit uses +the canonical name in code and docs. ## Anatomy of a catalogue entry @@ -52,7 +52,7 @@ canonical name in code and docs. | Field | Source of truth | Why it's pinned | |---|---|---| | `tag` | Upstream git tag (or branch label for pre-releases) | User-facing identifier; what `--version` accepts. | -| `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. We fetch via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | +| `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. DevKit fetches via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | | `content_sha` | `scripts/compute-tree-sha.sh` | SHA-256 over the extracted `cluster/compose/localnet/` subtree (sorted by path). Stable across upstream gzip-envelope rewrites; this is the authoritative integrity check at fetch time. | | `size` | byte count of the source-tarball | Informational; used to print a hint before download and to size the in-flight body cap. | | `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major adapter in `internal/splice/v0X/`. | @@ -79,8 +79,8 @@ Status flags per row: |---|---| | `supported` | Catalogued; upstream pin matches. Safe to use. | | `drifted` | Catalogued; upstream tag has been force-moved to a different commit. **Security signal** — re-review the catalogue entry before trusting. | -| `available` | Upstream has the tag; not yet in our catalogue. A maintainer can add it via the helper below. | -| `catalogued-only` | We catalogue it, but the online tag listing does not contain the same label. For stable entries this usually means the upstream tag was deleted and should be investigated before removal; branch-backed alpha entries such as `token-standard-v2` can also appear this way until branch/ref-aware status is added. | +| `available` | Upstream has the tag; not yet in the catalogue. A maintainer can add it via the helper below. | +| `catalogued-only` | In the catalogue, but the online tag listing does not contain the same label. For stable entries this usually means the upstream tag was deleted and should be investigated before removal; branch-backed alpha entries such as `token-standard-v2` can also appear this way until branch/ref-aware status is added. | ## Adding a new version (maintainer flow) @@ -96,8 +96,8 @@ The script: 5. Inserts a new entry into `versions.json` (sorted by tag). 6. Prints the diff. **Does not commit.** -A reviewer then: -- Verifies the diff. +A maintainer then: +- Reviews the diff. - Bumps `latest_alias` if the new tag should become the default `--version latest`. - Optionally runs the integration test against the new entry before @@ -106,7 +106,7 @@ A reviewer then: ## Two-layer resolution -DevKit now exposes the catalogue as the *default* tier of a two-layer +DevKit exposes the catalogue as the *default* tier of a two-layer version model — the curated path stays audited, and an explicit opt-in unlocks arbitrary upstream tags for prerelease testing. @@ -121,14 +121,13 @@ DevKit can't promise the bits were tested against this release. Orchestrators print a one-line "Using uncurated Splice tag" warning on the layer-2 path so the user is never surprised. -Because layer 2 exists, the previous weekly cron that auto-bumped -the catalogue was removed in this change — it added latency without -solving the prerelease use case, and the catalogue is now strictly -the curated-by-humans surface. +Because layer 2 covers the prerelease use case, the catalogue is +strictly a curated-by-humans surface — entries are only added by a +maintainer, never by automation. ## Why not just point at the latest tag? -Three reasons we curate: +Three reasons the catalogue is curated: 1. **Reproducibility.** A user running `localnet up --version 0.6.4` today must get exactly the bits that were tested when the entry @@ -137,19 +136,19 @@ Three reasons we curate: 2. **Surface area control.** Splice ships pre-release tags (`next-cilr`, etc.) and partial-release tags that aren't intended - for downstream consumption. We don't want to support every commit - that happens to land in the repo. + for downstream consumption. DevKit doesn't aim to support every + commit that happens to land in the repo. 3. **Adapter routing.** DevKit ships per-major adapters (`internal/splice/v05/`, `v06/`). A new major version (e.g. `0.7.x`) needs a corresponding adapter before it can be added — the script leaves `major` blank for non-N.N.N tags so a maintainer notices. -## What changed in this refactor +## Why the content SHA, not the tarball hash -Pre-2026-05, the catalogue lived in `versions.go` as a Go map literal -and pinned both the gzip-tarball SHA and the ContentSHA. The gzip -hash was brittle: GitHub regenerates source-tarballs lazily and the -gzip metadata can drift. The current model drops the gzip hash; the -commit SHA in the URL + ContentSHA over the extracted tree is the full -integrity check, and it's stable across gzip envelope rewrites. +Pinning the gzip-tarball SHA would be brittle: GitHub regenerates +source-tarballs lazily and the gzip metadata can drift, so the same +source tree can yield different tarball hashes over time. The catalogue +therefore pins the commit SHA in the URL plus a ContentSHA over the +extracted tree — a complete integrity check that is stable across gzip +envelope rewrites. diff --git a/telemetry-collector/DEPLOY.md b/telemetry-collector/DEPLOY.md index 07ee3f5a..dd46c24a 100644 --- a/telemetry-collector/DEPLOY.md +++ b/telemetry-collector/DEPLOY.md @@ -1,8 +1,8 @@ # Deploying the telemetry collector -Two scenarios: **local testing** (your dev machine) and **production / -mainnet release** (a host the released CLI fleet phones home to). Same -compose stack, different hardening. +Two scenarios: **local testing** (your dev machine) and **production** +(a host the released CLI fleet reports to). Same compose stack, +different hardening. The stack is three containers: @@ -52,9 +52,9 @@ Tear down (and wipe data): `docker compose down -v`. --- -## 2. Production / mainnet release +## 2. Production -### 2a. The production override + our hosted instance +### 2a. The production override For any internet-facing deployment, run the base compose **with the production override**: @@ -73,17 +73,13 @@ The override (`docker-compose.prod.yml`) hardens the dev stack: - Exposes the collector on `127.0.0.1:${COLLECTOR_PORT:-8090}` so it can sit beside other services on a shared host. -> **Our hosted instance (`canton-devkit-telemetry.bitdynamics.me`).** The -> nginx vhost, TLS/certbot scripts, the `.env`, and the deployment runbook for -> the instance we operate do **not** live in this repo — they live in the -> **`canton-infra`** repo under **`telemetry/`** (tracked as task 024). That -> repo holds only the server/deploy glue; the collector code and compose -> files stay here in `canton-devkit` as the single source of truth. The -> server checks out both repos and runs this compose with `--env-file` -> pointing at `canton-infra/telemetry/.env`. See `canton-infra/telemetry/README.md`. +The collector code and compose files in this directory are the single +source of truth; host-specific glue (reverse-proxy vhost, TLS scripts, +the production `.env`) lives wherever you manage your own server config — +run this compose with `--env-file` pointing at it. -The rest of this section documents the generic self-host path (your own VM, -your own proxy) for anyone running their own collector. +The rest of this document describes the generic self-host path (your own +VM, your own proxy) for anyone running their own collector. ### 2a-bis. Where to host @@ -213,11 +209,10 @@ INGEST_TOKEN=$(openssl rand -hex 24) # optional; see note below ``` `INGEST_TOKEN` makes the collector require `X-Telemetry-Token`. The CLI -doesn't send that header yet, so for the first rollout leave it **empty** -and rely on: (a) HTTPS, (b) the endpoint being zero-PII anyway. If you -later want authenticated ingest, that's a small CLI follow-up (add a -`CANTON_DEVKIT_TELEMETRY_TOKEN` env → header); the collector side is -already done. +does not currently send that header, so leave it **empty** and rely on: +(a) HTTPS, (b) the endpoint being zero-PII anyway. The collector side of +authenticated ingest is already implemented; enabling it would require a +CLI change to send the header. ### 2d. Bake the endpoint into release binaries @@ -230,7 +225,7 @@ with: ``` where `TELEMETRY_ENDPOINT` comes from the repo variable `vars.TELEMETRY_ENDPOINT`. -So to turn on telemetry for the released ("mainnet") fleet: +So to turn on telemetry for the binaries you release: 1. **Settings → Secrets and variables → Actions → Variables → New repository variable** @@ -238,17 +233,17 @@ So to turn on telemetry for the released ("mainnet") fleet: - Value: the HTTPS `/v1/counters` URL of the collector. 2. Cut a release tag. The published binaries now POST there by default. -> **Current state:** the variable is **set** to our deployed instance, -> `https://canton-devkit-telemetry.bitdynamics.me/v1/counters` (the collector -> running on the TestNet host — see `canton-infra` task 024). The next stable -> release tag will bake this in; binaries cut before that still ship "dark". +> In the canonical `canton-devkit` repository this variable is set to the +> project's hosted collector, +> `https://canton-devkit-telemetry.bitdynamics.me/v1/counters`, so official +> release binaries report there by default. Leave the variable **unset** and binaries ship "dark" (counters spool locally, nothing sent). Users can always override or disable at runtime: `CANTON_DEVKIT_TELEMETRY_ENDPOINT=...`, `dpm telemetry off`, `DPM_TELEMETRY=off`, or `DO_NOT_TRACK=1`. -### 2e. Backups (your "export when I need", automated) +### 2e. Backups (automated export) The data lives in the `pgdata` Docker volume. Nightly logical backup: @@ -273,13 +268,14 @@ Restore: `gunzip -c backup.sql.gz | docker compose exec -T postgres psql -U post The pipeline is zero-PII by construction (allow-listed counter names, coarse period buckets, integers — no IDs, IPs are not stored, bodies are -never logged) and **opt-out** with a first-run notice. For a public -release, link a short privacy note from the docs stating what's collected -and how to opt out (`dpm telemetry off` / `DO_NOT_TRACK=1`). +never logged) and **opt-out** with a first-run notice. If you distribute +binaries that report to your collector, link a short privacy note from +your docs stating what's collected and how to opt out +(`dpm telemetry off` / `DO_NOT_TRACK=1`). --- -## Mainnet release checklist +## Production release checklist - [ ] VM provisioned; Docker + compose installed - [ ] `.env` with strong `POSTGRES_PASSWORD` @@ -289,7 +285,6 @@ and how to opt out (`dpm telemetry off` / `DO_NOT_TRACK=1`). - [ ] Smoke: POST a sample payload, confirm a row in `counter_period` - [ ] Metabase admin created; `telemetry` DB added; a starter dashboard saved - [ ] Nightly `pg_dump` cron → off-host storage -- [x] Repo variable `TELEMETRY_ENDPOINT` set to the HTTPS `/v1/counters` URL - (`https://canton-devkit-telemetry.bitdynamics.me/v1/counters`) +- [ ] Repo variable `TELEMETRY_ENDPOINT` set to the HTTPS `/v1/counters` URL - [ ] Cut a release tag; download a binary; confirm a counter lands after a day’s use - [ ] Privacy note published; opt-out verified (`dpm telemetry off`) diff --git a/telemetry-collector/README.md b/telemetry-collector/README.md index d8458922..3d62f28b 100644 --- a/telemetry-collector/README.md +++ b/telemetry-collector/README.md @@ -18,9 +18,9 @@ cp .env.example .env # set POSTGRES_PASSWORD docker compose up -d --build ``` -> **Deploying for real** — local testing vs production / mainnet release, -> TLS, secrets, backups, and baking the endpoint into release binaries: -> see **[DEPLOY.md](DEPLOY.md)**. +> **Deploying for real** — local testing vs production, TLS, secrets, +> backups, and baking the endpoint into release binaries: see +> **[DEPLOY.md](DEPLOY.md)**. This starts: @@ -36,7 +36,7 @@ builds via `-ldflags`): ```bash # Local/dev stack: export CANTON_DEVKIT_TELEMETRY_ENDPOINT=http://:8080/v1/counters -# Our deployed instance (production): +# Hosted collector used by official release builds: # export CANTON_DEVKIT_TELEMETRY_ENDPOINT=https://canton-devkit-telemetry.bitdynamics.me/v1/counters ``` @@ -52,18 +52,19 @@ MB_USER='you@example.com' MB_PASS='...' DB_PASS="$POSTGRES_PASSWORD" python3 set [`setup-metabase.py`](setup-metabase.py) is idempotent (re-runs update in place) and stdlib-only. It connects the `telemetry` database, creates a -**canton-devkit telemetry** collection, and assembles a **milestone- -grouped** `canton-devkit usage` dashboard — M1/M2/M3/M4 sections, each -chart tagged by source (`[telemetry]` / `[GitHub]` / `[qualitative]`) so -it doubles as the adoption-transparency artifact: - -- **M1** — commands/day, LocalNet start ok-vs-fail, platform split (incl. - Windows), top commands -- **M2** — Web UI features used, CI-vs-interactive, AI-agent usage -- **M3** — token actions (the CIP-0112 create → mint → transfer flow) -- **M4** — cumulative downloads (toward the 250 floor), stars/forks, and - the **`adoption_evidence`** table — the named external teams/projects you - log by hand (the leg neither telemetry nor GitHub can provide) +**canton-devkit telemetry** collection, and assembles a `canton-devkit +usage` dashboard grouped into topic sections, each chart tagged by source +(`[telemetry]` / `[GitHub]` / `[qualitative]`): + +- **LocalNet lifecycle** — commands/day, LocalNet start ok-vs-fail, + platform split (incl. Windows), top commands +- **Web UI & tooling** — Web UI features used, CI-vs-interactive, + AI-agent usage +- **Token flows** — token actions (the CIP-0112 create → mint → transfer + flow) +- **Adoption** — cumulative downloads, stars/forks, and the + **`adoption_evidence`** table — externally reported usage you log by + hand (the one signal neither telemetry nor GitHub can provide) Works the same against a production Metabase by setting `MB_URL`. @@ -72,13 +73,13 @@ manually and chart `counter_period` / the `v_command_usage` view. ## GitHub adoption signals (downloads, stars/forks) -Telemetry is zero-PII, so it **can't count unique installs**. The -install/visibility legs of the proposal's composite adoption measure come -from GitHub instead. [`cmd/github-stats`](cmd/github-stats) snapshots -release-asset download counts and repo stars/forks/watchers into the same -Postgres (tables `github_release_downloads`, `github_repo_stats`, view -`v_downloads_total`), so Metabase charts the cumulative-download trend -toward the Milestone-4 floor and the visibility curve. +Telemetry is zero-PII, so it **can't count unique installs**. Install and +visibility signals come from GitHub instead. +[`cmd/github-stats`](cmd/github-stats) snapshots release-asset download +counts and repo stars/forks/watchers into the same Postgres (tables +`github_release_downloads`, `github_repo_stats`, view +`v_downloads_total`), so Metabase can chart the cumulative-download trend +and the visibility curve. Run it daily (cron / GitHub Action — see [`deploy/github-stats.cron.yml`](deploy/github-stats.cron.yml)): @@ -161,5 +162,7 @@ adds nothing. It never logs request bodies. go test ./... # handler tests; no database required (fake store) ``` -End-to-end (collector → real Postgres) is exercised against a throwaway -`postgres:16` container; see the PR description for the verified run. +End-to-end coverage (collector → real Postgres) lives in +[`aggregate_integration_test.go`](aggregate_integration_test.go); it is +skipped unless `TEST_DATABASE_URL` points at a Postgres (e.g. a throwaway +`postgres:16` container) with `schema.sql` applied. From c64818f0386ffd137536514c155a2be94118ba21 Mon Sep 17 00:00:00 2001 From: srikanth-bitdynamics <259878899+srikanth-bitdynamics@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:36:04 +0530 Subject: [PATCH 25/68] refactor: repo-wide simplification pass (no behavior change) Remove comments that restate the code, historical narration, and internal process references; compress verbose rationale to its load- bearing core; delete provably dead unexported code; apply mechanical simplifications (redundant else-after-return, unneeded conversions, single-use trivial helpers) where equivalence is certain. Deliberately unchanged: all exported identifiers and signatures, error strings, exit codes, CLI flags and help text, JSON field names, HTTP routes, log formats, and generated files. Genuine why/invariant/ security/concurrency comments stay. Full go test, golangci-lint, tsc, and vitest suites pass identically. --- Makefile | 39 +- assets/assets.go | 29 +- assets/compose/prometheus.yml | 8 +- assets/compose/shared-prometheus.yml | 4 +- assets/dashboard_test.go | 8 +- e2e-tests/daml-test-contracts/daml/Token.daml | 18 - frontend/src/App.tsx | 27 +- frontend/src/api.ts | 110 ++---- frontend/src/screens/AgentSkillsScreen.tsx | 10 +- frontend/src/screens/BackupRestore.test.tsx | 13 +- frontend/src/screens/BackupRestore.tsx | 63 +-- frontend/src/screens/ContainerHealth.tsx | 50 +-- frontend/src/screens/ContainerLogsModal.tsx | 28 +- frontend/src/screens/ContractDetailDrawer.tsx | 52 +-- frontend/src/screens/CreateLocalNetModal.tsx | 214 ++++------ frontend/src/screens/CreatingPanel.tsx | 73 ++-- frontend/src/screens/DARDiff.tsx | 16 +- frontend/src/screens/DARPackageTree.tsx | 20 +- frontend/src/screens/DARScreen.test.tsx | 5 +- frontend/src/screens/DARScreen.tsx | 118 +++--- frontend/src/screens/Dashboard.tsx | 43 +- frontend/src/screens/DeveloperSetup.tsx | 35 +- frontend/src/screens/DoctorScreen.tsx | 19 +- frontend/src/screens/ExplorerScreen.tsx | 269 +++---------- frontend/src/screens/InstanceDetail.tsx | 132 +++---- frontend/src/screens/MetricsScreen.tsx | 101 ++--- frontend/src/screens/Screens.smoke.test.tsx | 20 +- frontend/src/screens/TokensScreen.test.tsx | 2 +- frontend/src/screens/TokensScreen.tsx | 88 ++--- frontend/src/screens/TxReplayDrawer.tsx | 15 +- frontend/src/screens/VersionPicker.test.tsx | 26 +- frontend/src/screens/WalletScreen.tsx | 84 ++-- frontend/src/screens/remediation.test.ts | 41 +- frontend/src/screens/remediation.ts | 28 +- frontend/src/screens/useCreateProgress.ts | 108 ++--- frontend/src/shell/CommandPalette.tsx | 45 +-- frontend/src/shell/ErrorBoundary.tsx | 39 +- frontend/src/shell/Shell.test.tsx | 17 +- frontend/src/shell/Shell.tsx | 42 +- frontend/src/shell/routes.ts | 13 +- frontend/src/shell/useConnectionHealth.ts | 32 +- .../src/shell/useInstanceSelection.test.tsx | 22 +- frontend/src/shell/useInstanceSelection.ts | 54 +-- frontend/src/tokens.ts | 5 +- internal/api/types/doc.go | 35 +- internal/api/types/explorer.go | 8 +- internal/api/types/log.go | 4 +- internal/api/types/preflight.go | 7 +- internal/api/types/schema_pin_test.go | 27 +- internal/api/types/types_test.go | 2 +- internal/canton/admin/admin_test.go | 13 +- internal/canton/ledger/admin.go | 23 +- internal/canton/ledger/client.go | 23 +- internal/canton/ledger/client_test.go | 27 +- internal/canton/ledger/commands.go | 2 +- internal/canton/ledger/completions.go | 2 +- internal/canton/ledger/doc.go | 9 +- internal/canton/ledger/packages.go | 8 +- internal/canton/ledger/project.go | 15 +- internal/canton/ledger/stream.go | 2 +- internal/canton/ledger/stream_test.go | 34 +- internal/canton/ledger/unary_test.go | 10 +- internal/canton/registry/client.go | 14 +- internal/canton/registry/doc.go | 11 +- internal/canton/registry/transfer_v2.go | 23 +- internal/canton/registry/transfer_v2_test.go | 24 +- internal/cli/app.go | 60 ++- internal/cli/cli_test.go | 13 +- internal/cli/help.go | 139 +++---- internal/cli/help_test.go | 47 +-- internal/cli/localnet/container.go | 27 +- internal/cli/localnet/contracts.go | 54 +-- .../cli/localnet/contracts_render_test.go | 4 +- internal/cli/localnet/contracts_test.go | 14 +- internal/cli/localnet/dar/buildupload.go | 15 +- internal/cli/localnet/dar/connect.go | 31 +- internal/cli/localnet/dar/dar.go | 16 +- internal/cli/localnet/dar/dar_test.go | 4 +- internal/cli/localnet/dar/diff.go | 2 + internal/cli/localnet/dar/download.go | 23 +- internal/cli/localnet/dar/info.go | 37 +- internal/cli/localnet/dar/list.go | 23 +- internal/cli/localnet/dar/safe_basename.go | 30 +- .../cli/localnet/dar/safe_basename_test.go | 5 +- internal/cli/localnet/dar/upload.go | 17 +- internal/cli/localnet/dar/watch.go | 22 +- internal/cli/localnet/dar/watch_test.go | 6 +- internal/cli/localnet/doctor.go | 8 +- internal/cli/localnet/down.go | 51 +-- internal/cli/localnet/env.go | 11 +- internal/cli/localnet/ledger_filter.go | 6 +- internal/cli/localnet/localnet.go | 13 +- internal/cli/localnet/metrics.go | 70 ++-- internal/cli/localnet/observability.go | 32 +- internal/cli/localnet/refresh.go | 22 +- internal/cli/localnet/skills.go | 11 +- internal/cli/localnet/token/activity.go | 9 +- internal/cli/localnet/token/balance.go | 8 +- internal/cli/localnet/token/balances.go | 15 +- internal/cli/localnet/token/burn.go | 19 +- internal/cli/localnet/token/create.go | 30 +- internal/cli/localnet/token/demo.go | 10 +- internal/cli/localnet/token/faucet.go | 8 +- internal/cli/localnet/token/mint.go | 5 +- internal/cli/localnet/token/party.go | 9 +- internal/cli/localnet/token/summary.go | 8 +- internal/cli/localnet/token/token.go | 17 +- internal/cli/localnet/ui.go | 36 +- internal/cli/localnet/up.go | 16 +- internal/cli/localnet/versions.go | 50 +-- internal/cli/telemetry.go | 10 +- internal/dar/bomb_test.go | 3 +- internal/dar/bounded_read.go | 23 +- internal/dar/bounded_read_test.go | 17 +- internal/dar/dar.go | 15 +- internal/dar/diff.go | 25 +- internal/dar/diff_test.go | 27 +- internal/dar/lf_inspect.go | 2 + internal/dar/reader.go | 36 +- internal/docker/checks.go | 2 - internal/docker/compose.go | 161 +++----- internal/docker/compose_test.go | 23 +- internal/docker/preflight.go | 8 +- internal/docker/threshold_parity_test.go | 33 +- internal/localnet/adapters.go | 23 +- internal/localnet/canton_ports.go | 83 ++-- internal/localnet/composeenv.go | 2 +- internal/localnet/containers/containers.go | 28 +- internal/localnet/containers/follow.go | 31 +- internal/localnet/darops/darops.go | 27 +- internal/localnet/diagnose.go | 14 +- internal/localnet/down.go | 10 +- internal/localnet/env.go | 37 +- internal/localnet/env_test.go | 2 +- internal/localnet/friendly_errors.go | 39 +- internal/localnet/ledger_endpoint.go | 16 +- internal/localnet/observability.go | 3 - internal/localnet/observability_overlay.go | 21 +- internal/localnet/portblock.go | 13 +- internal/localnet/preflight_report.go | 3 +- internal/localnet/progress.go | 190 +++------ internal/localnet/restart.go | 10 +- internal/localnet/snapshot/snapshot.go | 23 +- internal/localnet/token/actions.go | 112 ++---- internal/localnet/token/activity.go | 9 +- internal/localnet/token/activity_test.go | 7 +- internal/localnet/token/dar_bundle.go | 10 +- internal/localnet/token/decimal_test.go | 11 +- internal/localnet/token/exercise_v2.go | 8 +- internal/localnet/token/faucet.go | 8 +- internal/localnet/token/instrument_v2.go | 8 +- internal/localnet/token/ledger.go | 111 ++---- internal/localnet/token/party.go | 11 +- internal/localnet/token/plan.go | 5 +- internal/localnet/token/run_transfer.go | 30 +- .../localnet/token/run_transfer_onledger.go | 28 +- .../token/run_transfer_onledger_test.go | 12 +- internal/localnet/token/token.go | 31 +- internal/localnet/token/v1_write_test.go | 2 +- internal/localnet/token/v2_surface.go | 86 ++-- internal/localnet/token/validate_test.go | 18 +- internal/localnet/token/value_builders.go | 8 +- internal/localnet/token/workspace.go | 23 +- internal/localnet/up.go | 98 ++--- internal/localnet/up_profiles_test.go | 2 +- internal/metricsq/queries.go | 46 +-- internal/metricsq/smoke_test.go | 4 - internal/registry/index_lock_windows.go | 15 +- internal/registry/lock_unix.go | 5 +- internal/registry/lock_windows.go | 4 - internal/registry/state.go | 218 ++++------ internal/registry/state_test.go | 23 +- internal/skills/skills.go | 2 +- internal/skills/skills_test.go | 2 +- internal/splice/cache.go | 15 +- internal/splice/core_services.go | 20 +- internal/splice/fetcher.go | 5 +- internal/splice/fetcher_test.go | 36 +- internal/splice/partyhint_test.go | 20 +- internal/splice/resolver.go | 7 +- internal/splice/resolver_test.go | 2 +- internal/splice/upstream.go | 5 +- internal/splice/versions.go | 27 +- internal/splice/versions_test.go | 7 +- internal/telemetry/adoption_counters_test.go | 6 +- internal/telemetry/allowlist.go | 29 +- internal/telemetry/config.go | 23 +- internal/telemetry/install.go | 29 +- internal/telemetry/installid.go | 33 +- internal/telemetry/installid_test.go | 7 +- internal/telemetry/store.go | 27 +- internal/telemetry/telemetry_test.go | 7 +- internal/telemetry/uploader.go | 11 +- internal/ui/assets.go | 94 ++--- internal/ui/csrf.go | 99 ++--- internal/ui/csrf_test.go | 105 ++--- internal/ui/feature_telemetry_test.go | 2 +- internal/ui/frontend_schema_test.go | 31 +- internal/ui/handlers/auth.go | 89 ++--- internal/ui/handlers/contracts.go | 39 +- .../handlers/contracts_transactions_test.go | 9 +- internal/ui/handlers/create_test.go | 13 +- internal/ui/handlers/dar.go | 6 +- internal/ui/handlers/dar_inspect.go | 11 +- internal/ui/handlers/doctor.go | 7 +- internal/ui/handlers/instances.go | 374 ++++-------------- internal/ui/handlers/instances_test.go | 4 +- internal/ui/handlers/jobs.go | 4 +- internal/ui/handlers/metrics.go | 29 +- internal/ui/handlers/preflight.go | 20 +- internal/ui/handlers/reconciler.go | 22 +- internal/ui/handlers/snapshots.go | 10 +- internal/ui/handlers/splice_versions.go | 22 +- internal/ui/handlers/tokens.go | 29 +- internal/ui/handlers/tokens_test.go | 5 - internal/ui/handlers/transactions.go | 46 +-- internal/ui/httpsec/httpsec.go | 61 +-- internal/ui/progress/sse_progress.go | 188 +++------ internal/ui/progress/sse_progress_test.go | 7 +- internal/ui/router.go | 138 +++---- internal/ui/server.go | 115 ++---- internal/ui/server_test.go | 24 +- internal/ui/sse.go | 70 ++-- internal/ui/sse_test.go | 18 +- internal/ui/stream/buffer_test.go | 28 +- internal/ui/stream/hub.go | 254 ++++-------- internal/ui/stream/hub_test.go | 98 ++--- internal/ui/term/box.go | 17 +- internal/ui/term/color.go | 9 +- internal/ui/term/parity_test.go | 38 +- internal/ui/term/section.go | 7 +- internal/ui/term/spinner.go | 23 +- internal/ui/term/step.go | 12 +- internal/ui/term/table.go | 4 +- internal/ui/term/term_test.go | 24 +- internal/ui/term/welcome.go | 50 +-- packaging/component_test.go | 2 +- scripts/add-splice-version.sh | 4 +- scripts/assemble-dpm-component.sh | 2 +- scripts/demo.sh | 4 +- scripts/update-homebrew-formula.sh | 5 +- telemetry-collector/cmd/collector/main.go | 3 +- telemetry-collector/collector.go | 29 +- telemetry-collector/githubstats.go | 11 +- telemetry-collector/postgres.go | 39 +- telemetry-collector/ratelimit.go | 2 - telemetry-collector/schema.sql | 21 +- 247 files changed, 2766 insertions(+), 5405 deletions(-) diff --git a/Makefile b/Makefile index 15e7e34e..db4a89b1 100644 --- a/Makefile +++ b/Makefile @@ -5,29 +5,21 @@ LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) .PHONY: build clean docker-build lint test frontend frontend-install frontend-test ui -# frontend-install: ensure the Vite project has its node_modules. -# Idempotent: a no-op when the lockfile + node_modules are in sync. -# Pulled out so `make frontend` can be the build-only target for -# CI runners that pre-cache deps. +# frontend-install: sync frontend/node_modules with the lockfile. +# Separate target so CI runners with pre-cached deps can build only. frontend-install: cd frontend && npm ci --silent -# frontend: produce the production Vite bundle into -# internal/ui/dist/. The Go binary's //go:embed picks it up at -# `go build` time, so the canonical release flow is: -# -# make frontend && make build -# -# Without `make frontend`, `go build` embeds the dev placeholder -# (internal/ui/dist/index.html with DEVKIT_FRONTEND_PLACEHOLDER) -# and `dpm localnet ui` prints a stderr warning at startup. See -# internal/ui/assets.go IsPlaceholderBundle. +# frontend: build the production Vite bundle into internal/ui/dist/, +# which `go build` embeds via //go:embed. Canonical release flow: +# `make frontend && make build`. Without it the binary embeds the dev +# placeholder and `dpm localnet ui` warns at startup (see +# internal/ui/assets.go IsPlaceholderBundle). frontend: frontend-install cd frontend && npm run build -# frontend-test: run the Vitest suite (jsdom + RTL). Fast — no -# Vite bundle, no Go embed step. Wire into CI alongside `make test` -# once the frontend lands on main. +# frontend-test: run the Vitest suite (jsdom + RTL); no Vite bundle +# or Go embed step needed. frontend-test: frontend-install cd frontend && npm test @@ -35,15 +27,10 @@ build: mkdir -p bin go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_NAME) ./cmd/canton-devkit -# ui: convenience target for `dpm localnet ui` development. Builds -# the Vite bundle THEN the Go binary, so the embedded //go:embed -# always reflects current frontend source. Use this instead of -# plain `make build` when you've touched frontend/ and want to -# poke the running UI in a browser. -# -# Plain `make build` is preserved for Go-only contributors who -# don't have node installed — the placeholder warning at startup -# is the intentional signal that they need `make frontend` first. +# ui: build the Vite bundle then the Go binary so the embedded assets +# reflect current frontend source. Plain `make build` stays node-free +# for Go-only contributors — the placeholder warning at startup is the +# signal to run `make frontend` first. ui: frontend build test: diff --git a/assets/assets.go b/assets/assets.go index d8b88832..59631e49 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -3,31 +3,24 @@ // observability overlay (compose file, prometheus.yml, dashboard JSON, // provisioning configs). // -// Why this package lives at the repo root next to `assets/compose/` -// and `assets/grafana/` rather than under `internal/`: +// It lives at the repo root next to `assets/compose/` and +// `assets/grafana/` rather than under `internal/` because Go's +// `//go:embed` directive rejects paths containing `..`: embedded trees +// must live inside (or below) the directory of the .go file declaring +// the directive, so this tiny package's only job is to hold the embed +// and expose the FS. // -// - Go's `//go:embed` directive rejects paths containing `..` — embedded -// trees must live inside (or below) the directory of the .go file -// declaring the directive. The previous home for this embed was -// `internal/localnet/observability_overlay.go` with -// `//go:embed all:../../assets/compose` — that does not compile -// ("invalid pattern syntax"). -// - Co-locating the .go file with the asset trees is the conventional -// workaround: one tiny package whose only job is to hold the embed -// and expose the FS to anyone who needs it. -// -// Consumers import this package and read from [Observability], using -// `fs.WalkDir` to materialize files to a per-instance destination on -// disk. See internal/localnet/observability_overlay.go for the writer. +// Consumers read from [FS] using `fs.WalkDir` to materialize files to +// a per-instance destination on disk. See +// internal/localnet/observability_overlay.go for the writer. package assets import "embed" // FS embeds the compose overlays + Grafana provisioning that the // `localnet up --profile ` overlays materialize into an instance's -// data directory at boot. It is NOT observability-specific — it was -// once named Observability, which misled the tokens-v2 overlay author — -// every profile's compose fragment lives under compose/ in this tree. +// data directory at boot. It is NOT observability-specific: every +// profile's compose fragment lives under compose/ in this tree. // // Tree shape (post-walk): // diff --git a/assets/compose/prometheus.yml b/assets/compose/prometheus.yml index 9141f3f4..1cf1202e 100644 --- a/assets/compose/prometheus.yml +++ b/assets/compose/prometheus.yml @@ -6,12 +6,8 @@ # That file enables the built-in Prometheus reporter on port 10013 # (0.0.0.0:10013/metrics) with JVM metrics, all qualifiers # (errors/latency/saturation/traffic/debug), and 40k cardinality. -# -# We discovered this by inspecting the upstream image — prior -# versions of this comment assumed metrics were OFF in stock Splice; -# they're not. The reporter is ON by default in both images and -# active for the canton and splice JVM services without any extra -# configuration overlay on our side. +# The reporter is therefore ON by default in both images — no extra +# configuration overlay is needed on our side. # # Both `canton` and `splice` containers join the project's default # docker network when the observability overlay is applied, so the diff --git a/assets/compose/shared-prometheus.yml b/assets/compose/shared-prometheus.yml index 7f73d038..2dda3790 100644 --- a/assets/compose/shared-prometheus.yml +++ b/assets/compose/shared-prometheus.yml @@ -1,7 +1,7 @@ # Prometheus scrape config for the SHARED, host-level observability # stack (canton-devkit-observability project). Unlike the per-instance -# observability.yaml — which ran a Prometheus INSIDE each instance's -# network and scraped canton:10013/splice:10013 by service-name DNS — +# observability.yaml — which runs a Prometheus INSIDE each instance's +# network and scrapes canton:10013/splice:10013 by service-name DNS — # this single Prometheus lives in its own project/network and cannot # resolve those service names (and they collide: every instance has a # `canton`). Instead each instance publishes its canton/splice :10013 diff --git a/assets/dashboard_test.go b/assets/dashboard_test.go index 672d3b37..7834222d 100644 --- a/assets/dashboard_test.go +++ b/assets/dashboard_test.go @@ -30,10 +30,10 @@ func TestDashboardJSONIsValid(t *testing.T) { } // TestDashboardHasACSAndThroughputPanels pins the two live-audited -// panels added to satisfy the completeness review. Stock Splice -// 0.6.4 does not expose exact ACS cardinality or template-grain -// submission counters via Prometheus, so these panel titles and -// queries must stay honest about the signals they actually show. +// ACS and throughput panels. Stock Splice 0.6.4 does not expose +// exact ACS cardinality or template-grain submission counters via +// Prometheus, so these panel titles and queries must stay honest +// about the signals they actually show. func TestDashboardHasACSAndThroughputPanels(t *testing.T) { raw, err := FS.ReadFile("grafana/dashboards/canton-localnet.json") if err != nil { diff --git a/e2e-tests/daml-test-contracts/daml/Token.daml b/e2e-tests/daml-test-contracts/daml/Token.daml index b0ec7380..38db82a6 100755 --- a/e2e-tests/daml-test-contracts/daml/Token.daml +++ b/e2e-tests/daml-test-contracts/daml/Token.daml @@ -1,30 +1,12 @@ -- Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -- SPDX-License-Identifier: Apache-2.0 --- A Daml file defines a module. module Token where --- Contract Templates define a type of contract that can exist on the ledger, --- together with its data, and involved parties. - -- | `Token` is a contract that has no state other than its existence and -- who holds the `Token` stored in field `owner`. template Token - -- Each `template` has a `with` block defining the data type of the data - -- stored on an instance of that contract. - -- Blocks are indicated through indentation. The `template` is a block so - -- `with` is indented. The contents of the `with` block are indented further. with - -- `owner` is the only field on an instance of `Token`. It has type - -- `Party`, which is an inbuilt type representing an entity present on - -- the ledger owner : Party - -- Following the `with` block is a `where` block, which gives the contract - -- meaning by defining the roles parties play and how contracts can - -- be transformed. where - -- Every `template` has a `signatory` expression in its `where` block. - -- The `signatory` expression defines one or more _parties_ to be - -- _signatories_. The signatories must authorize the creation of a - -- contract and verify the validity of any action performed on it. signatory owner diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a76b3edb..10d70ac4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,18 +15,9 @@ import { AgentSkillsScreen } from "./screens/AgentSkillsScreen"; import { TokensScreen } from "./screens/TokensScreen"; import { W } from "./tokens"; -// App boots by doing the schema-version handshake against the -// backend. Until the handshake completes (or fails) we render a -// minimal loading panel — we never want to render UI that -// silently mis-decodes a v2 backend. -// -// Handshake outcomes: -// - match: render the shell + routed screens -// - mismatch: refuse to render, tell the user to restart -// - network error: refuse to render, suggest the binary isn't running -// -// All three outcomes show the same loopback-only / dev-binary -// guidance so the user knows what's expected of their host. +// App boots with a schema-version handshake against the backend and +// renders the shell only on a match — a UI bundle must never silently +// mis-decode responses from a backend with a different schema. export function App() { const [status, setStatus] = useState<"loading" | "ready" | "mismatch" | "offline">( "loading", @@ -62,15 +53,9 @@ export function App() { ); } -// RoutedSurface lives inside the Router so it can use -// useLocation() — its pathname becomes the boundary's reset key, -// so a crash in /explorer doesn't follow you to /overview when -// you navigate away. -// -// One boundary per route element (rather than one around all -// Routes) so a crash in /explorer keeps the topbar interactive -// AND keeps the sibling /metrics route renderable when the user -// navigates to it. +// RoutedSurface wraps each route element in its own ErrorBoundary, +// keyed by pathname, so a crash on one screen neither follows the +// user to the next route nor takes down the shell around it. function RoutedSurface() { const loc = useLocation(); return ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c8966a08..29e3cff9 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -57,14 +57,11 @@ export async function apiFetch(path: string, init?: RequestInit): Promise }, }); const text = await resp.text(); - // A proxy or a panicking server can return a non-empty body that - // is NOT our JSON envelope (an HTML 502 page, a plain-text stack - // trace). Parsing must not throw a raw SyntaxError here — that - // would escape every screen's `e instanceof ApiError` branch and - // surface as an opaque "failed to load" with no status/code. Fall - // back to `undefined` so the !resp.ok branch still emits a proper - // ApiError (carrying the HTTP status), and a malformed 2xx body - // surfaces as `undefined` rather than crashing the caller. + // A proxy or panicking server can return a non-JSON body (HTML 502 + // page, plain-text stack trace). A raw SyntaxError here would escape + // every screen's `e instanceof ApiError` branch, so fall back to + // `undefined`: !resp.ok still throws a proper ApiError with the HTTP + // status, and a malformed 2xx decodes to `undefined` without crashing. let body: unknown; try { body = text ? (JSON.parse(text) as unknown) : undefined; @@ -474,43 +471,24 @@ export const fetchDoctor = (version?: string) => : "/api/doctor", ); -// stopInstance invokes POST /api/instances/{name}/down — runs -// `docker compose down` against the named instance, preserving -// Docker volumes and the registry entry (status=stopped). -// Synchronous on the wire (down is fast, ~10-30s on the happy -// path); the call blocks until the server returns 204 or 5xx. -// -// On failure, the server's error envelope includes a one-line -// summary the modal shows to the user; the full output goes to -// the server log. -// snapshot / restore. -// -// downloadSnapshot triggers POST /api/instances/:name/snapshot and -// hands the gzipped tar to the browser via an click. We -// don't use fetch() + Blob here for one reason: a snapshot can be -// 100s of MB, and putting the whole body into JS memory just to hand -// it back to the browser is wasteful. The form-submit trick keeps the -// response entirely in the browser's download pipeline. +// ── snapshot / restore ───────────────────────────────────── + +// downloadSnapshot POSTs to /api/instances/:name/snapshot via a +// hidden-iframe form submit instead of fetch()+Blob: a snapshot can be +// 100s of MB, and the form submit keeps the body entirely in the +// browser's download pipeline instead of JS memory. // -// Error surfacing: on success the server replies with -// Content-Disposition: attachment, so the browser hands the body to -// the download manager and the hidden iframe never navigates — no -// `load` event fires. On failure (instance not found, docker error, -// 5xx) the server replies with an inline JSON error body and NO -// Content-Disposition, so the iframe DOES navigate to it and fires a -// `load` event. We listen for that asymmetry: a `load` on the iframe -// means the download did not happen and an error document was -// rendered instead, so we reject with an ApiError the caller can -// toast. A successful download is detected by absence (a settle -// timeout resolves once the dispatch is clean), since we cannot read -// the cross-document iframe body. +// Error detection relies on an asymmetry: on success the server sends +// Content-Disposition: attachment, the body goes to the download +// manager, and the iframe never navigates (no `load` event). On +// failure the server sends an inline JSON error document, the iframe +// navigates to it, and `load` fires — we can't read the cross-document +// body, so we reject with a generic ApiError. Success is detected by +// absence: a settle timeout resolves once no `load` arrived. // -// Returns a Promise that REJECTS with an ApiError when the server -// returned an error instead of a download, and otherwise resolves -// once the request has been dispatched (not when the download -// completes — the browser owns that). The hidden iframe is reused -// across downloads (one per document), and each call attaches a -// one-shot `load` listener so handlers don't accumulate. +// Resolves once the request is dispatched (the browser owns download +// completion). The iframe is reused across downloads; each call +// attaches a one-shot `load` listener so handlers don't accumulate. const DOWNLOAD_SETTLE_MS = 1200; export function downloadSnapshot(name: string): Promise { @@ -518,10 +496,6 @@ export function downloadSnapshot(name: string): Promise { const form = document.createElement("form"); form.method = "POST"; form.action = `/api/instances/${encodeURIComponent(name)}/snapshot`; - // Hidden iframe target avoids navigating away from the SPA on - // success. Browsers attach the download attribute on the response - // headers (Content-Disposition), so the iframe never actually - // renders anything — the file goes straight to the downloads bar. form.target = "_dpm_dl"; let frame = document.querySelector( 'iframe[name="_dpm_dl"]', @@ -536,13 +510,9 @@ export function downloadSnapshot(name: string): Promise { let settled = false; let settleTimer: ReturnType | undefined; const onLoad = () => { - // The iframe navigated → the server returned an inline - // document (the JSON error body) rather than an attachment. - // A successful download hands the body to the download - // manager and never navigates the iframe, so a `load` here - // means the snapshot did NOT download. We can't read the - // cross-document body safely, so surface a generic-but-honest - // error instead of failing silently. + // Iframe navigated → the server returned an inline error + // document rather than an attachment; the snapshot did NOT + // download. if (settled) return; settled = true; if (settleTimer !== undefined) clearTimeout(settleTimer); @@ -608,7 +578,7 @@ export function restoreSnapshot( if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as RestoreResponse); - } catch (e) { + } catch { reject( new ApiError(xhr.status, { code: "UNKNOWN", @@ -737,11 +707,7 @@ export interface MetricsSummary { }; } -// fetchMetricsSummary returns the headline panel data. The -// caller MUST handle ApiError with body.code === "OBSERVABILITY_PROFILE_OFF" -// to render the "raise observability" empty state — that's not -// a hard failure, just a missing profile. -// DAR Manager. +// ── DAR Manager ───────────────────────────────────────── // // The Web UI lists DARs uploaded to a participant. Role defaults // to app-user (the common dev target). The backend reads the @@ -823,7 +789,7 @@ export function uploadDARs( if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as DARUploadResponse); - } catch (e) { + } catch { reject( new ApiError(xhr.status, { code: "UNKNOWN", @@ -1026,10 +992,8 @@ export function subscribeDARWatch( const ev = JSON.parse(e.data) as DARWatchEvent; onEvent(ev); } catch { - // Drop malformed payloads silently — the backend pins the - // event-enum on the publish side, so a parse failure here - // would indicate a wire-format change worth surfacing in - // the network tab, not the UI. + // Drop malformed payloads — a parse failure indicates a + // wire-format change, visible in the network tab, not the UI. } }); return es; @@ -1263,6 +1227,9 @@ export const fetchTxReplay = ( ); }; +// fetchMetricsSummary returns the headline panel data. Callers MUST +// handle ApiError code "OBSERVABILITY_PROFILE_OFF" as the "raise +// observability" empty state — a missing profile, not a hard failure. export const fetchMetricsSummary = (name: string, signal?: AbortSignal) => apiFetch( `/api/instances/${encodeURIComponent(name)}/metrics/summary`, @@ -1333,6 +1300,10 @@ export const fetchMetricsRange = ( ); }; +// stopInstance invokes POST /api/instances/{name}/down — runs +// `docker compose down`, preserving Docker volumes and the registry +// entry (status=stopped). Synchronous on the wire: blocks until the +// server returns 204 or 5xx. export async function stopInstance(name: string): Promise { const resp = await fetch( `/api/instances/${encodeURIComponent(name)}/down`, @@ -1518,15 +1489,6 @@ interface CancelledEvent { reason?: string; } -// ApiErrorBody is re-exported here because cancelInstanceUp uses -// it directly (it bypasses apiFetch for the raw fetch path). -interface ApiErrorBody { - code: string; - error: string; - detail?: string; - remediation?: string[]; -} - // ── Agent Skills ───────────────────────────────────────── // Mirrors internal/skills.Skill + the /api/skills handler. The same // embedded docs back the CLI `localnet skills` command. diff --git a/frontend/src/screens/AgentSkillsScreen.tsx b/frontend/src/screens/AgentSkillsScreen.tsx index b9036acd..55764a4d 100644 --- a/frontend/src/screens/AgentSkillsScreen.tsx +++ b/frontend/src/screens/AgentSkillsScreen.tsx @@ -7,12 +7,10 @@ import { } from "../api"; import { W, wMono } from "../tokens"; -// AgentSkillsScreen — . -// -// Browses the bundled AI-agent skill docs (served by /api/skills, -// the SAME embedded markdown the CLI `localnet skills` command -// ships) and offers one-click install into ~/.claude/skills or -// ~/.codex/skills. CLI ↔ UI parity: both surfaces read internal/skills. +// AgentSkillsScreen browses the bundled AI-agent skill docs (served by +// /api/skills — the same embedded markdown the CLI `localnet skills` +// command ships) and offers one-click install into ~/.claude/skills or +// ~/.codex/skills. Both surfaces read internal/skills. export function AgentSkillsScreen() { const [state, setState] = useState< | { kind: "loading" } diff --git a/frontend/src/screens/BackupRestore.test.tsx b/frontend/src/screens/BackupRestore.test.tsx index 0b3cf56c..53dfbc23 100644 --- a/frontend/src/screens/BackupRestore.test.tsx +++ b/frontend/src/screens/BackupRestore.test.tsx @@ -76,10 +76,9 @@ describe("BackupRestore card", () => { }); it("surfaces a download-failure banner when the server returns an error instead of a file", async () => { - // Regression: the hidden-iframe download swallowed server errors - // (instance gone, docker failure, 5xx) — the button just flashed - // and the user assumed success. The iframe navigates to the JSON - // error body and fires `load`; the card must show an alert. + // The hidden-iframe download must not swallow server errors + // (instance gone, docker failure, 5xx): the iframe navigates to the + // JSON error body and fires `load`; the card must show an alert. vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation( function (this: HTMLFormElement) { const frame = document.querySelector( @@ -194,10 +193,8 @@ describe("BackupRestore card", () => { }); it("resyncs target name when the parent switches instances", async () => { - // Repro for the user-reported bug: open the UI on "dev", click - // pebble, BackupRestore's targetName state was stuck at "dev" - // because useState only honors its initial value on first mount. - // After the fix, switching instances must update the input. + // useState only honors its initial value on first mount, so + // switching instances must update the input explicitly. const { rerender } = render(); const input = screen.getByLabelText( /restore target instance name/i, diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index a6995485..5f92e988 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -7,34 +7,20 @@ import { } from "../api"; import { W, wMono } from "../tokens"; -// Backup & restore card. -// -// Two actions in one card: -// 1. Download snapshot — POST /api/instances/:name/snapshot; -// browser saves the tar via Content-Disposition. Single click, -// no extra dialog; the server picks a stable filename. -// 2. Restore from snapshot — drag-drop OR file picker, with an +// Backup & restore card. Two actions: +// 1. Download snapshot — POST /api/instances/:name/snapshot; the +// browser saves the tar via Content-Disposition. +// 2. Restore from snapshot — drag-drop or file picker, with an // optional target-name override and a `--force` checkbox for // cross-version restores. // -// The card lives inside InstanceDetail so the "current instance" -// context is already known. Restore-from-here uploads to /restore -// with name=currentInstance by default, but the user can type a -// different name (unblocks the cross-name case properly; -// today it works modulo the volume-rename limitation documented -// in that ticket). -// -// # CLI ↔ UI parity (AGENTS.md) -// -// Mirrors `localnet snapshot --name X --to ` and -// `localnet restore --name X --from [--force]`. Same -// server-side validation, same error taxonomy (the toast text -// comes from the same ErrorCode list). +// CLI ↔ UI parity (CONTRIBUTING.md): mirrors `localnet snapshot --name +// X --to ` and `localnet restore --name X --from +// [--force]` — same server-side validation, same error taxonomy. interface Props { - // The instance this card lives under. Snapshot downloads - // ALWAYS use this name. Restore defaults to this name but lets - // the user override. + // Snapshot downloads always use this name; restore defaults to it + // but lets the user override. instanceName: string; } @@ -53,14 +39,9 @@ export function BackupRestore({ instanceName }: Props) { const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); - // Sync targetName when the user navigates between instances. - // Without this, the card mounted under "dev" keeps the initial - // value forever — clicking into "pebble" still shows "dev" in - // the target-name input. useState only honors its initial value - // on first mount; the parent's prop change must be reflected - // explicitly. Also resets the result banner on switch so a - // success message from a previous restore doesn't bleed across - // instances. + // useState only honors its initial value on first mount, so switching + // instances must resync targetName explicitly — and reset the result + // banner/options so state doesn't bleed across instances. useEffect(() => { setTargetName(instanceName); setRestore({ kind: "idle" }); @@ -69,19 +50,18 @@ export function BackupRestore({ instanceName }: Props) { }, [instanceName]); async function onDownload() { - // The snapshot is application-consistent on both surfaces: the backend - // pauses the instance's node containers for the duration of the dump - // (the same quiesce the CLI does), so there is no crash-consistency - // caveat for this card to surface. + // The snapshot is application-consistent: the backend pauses the + // instance's node containers for the duration of the dump (the same + // quiesce the CLI does), so there is no crash-consistency caveat to + // surface. setDownloading(true); setDownloadError(null); try { await downloadSnapshot(instanceName); } catch (e) { // downloadSnapshot rejects when the server returned an error - // document instead of a file (instance gone, docker failure, - // 5xx). Without surfacing it the button would just flash and - // the user would assume the download succeeded. + // document instead of a file; without surfacing it the button + // would just flash and the user would assume success. setDownloadError( e instanceof ApiError ? e.message : "snapshot download failed", ); @@ -92,10 +72,9 @@ export function BackupRestore({ instanceName }: Props) { async function onFileChosen(file: File | null) { if (!file) return; - // Yellow Y15: client-side size cap. Snapshots can be large but - // 4 GiB is the practical ceiling for an XHR upload (browsers - // buffer the whole body in memory). Refuse client-side rather - // than OOM the tab on a stray drop. + // 4 GiB is the practical ceiling for an XHR upload (browsers buffer + // the whole body in memory) — refuse client-side rather than OOM + // the tab on a stray drop. const MAX_TARBALL_BYTES = 4 * 1024 * 1024 * 1024; if (file.size > MAX_TARBALL_BYTES) { setRestore({ diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index 938f46a7..f08ffc80 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -9,18 +9,12 @@ import { W, wMono } from "../tokens"; import { ContainerLogsModal } from "./ContainerLogsModal"; // ContainerHealth — live per-container status panel. Polls -// /api/instances/{name}/containers every POLL_MS so the user -// sees real-time docker truth (e.g. "canton: Up 27 seconds -// (healthy)" — recently restarted) instead of the coarse -// registry status enum (running / failed / etc). +// /api/instances/{name}/containers every POLL_MS so the user sees +// real-time docker truth ("is canton in a restart loop, or did +// postgres crash?") instead of the coarse registry status enum. // -// Answers the user's question: "is canton in a restart loop, -// or is splice just slow on first init, or did postgres crash?" -// The registry can't tell you any of that — docker can. -// -// Renders nothing when the instance has no docker project -// (returns 503 from the backend) — the InstanceDetail card -// stays usable; the panel just doesn't show. +// Renders nothing when the instance has no docker project (backend +// returns 503) — the InstanceDetail card stays usable. const POLL_MS = 3000; @@ -33,10 +27,8 @@ export function ContainerHealth({ name }: { name: string }) { >({ kind: "loading" }); // Selected container for the logs modal. Null = closed. const [logsOpen, setLogsOpen] = useState(null); - // Tracks which container restart is in flight (one at a time - // is fine — UI disables that row's button + shows spinner). - // Inline string-set so multiple rapid clicks on different - // rows can each show their own pending state. + // Containers with a restart in flight; a Set so rapid clicks on + // different rows each show their own pending state. const [restarting, setRestarting] = useState>(new Set()); const [restartErr, setRestartErr] = useState(null); @@ -48,8 +40,7 @@ export function ContainerHealth({ name }: { name: string }) { setRestartErr(null); try { await restartContainer(name, container); - // Poll loop picks up the new "Up X seconds" automatically; - // no manual refresh needed. + // The poll loop picks up the new status; no manual refresh needed. } catch (e) { setRestartErr( `Restart ${container} failed: ` + @@ -64,8 +55,6 @@ export function ContainerHealth({ name }: { name: string }) { } } - // Poll loop. Restarts when name changes; tears down on - // unmount via the cleanup closure. useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; @@ -205,11 +194,8 @@ function ContainersTable({
); } - // Stable sort: unhealthy/restarting first, then starting, then - // healthy/running. Makes the failure-mode rows pop to the top. - const sorted = [...containers].sort((a, b) => { - return severity(a) - severity(b); - }); + // Failure-mode rows sort to the top. + const sorted = [...containers].sort((a, b) => severity(a) - severity(b)); return (
{ const { color, glyph } = signalFor(c); const onLogs = (e: React.MouseEvent) => { - // Stop propagation so the click that opens the modal can't - // also be interpreted as a backdrop click on the modal's - // overlay (which would close it immediately). + // Don't let the opening click double as a backdrop click on + // the modal overlay (which would close it immediately). e.stopPropagation(); onPickLogs(c.name); }; @@ -250,10 +235,9 @@ function ContainersTable({ onRestart(c.name); }; const isRestarting = restarting.has(c.name); - // display:contents rows can't carry click handlers, so - // each cell gets its own onClick + cursor:pointer. The - // restart button cell stops propagation so clicking the - // button doesn't ALSO open the logs modal. + // display:contents rows can't carry click handlers, so each + // cell gets its own onClick; the restart cell stops propagation + // so the button doesn't also open the logs modal. const cellBase: React.CSSProperties = { cursor: "pointer", padding: "2px 0", @@ -334,8 +318,8 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { ); } -// severity orders rows so failure-mode containers come first. -// Lower number = higher priority (sorts earlier). +// severity orders rows so failure-mode containers come first +// (lower sorts earlier). function severity(c: { state: string; health?: string }): number { if (c.state === "restarting") return 0; if (c.state === "dead" || c.state === "exited") return 1; diff --git a/frontend/src/screens/ContainerLogsModal.tsx b/frontend/src/screens/ContainerLogsModal.tsx index 6c6cd039..fe59a59f 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -3,13 +3,10 @@ import { ApiError, fetchContainerLogs } from "../api"; import { W, wMono, wSans } from "../tokens"; // ContainerLogsModal — opens when the user clicks a row in -// ContainerHealth. Polls docker logs for the selected container -// at LOG_POLL_MS, renders in a terminal-styled
.
-//
-// Tail size + since duration are user-tunable via the toolbar.
-// Auto-scroll-to-bottom is on by default but disabled if the
-// user scrolls up (so manual review of older lines isn't
-// disrupted by the next poll).
+// ContainerHealth. Polls docker logs for the selected container at
+// LOG_POLL_MS and renders them in a terminal-styled 
. Tail size
+// and since duration are toolbar-tunable; auto-scroll-to-bottom is on
+// by default but disabled once the user scrolls up.
 
 const LOG_POLL_MS = 3000;
 
@@ -28,14 +25,12 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
   const [loading, setLoading] = useState(false);
   const preRef = useRef(null);
   const autoScrollRef = useRef(true);
-  // Track whether mousedown started on the overlay itself. Without
-  // this, the click that opened the modal (mousedown on a row cell,
-  // mouseup after the modal mounted) can land on the overlay and
-  // immediately close it. Only close when mousedown AND click both
-  // originated on the overlay.
+  // Only close when both mousedown AND click originated on the overlay;
+  // otherwise the click that opened the modal (mousedown on a row cell,
+  // mouseup after the modal mounted) would immediately close it.
   const downOnOverlayRef = useRef(false);
 
-  // Esc closes — same gate as the other modals.
+  // Esc closes.
   useEffect(() => {
     if (!open) return;
     function onKey(e: KeyboardEvent) {
@@ -45,8 +40,6 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
     return () => window.removeEventListener("keydown", onKey);
   }, [open, onClose]);
 
-  // Poll. Restarts when any input (instance/container/tail/since)
-  // changes; clears on close/unmount.
   useEffect(() => {
     if (!open) return;
     let cancelled = false;
@@ -78,9 +71,8 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
     };
   }, [open, instance, container, tail, since]);
 
-  // Auto-scroll to bottom on body change, unless the user has
-  // scrolled up. Tracking via a ref so we don't add a state
-  // update for every scroll event.
+  // Auto-scroll to bottom on new content unless the user scrolled up;
+  // tracked via a ref to avoid a state update per scroll event.
   useEffect(() => {
     if (!preRef.current || !autoScrollRef.current) return;
     preRef.current.scrollTop = preRef.current.scrollHeight;
diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx
index 1fc01d03..021f28aa 100644
--- a/frontend/src/screens/ContractDetailDrawer.tsx
+++ b/frontend/src/screens/ContractDetailDrawer.tsx
@@ -8,26 +8,17 @@ import {
 } from "../api";
 import { W, wMono } from "../tokens";
 
-// ContractDetailDrawer
+// ContractDetailDrawer slides in from the right of the ACS table when
+// a row is clicked. Fetches the deep view from
+// /api/instances/{name}/contracts/{cid}
+// (EventQueryService.GetEventsByContractId) for the create event's full
+// payload, signatories, observers, and archive metadata. While that
+// loads it shows the row-level ACS fields so the user always has
+// something to read.
 //
-// Slides in from the right of the ACS table when a row is clicked.
-// Fetches the deep view from /api/instances/{name}/contracts/{cid}
-// (EventQueryService.GetEventsByContractId behind the scenes) so the
-// drawer can show the create event's full payload, signatories,
-// observers, and — if the contract has been archived — the archive
-// offset.
-//
-// While the deep view is loading the drawer immediately shows the
-// row-level fields that came from the ACS snapshot so the user
-// always has something to read.
-//
-// Keyboard behaviour (parent wires the handlers, the drawer renders
-// the help line):
-//   - Esc    → close the drawer
-//   - J / K  → next / previous row in the underlying table
-//
-// The parent owns row navigation because it owns the filtered table
-// state. The drawer itself only owns the deep-view fetch lifecycle.
+// Keyboard: Esc closes, J/K move to the next/previous row. The parent
+// owns row navigation because it owns the filtered table state; the
+// drawer only owns the deep-view fetch lifecycle.
 
 export interface ContractDetailDrawerProps {
   instance: string;
@@ -78,11 +69,9 @@ export function ContractDetailDrawer({
     };
   }, [instance, role, row.contract_id]);
 
-  // Keyboard: Esc / J / K. We add to window so the drawer responds
-  // to keystrokes from anywhere on the page, mirroring the
-  // ExplorerScreen's "/" shortcut. INPUT/TEXTAREA/contenteditable
-  // are ignored so users typing in the search box don't trigger
-  // navigation.
+  // Esc / J / K, listened on window so keystrokes work from anywhere
+  // on the page. INPUT/TEXTAREA/contenteditable are ignored so typing
+  // in the search box doesn't trigger navigation.
   useEffect(() => {
     const onKey = (e: KeyboardEvent) => {
       const active = document.activeElement as HTMLElement | null;
@@ -275,7 +264,7 @@ export function ContractDetailDrawer({
         
       )}
       
- +
{detail.created_at && (
@@ -450,15 +439,10 @@ function PartyChip({ party, kind }: { party: string; kind: "sig" | "obs" }) { ); } -// PayloadRenderer — recursive JSON-like view for the contract's -// payload. Renders objects as label : value pairs, arrays as -// bracketed lists, primitives in place. Each level indents by 12 px -// — enough to see structure without burning horizontal space in -// the 380 px drawer. -function PayloadRenderer({ value }: { value: unknown }): JSX.Element { - return ; -} - +// PayloadNode — recursive JSON-like view for the contract payload: +// objects as label:value pairs, arrays as indexed lists, primitives in +// place. Each level indents 12px — enough to see structure without +// burning horizontal space in the 380px drawer. function PayloadNode({ value, depth, diff --git a/frontend/src/screens/CreateLocalNetModal.tsx b/frontend/src/screens/CreateLocalNetModal.tsx index 665fff72..bf948a2f 100644 --- a/frontend/src/screens/CreateLocalNetModal.tsx +++ b/frontend/src/screens/CreateLocalNetModal.tsx @@ -21,28 +21,26 @@ import { useCreateProgress, } from "./useCreateProgress"; -// CreateLocalNetModal — the "Create LocalNet" flow from -// webui-create.jsx. Drives three top-level stages internally: +// CreateLocalNetModal — the "Create LocalNet" flow. Three top-level +// stages: // -// 1. form — empty/validating; name + version + advanced +// 1. form — name + version + advanced options // 2. submitting — POST in flight; brief (<1s) // 3. progress — 202 received; EventSource open; render steps // // "Done" / "failed" / "cancelled" are sub-states of progress — // the modal stays open until the user closes it. // -// Validation: name regex matches internal/registry's RFC 1123 -// DNS-label rule. We pre-validate client-side for snappy feedback; -// the server validates too, so a stale regex here is a UX bug -// not a security one. +// The name regex matches internal/registry's RFC 1123 DNS-label rule. +// Client-side validation is for snappy feedback only; the server +// validates too, so a stale regex here is a UX bug, not a security one. const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; interface Props { open: boolean; onClose: () => void; - // Called on success so the dashboard refreshes its instance - // list and selects the new instance. Optional — most callers - // pass () => sel.refresh(). + // Called on success so the dashboard can refresh its instance list + // and select the new instance. onCreated?: (name: string) => void; } @@ -56,14 +54,12 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { const [name, setName] = useState(""); const [version, setVersion] = useState(""); const [allowUncurated, setAllowUncurated] = useState(false); - // observability: split into two per-component toggles so users can - // enable Prometheus and Grafana independently (e.g. metrics-only - // setups, or Grafana pointed at an external scrape source). - // Default OFF for both — the overlay pulls extra container images - // and adds memory pressure, opt-in is friendlier for "just spin - // one up". When grafana is on but prometheus is off the form - // shows a warning (not a block) so the combination is reachable - // but the empty-dashboard surprise is signposted. + // Prometheus and Grafana are independent toggles (metrics-only + // setups, or Grafana pointed at an external scrape source). Both + // default OFF — the overlay pulls extra images and adds memory + // pressure. Grafana-without-Prometheus shows a warning, not a block, + // so the combination stays reachable but the empty-dashboard surprise + // is signposted. const [prometheus, setPrometheus] = useState(false); const [grafana, setGrafana] = useState(false); // tokensV2: when on, bring-up adds the Token Standard V2 alpha-protocol @@ -77,11 +73,9 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { const [versionsLoading, setVersionsLoading] = useState(false); const [versionsError, setVersionsError] = useState(null); const [stage, setStage] = useState({ kind: "form" }); - // Per-version system-requirements check. "idle" = no version - // picked yet; "loading" = probe in flight; "ok" = host meets - // floor; "blocked" = at least one FAIL — Create button disabled. - // Warnings (WARN-only report) still allow submit; the user sees - // them inline as a heads-up. + // Per-version system-requirements probe. "blocked" (any FAIL) + // disables Create; a WARN-only report still allows submit and renders + // inline as a heads-up. const [preflight, setPreflight] = useState< | { kind: "idle" } | { kind: "loading" } @@ -95,8 +89,7 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { stage.kind === "progress" ? stage.accepted.events_url : null, ); - // Reset every time the modal opens. Stale form values from a - // previous launch are confusing — each open is a fresh form. + // Reset on every open — each open is a fresh form. useEffect(() => { if (open) { setName(""); @@ -108,23 +101,22 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { setPortBase(""); setStage({ kind: "form" }); requestAnimationFrame(() => inputRef.current?.focus()); - // Refresh the version catalogue on open. Cached on the - // server (versions.json is embedded), so this is fast. + // Refresh the version catalogue on open. Server-cached (embedded + // versions.json), so this is fast. setVersionsLoading(true); setVersionsError(null); fetchSpliceVersions() .then((r) => { setVersions(r.versions); - // Pre-select the "latest" entry so the user doesn't - // have to click anything for the common case. + // Pre-select the "latest" entry for the common case. if (!version) { const latest = r.versions.find((v) => v.status === "latest"); if (latest) setVersion(latest.tag); } }) .catch((e) => { - // Distinguish failure from "still loading": a collapsed empty - // state left the picker showing "Loading…" forever on a 5xx. + // Distinguish failure from "still loading" so a 5xx doesn't + // leave the picker on "Loading…" forever. setVersions([]); setVersionsError( e instanceof ApiError ? e.message : "Couldn't load the version catalogue", @@ -136,9 +128,9 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // Escape closes the modal — but only from the form/done/failed - // stages, not mid-submit. Accidentally cancelling a 90-second - // up because of a stray Esc is much worse than the extra click. + // Escape closes — but not mid-submit or while a bring-up is running. + // Accidentally cancelling a 90-second up with a stray Esc is much + // worse than the extra click. useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { @@ -153,17 +145,11 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { return () => window.removeEventListener("keydown", onKey); }, [open, stage, progress.banner.kind, onClose]); - // When the up succeeds, fire onCreated so the dashboard can - // refresh its list and pick up the new instance. The modal - // stays open until the user closes it explicitly — the - // "is ready" banner is the celebratory beat. - // - // Fires AT MOST ONCE per (stage instance) — without this the - // callback gets re-invoked on every render because parents - // typically pass a fresh arrow function as `onCreated`, which - // changes the effect's identity each render and re-triggers - // the body. With `firedRef` we guarantee one call per accepted - // instance, regardless of how the parent typed the callback. + // Fire onCreated when the up succeeds — at most once per accepted + // instance. Parents typically pass a fresh arrow function as + // `onCreated`, which changes the effect's identity every render and + // would re-invoke the callback; firedRef guarantees one call + // regardless of how the parent typed it. const firedRef = useRef(null); useEffect(() => { if ( @@ -175,18 +161,16 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { onCreated?.(stage.accepted.instance); } }, [progress.banner.kind, stage, onCreated]); - // Reset the fired guard whenever the modal closes so a NEW - // open with a NEW create can fire onCreated again. + // Reset the guard on close so a new create can fire onCreated again. useEffect(() => { if (!open) firedRef.current = null; }, [open]); - // Probe system requirements whenever the picked version changes - // (while still on the form stage). Skipped for the uncurated-tag - // bypass since the server doesn't enforce a per-version floor - // for tags not in the catalogue. Race-safe via a cancelled flag — - // a fast-clicker who flips between versions only sees the latest - // result, not whichever subprocess probe finishes last. + // Probe system requirements when the picked version changes (form + // stage only). Skipped for uncurated tags — the server doesn't + // enforce a per-version floor for tags outside the catalogue. The + // cancelled flag keeps only the latest result when the user flips + // versions quickly, not whichever probe finishes last. useEffect(() => { if (!open || stage.kind !== "form") return; if (!version || allowUncurated) { @@ -222,9 +206,8 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (!open) return null; const nameValid = NAME_RE.test(name); - // Gating: name validity AND preflight not blocked. "loading" or - // "err" still allows submit — preflight is advisory in those - // states; the server's own gate is the source of truth. + // "loading"/"err" preflight still allows submit — it's advisory in + // those states; the server's own gate is the source of truth. const preflightBlocks = preflight.kind === "blocked"; const canSubmit = nameValid && stage.kind === "form" && !preflightBlocks; @@ -251,10 +234,9 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { setStage({ kind: "progress", accepted }); } catch (e) { if (e instanceof PreflightFailedError) { - // Server-side gate caught what the inline probe missed - // (race with the user, or first time the version was - // chosen). Drop back to form stage with the report - // populated so the inline panel renders the findings. + // Server-side gate caught what the inline probe missed. Drop + // back to the form with the report populated so the inline + // panel renders the findings. setPreflight({ kind: "blocked", report: e.report }); setStage({ kind: "form" }); return; @@ -272,13 +254,11 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (stage.kind !== "progress") return; try { await cancelInstanceUp(stage.accepted.instance); - // The SSE stream will deliver the kind=cancelled event; - // the reducer flips banner.kind to "cancelled". No local - // state mutation needed. + // The SSE stream delivers kind=cancelled and the reducer flips + // the banner; no local state mutation needed. } catch { - // Cancel-already-finished is a 404 swallowed by the API - // client; other errors are rare enough that an inline - // toast is overkill. + // Cancel-after-finish is a 404 swallowed by the API client; + // other errors are rare enough that an inline toast is overkill. } } @@ -432,15 +412,13 @@ function ModalFooter({ )} {isRunning ? ( - <> - - + ) : stage.kind === "form" ? ( <>
)} @@ -949,133 +907,32 @@ function AcsRow({ ); } -function DetailDrawer({ row }: { row: ContractRow | null }) { - if (!row) { - return ( -
- Select a contract to inspect. -
- ); - } +// EmptyDetailPanel is the right-column placeholder shown until a +// contract is selected; ContractDetailDrawer takes over after that. +function EmptyDetailPanel() { return (
-
-
- active - - visible to {row.signatories.length + row.observers.length} - -
-
- - {row.template_id.split(":").slice(1).join(":")} - - {row.package_name && ( - - {row.package_name} - - )} -
-
- {row.contract_id} -
-
-
-
-          {JSON.stringify(row.payload ?? {}, null, 2)}
-        
-
-
- {row.signatories.length === 0 ? ( - None - ) : ( - row.signatories.map((p) => ( -
- {p} -
- )) - )} -
- {row.observers.length > 0 && ( -
- {row.observers.map((p) => ( -
- {p} -
- ))} -
- )} - {row.created_at && ( -
-
- {row.created_at} -
-
- {ago(row.created_at)} -
-
- )} + Select a contract to inspect.
); } // TransactionsView — table of recent ledger updates (transactions, -// reassignments, topology events) projected from -// UpdateService.GetUpdates. Each transaction row expands inline to -// show its event tree and can be replayed as a per-party visibility -// projection. The filter bar mirrors the CLI `tx ls --party / -// --template / --from / --to`: filters are applied server-side -// over the offset window, so a contract outside the row cap can still -// be found by narrowing the query — not just hidden by a client-side -// filter over an already-truncated snapshot. +// reassignments, topology events). Each row expands inline to show +// its event tree and can be replayed as a per-party visibility +// projection. The filter bar mirrors the CLI `tx ls` flags; filters +// are applied server-side over the offset window, so narrowing the +// query can find contracts beyond the row cap. function TransactionsView({ name, role }: { name: string; role: Role }) { const [state, setState] = useState< | { kind: "loading" } @@ -1087,8 +944,8 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { const [openId, setOpenId] = useState(null); const [replayId, setReplayId] = useState(null); // Draft filter inputs (raw strings) vs the applied filters used in - // the fetch effect. Applying on submit (not keystroke) avoids a - // round-trip per character and a focus-stealing re-render storm. + // the fetch effect; applying on submit avoids a round-trip per + // keystroke. const [draft, setDraft] = useState(emptyDraft); const [applied, setApplied] = useState({}); @@ -1326,9 +1183,8 @@ interface TxFilterDraft { const emptyDraft: TxFilterDraft = { party: "", template: "", from: "", to: "" }; // parseDraft converts the raw inputs into the typed TransactionFilters -// the API client forwards. Blank fields drop out; non-numeric -// from/to are ignored (the input is type=number, so this is belt + -// braces). +// the API client forwards. Blank fields drop out; non-numeric from/to +// are ignored. function parseDraft(d: TxFilterDraft): TransactionFilters { const split = (s: string) => s @@ -1462,7 +1318,6 @@ function TxRowComponent({ onToggle: () => void; onReplay?: () => void; }) { - const kindColor = TX_KIND_COLOR; return ( <>
({ kind: "loading" }); - // Click = persistent selection. Hover = preview (only renders - // detail when nothing is selected). Click again to clear, Esc - // also clears. + // Click = persistent selection; hover = preview when nothing is + // selected. Click again or Esc clears. const [selectedIdx, setSelectedIdx] = useState(null); const [hoverIdx, setHoverIdx] = useState(null); @@ -1718,12 +1571,10 @@ function TimelineView({ name, role }: { name: string; role: Role }) { const txs = state.data.transactions; // Bucket updates into time slots for the strip — newest on the right. const buckets = bucketByTime(txs, 60); - // Selection wins over hover: once a glyph is clicked, the right + // Selection wins over hover: once a glyph is clicked, the side // panel sticks to that update so the user can read the events - // without keeping the cursor over the strip. Hover is preview- - // only when nothing is selected. - const focusedIdx = - selectedIdx !== null ? selectedIdx : hoverIdx; + // without keeping the cursor over the strip. + const focusedIdx = selectedIdx ?? hoverIdx; const focused = focusedIdx !== null ? txs[focusedIdx] ?? null : null; return ( diff --git a/frontend/src/screens/InstanceDetail.tsx b/frontend/src/screens/InstanceDetail.tsx index ab1303b2..abc593cb 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -13,29 +13,20 @@ import { import { W, wMono } from "../tokens"; import { BackupRestore } from "./BackupRestore"; -// InstanceDetail — the per-instance detail card the dashboard -// pops above the Developer setup card when a row is selected. -// -// Surfaces the fields that GET /api/instances/:name returns -// beyond the summary row (compose project, docker network, -// data dir, container prefix, uptime, live-probe state). The -// summary table only carries name/status/version/ports/started -// — the rest is hidden behind this fetch. -// -// Pure-frontend slice: this wires the existing detail endpoint -// into a screen without changing the backend. +// InstanceDetail — the per-instance detail card the dashboard shows +// when a row is selected. Surfaces the fields GET /api/instances/:name +// returns beyond the summary row (compose project, docker network, +// data dir, container prefix, uptime, live-probe state). interface Props { name: string; - // statusHint comes from sel.instances (the always-fresh list) - // and gates which action button renders. Falls back to the - // status in the fetched-instance state if omitted — but the - // dashboard should pass it so the button reflects the latest - // list state immediately after onChanged, not the stale copy - // from this component's own mount-time fetch. + // statusHint comes from the dashboard's always-fresh instance list + // and gates which action button renders. Falls back to this card's + // own fetched status if omitted — but the dashboard should pass it + // so the button reflects the latest list state immediately after + // onChanged, not the stale copy from this card's mount-time fetch. statusHint?: string; - // Optional: refresh the dashboard's instance list after a Stop - // succeeds so the row's status updates (running → stopped) and - // the DeveloperSetup panel hides. + // Refresh the dashboard's instance list after an action succeeds so + // the row's status updates. onChanged?: () => void; } @@ -45,9 +36,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { | { kind: "ok"; instance: Instance } | { kind: "err"; error: string } >({ kind: "loading" }); - // Bumped to force a refetch after a successful Stop/Remove so - // the cached instance.status doesn't lie about the post-action - // state. + // Bumped after an action so the cached instance.status doesn't lie + // about the post-action state. const [refetchTick, setRefetchTick] = useState(0); const [stopping, setStopping] = useState< | { kind: "idle" } @@ -63,9 +53,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { try { await stopInstance(name); setStopping({ kind: "idle" }); - // Bump our own refetch tick so this card's status field - // updates from running → stopped, then notify the parent - // so the dashboard's row + ActionButton catch up too. + // Refetch our own status, then notify the parent so the + // dashboard's row + ActionButton catch up too. setRefetchTick((n) => n + 1); onChanged?.(); } catch (e) { @@ -116,10 +105,9 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { setStopping({ kind: "running" }); try { await recreateInstance(name); - // 202 — recreate is async (down → up). The dashboard's 15s - // poll will pick up the transitional `creating` status when - // the goroutine reaches the up phase; refresh both surfaces - // eagerly so the user sees movement within the next tick. + // 202 — recreate is async (down → up). Refresh both surfaces + // eagerly so the user sees the transitional status before the + // dashboard's next poll. setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -135,10 +123,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { setStopping({ kind: "running" }); try { await resumeInstance(name); - // 202 — bring-up is in progress. The dashboard's 15s - // poll will pick up the running status when the - // reconciler sees it. Refresh both surfaces eagerly so - // the user sees "creating" within the next tick. + // 202 — bring-up is in progress. Refresh both surfaces eagerly + // so the user sees "creating" before the dashboard's next poll. setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -162,9 +148,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { await scrubInstance(name); setStopping({ kind: "idle" }); onChanged?.(); - // No setRefetchTick — the entry is gone, the parent's - // refresh will drop this whole card via sel.selected - // changing or the conditional render hiding it. + // No setRefetchTick — the entry is gone; the parent's refresh + // drops this whole card. } catch (e) { const msg = e instanceof ApiError ? e.message : "failed to remove"; setStopping({ kind: "err", message: msg }); @@ -174,11 +159,9 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { useEffect(() => { let cancelled = false; - // Only show the loading placeholder on a true name-change - // mount, not on a refetchTick bump — the latter is a - // background refresh and the cached data is still valid - // until the new fetch resolves. Without this guard, every - // Stop/Remove would briefly blank the detail card. + // Show the loading placeholder only on a true name-change mount, + // not on a refetchTick bump — without this guard, every action + // would briefly blank the detail card. if (refetchTick === 0) { setState({ kind: "loading" }); } @@ -227,12 +210,9 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { )} - {/* Status source priority: - 1. statusHint from parent (always-fresh sel.instances row) - 2. state.instance.status (this card's own fetch) - This keeps the action button accurate the instant the - dashboard refreshes after Stop, without waiting for - this card's own refetch to settle. */} + {/* Prefer statusHint (parent's fresh list) over this card's + own fetch so the action button updates the instant the + dashboard refreshes. */} {(statusHint || state.kind === "ok") && ( {state.error}
)} {state.kind === "ok" && } - {/* Backup & restore lives inside the detail card so - the instance-name context is implicit. Renders even on - loading/error so the user can still take a snapshot of a - mostly-broken instance for support tickets. */} + {/* Rendered even on loading/error so the user can still take a + snapshot of a mostly-broken instance for support tickets. */} ); } function DetailGrid({ instance }: { instance: Instance }) { - // Field order mirrors the mockup's "About this instance" card: - // identity first, then runtime, then on-disk locations. + // Identity first, then runtime, then on-disk locations. const rows: Array<[string, React.ReactNode]> = [ ["splice", instance.splice_version], ["status", instance.status], @@ -318,24 +295,18 @@ function DetailGrid({ instance }: { instance: Instance }) { } // ActionButton dispatches the right verb(s) per instance status. -// Registry status alone isn't enough — docker truth may diverge -// (the ContainerHealth panel shows this). Specifically: +// Registry status alone isn't enough — docker truth may diverge: // -// - running → Stop (containers are live by definition) -// - failed/partial → Stop + Remove (containers MAY still be up -// — the orchestrator gave up but docker -// compose down is the right cleanup; if no -// project exists docker no-ops cleanly) -// - stopped → Remove only (containers definitely gone) -// - creating → no button (CreatingPanel owns that surface) -// - other → no button (defensive) +// - running/paused → Pause/Resume + Recreate + Stop +// - failed/partial → Recreate + Stop + Remove (containers MAY still +// be up even though the orchestrator gave up; +// compose down no-ops cleanly if not) +// - stopped → Start + Remove +// - creating/other → no button (CreatingPanel owns that surface) // -// The Stop variant on failed/partial is labeled "Stop containers" -// (distinct from "Stop" on running) so the user knows it's a -// force-cleanup rather than a graceful shutdown of a healthy -// instance. The wording difference also matters because docker -// compose down with --volumes is destructive — surface it -// explicitly when the registry's been lying. +// The failed/partial Stop is labeled "Stop containers" (distinct from +// "Stop" on running) to signal a force-cleanup rather than a graceful +// shutdown of a healthy instance. function ActionButton({ status, busy, @@ -355,12 +326,6 @@ function ActionButton({ onRemove: () => void; onRecreate: () => void; }) { - // Recreate is offered alongside the existing controls on every - // non-transitional status: running, paused, failed, partial. The - // `creating` and `stopping` statuses are in-flight transitions - // where ActionButton renders nothing (the CreatingPanel and the - // disabled-by-busy guard cover those), so the Recreate button is - // implicitly hidden during those phases. if (status === "running") { return (
@@ -422,11 +387,9 @@ function ActionButton({ ); } if (status === "failed" || status === "partial") { - // Both Stop and Remove — docker may still have live containers - // even though the registry gave up. Recreate is also offered: - // failed/partial often comes from a transient compose hiccup - // that a clean down + up sequence resolves without losing the - // instance metadata. + // Recreate is offered because failed/partial often comes from a + // transient compose hiccup that a clean down + up resolves + // without losing the instance metadata. return (
+ +
+ +
+

E2E Test Plan — Milestone 1: LocalNet Management CLI 18 Tests

+
+ Proposal: original-devkit-proposal.md, Milestone 1 + Delivery: Month 3 + Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) +
+
+
+
0 / 0 steps completed
+
+
+ + +
+ Scope. 18 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, down, restart, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised. +
+ + +

Conventions & Environment Setup

+ +
+

CLI modes: Commands use $CLI as a placeholder. Set to dpm localnet (DPM component) or canton-devkit localnet (standalone). Run the full suite once per mode.

+
    +
  • Exit code 0 = success. Non-zero = failure (specific codes noted where relevant).
  • +
  • Output verification uses grep -qE patterns. A step passes if the grep matches.
  • +
  • $PLATFORM is one of macos, linux, windows.
  • +
  • Default step timeout: 30 seconds unless noted.
  • +
+
+ +
# Set CLI mode (run full suite twice -- once per mode)
+export CLI="dpm localnet"       # DPM component mode
+# OR
+export CLI="canton-devkit localnet"  # standalone mode
+
+# Ensure Docker is running
+docker info > /dev/null 2>&1 || { echo "FAIL: Docker not running"; exit 1; }
+
+# Ensure clean state before test suite
+$CLI clean --name e2e-test-default --force 2>/dev/null || true
+$CLI clean --name e2e-test-a --force 2>/dev/null || true
+$CLI clean --name e2e-test-b --force 2>/dev/null || true
+ + +

Test Cases

+ + +
+ + + M1-INST-001 + Install via DPM component + Installation + +
+
+ Preconditions: DPM CLI installed, network access to OCI registry + Platforms: All +
+ +
+ +
+

Step 1. Install the DevKit DPM component:

+
dpm install package canton-devkit
+
Expected: Exit code 0.
+

Verify dpm localnet --help exits 0 and output matches:

+
dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
+
+ +
+ +
+

Step 2. Confirm the localnet top-level command is registered:

+
dpm --help 2>&1 | grep -qE "localnet"
+
Expected: Match found (exit 0).
+
+
+ +
Cleanup: None.
+
+
+ + +
+ + + M1-INST-002 + Install standalone binary + Installation + +
+
+ Preconditions: Network access to GitHub Releases + Platforms: All (platform-specific binary) +
+ +
+ +
+

Step 1. Download the correct binary for the current platform:

+
# macOS (Apple Silicon)
+curl -L -o canton-devkit https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-darwin-arm64
+chmod +x canton-devkit
+
+# Linux (amd64)
+curl -L -o canton-devkit https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-linux-amd64
+chmod +x canton-devkit
+
+# Windows (amd64) -- PowerShell
+# Invoke-WebRequest -Uri https://github.com/<org>/canton-devkit/releases/latest/download/canton-devkit-windows-amd64.exe -OutFile canton-devkit.exe
+
Expected: File downloaded, non-zero size.
+
+
+ +
+ +
+

Step 2. Verify the binary runs:

+
./canton-devkit localnet --help
+
Expected: Exit code 0, output matches:
+
./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
+
+ +
+ +
+

Step 3. Verify checksum (if published):

+
curl -L -o checksums.txt https://github.com/<org>/canton-devkit/releases/latest/download/checksums.txt
+sha256sum -c checksums.txt 2>&1 | grep -qE "canton-devkit.*OK"
+
Expected: Checksum matches.
+
+
+ +
Cleanup: rm -f canton-devkit checksums.txt
+
+
+ + +
+ + + M1-INST-003 + Verify binary on all platforms + Installation + +
+
+ Preconditions: Binary installed (M1-INST-001 or M1-INST-002) + Platforms: All (run once per platform) +
+ +
+ +
+

Step 1. Check version output:

+
$CLI --version
+
Expected: Exit code 0, output matches a semver pattern:
+
$CLI --version 2>&1 | grep -qE "[0-9]+\.[0-9]+\.[0-9]+"
+
+
+ +
+ +
+

Step 2. Check help output includes all Milestone 1 commands:

+
$CLI --help 2>&1 | grep -qE "up"
+$CLI --help 2>&1 | grep -qE "down"
+$CLI --help 2>&1 | grep -qE "restart"
+$CLI --help 2>&1 | grep -qE "clean"
+$CLI --help 2>&1 | grep -qE "status"
+$CLI --help 2>&1 | grep -qE "logs"
+$CLI --help 2>&1 | grep -qE "snapshot"
+$CLI --help 2>&1 | grep -qE "restore"
+$CLI --help 2>&1 | grep -qE "doctor"
+
Expected: All grep commands exit 0.
+
+
+ +
+ +
+

Step 3. Verify no runtime dependencies required (no Go, Node, Python, Rust):

+
# Binary should be statically linked / self-contained
+file $(which canton-devkit) 2>/dev/null || file $(which dpm) 2>/dev/null
+
Expected: Output indicates a compiled binary (e.g., "Mach-O", "ELF", "PE32").
+
+
+ +
Cleanup: None.
+
+
+ + +
+ + + M1-DOC-001 + Doctor — all checks pass + Preflight + +
+
+ Preconditions: Docker running, Compose v2 available, sufficient resources + Platforms: All +
+ +
+ +
+

Step 1. Run doctor:

+
$CLI doctor
+
Expected: Exit code 0.
+

Verify output includes pass indicators for all checks:

+
$CLI doctor 2>&1 | grep -qiE "(docker cli|docker daemon|compose v2|ports|disk|memory)"
+

Verify no failures reported:

+
$CLI doctor 2>&1 | grep -qiE "(fail|error|missing)" && echo "FAIL: doctor reports issues" || echo "PASS"
+
+
+ +
Cleanup: None.
+
+
+ + +
+ + + M1-DOC-002 + Doctor — Docker not installed + Preflight + +
+
+ Preconditions: Docker CLI removed from PATH or Docker daemon stopped + Platforms: All +
+ +
+ +
+

Step 1. Temporarily hide Docker from PATH:

+
PATH_BACKUP="$PATH"
+export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v docker | tr '\n' ':')
+
+
+ +
+ +
+

Step 2. Run doctor:

+
$CLI doctor
+
Expected: Non-zero exit code.
+

Verify remediation instructions in output:

+
$CLI doctor 2>&1 | grep -qiE "(install docker|docker not found|docker desktop)"
+
+
+ +
+ +
+

Step 3. Restore PATH:

+
export PATH="$PATH_BACKUP"
+
+
+ +
Cleanup: PATH restored in step 3.
+
+
+ + +
+ + + M1-DOC-003 + Doctor — insufficient resources + Preflight + +
+
+ Preconditions: Docker running but with known resource constraints (e.g., low memory limit on Docker Desktop) + Platforms: macOS, Windows (Docker Desktop with configurable resource limits) +
+ +
+ +
+

Step 1. Run doctor with constrained Docker resources:

+
$CLI doctor
+
Expected: Non-zero exit code OR exit 0 with warnings.
+

Verify resource warnings in output:

+
$CLI doctor 2>&1 | grep -qiE "(memory|disk|insufficient|warning)"
+
+
+ +
+

Note: This test may require manual Docker Desktop resource configuration. On Linux with native Docker, simulate by setting --memory limits on the daemon. If the environment has sufficient resources, verify that doctor reports adequate resources instead.

+
+ +
Cleanup: Restore Docker resource settings to original values.
+
+
+ + +
+ + + M1-UP-001 + LocalNet up (default) + Lifecycle + +
+
+ Preconditions: Docker running, no existing LocalNet named e2e-test-default + Platforms: All + Timeout: 300s +
+ +
+ +
+

Step 1. Start a default LocalNet:

+
$CLI up --name e2e-test-default
+
Expected: Exit code 0.
+

Verify endpoints printed:

+
$CLI up --name e2e-test-default 2>&1 | grep -qiE "(endpoint|port|url|ledger|json.api)"
+

Verify readiness wait completed:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
+ +
+

Step 2. Verify Docker resources are labeled correctly:

+
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default"
+
Expected: At least one container matches.
+
+
+ +
+ +
+

Step 3. Verify deterministic Docker Compose project name:

+
docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-default"
+
Expected: Project listed.
+
+
+ +
Cleanup: $CLI down --name e2e-test-default
+
+
+ + +
+ + + M1-UP-002 + LocalNet up with --name + Lifecycle + +
+
+ Preconditions: Docker running + Platforms: All + Timeout: 300s +
+ +
+ +
+

Step 1. Start a named LocalNet:

+
$CLI up --name e2e-named-test
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 2. Verify the instance uses the specified name:

+
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-named-test"
+
Expected: Match found.
+
+
+ +
+ +
+

Step 3. Verify status references the correct name:

+
$CLI status --name e2e-named-test 2>&1 | grep -qiE "e2e-named-test"
+
Expected: Exit code 0, name appears in output.
+
+
+ +
Cleanup: $CLI down --name e2e-named-test && $CLI clean --name e2e-named-test --force
+
+
+ + +
+ + + M1-UP-003 + LocalNet up with --version + Lifecycle + +
+
+ Preconditions: Docker running, known valid Splice version from compatibility matrix + Platforms: All + Timeout: 300s +
+ +
+ +
+

Step 1. Start a LocalNet with explicit version:

+
SPLICE_VERSION="<known-valid-version>"  # from compatibility matrix
+$CLI up --name e2e-version-test --version "$SPLICE_VERSION"
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 2. Verify the selected version is reflected in status:

+
$CLI status --name e2e-version-test 2>&1 | grep -qE "$SPLICE_VERSION"
+
Expected: Version string appears in output.
+
+
+ +
+ +
+

Step 3. Test with invalid version:

+
$CLI up --name e2e-bad-version --version "0.0.0-nonexistent"
+
Expected: Non-zero exit code, error message about invalid/unavailable version.
+
+
+ +
Cleanup: $CLI down --name e2e-version-test && $CLI clean --name e2e-version-test --force
+
+
+ + +
+ + + M1-STS-001 + Status shows healthy services + Status + +
+
+ Preconditions: LocalNet e2e-test-default running (depends on M1-UP-001 setup) + Platforms: All +
+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Check status:

+
$CLI status --name e2e-test-default
+
Expected: Exit code 0.
+

Verify output includes required information:

+
OUTPUT=$($CLI status --name e2e-test-default 2>&1)
+echo "$OUTPUT" | grep -qiE "(healthy|running|ready)"          # service health
+echo "$OUTPUT" | grep -qiE "(port|endpoint)"                   # ports/endpoints
+echo "$OUTPUT" | grep -qiE "(participant)"                     # participant readiness
+echo "$OUTPUT" | grep -qiE "(version|splice)"                  # selected version
+
+
+ +
+ +
+

Step 3. Check status for non-existent LocalNet:

+
$CLI status --name nonexistent-localnet-xyz
+
Expected: Non-zero exit code, clear error message.
+
+
+ +
Cleanup: $CLI down --name e2e-test-default
+
+
+ + +
+ + + M1-LOG-001 + Logs — full and service-filtered + Logs + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All +
+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Tail full logs (non-blocking with timeout):

+
timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -50
+
Expected: Output is non-empty (logs are streaming).
+

Verify:

+
timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -5 | wc -l | grep -qE "[1-9]"
+
+
+ +
+ +
+

Step 3. Tail logs for a specific service:

+
timeout 10 $CLI logs participant --name e2e-test-default 2>&1 | head -20
+
Expected: Output is non-empty, logs come from the specified service only.
+
+
+ +
+ +
+

Step 4. Tail logs for non-existent service:

+
$CLI logs nonexistent-service --name e2e-test-default
+
Expected: Non-zero exit code or clear error message about unknown service.
+
+
+ +
Cleanup: $CLI down --name e2e-test-default
+
+
+ + +
+ + + M1-RST-001 + Restart full + single service + Lifecycle + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All + Timeout: 300s +
+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Restart the full LocalNet:

+
$CLI restart --name e2e-test-default
+
Expected: Exit code 0.
+

Verify readiness after restart:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
+ +
+

Step 3. Restart a single service:

+
$CLI restart participant --name e2e-test-default
+
Expected: Exit code 0.
+

Verify the restarted service is healthy:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
Cleanup: $CLI down --name e2e-test-default
+
+
+ + +
+ + + M1-DWN-001 + Down stops instance cleanly + Lifecycle + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All +
+ +
+ +
+

Step 1. Start LocalNet:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Verify it is running:

+
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default"
+
+
+ +
+ +
+

Step 3. Stop it:

+
$CLI down --name e2e-test-default
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 4. Verify containers stopped:

+
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS"
+
+
+ +
+ +
+

Step 5. Verify unrelated Docker resources are not affected:

+
# If other non-DevKit containers were running before, they should still be running
+docker ps --format '{{.Names}}' | grep -v "canton-devkit" | wc -l
+
Expected: Count unchanged from before test.
+
+
+ +
Cleanup: $CLI clean --name e2e-test-default --force 2>/dev/null || true
+
+
+ + +
+ + + M1-CLN-001 + Clean removes all resources + Lifecycle + +
+
+ Preconditions: LocalNet e2e-test-default has been started and stopped + Platforms: All +
+ +
+ +
+

Step 1. Start and stop a LocalNet:

+
$CLI up --name e2e-test-default
+$CLI down --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Verify resources exist (volumes, networks):

+
docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default"
+
Expected: Volumes exist from the stopped instance.
+
+
+ +
+ +
+

Step 3. Clean the instance:

+
$CLI clean --name e2e-test-default
+
Expected: Exit code 0. May prompt for confirmation (use --force if non-interactive).
+

If confirmation is required:

+
echo "y" | $CLI clean --name e2e-test-default
+# OR
+$CLI clean --name e2e-test-default --force
+
+
+ +
+ +
+

Step 4. Verify all DevKit-managed resources removed:

+
docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: volumes remain" || echo "PASS"
+docker network ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: networks remain" || echo "PASS"
+docker ps -a --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers remain" || echo "PASS"
+
+
+ +
Cleanup: None (test is self-cleaning).
+
+
+ + +
+ + + M1-SNP-001 + Snapshot and restore + State + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All + Timeout: 300s +
+ +
+ +
+

Step 1. Start LocalNet and wait for readiness:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Create a snapshot:

+
$CLI snapshot --name e2e-test-default
+
Expected: Exit code 0.
+

Verify snapshot reference is output:

+
$CLI snapshot --name e2e-test-default 2>&1 | grep -qiE "(snapshot|saved|created)"
+
+
+ +
+ +
+

Step 3. Stop and clean the LocalNet:

+
$CLI down --name e2e-test-default
+$CLI clean --name e2e-test-default --force
+
+
+ +
+ +
+

Step 4. Restore from snapshot:

+
$CLI restore --name e2e-test-default
+
Expected: Exit code 0.
+

Verify LocalNet is running and healthy after restore:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
Cleanup: $CLI down --name e2e-test-default && $CLI clean --name e2e-test-default --force
+
+
+ + +
+ + + M1-ISO-001 + Two named instances, non-conflicting ports + Isolation + +
+
+ Preconditions: Docker running, sufficient resources for two LocalNets + Platforms: All + Timeout: 600s +
+ +
+ +
+

Step 1. Start first instance with explicit ports:

+
$CLI up --name e2e-test-a
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 2. Start second instance with non-conflicting ports:

+
$CLI up --name e2e-test-b
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 3. Verify both instances are running and isolated:

+
$CLI status --name e2e-test-a 2>&1 | grep -qiE "(healthy|ready|running)"
+$CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
+ +
+

Step 4. Verify port isolation (no port conflicts):

+
PORTS_A=$($CLI status --name e2e-test-a 2>&1 | grep -oE "[0-9]{4,5}" | sort)
+PORTS_B=$($CLI status --name e2e-test-b 2>&1 | grep -oE "[0-9]{4,5}" | sort)
+OVERLAP=$(comm -12 <(echo "$PORTS_A") <(echo "$PORTS_B"))
+[ -z "$OVERLAP" ] && echo "PASS: no port overlap" || echo "FAIL: overlapping ports: $OVERLAP"
+
+
+ +
+ +
+

Step 5. Verify Docker resource isolation (separate project names):

+
docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-a"
+docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-b"
+
+
+ +
+ +
+

Step 6. Stop one instance and verify the other is unaffected:

+
$CLI down --name e2e-test-a
+$CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)"
+
Expected: Instance B still healthy.
+
+
+ +
Cleanup: +
$CLI down --name e2e-test-a 2>/dev/null || true
+$CLI down --name e2e-test-b 2>/dev/null || true
+$CLI clean --name e2e-test-a --force 2>/dev/null || true
+$CLI clean --name e2e-test-b --force 2>/dev/null || true
+
+
+
+ + +
+ + + M1-ENV-001 + Env export outputs valid config + Automation + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All +
+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Export environment:

+
$CLI env --name e2e-test-default
+
Expected: Exit code 0.
+

Verify .env-style output:

+
OUTPUT=$($CLI env --name e2e-test-default 2>&1)
+echo "$OUTPUT" | grep -qE "^[A-Z_]+=.+"                        # KEY=value format
+echo "$OUTPUT" | grep -qiE "(LEDGER|JSON.API|ADMIN|PARTICIPANT)" # expected keys
+
+
+ +
+ +
+

Step 3. Verify exported values are usable (source and test a variable):

+
eval "$($CLI env --name e2e-test-default)"
+# Verify at least one URL/port is reachable
+curl -sf "http://${LEDGER_API_HOST:-localhost}:${LEDGER_API_PORT:-6865}/health" > /dev/null 2>&1 || \
+curl -sf "http://${JSON_API_HOST:-localhost}:${JSON_API_PORT:-7575}/health" > /dev/null 2>&1 || \
+echo "WARN: Could not reach exported endpoints (may require different health check path)"
+
+
+ +
Cleanup: $CLI down --name e2e-test-default
+
+
+ + +
+ + + M1-LST-001 + List discovers running instances + Automation + +
+
+ Preconditions: At least one named LocalNet running + Platforms: All +
+ +
+ +
+

Step 1. Start two instances:

+
$CLI up --name e2e-test-a
+$CLI up --name e2e-test-b
+
+
+ +
+ +
+

Step 2. List instances:

+
$CLI list
+
Expected: Exit code 0.
+

Verify both instances appear:

+
OUTPUT=$($CLI list 2>&1)
+echo "$OUTPUT" | grep -qE "e2e-test-a"
+echo "$OUTPUT" | grep -qE "e2e-test-b"
+
+
+ +
+ +
+

Step 3. Stop one instance and re-list:

+
$CLI down --name e2e-test-a
+OUTPUT=$($CLI list 2>&1)
+echo "$OUTPUT" | grep -qE "e2e-test-b"
+
Expected: Instance B still listed, instance A either removed or shown as stopped.
+
+
+ +
+ +
+

Step 4. Verify no non-DevKit containers appear in the list:

+
$CLI list 2>&1 | grep -qiE "(canton-devkit|localnet|e2e-test)" || echo "WARN: list output format unclear"
+
+
+ +
Cleanup: +
$CLI down --name e2e-test-a 2>/dev/null || true
+$CLI down --name e2e-test-b 2>/dev/null || true
+$CLI clean --name e2e-test-a --force 2>/dev/null || true
+$CLI clean --name e2e-test-b --force 2>/dev/null || true
+
+
+
+ + + +

Exit Code Contract

+
+ + + + + + + + + + + +
Exit CodeMeaning
0Success
1General error
2Docker not available or preflight check failed
3LocalNet instance not found
4Port conflict
5Resource insufficient (memory/disk)
Non-zeroAny failure (agent should capture stderr for diagnostics)
+
+

Exact exit codes are subject to implementation. The key contract is: 0 = success, non-zero = failure with diagnostic output on stderr.

+ +

Cross-Platform Notes

+
+ + + + + + + +
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. file command shows "Mach-O 64-bit executable arm64". Ports bind to localhost by default.
Linux (amd64)Native Docker or Docker Desktop. Doctor should check Linux Docker permissions (user in docker group or rootless Docker). file command shows "ELF 64-bit LSB executable, x86-64".
Windows (amd64)Docker Desktop with WSL 2 backend. Commands use PowerShell or WSL. file equivalent: Get-Command canton-devkit.exe. Path separators differ.
+
+ +

Test Execution Summary

+
+ + + + + + + + + + + + + + + + + + + + + + +
IDTest NameCategoryDepends On
M1-INST-001Install via DPM componentInstallation
M1-INST-002Install standalone binaryInstallation
M1-INST-003Verify binary on all platformsInstallationM1-INST-001 or M1-INST-002
M1-DOC-001Doctor — all checks passPreflightM1-INST-003
M1-DOC-002Doctor — Docker not installedPreflightM1-INST-003
M1-DOC-003Doctor — insufficient resourcesPreflightM1-INST-003
M1-UP-001LocalNet up (default)LifecycleM1-DOC-001
M1-UP-002LocalNet up with --nameLifecycleM1-DOC-001
M1-UP-003LocalNet up with --versionLifecycleM1-DOC-001
M1-STS-001Status shows healthy servicesStatusM1-UP-001
M1-LOG-001Logs — full and service-filteredLogsM1-UP-001
M1-RST-001Restart full + single serviceLifecycleM1-UP-001
M1-DWN-001Down stops instance cleanlyLifecycleM1-UP-001
M1-CLN-001Clean removes all resourcesLifecycleM1-DWN-001
M1-SNP-001Snapshot and restoreStateM1-UP-001
M1-ISO-001Two named instancesIsolationM1-DOC-001
M1-ENV-001Env export outputs valid configAutomationM1-UP-001
M1-LST-001List discovers running instancesAutomationM1-UP-001
+
+ +
+ +
+ + + + + diff --git a/docs/tests/e2e-test-milestone-1.md b/docs/tests/e2e-test-milestone-1.md new file mode 100644 index 00000000..e535ff65 --- /dev/null +++ b/docs/tests/e2e-test-milestone-1.md @@ -0,0 +1,843 @@ +# E2E Test Plan — Milestone 1: LocalNet Management CLI + +> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 1 (Lines 230–247) +> **Estimated Delivery:** Month 3 +> **Total Tests:** 18 +> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) + +--- + +## Overview + +This test plan validates the core LocalNet lifecycle management CLI delivered in Milestone 1. Every test is designed for mechanical execution by an AI agent or CI pipeline. + +### Conventions + +- Commands are shown in both forms: `dpm localnet ...` (DPM component) and `canton-devkit localnet ...` (standalone). Both must be tested. +- `$CLI` is used as a placeholder — set it to either `dpm localnet` or `canton-devkit localnet` before running. +- Exit code `0` = success. Non-zero = failure (specific codes noted where relevant). +- Output verification uses `grep -qE` patterns. A test step passes if the grep matches. +- `$PLATFORM` is one of `macos`, `linux`, `windows`. +- Timeouts are specified per-step where relevant. Default step timeout: 30 seconds unless noted. + +### Environment Setup + +```bash +# Set CLI mode (run full suite twice — once per mode) +export CLI="dpm localnet" # DPM component mode +# OR +export CLI="canton-devkit localnet" # standalone mode + +# Ensure Docker is running +docker info > /dev/null 2>&1 || { echo "FAIL: Docker not running"; exit 1; } + +# Ensure clean state before test suite +$CLI clean --name e2e-test-default --force 2>/dev/null || true +$CLI clean --name e2e-test-a --force 2>/dev/null || true +$CLI clean --name e2e-test-b --force 2>/dev/null || true +``` + +--- + +## Test Cases + +--- + +### M1-INST-001: Install via DPM component + +**Preconditions:** DPM CLI installed, network access to OCI registry. +**Platforms:** All + +**Steps:** + +1. Install the DevKit DPM component: + ```bash + dpm install package canton-devkit + ``` + - **Expected:** Exit code `0`. + - **Verify:** `dpm localnet --help` exits `0` and output matches: + ```bash + dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)" + ``` + +2. Confirm the `localnet` top-level command is registered: + ```bash + dpm --help 2>&1 | grep -qE "localnet" + ``` + - **Expected:** Match found (exit `0`). + +**Cleanup:** None. + +--- + +### M1-INST-002: Install standalone binary + +**Preconditions:** Network access to GitHub Releases. +**Platforms:** All (platform-specific binary) + +**Steps:** + +1. Download the correct binary for the current platform: + ```bash + # macOS (Apple Silicon) + curl -L -o canton-devkit https://github.com//canton-devkit/releases/latest/download/canton-devkit-darwin-arm64 + chmod +x canton-devkit + + # Linux (amd64) + curl -L -o canton-devkit https://github.com//canton-devkit/releases/latest/download/canton-devkit-linux-amd64 + chmod +x canton-devkit + + # Windows (amd64) — PowerShell + # Invoke-WebRequest -Uri https://github.com//canton-devkit/releases/latest/download/canton-devkit-windows-amd64.exe -OutFile canton-devkit.exe + ``` + - **Expected:** File downloaded, non-zero size. + +2. Verify the binary runs: + ```bash + ./canton-devkit localnet --help + ``` + - **Expected:** Exit code `0`, output matches: + ```bash + ./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)" + ``` + +3. Verify checksum (if published): + ```bash + curl -L -o checksums.txt https://github.com//canton-devkit/releases/latest/download/checksums.txt + sha256sum -c checksums.txt 2>&1 | grep -qE "canton-devkit.*OK" + ``` + - **Expected:** Checksum matches. + +**Cleanup:** `rm -f canton-devkit checksums.txt` + +--- + +### M1-INST-003: Verify binary on all platforms + +**Preconditions:** Binary installed (M1-INST-001 or M1-INST-002). +**Platforms:** All (run once per platform) + +**Steps:** + +1. Check version output: + ```bash + $CLI --version + ``` + - **Expected:** Exit code `0`, output matches a semver pattern: + ```bash + $CLI --version 2>&1 | grep -qE "[0-9]+\.[0-9]+\.[0-9]+" + ``` + +2. Check help output includes all Milestone 1 commands: + ```bash + $CLI --help 2>&1 | grep -qE "up" + $CLI --help 2>&1 | grep -qE "down" + $CLI --help 2>&1 | grep -qE "restart" + $CLI --help 2>&1 | grep -qE "clean" + $CLI --help 2>&1 | grep -qE "status" + $CLI --help 2>&1 | grep -qE "logs" + $CLI --help 2>&1 | grep -qE "snapshot" + $CLI --help 2>&1 | grep -qE "restore" + $CLI --help 2>&1 | grep -qE "doctor" + ``` + - **Expected:** All grep commands exit `0`. + +3. Verify no runtime dependencies required (no Go, Node, Python, Rust): + ```bash + # Binary should be statically linked / self-contained + file $(which canton-devkit) 2>/dev/null || file $(which dpm) 2>/dev/null + ``` + - **Expected:** Output indicates a compiled binary (e.g., "Mach-O", "ELF", "PE32"). + +**Cleanup:** None. + +--- + +### M1-DOC-001: Doctor — all checks pass + +**Preconditions:** Docker running, Compose v2 available, sufficient resources. +**Platforms:** All + +**Steps:** + +1. Run doctor: + ```bash + $CLI doctor + ``` + - **Expected:** Exit code `0`. + - **Verify output includes pass indicators for all checks:** + ```bash + $CLI doctor 2>&1 | grep -qiE "(docker cli|docker daemon|compose v2|ports|disk|memory)" + ``` + - **Verify no failures reported:** + ```bash + $CLI doctor 2>&1 | grep -qiE "(fail|error|missing)" && echo "FAIL: doctor reports issues" || echo "PASS" + ``` + +**Cleanup:** None. + +--- + +### M1-DOC-002: Doctor — Docker not installed + +**Preconditions:** Docker CLI removed from PATH or Docker daemon stopped. +**Platforms:** All + +**Steps:** + +1. Temporarily hide Docker from PATH: + ```bash + PATH_BACKUP="$PATH" + export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v docker | tr '\n' ':') + ``` + +2. Run doctor: + ```bash + $CLI doctor + ``` + - **Expected:** Non-zero exit code. + - **Verify remediation instructions in output:** + ```bash + $CLI doctor 2>&1 | grep -qiE "(install docker|docker not found|docker desktop)" + ``` + +3. Restore PATH: + ```bash + export PATH="$PATH_BACKUP" + ``` + +**Cleanup:** PATH restored in step 3. + +--- + +### M1-DOC-003: Doctor — insufficient resources + +**Preconditions:** Docker running but with known resource constraints (e.g., low memory limit on Docker Desktop). +**Platforms:** macOS, Windows (Docker Desktop with configurable resource limits) + +**Steps:** + +1. Run doctor with constrained Docker resources: + ```bash + $CLI doctor + ``` + - **Expected:** Non-zero exit code OR exit `0` with warnings. + - **Verify resource warnings in output:** + ```bash + $CLI doctor 2>&1 | grep -qiE "(memory|disk|insufficient|warning)" + ``` + +**Note:** This test may require manual Docker Desktop resource configuration. On Linux with native Docker, simulate by setting `--memory` limits on the daemon. If the environment has sufficient resources, verify that doctor reports adequate resources instead. + +**Cleanup:** Restore Docker resource settings to original values. + +--- + +### M1-UP-001: LocalNet up (default) + +**Preconditions:** Docker running, no existing LocalNet named `e2e-test-default`. +**Platforms:** All +**Timeout:** 300 seconds (5 minutes for full startup + readiness) + +**Steps:** + +1. Start a default LocalNet: + ```bash + $CLI up --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify endpoints printed:** + ```bash + $CLI up --name e2e-test-default 2>&1 | grep -qiE "(endpoint|port|url|ledger|json.api)" + ``` + - **Verify readiness wait completed (command did not return until services ready):** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +2. Verify Docker resources are labeled correctly: + ```bash + docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" + ``` + - **Expected:** At least one container matches. + +3. Verify deterministic Docker Compose project name: + ```bash + docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-default" + ``` + - **Expected:** Project listed. + +**Cleanup:** `$CLI down --name e2e-test-default` + +--- + +### M1-UP-002: LocalNet up with --name + +**Preconditions:** Docker running. +**Platforms:** All +**Timeout:** 300 seconds + +**Steps:** + +1. Start a named LocalNet: + ```bash + $CLI up --name e2e-named-test + ``` + - **Expected:** Exit code `0`. + +2. Verify the instance uses the specified name: + ```bash + docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-named-test" + ``` + - **Expected:** Match found. + +3. Verify status references the correct name: + ```bash + $CLI status --name e2e-named-test 2>&1 | grep -qiE "e2e-named-test" + ``` + - **Expected:** Exit code `0`, name appears in output. + +**Cleanup:** `$CLI down --name e2e-named-test && $CLI clean --name e2e-named-test --force` + +--- + +### M1-UP-003: LocalNet up with --version + +**Preconditions:** Docker running, known valid Splice version from compatibility matrix. +**Platforms:** All +**Timeout:** 300 seconds + +**Steps:** + +1. Start a LocalNet with explicit version: + ```bash + SPLICE_VERSION="" # from compatibility matrix + $CLI up --name e2e-version-test --version "$SPLICE_VERSION" + ``` + - **Expected:** Exit code `0`. + +2. Verify the selected version is reflected in status: + ```bash + $CLI status --name e2e-version-test 2>&1 | grep -qE "$SPLICE_VERSION" + ``` + - **Expected:** Version string appears in output. + +3. Test with invalid version: + ```bash + $CLI up --name e2e-bad-version --version "0.0.0-nonexistent" + ``` + - **Expected:** Non-zero exit code, error message about invalid/unavailable version. + +**Cleanup:** `$CLI down --name e2e-version-test && $CLI clean --name e2e-version-test --force` + +--- + +### M1-STS-001: Status shows healthy services + +**Preconditions:** LocalNet `e2e-test-default` running (depends on M1-UP-001 setup). +**Platforms:** All + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Check status: + ```bash + $CLI status --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify output includes required information:** + ```bash + OUTPUT=$($CLI status --name e2e-test-default 2>&1) + echo "$OUTPUT" | grep -qiE "(healthy|running|ready)" # service health + echo "$OUTPUT" | grep -qiE "(port|endpoint)" # ports/endpoints + echo "$OUTPUT" | grep -qiE "(participant)" # participant readiness + echo "$OUTPUT" | grep -qiE "(version|splice)" # selected version + ``` + +3. Check status for non-existent LocalNet: + ```bash + $CLI status --name nonexistent-localnet-xyz + ``` + - **Expected:** Non-zero exit code, clear error message. + +**Cleanup:** `$CLI down --name e2e-test-default` + +--- + +### M1-LOG-001: Logs — full and service-filtered + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Tail full logs (non-blocking with timeout): + ```bash + timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -50 + ``` + - **Expected:** Output is non-empty (logs are streaming). + - **Verify:** + ```bash + timeout 10 $CLI logs --name e2e-test-default 2>&1 | head -5 | wc -l | grep -qE "[1-9]" + ``` + +3. Tail logs for a specific service: + ```bash + timeout 10 $CLI logs participant --name e2e-test-default 2>&1 | head -20 + ``` + - **Expected:** Output is non-empty, logs come from the specified service only. + +4. Tail logs for non-existent service: + ```bash + $CLI logs nonexistent-service --name e2e-test-default + ``` + - **Expected:** Non-zero exit code or clear error message about unknown service. + +**Cleanup:** `$CLI down --name e2e-test-default` + +--- + +### M1-RST-001: Restart full + single service + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All +**Timeout:** 300 seconds + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Restart the full LocalNet: + ```bash + $CLI restart --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify readiness after restart:** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +3. Restart a single service: + ```bash + $CLI restart participant --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify the restarted service is healthy:** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +**Cleanup:** `$CLI down --name e2e-test-default` + +--- + +### M1-DWN-001: Down stops instance cleanly + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All + +**Steps:** + +1. Start LocalNet: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Verify it is running: + ```bash + docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" + ``` + +3. Stop it: + ```bash + $CLI down --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + +4. Verify containers stopped: + ```bash + docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS" + ``` + +5. Verify unrelated Docker resources are not affected: + ```bash + # If other non-DevKit containers were running before, they should still be running + docker ps --format '{{.Names}}' | grep -v "canton-devkit" | wc -l + ``` + - **Expected:** Count unchanged from before test. + +**Cleanup:** `$CLI clean --name e2e-test-default --force 2>/dev/null || true` + +--- + +### M1-CLN-001: Clean removes all resources + +**Preconditions:** LocalNet `e2e-test-default` has been started and stopped. +**Platforms:** All + +**Steps:** + +1. Start and stop a LocalNet: + ```bash + $CLI up --name e2e-test-default + $CLI down --name e2e-test-default + ``` + +2. Verify resources exist (volumes, networks): + ```bash + docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" + ``` + - **Expected:** Volumes exist from the stopped instance. + +3. Clean the instance: + ```bash + $CLI clean --name e2e-test-default + ``` + - **Expected:** Exit code `0`. May prompt for confirmation (use `--force` if non-interactive). + - If confirmation is required: + ```bash + echo "y" | $CLI clean --name e2e-test-default + # OR + $CLI clean --name e2e-test-default --force + ``` + +4. Verify all DevKit-managed resources removed: + ```bash + docker volume ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: volumes remain" || echo "PASS" + docker network ls --format '{{.Name}}' | grep -qE "e2e-test-default" && echo "FAIL: networks remain" || echo "PASS" + docker ps -a --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers remain" || echo "PASS" + ``` + +**Cleanup:** None (test is self-cleaning). + +--- + +### M1-SNP-001: Snapshot and restore + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All +**Timeout:** 300 seconds + +**Steps:** + +1. Start LocalNet and wait for readiness: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Create a snapshot: + ```bash + $CLI snapshot --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify snapshot reference is output:** + ```bash + $CLI snapshot --name e2e-test-default 2>&1 | grep -qiE "(snapshot|saved|created)" + ``` + +3. Stop and clean the LocalNet: + ```bash + $CLI down --name e2e-test-default + $CLI clean --name e2e-test-default --force + ``` + +4. Restore from snapshot: + ```bash + $CLI restore --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify LocalNet is running and healthy after restore:** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +**Cleanup:** `$CLI down --name e2e-test-default && $CLI clean --name e2e-test-default --force` + +--- + +### M1-ISO-001: Two named instances, non-conflicting ports + +**Preconditions:** Docker running, sufficient resources for two LocalNets. +**Platforms:** All +**Timeout:** 600 seconds (10 minutes for two full startups) + +**Steps:** + +1. Start first instance with explicit ports: + ```bash + $CLI up --name e2e-test-a + ``` + - **Expected:** Exit code `0`. + +2. Start second instance with non-conflicting ports: + ```bash + $CLI up --name e2e-test-b + ``` + - **Expected:** Exit code `0`. + +3. Verify both instances are running and isolated: + ```bash + $CLI status --name e2e-test-a 2>&1 | grep -qiE "(healthy|ready|running)" + $CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +4. Verify port isolation (no port conflicts): + ```bash + PORTS_A=$($CLI status --name e2e-test-a 2>&1 | grep -oE "[0-9]{4,5}" | sort) + PORTS_B=$($CLI status --name e2e-test-b 2>&1 | grep -oE "[0-9]{4,5}" | sort) + OVERLAP=$(comm -12 <(echo "$PORTS_A") <(echo "$PORTS_B")) + [ -z "$OVERLAP" ] && echo "PASS: no port overlap" || echo "FAIL: overlapping ports: $OVERLAP" + ``` + +5. Verify Docker resource isolation (separate project names): + ```bash + docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-a" + docker compose ls --format json 2>/dev/null | grep -qE "e2e-test-b" + ``` + +6. Stop one instance and verify the other is unaffected: + ```bash + $CLI down --name e2e-test-a + $CLI status --name e2e-test-b 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + - **Expected:** Instance B still healthy. + +**Cleanup:** +```bash +$CLI down --name e2e-test-a 2>/dev/null || true +$CLI down --name e2e-test-b 2>/dev/null || true +$CLI clean --name e2e-test-a --force 2>/dev/null || true +$CLI clean --name e2e-test-b --force 2>/dev/null || true +``` + +--- + +### M1-ENV-001: Env export outputs valid config + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Export environment: + ```bash + $CLI env --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + - **Verify `.env`-style output:** + ```bash + OUTPUT=$($CLI env --name e2e-test-default 2>&1) + echo "$OUTPUT" | grep -qE "^[A-Z_]+=.+" # KEY=value format + echo "$OUTPUT" | grep -qiE "(LEDGER|JSON.API|ADMIN|PARTICIPANT)" # expected keys + ``` + +3. Verify exported values are usable (source and test a variable): + ```bash + eval "$($CLI env --name e2e-test-default)" + # Verify at least one URL/port is reachable + curl -sf "http://${LEDGER_API_HOST:-localhost}:${LEDGER_API_PORT:-6865}/health" > /dev/null 2>&1 || \ + curl -sf "http://${JSON_API_HOST:-localhost}:${JSON_API_PORT:-7575}/health" > /dev/null 2>&1 || \ + echo "WARN: Could not reach exported endpoints (may require different health check path)" + ``` + +**Cleanup:** `$CLI down --name e2e-test-default` + +--- + +### M1-LST-001: List discovers running instances + +**Preconditions:** At least one named LocalNet running. +**Platforms:** All + +**Steps:** + +1. Start two instances: + ```bash + $CLI up --name e2e-test-a + $CLI up --name e2e-test-b + ``` + +2. List instances: + ```bash + $CLI list + ``` + - **Expected:** Exit code `0`. + - **Verify both instances appear:** + ```bash + OUTPUT=$($CLI list 2>&1) + echo "$OUTPUT" | grep -qE "e2e-test-a" + echo "$OUTPUT" | grep -qE "e2e-test-b" + ``` + +3. Stop one instance and re-list: + ```bash + $CLI down --name e2e-test-a + OUTPUT=$($CLI list 2>&1) + echo "$OUTPUT" | grep -qE "e2e-test-b" + ``` + - **Expected:** Instance B still listed, instance A either removed or shown as stopped. + +4. Verify no non-DevKit containers appear in the list: + ```bash + $CLI list 2>&1 | grep -qiE "(canton-devkit|localnet|e2e-test)" || echo "WARN: list output format unclear" + ``` + +**Cleanup:** +```bash +$CLI down --name e2e-test-a 2>/dev/null || true +$CLI down --name e2e-test-b 2>/dev/null || true +$CLI clean --name e2e-test-a --force 2>/dev/null || true +$CLI clean --name e2e-test-b --force 2>/dev/null || true +``` + +--- + +## Exit Code Contract + +| Exit Code | Meaning | +|---|---| +| `0` | Success | +| `1` | General error | +| `2` | Docker not available or preflight check failed | +| `3` | LocalNet instance not found | +| `4` | Port conflict | +| `5` | Resource insufficient (memory/disk) | +| Non-zero | Any failure (agent should capture stderr for diagnostics) | + +*Note: Exact exit codes are subject to implementation. The key contract is: `0` = success, non-zero = failure with diagnostic output on stderr.* + +--- + +## Cross-Platform Notes + +| Platform | Special Considerations | +|---|---| +| **macOS (Apple Silicon)** | Docker Desktop required. `file` command shows "Mach-O 64-bit executable arm64". Ports bind to `localhost` by default. | +| **Linux (amd64)** | Native Docker or Docker Desktop. Doctor should check Linux Docker permissions (user in `docker` group or rootless Docker). `file` command shows "ELF 64-bit LSB executable, x86-64". | +| **Windows (amd64)** | Docker Desktop with WSL 2 backend. Commands use PowerShell or WSL. `file` equivalent: `Get-Command canton-devkit.exe`. Path separators differ. | + +--- + +## Test Execution Summary + +| ID | Test Name | Category | Depends On | +|---|---|---|---| +| M1-INST-001 | Install via DPM component | Installation | — | +| M1-INST-002 | Install standalone binary | Installation | — | +| M1-INST-003 | Verify binary on all platforms | Installation | M1-INST-001 or M1-INST-002 | +| M1-DOC-001 | Doctor — all checks pass | Preflight | M1-INST-003 | +| M1-DOC-002 | Doctor — Docker not installed | Preflight | M1-INST-003 | +| M1-DOC-003 | Doctor — insufficient resources | Preflight | M1-INST-003 | +| M1-UP-001 | LocalNet up (default) | Lifecycle | M1-DOC-001 | +| M1-UP-002 | LocalNet up with --name | Lifecycle | M1-DOC-001 | +| M1-UP-003 | LocalNet up with --version | Lifecycle | M1-DOC-001 | +| M1-STS-001 | Status shows healthy services | Status | M1-UP-001 | +| M1-LOG-001 | Logs — full and service-filtered | Logs | M1-UP-001 | +| M1-RST-001 | Restart full + single service | Lifecycle | M1-UP-001 | +| M1-DWN-001 | Down stops instance cleanly | Lifecycle | M1-UP-001 | +| M1-CLN-001 | Clean removes all resources | Lifecycle | M1-DWN-001 | +| M1-SNP-001 | Snapshot and restore | State | M1-UP-001 | +| M1-ISO-001 | Two named instances | Isolation | M1-DOC-001 | +| M1-ENV-001 | Env export outputs valid config | Automation | M1-UP-001 | +| M1-LST-001 | List discovers running instances | Automation | M1-UP-001 | + +--- + +## Execution Results — macOS (standalone mode, no DPM) + +**Date:** 2026-06-06 +**Platform:** macOS (Apple Silicon / arm64), Docker Desktop 29.5.2 +**CLI mode:** `canton-devkit localnet` (standalone — no DPM) +**Binary version:** dev +**Splice version (default/latest):** 0.6.4 +**Splice version (explicit):** 0.6.3 +**Script:** `scripts/e2e-milestone1.sh` + +### CLI Syntax Adaptations + +The test plan assumes command syntax that differs from the actual CLI implementation. The following adaptations were applied: + +| Test Plan Syntax | Actual CLI Syntax | Notes | +|---|---|---| +| `$CLI restart participant --name X` | `$CLI restart --name X --service participant` | Service is a `--service` flag, not positional | +| `$CLI logs participant --name X` | `$CLI logs --name X --service participant` | Service is a `--service` flag, not positional | +| `$CLI snapshot --name X` | `$CLI snapshot --name X --to ` | `--to` is required — output path | +| `$CLI restore --name X` | `$CLI restore --name X --from ` | `--from` is required — input path | +| `$CLI --version` → semver | `$CDK --version` → `canton-devkit version dev` | Version is top-level, may be `dev` in local builds | +| `$CLI --help` shows `clean`, `restart` | Hidden commands; not in `--help` output | Exist and work via `--help` on each subcommand | +| Docker label `canton-devkit` | `com.docker.compose.project=canton-` | Docker compose project label, not a custom label | +| `$CLI down` then `$CLI clean` | `$CLI clean --force` on running instance | `down` deregisters the instance; `clean` can't find it after. Use `clean --force` directly | + +### Skipped Tests + +| Test | Reason | +|---|---| +| M1-INST-001 | DPM mode excluded from this run | +| M1-INST-002 | Binary already built locally; no release URL to download from | +| M1-DOC-003 | Requires manually changing Docker Desktop resource limits — destructive to dev environment | +| M1-ISO-001 | Requires two concurrent LocalNets (~16 GB Docker memory); machine has 8.84 GB available | + +### Results + +| ID | Result | Duration | Notes | +|---|---|---|---| +| M1-INST-003 | **PASS** | <1s | Version (`dev`), help (10 visible + 2 hidden commands), Mach-O arm64 | +| M1-DOC-001 | **PASS** | <2s | 0 issues, 1 warning (memory 8.84/12 GB). Exit 0. | +| M1-DOC-002 | **PASS** | <2s | Exit 2 when Docker hidden from PATH. Remediation: "Install Docker Desktop for Mac" | +| M1-UP-001 | **PASS** | ~2-4 min | Splice 0.6.4, cached images. Status: healthy. Docker compose project verified. | +| M1-STS-001 | **PASS** | <2s | Status includes health, endpoints, participant info. Non-existent instance → exit 1. | +| M1-LOG-001 | **PASS** | <10s | Full logs: 308 lines. Service-filtered (`canton`): 20 lines. | +| M1-ENV-001 | **PASS** | <1s | `export CANTON_*` format. Contains JWT (redacted), audience, port variables. | +| M1-RST-001 | **PASS** | ~5-8 min | Full restart + single-service (`--service canton`) restart. Readiness wait is slow post-restart. | +| M1-SNP-001 | **PASS*** | ~10 min | Snapshot: 78 MB .tgz. Restore + re-up works but splice re-sync can exceed 5 min (crash-consistent, not app-consistent). | +| M1-DWN-001 | **PASS** | ~5s | Containers stopped, non-devkit containers unaffected. | +| M1-CLN-001 | **PASS*** | ~10 min | See finding below. `clean --force` on running instance removes all resources (containers, volumes, networks). | +| M1-UP-002 | **PASS** | ~2-4 min | Named instance `e2e-named-test` created, Docker containers + status verified. | +| M1-UP-003 | **PASS** | ~2-4 min | Splice 0.6.3 (explicit `--version`). Status shows version. Invalid version `0.0.0-nonexistent` → exit 1 with clear error. | +| M1-LST-001 | **PASS** | <2s | List shows running instance with name, splice version, status, ports. Adapted to single-instance (resource constraint). | + +### Findings + +#### Finding 1: `down` + `clean` workflow leaves orphaned volumes + +**Severity:** Medium +**Test:** M1-CLN-001 + +`localnet down` (default) deregisters the instance from the registry on success. A subsequent `localnet clean --name X --force` then reports "Nothing to clean" but Docker volumes remain on disk. This is a design gap — both commands work correctly individually but don't compose in the `down` → `clean` sequence. + +**Workaround:** Use `localnet clean --name X --force` directly on a running instance (it runs `down` internally before removing volumes). Do not call `down` before `clean`. + +#### Finding 2: Post-restore `up` may exceed readiness timeout + +**Severity:** Low +**Test:** M1-SNP-001 + +After `snapshot` → `down` → `clean` → `restore` → `up`, the Splice service must re-sync from scratch. On a machine with 8.84 GB Docker memory, this consistently exceeds the 5-minute default readiness wait, causing `up` to exit with a timeout. The services are actually healthy — they just need more time. + +**Recommendation:** Document that post-restore bring-up may take longer than a fresh `up`, especially on resource-constrained hosts. Consider a `--timeout` flag on `up`. + +#### Finding 3: `restart` readiness wait is very slow + +**Severity:** Low +**Test:** M1-RST-001 + +Full `localnet restart` and single-service `restart --service canton` both work correctly, but the post-restart readiness wait can take 5+ minutes. The services come back healthy; the wait just takes time. + +**Recommendation:** Consider `--no-wait` as a practical default for CI scripts, with a separate `localnet status --wait` for blocking on readiness. diff --git a/docs/tests/e2e-test-milestone-2.html b/docs/tests/e2e-test-milestone-2.html new file mode 100644 index 00000000..a56ff3b2 --- /dev/null +++ b/docs/tests/e2e-test-milestone-2.html @@ -0,0 +1,1942 @@ + + + + + +E2E Test Plan -- Milestone 2: Web UI, Observability, DAR & Contract Tooling + + + + + + + +
+ +
+

E2E Test Plan — Milestone 2 26 TESTS

+
+ Scope: Web UI, Observability, DAR & Contract Tooling + Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) + Prerequisite: Milestone 1 passing + Delivery: Month 6 +
+
+ 0 / 0 steps completed +
+
+
+ + +
+ TL;DR. 26 end-to-end tests covering the Web UI dashboard and lifecycle controls, DAR package management (upload, list, info, download, diff, remove, watch, build-upload), live contract tracking and transaction exploration, Prometheus/Grafana observability toggle and dashboards, CI automation with --json output, and AI agent skill document validation. +
+ + +

Conventions & Environment Setup

+ +
+
    +
  • $CLI = dpm localnet or canton-devkit localnet (run full suite twice).
  • +
  • Test DAR: daml-intro-contracts project (Token template, Daml SDK 3.5.1).
  • +
  • $DAR_PATH = path to the built .dar file.
  • +
  • $WEB_UI_URL = URL of the Web UI (from $CLI up or $CLI status).
  • +
  • Web UI tests use curl for HTTP-level validation.
  • +
  • Default step timeout: 30 seconds unless noted.
  • +
+
+ +
+ + ENV + Environment Setup + Setup + +
+
# Set CLI mode
+export CLI="dpm localnet"       # or "canton-devkit localnet"
+
+# Build the test DAR
+cd daml-intro-contracts
+daml build
+export DAR_PATH="$(pwd)/.daml/dist/daml-intro-contracts-1.0.0.dar"
+cd ..
+
+# Ensure clean state
+$CLI clean --name e2e-m2-test --force 2>/dev/null || true
+
+# Start LocalNet for Milestone 2 tests
+$CLI up --name e2e-m2-test
+
+# Capture Web UI URL from status output
+export WEB_UI_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1)
+
+
+ + + + +

Web UI

+ + +
+ + M2-WEB-001 + Web UI launches and is accessible + Web UI + +
+
+ Preconditions: LocalNet e2e-m2-test running. + Platforms: All +
+ +
+
+ + Step 1. Verify the Web UI URL is printed during startup: +
+
$CLI status --name e2e-m2-test 2>&1 | grep -qiE "(web.ui|dashboard|http.*ui)"
+
Expected: URL found in output.
+
+ +
+
+ + Step 2. Verify the Web UI is reachable via HTTP: +
+
curl -sf -o /dev/null -w "%{http_code}" "$WEB_UI_URL"
+
Expected: HTTP 200.
+
+ +
+
+ + Step 3. Verify the Web UI serves HTML: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "<html|<!DOCTYPE"
+
Expected: Valid HTML response.
+
+ +
Cleanup: None (LocalNet stays running for subsequent tests).
+
+
+ + +
+ + M2-WEB-002 + Web UI lifecycle actions (start/stop/restart) + Web UI + +
+
+ Preconditions: Web UI accessible (M2-WEB-001). + Platforms: All +
+ +
+
+ + Step 1. Verify the Web UI exposes lifecycle action endpoints or renders action buttons: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(start|stop|restart|status|clean)"
+
Expected: Lifecycle actions are present in the UI HTML.
+
+ +
+
+ + Step 2. Test the status view via Web UI (API endpoint if available): +
+
curl -sf "$WEB_UI_URL/api/status" 2>/dev/null || \
+curl -sf "$WEB_UI_URL/status" 2>/dev/null
+
Expected: JSON or HTML response showing LocalNet health.
+
+ +
+ Note: Full interactive testing of start/stop/restart via the Web UI requires browser automation (e.g., Playwright, Puppeteer). The above steps validate endpoint availability. An AI agent should verify that clicking "Restart" in the UI triggers $CLI restart behavior and the UI updates to reflect the new state. +
+ +
Cleanup: None.
+
+
+ + +
+ + M2-WEB-003 + Web UI LocalNet dashboard content + Web UI + +
+
+ Preconditions: Web UI accessible, LocalNet running. + Platforms: All +
+ +
+
+ + Step 1. Verify dashboard shows named instances: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "e2e-m2-test"
+
+ +
+
+ + Step 2. Verify dashboard shows service health indicators: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(healthy|running|ready|status)"
+
+ +
+
+ + Step 3. Verify dashboard shows endpoints and ports: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(endpoint|port|localhost|[0-9]{4,5})"
+
+ +
+
+ + Step 4. Verify dashboard shows participant information: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(participant|party)"
+
+ +
+
+ + Step 5. Verify dashboard shows Splice version: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(version|splice)"
+
+ +
Cleanup: None.
+
+
+ + + + +

DAR Management

+ + +
+ + M2-DAR-001 + DAR upload to single participant + DAR + +
+
+ Preconditions: LocalNet running, $DAR_PATH exists. + Platforms: All +
+ +
+
+ + Step 1. Upload DAR to a single participant: +
+
$CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify upload confirmation:

+
$CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "(uploaded|success|package)"
+
+ +
+
+ + Step 2. Verify the package appears in the list: +
+
$CLI dar list --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts"
+
+ +
Cleanup: None (package remains for subsequent tests).
+
+
+ + +
+ + M2-DAR-002 + DAR upload to all participants + DAR + +
+
+ Preconditions: LocalNet running, $DAR_PATH exists. + Platforms: All +
+ +
+
+ + Step 1. Upload DAR to all participants: +
+
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test
+
Expected: Exit code 0.
+
+ +
+
+ + Step 2. Verify package is listed on multiple participants: +
+
$CLI dar list --name e2e-m2-test 2>&1 | grep -ciE "daml-intro-contracts"
+
Expected: Count >= 2 (one entry per participant).
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-003 + DAR upload with --vet and --dry-run + DAR + +
+
+ Preconditions: LocalNet running, $DAR_PATH exists. + Platforms: All +
+ +
+
+ + Step 1. Dry-run upload (should not actually upload): +
+
$CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test
+
Expected: Exit code 0, output shows what would happen without executing.
+

Verify:

+
$CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test 2>&1 | grep -qiE "(dry.run|would|simulate)"
+
+ +
+
+ + Step 2. Upload with vetting for SCU: +
+
$CLI dar upload "$DAR_PATH" --all-participants --vet --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify vetting status:

+
$CLI dar list --name e2e-m2-test 2>&1 | grep -iE "daml-intro-contracts" | grep -qiE "(vetted|vet)"
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-004 + DAR list packages + DAR + +
+
+ Preconditions: DAR uploaded (M2-DAR-001 or M2-DAR-002). + Platforms: All +
+ +
+
+ + Step 1. List packages: +
+
$CLI dar list --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output includes required fields:

+
OUTPUT=$($CLI dar list --name e2e-m2-test 2>&1)
+echo "$OUTPUT" | grep -qiE "(package.id|name|version)"  # identifiers
+echo "$OUTPUT" | grep -qiE "(daml.lf|module)"            # metadata
+echo "$OUTPUT" | grep -qiE "daml-intro-contracts"        # our package
+
+ +
+
+ + Step 2. List packages filtered by participant: +
+
$CLI dar list --participant participant1 --name e2e-m2-test
+
Expected: Exit code 0, list is scoped to that participant.
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-005 + DAR info (modules, templates, choices) + DAR + +
+
+ Preconditions: DAR uploaded. + Platforms: All +
+ +
+
+ + Step 1. Get package info by name: +
+
$CLI dar info daml-intro-contracts --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output includes structural details:

+
OUTPUT=$($CLI dar info daml-intro-contracts --name e2e-m2-test 2>&1)
+echo "$OUTPUT" | grep -qiE "Token"           # template name
+echo "$OUTPUT" | grep -qiE "owner"            # field name
+echo "$OUTPUT" | grep -qiE "(module|Token)"   # module listing
+echo "$OUTPUT" | grep -qiE "(dependency|hash)" # metadata
+
+ +
+
+ + Step 2. Get package info by package ID: +
+
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
+$CLI dar info "$PKG_ID" --name e2e-m2-test
+
Expected: Exit code 0, same info as by name.
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-006 + DAR download + DAR + +
+
+ Preconditions: DAR uploaded. + Platforms: All +
+ +
+
+ + Step 1. Download a DAR by package ID: +
+
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
+$CLI dar download "$PKG_ID" --out /tmp/downloaded.dar --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify file exists and is non-empty:

+
[ -s /tmp/downloaded.dar ] && echo "PASS" || echo "FAIL: downloaded DAR is empty or missing"
+
+ +
+
+ + Step 2. Verify downloaded DAR is a valid archive: +
+
file /tmp/downloaded.dar | grep -qiE "(zip|archive|data)"
+
+ +
Cleanup: rm -f /tmp/downloaded.dar
+
+
+ + +
+ + M2-DAR-007 + DAR diff between two versions + DAR + +
+
+ Preconditions: Two different DAR versions uploaded (or same DAR can be diffed against itself). + Platforms: All +
+ +
+
+ + Step 1. Build a second version of the DAR (modify version in daml.yaml): +
+
cd daml-intro-contracts
+cp daml.yaml daml.yaml.bak
+sed -i.tmp 's/version: 1.0.0/version: 2.0.0/' daml.yaml
+daml build
+export DAR_PATH_V2="$(pwd)/.daml/dist/daml-intro-contracts-2.0.0.dar"
+mv daml.yaml.bak daml.yaml
+rm -f daml.yaml.tmp
+cd ..
+$CLI dar upload "$DAR_PATH_V2" --all-participants --name e2e-m2-test
+
+ +
+
+ + Step 2. Diff the two versions: +
+
$CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output shows diff information:

+
$CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test 2>&1 | grep -qiE "(template|choice|field|change|diff|identical|scu|compatible)"
+
+ +
Cleanup: rm -f "$DAR_PATH_V2"
+
+
+ + +
+ + M2-DAR-008 + DAR remove / unvet + DAR + +
+
+ Preconditions: DAR uploaded. + Platforms: All +
+ +
+
+ + Step 1. Get the package ID to remove: +
+
PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1)
+
+ +
+
+ + Step 2. Remove / unvet the package: +
+
$CLI dar remove "$PKG_ID" --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify package is no longer listed (or marked as unvetted):

+
$CLI dar list --name e2e-m2-test 2>&1 | grep -i "$PKG_ID" | grep -qiE "(unvetted|removed)" || \
+! $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "$PKG_ID"
+
+ +
+
+ + Step 3. Re-upload for subsequent tests: +
+
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-009 + DAR build-upload (dpm build integration) + DAR + +
+
+ Preconditions: dpm available (skip if standalone mode and dpm not installed), daml-intro-contracts project. + Platforms: All +
+ +
+
+ + Step 1. Run build-upload from the project directory: +
+
$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify both build and upload occurred:

+
$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(build|compil)" 
+$CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(upload|deploy)"
+
+ +
+
+ + Step 2. If dpm is not available (standalone mode), verify graceful skip: +
+
# Only if dpm is not on PATH:
+which dpm > /dev/null 2>&1 || {
+  $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(skip|not available|dpm not found)"
+  echo "PASS: graceful skip when dpm unavailable"
+}
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-DAR-010 + DAR watch mode (hot-deploy) + DAR + +
+
+ Preconditions: LocalNet running, daml-intro-contracts project. + Platforms: All + Timeout: 60 seconds +
+ +
+
+ + Step 1. Start watch mode in the background: +
+
$CLI dar watch ./daml-intro-contracts --name e2e-m2-test &
+WATCH_PID=$!
+sleep 5  # let watch mode initialize
+
+ +
+
+ + Step 2. Trigger a rebuild by touching a source file: +
+
touch daml-intro-contracts/daml/Token.daml
+sleep 15  # wait for watch to detect change, rebuild, and re-upload
+
+ +
+
+ + Step 3. Verify re-upload occurred: +
+
$CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts"
+
Expected: Package is listed (re-uploaded).
+
+ +
+
+ + Step 4. Stop watch mode: +
+
kill $WATCH_PID 2>/dev/null || true
+wait $WATCH_PID 2>/dev/null || true
+
+ +
Cleanup: Watch process killed in step 4.
+
+
+ + +
+ + M2-DAR-011 + Web UI DAR drag-and-drop + package explorer + DAR + Web UI + +
+
+ Preconditions: Web UI accessible, DAR uploaded. + Platforms: All +
+ +
+
+ + Step 1. Verify DAR upload UI is present in the Web UI: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(upload|drag.*drop|dar)"
+
+ +
+
+ + Step 2. Verify package explorer tree is present: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(package|module|template|explorer)"
+
+ +
+
+ + Step 3. Verify uploaded packages appear in the Web UI: +
+
curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(daml-intro-contracts|Token)"
+
+ +
+ Note: Drag-and-drop upload and package tree navigation require browser automation for full interactive testing. The above steps validate that the UI elements are rendered. +
+ +
Cleanup: None.
+
+
+ + + + +

Contract Tracking & Exploration

+ + +
+ + M2-CTR-001 + Contracts watch (live streaming) + Contracts + +
+
+ Preconditions: LocalNet running, DAR uploaded with Token template. + Platforms: All + Timeout: 60 seconds +
+ +
+
+ + Step 1. Start contracts watch in the background: +
+
timeout 30 $CLI contracts watch --name e2e-m2-test > /tmp/watch-output.txt 2>&1 &
+WATCH_PID=$!
+sleep 3
+
+ +
+
+ + Step 2. Create a contract via the Ledger API or Daml Script to trigger a create event: +
+
# Use daml script or ledger API to create a Token contract
+# This step depends on the available parties from the LocalNet
+PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2)
+# Trigger contract creation via available means (daml script, JSON API, etc.)
+
+ +
+
+ + Step 3. Wait and check watch output: +
+
sleep 10
+kill $WATCH_PID 2>/dev/null || true
+wait $WATCH_PID 2>/dev/null || true
+cat /tmp/watch-output.txt | grep -qiE "(create|archive|contract|event)" && echo "PASS" || echo "FAIL: no events in watch output"
+
+ +
Cleanup: rm -f /tmp/watch-output.txt
+
+
+ + +
+ + M2-CTR-002 + TX ls with multi-dimensional filters + Contracts + +
+
+ Preconditions: LocalNet running, at least one transaction exists. + Platforms: All +
+ +
+
+ + Step 1. List all transactions: +
+
$CLI tx ls --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output has transaction entries:

+
$CLI tx ls --name e2e-m2-test 2>&1 | grep -qiE "(transaction|tx|offset)"
+
+ +
+
+ + Step 2. Filter by party: +
+
PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2)
+$CLI tx ls --party "$PARTY" --name e2e-m2-test
+
Expected: Exit code 0, only transactions visible to that party.
+
+ +
+
+ + Step 3. Filter by template: +
+
$CLI tx ls --template "Token:Token" --name e2e-m2-test
+
Expected: Exit code 0, only Token-related transactions.
+
+ +
+
+ + Step 4. Filter by offset range: +
+
$CLI tx ls --from 0 --to 100 --name e2e-m2-test
+
Expected: Exit code 0, transactions within offset range.
+
+ +
+
+ + Step 5. Combined multi-dimensional filter: +
+
$CLI tx ls --party "$PARTY" --template "Token:Token" --from 0 --name e2e-m2-test
+
Expected: Exit code 0, results satisfy all filters.
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-CTR-003 + TX replay per-party projection + Contracts + +
+
+ Preconditions: LocalNet running, at least one transaction exists. + Platforms: All +
+ +
+
+ + Step 1. Get a transaction ID from the listing: +
+
TX_ID=$($CLI tx ls --name e2e-m2-test 2>&1 | grep -oE "[a-f0-9-]{36,}" | head -1)
+
+ +
+
+ + Step 2. Replay the transaction showing per-party visibility: +
+
$CLI tx replay "$TX_ID" --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output shows party visibility projection:

+
$CLI tx replay "$TX_ID" --name e2e-m2-test 2>&1 | grep -qiE "(party|visible|projection|signatory|observer)"
+
+ +
+
+ + Step 3. Verify different parties see different projections: +
+
PARTY_A=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '1p' | cut -d= -f2)
+PARTY_B=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '2p' | cut -d= -f2)
+OUTPUT_A=$($CLI tx replay "$TX_ID" --party "$PARTY_A" --name e2e-m2-test 2>&1)
+OUTPUT_B=$($CLI tx replay "$TX_ID" --party "$PARTY_B" --name e2e-m2-test 2>&1)
+# At minimum, both should return successfully
+echo "$OUTPUT_A" | grep -qiE "(party|visible|projection)" && echo "PASS: Party A projection" || echo "WARN"
+echo "$OUTPUT_B" | grep -qiE "(party|visible|projection)" && echo "PASS: Party B projection" || echo "WARN"
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-CTR-004 + Web UI ACS explorer table + Contracts + Web UI + +
+
+ Preconditions: Web UI accessible, contracts exist. + Platforms: All +
+ +
+
+ + Step 1. Verify explorer section exists in Web UI: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(explorer|active.contract|acs)"
+
+ +
+
+ + Step 2. Verify ACS data is rendered (contracts visible): +
+
curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(contract|template|Token|signatory|observer)"
+
+ +
+
+ + Step 3. Verify party/template filter controls exist: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(filter|party|template|participant)"
+
+ +
+ Note: Full interactive filtering requires browser automation. The above validates the UI structure is present. +
+ +
Cleanup: None.
+
+
+ + +
+ + M2-CTR-005 + Web UI transaction timeline + Contracts + Web UI + +
+
+ Preconditions: Web UI accessible, transactions exist. + Platforms: All +
+ +
+
+ + Step 1. Verify transaction timeline section exists: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(transaction|timeline|history|tx)"
+
+ +
+
+ + Step 2. Verify transaction entries are rendered: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(create|exercise|archive|offset)"
+
+ +
+
+ + Step 3. Verify party visibility badges are present: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(party|visibility|badge)"
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-CTR-006 + Web UI contract detail view + Contracts + Web UI + +
+
+ Preconditions: Web UI accessible, contracts exist. + Platforms: All +
+ +
+
+ + Step 1. Verify contract detail view/drawer is accessible: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(detail|drawer|payload|lifecycle)"
+
+ +
+
+ + Step 2. Verify the detail view includes payload, lifecycle, and interface information: +
+
curl -sf "$WEB_UI_URL" | grep -qiE "(payload|json|lifecycle|created|signatory|observer)"
+
+ +
Cleanup: None.
+
+
+ + + + +

Observability and Monitoring

+ + +
+ + M2-OBS-001 + Prometheus/Grafana toggle enable/disable + Observability + +
+
+ Preconditions: LocalNet running. + Platforms: All +
+ +
+
+ + Step 1. Verify observability components can be enabled: +
+
# If observability is not already running, restart with it enabled
+$CLI down --name e2e-m2-test
+$CLI up --name e2e-m2-test --enable prometheus --enable grafana
+
Expected: Exit code 0.
+
+ +
+
+ + Step 2. Verify Prometheus is running: +
+
PROM_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*prometheus[^ ]*" | head -1)
+# Or use default port
+PROM_URL="${PROM_URL:-http://localhost:9090}"
+curl -sf "$PROM_URL/-/healthy" > /dev/null && echo "PASS: Prometheus healthy" || echo "FAIL"
+
+ +
+
+ + Step 3. Verify Grafana is running: +
+
GRAFANA_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*grafana[^ ]*" | head -1)
+GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}"
+curl -sf "$GRAFANA_URL/api/health" > /dev/null && echo "PASS: Grafana healthy" || echo "FAIL"
+
+ +
+
+ + Step 4. Verify selective disable works: +
+
$CLI down --name e2e-m2-test
+$CLI up --name e2e-m2-test --disable prometheus
+$CLI status --name e2e-m2-test 2>&1 | grep -qiE "prometheus" && echo "WARN: Prometheus should be disabled" || echo "PASS"
+
+ +
Cleanup: $CLI down --name e2e-m2-test && $CLI up --name e2e-m2-test
+
+
+ + +
+ + M2-OBS-002 + Grafana dashboards accessible with presets + Observability + +
+
+ Preconditions: LocalNet running with Grafana enabled. + Platforms: All +
+ +
+
+ + Step 1. Verify Grafana is accessible: +
+
GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}"
+curl -sf "$GRAFANA_URL/api/health" | grep -qiE "ok" && echo "PASS" || echo "FAIL"
+
+ +
+
+ + Step 2. Verify Canton-specific dashboard presets exist: +
+
curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -qiE "(canton|transaction|latency|throughput|contract)"
+
Expected: At least one Canton-specific dashboard preset is found.
+
+ +
+
+ + Step 3. Verify dashboards contain expected panels: +
+
DASHBOARD_UID=$(curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -oE '"uid":"[^"]*"' | head -1 | cut -d'"' -f4)
+curl -sf "$GRAFANA_URL/api/dashboards/uid/$DASHBOARD_UID" | grep -qiE "(transactions.sec|latency|active.contract|throughput)"
+
Expected: Dashboard includes DApp developer-focused panels.
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-OBS-003 + Metrics CLI summary output + Observability + +
+
+ Preconditions: LocalNet running with Grafana enabled. + Platforms: All +
+ +
+
+ + Step 1. Run metrics command: +
+
$CLI metrics --name e2e-m2-test
+
Expected: Exit code 0.
+

Verify output includes key metrics:

+
OUTPUT=$($CLI metrics --name e2e-m2-test 2>&1)
+echo "$OUTPUT" | grep -qiE "(throughput|transactions)"
+echo "$OUTPUT" | grep -qiE "(latency|p50|p99)"
+echo "$OUTPUT" | grep -qiE "(resource|cpu|memory)"
+
+ +
+
+ + Step 2. Verify Grafana dashboard URLs are printed: +
+
$CLI metrics --name e2e-m2-test 2>&1 | grep -qiE "https?://.*grafana"
+
Expected: At least one Grafana URL in output.
+
+ +
Cleanup: None.
+
+
+ + + + +

Automation Conveniences

+ + +
+ + M2-AUT-001 + Machine-readable --json output + Automation + +
+
+ Preconditions: LocalNet running. + Platforms: All +
+ +
+
+ + Step 1. Status with JSON output: +
+
$CLI status --name e2e-m2-test --json
+
Expected: Exit code 0.
+

Verify valid JSON:

+
$CLI status --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null
+

Verify JSON contains expected keys:

+
$CLI status --name e2e-m2-test --json 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'name' in d or 'status' in d or 'services' in d, 'Missing expected keys'"
+
+ +
+
+ + Step 2. DAR list with JSON output: +
+
$CLI dar list --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null
+
Expected: Valid JSON.
+
+ +
+
+ + Step 3. List with JSON output: +
+
$CLI list --json 2>&1 | python3 -m json.tool > /dev/null
+
Expected: Valid JSON.
+
+ +
Cleanup: None.
+
+
+ + +
+ + M2-AUT-002 + CI workflow: up → DAR upload → test → down + Automation + +
+
+ Preconditions: Docker running, daml-intro-contracts project available. + Platforms: All + Timeout: 600 seconds +
+

This test simulates a complete CI pipeline.

+ +
+
+ + Step 1. Start LocalNet: +
+
$CLI up --name e2e-ci-test
+EXIT_CODE=$?
+[ "$EXIT_CODE" -eq 0 ] && echo "PASS: up" || { echo "FAIL: up exited $EXIT_CODE"; exit 1; }
+
+ +
+
+ + Step 2. Wait for readiness (already handled by up, but verify): +
+
$CLI status --name e2e-ci-test 2>&1 | grep -qiE "(healthy|ready|running)"
+[ $? -eq 0 ] && echo "PASS: ready" || { echo "FAIL: not ready"; exit 1; }
+
+ +
+
+ + Step 3. Upload DAR: +
+
$CLI dar upload "$DAR_PATH" --all-participants --name e2e-ci-test
+[ $? -eq 0 ] && echo "PASS: dar upload" || { echo "FAIL: dar upload"; exit 1; }
+
+ +
+
+ + Step 4. Run application tests (simulate with a health check): +
+
# In a real CI pipeline, this would be: daml test, or integration tests
+$CLI dar list --name e2e-ci-test 2>&1 | grep -qiE "daml-intro-contracts"
+[ $? -eq 0 ] && echo "PASS: test verification" || { echo "FAIL: test verification"; exit 1; }
+
+ +
+
+ + Step 5. Teardown: +
+
$CLI down --name e2e-ci-test
+[ $? -eq 0 ] && echo "PASS: down" || { echo "FAIL: down"; exit 1; }
+$CLI clean --name e2e-ci-test --force
+[ $? -eq 0 ] && echo "PASS: clean" || { echo "FAIL: clean"; exit 1; }
+
+ +
+
+ + Step 6. Verify full cleanup: +
+
docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-ci-test" && echo "FAIL: containers remain" || echo "PASS: full cleanup"
+
+ +
Cleanup: Handled in steps 5-6.
+
+
+ + + + +

AI Agent Skill Documents

+ + +
+ + M2-SKL-001 + AI agent skill document validation + AI Skills + +
+
+ Preconditions: Skill documents exist in the DevKit distribution. + Platforms: All +
+ +
+
+ + Step 1. Verify skill documents are included in the distribution: +
+
# Check for skill docs in the installed package or binary directory
+find $(dirname $(which canton-devkit 2>/dev/null || echo ".")) -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -5
+# Or check a known documentation path
+ls -la docs/skills/ 2>/dev/null || ls -la skills/ 2>/dev/null || echo "Check skill document location"
+
+ +
+
+ + Step 2. Verify a skill document contains executable workflow steps: +
+
# Read a skill document and verify it contains dpm localnet commands
+SKILL_DOC=$(find . -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -1)
+if [ -n "$SKILL_DOC" ]; then
+  grep -qiE "dpm localnet|canton-devkit localnet" "$SKILL_DOC" && echo "PASS: contains CLI commands" || echo "FAIL: no CLI commands found"
+  grep -qiE "(up|down|status|dar upload|logs)" "$SKILL_DOC" && echo "PASS: contains lifecycle commands" || echo "FAIL: no lifecycle commands"
+else
+  echo "WARN: Skill document not found -- check distribution packaging"
+fi
+
+ +
+
+ + Step 3. Execute the basic workflow described in a skill document: +
+
# The skill document should describe a workflow like:
+# 1. Start LocalNet
+# 2. Check status
+# 3. Upload a DAR
+# 4. List packages
+# 5. Check logs
+# 6. Stop LocalNet
+# Execute each step and verify:
+$CLI up --name e2e-skill-test
+$CLI status --name e2e-skill-test
+$CLI dar upload "$DAR_PATH" --all-participants --name e2e-skill-test
+$CLI dar list --name e2e-skill-test
+timeout 5 $CLI logs --name e2e-skill-test 2>&1 | head -10
+$CLI down --name e2e-skill-test
+echo "PASS: skill workflow executed successfully"
+
+ +
Cleanup: $CLI clean --name e2e-skill-test --force 2>/dev/null || true
+
+
+ + + + +

Cross-Platform Notes

+ +
+ + + + + + + + + + + + + + + + + + +
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. Web UI accessible at localhost. Grafana/Prometheus default ports may conflict with local dev tools.
Linux (amd64)Native Docker. Ensure firewall allows localhost port access for Web UI and observability stack.
Windows (amd64)Docker Desktop with WSL 2. Web UI URL may differ (localhost vs WSL IP). curl available via WSL or PowerShell Invoke-WebRequest. timeout command replaced with PowerShell equivalent.
+
+ + + + +

Test Execution Summary

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTest NameCategoryDepends On
M2-WEB-001Web UI launches and is accessibleWeb UIM1 suite
M2-WEB-002Web UI lifecycle actionsWeb UIM2-WEB-001
M2-WEB-003Web UI dashboard contentWeb UIM2-WEB-001
M2-DAR-001DAR upload to single participantDARM1 suite
M2-DAR-002DAR upload to all participantsDARM1 suite
M2-DAR-003DAR upload with --vet and --dry-runDARM1 suite
M2-DAR-004DAR list packagesDARM2-DAR-001
M2-DAR-005DAR infoDARM2-DAR-001
M2-DAR-006DAR downloadDARM2-DAR-001
M2-DAR-007DAR diff between two versionsDARM2-DAR-001
M2-DAR-008DAR remove / unvetDARM2-DAR-001
M2-DAR-009DAR build-uploadDARM1 suite
M2-DAR-010DAR watch modeDARM1 suite
M2-DAR-011Web UI DAR + package explorerDAR / Web UIM2-WEB-001, M2-DAR-001
M2-CTR-001Contracts watch (live)ContractsM2-DAR-001
M2-CTR-002TX ls multi-filterContractsM2-DAR-001
M2-CTR-003TX replay per-partyContractsM2-CTR-002
M2-CTR-004Web UI ACS explorerContracts / Web UIM2-WEB-001
M2-CTR-005Web UI transaction timelineContracts / Web UIM2-WEB-001
M2-CTR-006Web UI contract detailContracts / Web UIM2-WEB-001
M2-OBS-001Prometheus/Grafana toggleObservabilityM1 suite
M2-OBS-002Grafana dashboards with presetsObservabilityM2-OBS-001
M2-OBS-003Metrics CLI summaryObservabilityM2-OBS-001
M2-AUT-001Machine-readable --json outputAutomationM1 suite
M2-AUT-002CI workflow E2EAutomationM1 suite
M2-SKL-001AI agent skill document validationAI SkillsM1 suite
+
+ +
+

Source: e2e-test-milestone-2.md

+

Proposal reference: original-devkit-proposal.md, Milestone 2 (Lines 249-266). Estimated delivery: Month 6.

+

This page is a self-contained companion artifact generated from the source markdown.

+
+ +
+ + + + + diff --git a/docs/tests/e2e-test-milestone-2.md b/docs/tests/e2e-test-milestone-2.md new file mode 100644 index 00000000..c4c7afa3 --- /dev/null +++ b/docs/tests/e2e-test-milestone-2.md @@ -0,0 +1,971 @@ +# E2E Test Plan — Milestone 2: Web UI, Observability, DAR & Contract Tooling + +> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 2 (Lines 249–266) +> **Estimated Delivery:** Month 6 +> **Total Tests:** 26 +> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) +> **Prerequisite:** All Milestone 1 tests passing. + +--- + +## Overview + +This test plan validates the Web UI, observability/monitoring stack, DAR package management, live contract/transaction exploration, automation conveniences, and optional AI agent skill documents delivered in Milestone 2. + +### Conventions + +- `$CLI` = `dpm localnet` or `canton-devkit localnet` (run full suite twice — once per mode). +- The test DAR is built from the `daml-intro-contracts` project (`Token` template, Daml SDK 3.5.1). +- `$DAR_PATH` = path to the built `.dar` file from `daml-intro-contracts`. +- `$WEB_UI_URL` = URL of the Web UI (printed by `$CLI up` or `$CLI status`). +- Web UI tests use `curl` for HTTP-level validation. Visual/interactive tests note what to verify manually or via browser automation. +- Default step timeout: 30 seconds unless noted. + +### Environment Setup + +```bash +# Set CLI mode +export CLI="dpm localnet" # or "canton-devkit localnet" + +# Build the test DAR +cd daml-intro-contracts +daml build +export DAR_PATH="$(pwd)/.daml/dist/daml-intro-contracts-1.0.0.dar" +cd .. + +# Ensure clean state +$CLI clean --name e2e-m2-test --force 2>/dev/null || true + +# Start LocalNet for Milestone 2 tests +$CLI up --name e2e-m2-test + +# Capture Web UI URL from status output +export WEB_UI_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1) +``` + +--- + +## Test Cases + +--- + +## Web UI + +--- + +### M2-WEB-001: Web UI launches and is accessible + +**Preconditions:** LocalNet `e2e-m2-test` running. +**Platforms:** All + +**Steps:** + +1. Verify the Web UI URL is printed during startup: + ```bash + $CLI status --name e2e-m2-test 2>&1 | grep -qiE "(web.ui|dashboard|http.*ui)" + ``` + - **Expected:** URL found in output. + +2. Verify the Web UI is reachable via HTTP: + ```bash + curl -sf -o /dev/null -w "%{http_code}" "$WEB_UI_URL" + ``` + - **Expected:** HTTP `200`. + +3. Verify the Web UI serves HTML: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "/dev/null || \ + curl -sf "$WEB_UI_URL/status" 2>/dev/null + ``` + - **Expected:** JSON or HTML response showing LocalNet health. + +**Note:** Full interactive testing of start/stop/restart via the Web UI requires browser automation (e.g., Playwright, Puppeteer). The above steps validate endpoint availability. An AI agent should verify that clicking "Restart" in the UI triggers `$CLI restart` behavior and the UI updates to reflect the new state. + +**Cleanup:** None. + +--- + +### M2-WEB-003: Web UI LocalNet dashboard content + +**Preconditions:** Web UI accessible, LocalNet running. +**Platforms:** All + +**Steps:** + +1. Verify dashboard shows named instances: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "e2e-m2-test" + ``` + +2. Verify dashboard shows service health indicators: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(healthy|running|ready|status)" + ``` + +3. Verify dashboard shows endpoints and ports: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(endpoint|port|localhost|[0-9]{4,5})" + ``` + +4. Verify dashboard shows participant information: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(participant|party)" + ``` + +5. Verify dashboard shows Splice version: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(version|splice)" + ``` + +**Cleanup:** None. + +--- + +## DAR Management + +--- + +### M2-DAR-001: DAR upload to single participant + +**Preconditions:** LocalNet running, `$DAR_PATH` exists. +**Platforms:** All + +**Steps:** + +1. Upload DAR to a single participant: + ```bash + $CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify upload confirmation:** + ```bash + $CLI dar upload "$DAR_PATH" --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "(uploaded|success|package)" + ``` + +2. Verify the package appears in the list: + ```bash + $CLI dar list --participant participant1 --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts" + ``` + +**Cleanup:** None (package remains for subsequent tests). + +--- + +### M2-DAR-002: DAR upload to all participants + +**Preconditions:** LocalNet running, `$DAR_PATH` exists. +**Platforms:** All + +**Steps:** + +1. Upload DAR to all participants: + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + +2. Verify package is listed on multiple participants: + ```bash + $CLI dar list --name e2e-m2-test 2>&1 | grep -ciE "daml-intro-contracts" + ``` + - **Expected:** Count >= 2 (one entry per participant). + +**Cleanup:** None. + +--- + +### M2-DAR-003: DAR upload with --vet and --dry-run + +**Preconditions:** LocalNet running, `$DAR_PATH` exists. +**Platforms:** All + +**Steps:** + +1. Dry-run upload (should not actually upload): + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, output shows what would happen without executing. + - **Verify:** + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --dry-run --name e2e-m2-test 2>&1 | grep -qiE "(dry.run|would|simulate)" + ``` + +2. Upload with vetting for SCU: + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --vet --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify vetting status:** + ```bash + $CLI dar list --name e2e-m2-test 2>&1 | grep -iE "daml-intro-contracts" | grep -qiE "(vetted|vet)" + ``` + +**Cleanup:** None. + +--- + +### M2-DAR-004: DAR list packages + +**Preconditions:** DAR uploaded (M2-DAR-001 or M2-DAR-002). +**Platforms:** All + +**Steps:** + +1. List packages: + ```bash + $CLI dar list --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output includes required fields:** + ```bash + OUTPUT=$($CLI dar list --name e2e-m2-test 2>&1) + echo "$OUTPUT" | grep -qiE "(package.id|name|version)" # identifiers + echo "$OUTPUT" | grep -qiE "(daml.lf|module)" # metadata + echo "$OUTPUT" | grep -qiE "daml-intro-contracts" # our package + ``` + +2. List packages filtered by participant: + ```bash + $CLI dar list --participant participant1 --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, list is scoped to that participant. + +**Cleanup:** None. + +--- + +### M2-DAR-005: DAR info (modules, templates, choices) + +**Preconditions:** DAR uploaded. +**Platforms:** All + +**Steps:** + +1. Get package info by name: + ```bash + $CLI dar info daml-intro-contracts --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output includes structural details:** + ```bash + OUTPUT=$($CLI dar info daml-intro-contracts --name e2e-m2-test 2>&1) + echo "$OUTPUT" | grep -qiE "Token" # template name + echo "$OUTPUT" | grep -qiE "owner" # field name + echo "$OUTPUT" | grep -qiE "(module|Token)" # module listing + echo "$OUTPUT" | grep -qiE "(dependency|hash)" # metadata + ``` + +2. Get package info by package ID: + ```bash + PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) + $CLI dar info "$PKG_ID" --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, same info as by name. + +**Cleanup:** None. + +--- + +### M2-DAR-006: DAR download + +**Preconditions:** DAR uploaded. +**Platforms:** All + +**Steps:** + +1. Download a DAR by package ID: + ```bash + PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) + $CLI dar download "$PKG_ID" --out /tmp/downloaded.dar --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify file exists and is non-empty:** + ```bash + [ -s /tmp/downloaded.dar ] && echo "PASS" || echo "FAIL: downloaded DAR is empty or missing" + ``` + +2. Verify downloaded DAR is a valid archive: + ```bash + file /tmp/downloaded.dar | grep -qiE "(zip|archive|data)" + ``` + +**Cleanup:** `rm -f /tmp/downloaded.dar` + +--- + +### M2-DAR-007: DAR diff between two versions + +**Preconditions:** Two different DAR versions uploaded (or same DAR can be diffed against itself). +**Platforms:** All + +**Steps:** + +1. Build a second version of the DAR (modify version in daml.yaml): + ```bash + cd daml-intro-contracts + cp daml.yaml daml.yaml.bak + sed -i.tmp 's/version: 1.0.0/version: 2.0.0/' daml.yaml + daml build + export DAR_PATH_V2="$(pwd)/.daml/dist/daml-intro-contracts-2.0.0.dar" + mv daml.yaml.bak daml.yaml + rm -f daml.yaml.tmp + cd .. + $CLI dar upload "$DAR_PATH_V2" --all-participants --name e2e-m2-test + ``` + +2. Diff the two versions: + ```bash + $CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output shows diff information:** + ```bash + $CLI dar diff daml-intro-contracts:1.0.0 daml-intro-contracts:2.0.0 --name e2e-m2-test 2>&1 | grep -qiE "(template|choice|field|change|diff|identical|scu|compatible)" + ``` + +**Cleanup:** `rm -f "$DAR_PATH_V2"` + +--- + +### M2-DAR-008: DAR remove / unvet + +**Preconditions:** DAR uploaded. +**Platforms:** All + +**Steps:** + +1. Get the package ID to remove: + ```bash + PKG_ID=$($CLI dar list --name e2e-m2-test 2>&1 | grep -i "daml-intro-contracts" | grep -oE "[a-f0-9]{64}" | head -1) + ``` + +2. Remove / unvet the package: + ```bash + $CLI dar remove "$PKG_ID" --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify package is no longer listed (or marked as unvetted):** + ```bash + $CLI dar list --name e2e-m2-test 2>&1 | grep -i "$PKG_ID" | grep -qiE "(unvetted|removed)" || \ + ! $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "$PKG_ID" + ``` + +3. Re-upload for subsequent tests: + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --name e2e-m2-test + ``` + +**Cleanup:** None. + +--- + +### M2-DAR-009: DAR build-upload (dpm build integration) + +**Preconditions:** `dpm` available (skip if standalone mode and `dpm` not installed), `daml-intro-contracts` project. +**Platforms:** All + +**Steps:** + +1. Run build-upload from the project directory: + ```bash + $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify both build and upload occurred:** + ```bash + $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(build|compil)" + $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(upload|deploy)" + ``` + +2. If `dpm` is not available (standalone mode), verify graceful skip: + ```bash + # Only if dpm is not on PATH: + which dpm > /dev/null 2>&1 || { + $CLI dar build-upload --project ./daml-intro-contracts --name e2e-m2-test 2>&1 | grep -qiE "(skip|not available|dpm not found)" + echo "PASS: graceful skip when dpm unavailable" + } + ``` + +**Cleanup:** None. + +--- + +### M2-DAR-010: DAR watch mode (hot-deploy) + +**Preconditions:** LocalNet running, `daml-intro-contracts` project. +**Platforms:** All +**Timeout:** 60 seconds + +**Steps:** + +1. Start watch mode in the background: + ```bash + $CLI dar watch ./daml-intro-contracts --name e2e-m2-test & + WATCH_PID=$! + sleep 5 # let watch mode initialize + ``` + +2. Trigger a rebuild by touching a source file: + ```bash + touch daml-intro-contracts/daml/Token.daml + sleep 15 # wait for watch to detect change, rebuild, and re-upload + ``` + +3. Verify re-upload occurred: + ```bash + $CLI dar list --name e2e-m2-test 2>&1 | grep -qiE "daml-intro-contracts" + ``` + - **Expected:** Package is listed (re-uploaded). + +4. Stop watch mode: + ```bash + kill $WATCH_PID 2>/dev/null || true + wait $WATCH_PID 2>/dev/null || true + ``` + +**Cleanup:** Watch process killed in step 4. + +--- + +### M2-DAR-011: Web UI DAR drag-and-drop + package explorer + +**Preconditions:** Web UI accessible, DAR uploaded. +**Platforms:** All + +**Steps:** + +1. Verify DAR upload UI is present in the Web UI: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(upload|drag.*drop|dar)" + ``` + +2. Verify package explorer tree is present: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(package|module|template|explorer)" + ``` + +3. Verify uploaded packages appear in the Web UI: + ```bash + curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(daml-intro-contracts|Token)" + ``` + +**Note:** Drag-and-drop upload and package tree navigation require browser automation for full interactive testing. The above steps validate that the UI elements are rendered. + +**Cleanup:** None. + +--- + +## Contract Tracking & Exploration + +--- + +### M2-CTR-001: Contracts watch (live streaming) + +**Preconditions:** LocalNet running, DAR uploaded with `Token` template. +**Platforms:** All +**Timeout:** 60 seconds + +**Steps:** + +1. Start contracts watch in the background: + ```bash + timeout 30 $CLI contracts watch --name e2e-m2-test > /tmp/watch-output.txt 2>&1 & + WATCH_PID=$! + sleep 3 + ``` + +2. Create a contract via the Ledger API or Daml Script to trigger a create event: + ```bash + # Use daml script or ledger API to create a Token contract + # This step depends on the available parties from the LocalNet + PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2) + # Trigger contract creation via available means (daml script, JSON API, etc.) + ``` + +3. Wait and check watch output: + ```bash + sleep 10 + kill $WATCH_PID 2>/dev/null || true + wait $WATCH_PID 2>/dev/null || true + cat /tmp/watch-output.txt | grep -qiE "(create|archive|contract|event)" && echo "PASS" || echo "FAIL: no events in watch output" + ``` + +**Cleanup:** `rm -f /tmp/watch-output.txt` + +--- + +### M2-CTR-002: TX ls with multi-dimensional filters + +**Preconditions:** LocalNet running, at least one transaction exists. +**Platforms:** All + +**Steps:** + +1. List all transactions: + ```bash + $CLI tx ls --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output has transaction entries:** + ```bash + $CLI tx ls --name e2e-m2-test 2>&1 | grep -qiE "(transaction|tx|offset)" + ``` + +2. Filter by party: + ```bash + PARTY=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY|ALICE" | head -1 | cut -d= -f2) + $CLI tx ls --party "$PARTY" --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, only transactions visible to that party. + +3. Filter by template: + ```bash + $CLI tx ls --template "Token:Token" --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, only Token-related transactions. + +4. Filter by offset range: + ```bash + $CLI tx ls --from 0 --to 100 --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, transactions within offset range. + +5. Combined multi-dimensional filter: + ```bash + $CLI tx ls --party "$PARTY" --template "Token:Token" --from 0 --name e2e-m2-test + ``` + - **Expected:** Exit code `0`, results satisfy all filters. + +**Cleanup:** None. + +--- + +### M2-CTR-003: TX replay per-party projection + +**Preconditions:** LocalNet running, at least one transaction exists. +**Platforms:** All + +**Steps:** + +1. Get a transaction ID from the listing: + ```bash + TX_ID=$($CLI tx ls --name e2e-m2-test 2>&1 | grep -oE "[a-f0-9-]{36,}" | head -1) + ``` + +2. Replay the transaction showing per-party visibility: + ```bash + $CLI tx replay "$TX_ID" --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output shows party visibility projection:** + ```bash + $CLI tx replay "$TX_ID" --name e2e-m2-test 2>&1 | grep -qiE "(party|visible|projection|signatory|observer)" + ``` + +3. Verify different parties see different projections: + ```bash + PARTY_A=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '1p' | cut -d= -f2) + PARTY_B=$($CLI env --name e2e-m2-test 2>&1 | grep -iE "PARTY" | sed -n '2p' | cut -d= -f2) + OUTPUT_A=$($CLI tx replay "$TX_ID" --party "$PARTY_A" --name e2e-m2-test 2>&1) + OUTPUT_B=$($CLI tx replay "$TX_ID" --party "$PARTY_B" --name e2e-m2-test 2>&1) + # At minimum, both should return successfully + echo "$OUTPUT_A" | grep -qiE "(party|visible|projection)" && echo "PASS: Party A projection" || echo "WARN" + echo "$OUTPUT_B" | grep -qiE "(party|visible|projection)" && echo "PASS: Party B projection" || echo "WARN" + ``` + +**Cleanup:** None. + +--- + +### M2-CTR-004: Web UI ACS explorer table + +**Preconditions:** Web UI accessible, contracts exist. +**Platforms:** All + +**Steps:** + +1. Verify explorer section exists in Web UI: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(explorer|active.contract|acs)" + ``` + +2. Verify ACS data is rendered (contracts visible): + ```bash + curl -sf "$WEB_UI_URL" 2>&1 | grep -qiE "(contract|template|Token|signatory|observer)" + ``` + +3. Verify party/template filter controls exist: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(filter|party|template|participant)" + ``` + +**Note:** Full interactive filtering requires browser automation. The above validates the UI structure is present. + +**Cleanup:** None. + +--- + +### M2-CTR-005: Web UI transaction timeline + +**Preconditions:** Web UI accessible, transactions exist. +**Platforms:** All + +**Steps:** + +1. Verify transaction timeline section exists: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(transaction|timeline|history|tx)" + ``` + +2. Verify transaction entries are rendered: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(create|exercise|archive|offset)" + ``` + +3. Verify party visibility badges are present: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(party|visibility|badge)" + ``` + +**Cleanup:** None. + +--- + +### M2-CTR-006: Web UI contract detail view + +**Preconditions:** Web UI accessible, contracts exist. +**Platforms:** All + +**Steps:** + +1. Verify contract detail view/drawer is accessible: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(detail|drawer|payload|lifecycle)" + ``` + +2. Verify the detail view includes payload, lifecycle, and interface information: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(payload|json|lifecycle|created|signatory|observer)" + ``` + +**Cleanup:** None. + +--- + +## Observability and Monitoring + +--- + +### M2-OBS-001: Prometheus/Grafana toggle enable/disable + +**Preconditions:** LocalNet running. +**Platforms:** All + +**Steps:** + +1. Verify observability components can be enabled: + ```bash + # If observability is not already running, restart with it enabled + $CLI down --name e2e-m2-test + $CLI up --name e2e-m2-test --enable prometheus --enable grafana + ``` + - **Expected:** Exit code `0`. + +2. Verify Prometheus is running: + ```bash + PROM_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*prometheus[^ ]*" | head -1) + # Or use default port + PROM_URL="${PROM_URL:-http://localhost:9090}" + curl -sf "$PROM_URL/-/healthy" > /dev/null && echo "PASS: Prometheus healthy" || echo "FAIL" + ``` + +3. Verify Grafana is running: + ```bash + GRAFANA_URL=$($CLI status --name e2e-m2-test 2>&1 | grep -oiE "https?://[^ ]*grafana[^ ]*" | head -1) + GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}" + curl -sf "$GRAFANA_URL/api/health" > /dev/null && echo "PASS: Grafana healthy" || echo "FAIL" + ``` + +4. Verify selective disable works: + ```bash + $CLI down --name e2e-m2-test + $CLI up --name e2e-m2-test --disable prometheus + $CLI status --name e2e-m2-test 2>&1 | grep -qiE "prometheus" && echo "WARN: Prometheus should be disabled" || echo "PASS" + ``` + +**Cleanup:** `$CLI down --name e2e-m2-test && $CLI up --name e2e-m2-test` + +--- + +### M2-OBS-002: Grafana dashboards accessible with presets + +**Preconditions:** LocalNet running with Grafana enabled. +**Platforms:** All + +**Steps:** + +1. Verify Grafana is accessible: + ```bash + GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}" + curl -sf "$GRAFANA_URL/api/health" | grep -qiE "ok" && echo "PASS" || echo "FAIL" + ``` + +2. Verify Canton-specific dashboard presets exist: + ```bash + curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -qiE "(canton|transaction|latency|throughput|contract)" + ``` + - **Expected:** At least one Canton-specific dashboard preset is found. + +3. Verify dashboards contain expected panels: + ```bash + DASHBOARD_UID=$(curl -sf "$GRAFANA_URL/api/search?type=dash-db" | grep -oE '"uid":"[^"]*"' | head -1 | cut -d'"' -f4) + curl -sf "$GRAFANA_URL/api/dashboards/uid/$DASHBOARD_UID" | grep -qiE "(transactions.sec|latency|active.contract|throughput)" + ``` + - **Expected:** Dashboard includes DApp developer-focused panels. + +**Cleanup:** None. + +--- + +### M2-OBS-003: Metrics CLI summary output + +**Preconditions:** LocalNet running with Grafana enabled. +**Platforms:** All + +**Steps:** + +1. Run metrics command: + ```bash + $CLI metrics --name e2e-m2-test + ``` + - **Expected:** Exit code `0`. + - **Verify output includes key metrics:** + ```bash + OUTPUT=$($CLI metrics --name e2e-m2-test 2>&1) + echo "$OUTPUT" | grep -qiE "(throughput|transactions)" + echo "$OUTPUT" | grep -qiE "(latency|p50|p99)" + echo "$OUTPUT" | grep -qiE "(resource|cpu|memory)" + ``` + +2. Verify Grafana dashboard URLs are printed: + ```bash + $CLI metrics --name e2e-m2-test 2>&1 | grep -qiE "https?://.*grafana" + ``` + - **Expected:** At least one Grafana URL in output. + +**Cleanup:** None. + +--- + +## Automation Conveniences + +--- + +### M2-AUT-001: Machine-readable --json output + +**Preconditions:** LocalNet running. +**Platforms:** All + +**Steps:** + +1. Status with JSON output: + ```bash + $CLI status --name e2e-m2-test --json + ``` + - **Expected:** Exit code `0`. + - **Verify valid JSON:** + ```bash + $CLI status --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null + ``` + - **Verify JSON contains expected keys:** + ```bash + $CLI status --name e2e-m2-test --json 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'name' in d or 'status' in d or 'services' in d, 'Missing expected keys'" + ``` + +2. DAR list with JSON output: + ```bash + $CLI dar list --name e2e-m2-test --json 2>&1 | python3 -m json.tool > /dev/null + ``` + - **Expected:** Valid JSON. + +3. List with JSON output: + ```bash + $CLI list --json 2>&1 | python3 -m json.tool > /dev/null + ``` + - **Expected:** Valid JSON. + +**Cleanup:** None. + +--- + +### M2-AUT-002: CI workflow: up → DAR upload → test → down + +**Preconditions:** Docker running, `daml-intro-contracts` project available. +**Platforms:** All +**Timeout:** 600 seconds + +This test simulates a complete CI pipeline. + +**Steps:** + +1. Start LocalNet: + ```bash + $CLI up --name e2e-ci-test + EXIT_CODE=$? + [ "$EXIT_CODE" -eq 0 ] && echo "PASS: up" || { echo "FAIL: up exited $EXIT_CODE"; exit 1; } + ``` + +2. Wait for readiness (already handled by `up`, but verify): + ```bash + $CLI status --name e2e-ci-test 2>&1 | grep -qiE "(healthy|ready|running)" + [ $? -eq 0 ] && echo "PASS: ready" || { echo "FAIL: not ready"; exit 1; } + ``` + +3. Upload DAR: + ```bash + $CLI dar upload "$DAR_PATH" --all-participants --name e2e-ci-test + [ $? -eq 0 ] && echo "PASS: dar upload" || { echo "FAIL: dar upload"; exit 1; } + ``` + +4. Run application tests (simulate with a health check): + ```bash + # In a real CI pipeline, this would be: daml test, or integration tests + $CLI dar list --name e2e-ci-test 2>&1 | grep -qiE "daml-intro-contracts" + [ $? -eq 0 ] && echo "PASS: test verification" || { echo "FAIL: test verification"; exit 1; } + ``` + +5. Teardown: + ```bash + $CLI down --name e2e-ci-test + [ $? -eq 0 ] && echo "PASS: down" || { echo "FAIL: down"; exit 1; } + $CLI clean --name e2e-ci-test --force + [ $? -eq 0 ] && echo "PASS: clean" || { echo "FAIL: clean"; exit 1; } + ``` + +6. Verify full cleanup: + ```bash + docker ps --filter "label=canton-devkit" --format '{{.Names}}' | grep -qE "e2e-ci-test" && echo "FAIL: containers remain" || echo "PASS: full cleanup" + ``` + +**Cleanup:** Handled in step 5-6. + +--- + +## AI Agent Skill Documents + +--- + +### M2-SKL-001: AI agent skill document validation + +**Preconditions:** Skill documents exist in the DevKit distribution. +**Platforms:** All + +**Steps:** + +1. Verify skill documents are included in the distribution: + ```bash + # Check for skill docs in the installed package or binary directory + find $(dirname $(which canton-devkit 2>/dev/null || echo ".")) -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -5 + # Or check a known documentation path + ls -la docs/skills/ 2>/dev/null || ls -la skills/ 2>/dev/null || echo "Check skill document location" + ``` + +2. Verify a skill document contains executable workflow steps: + ```bash + # Read a skill document and verify it contains dpm localnet commands + SKILL_DOC=$(find . -name "*.md" -path "*skill*" -o -name "*.md" -path "*agent*" 2>/dev/null | head -1) + if [ -n "$SKILL_DOC" ]; then + grep -qiE "dpm localnet|canton-devkit localnet" "$SKILL_DOC" && echo "PASS: contains CLI commands" || echo "FAIL: no CLI commands found" + grep -qiE "(up|down|status|dar upload|logs)" "$SKILL_DOC" && echo "PASS: contains lifecycle commands" || echo "FAIL: no lifecycle commands" + else + echo "WARN: Skill document not found — check distribution packaging" + fi + ``` + +3. Execute the basic workflow described in a skill document: + ```bash + # The skill document should describe a workflow like: + # 1. Start LocalNet + # 2. Check status + # 3. Upload a DAR + # 4. List packages + # 5. Check logs + # 6. Stop LocalNet + # Execute each step and verify: + $CLI up --name e2e-skill-test + $CLI status --name e2e-skill-test + $CLI dar upload "$DAR_PATH" --all-participants --name e2e-skill-test + $CLI dar list --name e2e-skill-test + timeout 5 $CLI logs --name e2e-skill-test 2>&1 | head -10 + $CLI down --name e2e-skill-test + echo "PASS: skill workflow executed successfully" + ``` + +**Cleanup:** `$CLI clean --name e2e-skill-test --force 2>/dev/null || true` + +--- + +## Cross-Platform Notes + +| Platform | Special Considerations | +|---|---| +| **macOS (Apple Silicon)** | Docker Desktop required. Web UI accessible at `localhost`. Grafana/Prometheus default ports may conflict with local dev tools. | +| **Linux (amd64)** | Native Docker. Ensure firewall allows localhost port access for Web UI and observability stack. | +| **Windows (amd64)** | Docker Desktop with WSL 2. Web UI URL may differ (`localhost` vs WSL IP). `curl` available via WSL or PowerShell `Invoke-WebRequest`. `timeout` command replaced with PowerShell equivalent. | + +--- + +## Test Execution Summary + +| ID | Test Name | Category | Depends On | +|---|---|---|---| +| M2-WEB-001 | Web UI launches and is accessible | Web UI | M1 suite | +| M2-WEB-002 | Web UI lifecycle actions | Web UI | M2-WEB-001 | +| M2-WEB-003 | Web UI dashboard content | Web UI | M2-WEB-001 | +| M2-DAR-001 | DAR upload to single participant | DAR | M1 suite | +| M2-DAR-002 | DAR upload to all participants | DAR | M1 suite | +| M2-DAR-003 | DAR upload with --vet and --dry-run | DAR | M1 suite | +| M2-DAR-004 | DAR list packages | DAR | M2-DAR-001 | +| M2-DAR-005 | DAR info | DAR | M2-DAR-001 | +| M2-DAR-006 | DAR download | DAR | M2-DAR-001 | +| M2-DAR-007 | DAR diff between two versions | DAR | M2-DAR-001 | +| M2-DAR-008 | DAR remove / unvet | DAR | M2-DAR-001 | +| M2-DAR-009 | DAR build-upload | DAR | M1 suite | +| M2-DAR-010 | DAR watch mode | DAR | M1 suite | +| M2-DAR-011 | Web UI DAR + package explorer | DAR Web UI | M2-WEB-001, M2-DAR-001 | +| M2-CTR-001 | Contracts watch (live) | Contracts | M2-DAR-001 | +| M2-CTR-002 | TX ls multi-filter | Contracts | M2-DAR-001 | +| M2-CTR-003 | TX replay per-party | Contracts | M2-CTR-002 | +| M2-CTR-004 | Web UI ACS explorer | Contracts Web UI | M2-WEB-001 | +| M2-CTR-005 | Web UI transaction timeline | Contracts Web UI | M2-WEB-001 | +| M2-CTR-006 | Web UI contract detail | Contracts Web UI | M2-WEB-001 | +| M2-OBS-001 | Prometheus/Grafana toggle | Observability | M1 suite | +| M2-OBS-002 | Grafana dashboards with presets | Observability | M2-OBS-001 | +| M2-OBS-003 | Metrics CLI summary | Observability | M2-OBS-001 | +| M2-AUT-001 | Machine-readable --json output | Automation | M1 suite | +| M2-AUT-002 | CI workflow E2E | Automation | M1 suite | +| M2-SKL-001 | AI agent skill document validation | AI Skills | M1 suite | diff --git a/docs/tests/e2e-test-milestone-3.html b/docs/tests/e2e-test-milestone-3.html new file mode 100644 index 00000000..f7e8fd1d --- /dev/null +++ b/docs/tests/e2e-test-milestone-3.html @@ -0,0 +1,1323 @@ + + + + + +E2E Test Plan -- Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) + + + + + + + +
+ +
+

E2E Test Plan — Milestone 3 10 Tests

+

Token Faucets & Token Standard Tooling (CIP-0112)

+
+ Proposal: original-devkit-proposal.md, Milestone 3 + Delivery: Month 9 + Platforms: macOS (Apple Silicon), Linux (amd64), Windows (amd64) + Prerequisite: Milestones 1 + 2 passing +
+
+
0 / 0 steps completed
+
+
+
+ + +
+ TL;DR. This test plan validates CIP-0112 token standard tooling: the token creation wizard, minting, transfer, burn, and balance CLI commands, a full lifecycle E2E flow, edge cases (partial burn to zero), Web UI token toolkit, and cross-platform regression across macOS, Linux, and Windows. +
+ + +

Overview

+

This plan covers 10 end-to-end test cases for Milestone 3 of the Canton DevKit project. All token commands target the CIP-0112 (V2) path as the default. Non-interactive flags are used for wizard-style commands to enable AI agent execution.

+ +

Conventions

+
    +
  • $CLI = dpm localnet or canton-devkit localnet (run the full suite twice, once per mode).
  • +
  • Token commands target the CIP-0112 (V2) path as the default.
  • +
  • $WEB_UI_URL = URL of the Web UI (from $CLI status).
  • +
  • Default step timeout: 30 seconds unless noted.
  • +
+ +

Environment Setup

+
# Set CLI mode
+export CLI="dpm localnet"       # or "canton-devkit localnet"
+
+# Ensure clean state
+$CLI clean --name e2e-m3-test --force 2>/dev/null || true
+
+# Start LocalNet for Milestone 3 tests
+$CLI up --name e2e-m3-test
+
+# Capture Web UI URL
+export WEB_UI_URL=$($CLI status --name e2e-m3-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1)
+
+# Capture available wallet/party info
+export WALLET_A=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|ALICE" | head -1 | cut -d= -f2)
+export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | head -1 | cut -d= -f2)
+ + +

Test Cases

+ + +
+ + M3-TOK-001 + Token create wizard (non-interactive) + Token Create + +
+
+ Preconditions: LocalNet e2e-m3-test running. + Platforms: All +
+ +
+ +
+

Step 1. Create a new token using non-interactive flags (CIP-0112 path):

+
$CLI token create \
+  --token-name "TestCoin" \
+  --symbol "TST" \
+  --decimals 8 \
+  --initial-supply 1000000 \
+  --name e2e-m3-test
+

Expected: Exit code 0.

+

Verify creation confirmation:

+
$CLI token create \
+  --token-name "TestCoin" \
+  --symbol "TST" \
+  --decimals 8 \
+  --initial-supply 1000000 \
+  --name e2e-m3-test 2>&1 | grep -qiE "(created|success|TestCoin|TST)"
+
+
+ +
+ +
+

Step 2. Verify the token exists by checking balance:

+
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(1000000|TestCoin|TST)"
+

Expected: Balance shows the initial supply.

+
+
+ +
+ +
+

Step 3. Verify CIP-0112 alignment:

+
$CLI token create \
+  --token-name "TestCoin" \
+  --symbol "TST" \
+  --decimals 8 \
+  --initial-supply 1000000 \
+  --name e2e-m3-test 2>&1 | grep -qiE "(cip.0112|v2|token.standard)"
+

Expected: Output references CIP-0112 / V2 path (or no V1 warnings).

+
+
+ +

Cleanup: None (token persists for subsequent tests).

+
+
+ + +
+ + M3-TOK-002 + Token mint + Token Ops + +
+
+ Preconditions: Token "TestCoin" created (M3-TOK-001). + Platforms: All +
+ +
+ +
+

Step 1. Mint additional tokens:

+
$CLI token mint TestCoin 500000 --name e2e-m3-test
+

Expected: Exit code 0.

+

Verify mint confirmation:

+
$CLI token mint TestCoin 500000 --name e2e-m3-test 2>&1 | grep -qiE "(minted|success|500000)"
+
+
+ +
+ +
+

Step 2. Verify updated balance:

+
$CLI token balance TestCoin --name e2e-m3-test
+

Expected: Balance is now 1500000 (initial 1000000 + minted 500000).

+

Verify:

+
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "1500000"
+
+
+ +
+ +
+

Step 3. Mint to a specific wallet:

+
$CLI token mint TestCoin 100000 --to "$WALLET_B" --name e2e-m3-test
+

Expected: Exit code 0.

+
+
+ +

Cleanup: None.

+
+
+ + +
+ + M3-TOK-003 + Token transfer + Token Ops + +
+
+ Preconditions: Token "TestCoin" minted (M3-TOK-002), multiple wallets available. + Platforms: All +
+ +
+ +
+

Step 1. Transfer tokens between wallets:

+
$CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test
+

Expected: Exit code 0.

+

Verify transfer confirmation:

+
$CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test 2>&1 | grep -qiE "(transferred|success|250000)"
+
+
+ +
+ +
+

Step 2. Verify sender balance decreased:

+
SENDER_BALANCE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
+# Sender balance should be 1500000 - 250000 = 1250000 (or adjusted based on previous mints)
+echo "Sender balance: $SENDER_BALANCE"
+
+
+ +
+ +
+

Step 3. Verify receiver balance increased:

+
$CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test 2>&1
+

Expected: Receiver has tokens from transfer + any direct mints.

+
+
+ +
+ +
+

Step 4. Attempt transfer with insufficient balance:

+
$CLI token transfer TestCoin 999999999999 --to "$WALLET_B" --name e2e-m3-test
+

Expected: Non-zero exit code, error message about insufficient balance.

+
+
+ +

Cleanup: None.

+
+
+ + +
+ + M3-TOK-004 + Token burn + Token Ops + +
+
+ Preconditions: Token "TestCoin" exists with balance > 0. + Platforms: All +
+ +
+ +
+

Step 1. Burn tokens:

+
$CLI token burn TestCoin 100000 --name e2e-m3-test
+

Expected: Exit code 0.

+

Verify burn confirmation:

+
$CLI token burn TestCoin 100000 --name e2e-m3-test 2>&1 | grep -qiE "(burned|burnt|success|100000)"
+
+
+ +
+ +
+

Step 2. Verify balance decreased after burn:

+
$CLI token balance TestCoin --name e2e-m3-test
+

Expected: Balance reduced by 100000 from pre-burn value.

+
+
+ +
+ +
+

Step 3. Attempt to burn more than available balance:

+
$CLI token burn TestCoin 999999999999 --name e2e-m3-test
+

Expected: Non-zero exit code, error message about insufficient balance.

+
+
+ +

Cleanup: None.

+
+
+ + +
+ + M3-TOK-005 + Token balance query + Token Ops + +
+
+ Preconditions: Token "TestCoin" exists. + Platforms: All +
+ +
+ +
+

Step 1. Query balance for default wallet:

+
$CLI token balance TestCoin --name e2e-m3-test
+

Expected: Exit code 0.

+

Verify output format:

+
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(TestCoin|TST|balance|[0-9]+)"
+
+
+ +
+ +
+

Step 2. Query balance for a specific wallet:

+
$CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test
+

Expected: Exit code 0, shows balance for wallet B.

+
+
+ +
+ +
+

Step 3. Query balance for non-existent token:

+
$CLI token balance NonExistentToken --name e2e-m3-test
+

Expected: Non-zero exit code or zero balance, with clear message.

+
+
+ +
+ +
+

Step 4. Query all token balances (if supported):

+
$CLI token balance --name e2e-m3-test
+

Expected: Exit code 0, lists all tokens and their balances.

+
+
+ +

Cleanup: None.

+
+
+ + +
+ + M3-TOK-006 + Full flow: create, mint, transfer, burn, balance + Token E2E + +
+
+ Preconditions: LocalNet e2e-m3-test running, clean token state preferred. + Platforms: All +
+

This test executes the complete token lifecycle in a single sequential flow, validating state after each step.

+ +
+ +
+

Step 1. Create a new token:

+
$CLI token create \
+  --token-name "E2ECoin" \
+  --symbol "E2E" \
+  --decimals 6 \
+  --initial-supply 0 \
+  --name e2e-m3-test
+

Verify: Exit code 0, creation confirmed.

+

Assert: $CLI token balance E2ECoin --name e2e-m3-test shows 0.

+
+
+ +
+ +
+

Step 2. Mint initial supply:

+
$CLI token mint E2ECoin 1000000 --name e2e-m3-test
+

Verify: Exit code 0.

+

Assert: $CLI token balance E2ECoin --name e2e-m3-test shows 1000000.

+
+
+ +
+ +
+

Step 3. Transfer to another wallet:

+
$CLI token transfer E2ECoin 400000 --to "$WALLET_B" --name e2e-m3-test
+

Verify: Exit code 0.

+

Assert sender: Balance = 600000.

+

Assert receiver: Balance = 400000.

+
+
+ +
+ +
+

Step 4. Burn from sender:

+
$CLI token burn E2ECoin 100000 --name e2e-m3-test
+

Verify: Exit code 0.

+

Assert sender: Balance = 500000.

+
+
+ +
+ +
+

Step 5. Final balance check:

+
$CLI token balance E2ECoin --name e2e-m3-test
+

Assert sender: 500000.

+
$CLI token balance E2ECoin --to "$WALLET_B" --name e2e-m3-test
+

Assert receiver: 400000.

+

Assert total supply: 900000 (1000000 minted - 100000 burned).

+
+
+ +
+ +
+

Step 6. Ledger verification — verify token operations created transactions:

+
$CLI tx ls --template "E2ECoin" --name e2e-m3-test 2>&1 | wc -l
+

Expected: At least 4 transactions (create, mint, transfer, burn).

+
+
+ +

Cleanup: None (E2ECoin persists for reference).

+
+
+ + +
+ + M3-TOK-007 + Token balance after partial burn + Token Edge + +
+
+ Preconditions: Token "TestCoin" exists with known balance. + Platforms: All +
+ +
+ +
+

Step 1. Record current balance:

+
BEFORE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
+echo "Balance before: $BEFORE"
+
+
+ +
+ +
+

Step 2. Burn a small amount:

+
BURN_AMOUNT=1
+$CLI token burn TestCoin $BURN_AMOUNT --name e2e-m3-test
+

Expected: Exit code 0.

+
+
+ +
+ +
+

Step 3. Verify exact balance after partial burn:

+
AFTER=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+")
+EXPECTED=$((BEFORE - BURN_AMOUNT))
+[ "$AFTER" -eq "$EXPECTED" ] && echo "PASS: balance is $AFTER (expected $EXPECTED)" || echo "FAIL: balance is $AFTER, expected $EXPECTED"
+
+
+ +
+ +
+

Step 4. Burn all remaining balance:

+
$CLI token burn TestCoin "$AFTER" --name e2e-m3-test
+

Expected: Exit code 0.

+
+
+ +
+ +
+

Step 5. Verify zero balance:

+
$CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "^0$\|: 0\|balance.*0"
+

Expected: Balance is exactly 0.

+
+
+ +

Cleanup: None.

+
+
+ + +
+ + M3-TOK-008 + Web UI token toolkit: create + mint + Token Web UI + +
+
+ Preconditions: Web UI accessible, LocalNet running. + Platforms: All +
+ +
+ +
+

Step 1. Verify token toolkit section exists in Web UI:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(token|faucet|mint|create.*token)"
+

Expected: Token section found.

+
+
+ +
+ +
+

Step 2. Verify token cards are rendered:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(token.*card|TestCoin|E2ECoin|TST|E2E)"
+

Expected: Previously created tokens appear as cards.

+
+
+ +
+ +
+

Step 3. Verify mint action UI elements:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(mint|amount|supply)"
+

Expected: Mint controls present.

+
+
+ +
+ +
+

Step 4. Verify create token form/wizard UI elements:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(create|wizard|name|symbol|decimals)"
+

Expected: Token creation form present.

+
+
+ +

Note: Full interactive token creation and minting via the Web UI requires browser automation. The above validates UI structure and that existing tokens are reflected.

+

Cleanup: None.

+
+
+ + +
+ + M3-TOK-009 + Web UI token transfer + activity feed + Token Web UI + +
+
+ Preconditions: Web UI accessible, tokens with balance exist. + Platforms: All +
+ +
+ +
+

Step 1. Verify transfer action UI elements:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(transfer|send|recipient|to.*wallet)"
+

Expected: Transfer controls present.

+
+
+ +
+ +
+

Step 2. Verify recent token activity feed:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(activity|recent|history|transaction|event)"
+

Expected: Activity feed section present.

+
+
+ +
+ +
+

Step 3. Verify token activity includes operations from CLI tests:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(mint|transfer|burn|create)"
+

Expected: Token operations from earlier tests appear in the activity feed.

+
+
+ +
+ +
+

Step 4. Verify burn action UI elements:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(burn|destroy)"
+

Expected: Burn controls present.

+
+
+ +
+ +
+

Step 5. Verify balance display:

+
curl -sf "$WEB_UI_URL" | grep -qiE "(balance|supply|[0-9]+)"
+

Expected: Token balances displayed.

+
+
+ +

Note: Full interactive transfer and activity feed verification requires browser automation.

+

Cleanup: None.

+
+
+ + +
+ + M3-TOK-010 + Cross-platform regression + Regression + +
+
+ Preconditions: This is a meta-test — run the full M3-TOK-001 through M3-TOK-009 suite on each platform. + Platforms: All (run once per platform) +
+ +
+ +
+

Step 1. Per-platform execution:

+
echo "Running on platform: $(uname -s) $(uname -m)"
+
+
+ +
+ +
+

Step 2. Execute the full token test suite on the current platform:

+
# Run M3-TOK-001 through M3-TOK-009 and record results
+PASS_COUNT=0
+FAIL_COUNT=0
+
+# M3-TOK-001: Token create
+$CLI token create --token-name "PlatformCoin" --symbol "PLT" --decimals 6 --initial-supply 1000 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
+
+# M3-TOK-002: Token mint
+$CLI token mint PlatformCoin 500 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
+
+# M3-TOK-003: Token transfer
+$CLI token transfer PlatformCoin 200 --to "$WALLET_B" --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
+
+# M3-TOK-004: Token burn
+$CLI token burn PlatformCoin 100 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
+
+# M3-TOK-005: Token balance
+$CLI token balance PlatformCoin --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1))
+
+echo "Platform regression results: PASS=$PASS_COUNT FAIL=$FAIL_COUNT"
+[ "$FAIL_COUNT" -eq 0 ] && echo "PASS: all platform tests passed" || echo "FAIL: $FAIL_COUNT tests failed"
+
+
+ +
+ +
+

Step 3. Verify platform-specific binary integrity:

+
case "$(uname -s)" in
+  Darwin) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "Mach-O" && echo "PASS: macOS binary" || echo "WARN" ;;
+  Linux)  file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "ELF" && echo "PASS: Linux binary" || echo "WARN" ;;
+  *)      echo "Windows: verify .exe manually" ;;
+esac
+
+
+ +
+ +
+

Step 4. Record platform and Docker environment:

+
echo "=== Platform Info ==="
+uname -a
+docker version --format '{{.Server.Version}}'
+docker compose version
+$CLI --version
+echo "===================="
+
+
+ +

Cleanup:

+
$CLI down --name e2e-m3-test 2>/dev/null || true
+$CLI clean --name e2e-m3-test --force 2>/dev/null || true
+
+
+ + +
+

CIP-0112 Scope Note

+

All token tests in this plan target the CIP-0112 (Token Standard V2) path as the default, consistent with the proposal's committed scope. CIP-56 (V1) compatibility and V1-to-V2 migration helpers are explicitly out of scope for this test plan. If CIP-56 support is added later, a supplementary test plan should be created.

+
+ + +

Test Execution Summary

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDTest NameCategoryDepends On
M3-TOK-001Token create wizard (non-interactive)Token CreateM1 + M2 suites
M3-TOK-002Token mintToken OpsM3-TOK-001
M3-TOK-003Token transferToken OpsM3-TOK-002
M3-TOK-004Token burnToken OpsM3-TOK-002
M3-TOK-005Token balance queryToken OpsM3-TOK-001
M3-TOK-006Full flow: create, mint, transfer, burn, balanceToken E2EM1 + M2 suites
M3-TOK-007Token balance after partial burnToken EdgeM3-TOK-001
M3-TOK-008Web UI token toolkit: create + mintToken Web UIM2-WEB-001
M3-TOK-009Web UI token transfer + activity feedToken Web UIM2-WEB-001
M3-TOK-010Cross-platform regressionRegressionAll M3 tests
+
+ + +

Cross-Platform Notes

+
+ + + + + + + + + + + + + + + + + + + + + +
PlatformSpecial Considerations
macOS (Apple Silicon)Docker Desktop required. Token operations go through Ledger API on localhost. No known arm64-specific token issues expected.
Linux (amd64)Native Docker. Token operations may be faster due to native container performance. Ensure user is in docker group.
Windows (amd64)Docker Desktop with WSL 2. Token CLI commands work via PowerShell or WSL bash. grep and cut available in WSL; use PowerShell equivalents (Select-String, ConvertFrom-Json) for native Windows testing.
+
+ + + +
+ + + + + diff --git a/docs/tests/e2e-test-milestone-3.md b/docs/tests/e2e-test-milestone-3.md new file mode 100644 index 00000000..b9b49311 --- /dev/null +++ b/docs/tests/e2e-test-milestone-3.md @@ -0,0 +1,526 @@ +# E2E Test Plan — Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) + +> **Proposal Reference:** `original-devkit-proposal.md`, Milestone 3 (Lines 268–277) +> **Estimated Delivery:** Month 9 +> **Total Tests:** 10 +> **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) +> **Prerequisites:** All Milestone 1 and Milestone 2 tests passing. + +--- + +## Overview + +This test plan validates the CIP-0112 token standard tooling delivered in Milestone 3: the token creation wizard, minting, transfer, burn, and balance commands, the full token lifecycle E2E flow, edge cases, Web UI token toolkit, and cross-platform regression. + +### Conventions + +- `$CLI` = `dpm localnet` or `canton-devkit localnet` (run full suite twice — once per mode). +- Token commands target the CIP-0112 (V2) path as the default. +- Non-interactive flags are used for wizard-style commands to enable AI agent execution. +- `$WEB_UI_URL` = URL of the Web UI (from `$CLI status`). +- Default step timeout: 30 seconds unless noted. + +### Environment Setup + +```bash +# Set CLI mode +export CLI="dpm localnet" # or "canton-devkit localnet" + +# Ensure clean state +$CLI clean --name e2e-m3-test --force 2>/dev/null || true + +# Start LocalNet for Milestone 3 tests +$CLI up --name e2e-m3-test + +# Capture Web UI URL +export WEB_UI_URL=$($CLI status --name e2e-m3-test 2>&1 | grep -oiE "https?://[^ ]*ui[^ ]*" | head -1) + +# Capture available wallet/party info +export WALLET_A=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|ALICE" | head -1 | cut -d= -f2) +export WALLET_B=$($CLI env --name e2e-m3-test 2>&1 | grep -iE "WALLET|BOB" | head -1 | cut -d= -f2) +``` + +--- + +## Test Cases + +--- + +### M3-TOK-001: Token create wizard (non-interactive) + +**Preconditions:** LocalNet `e2e-m3-test` running. +**Platforms:** All + +**Steps:** + +1. Create a new token using non-interactive flags (CIP-0112 path): + ```bash + $CLI token create \ + --token-name "TestCoin" \ + --symbol "TST" \ + --decimals 8 \ + --initial-supply 1000000 \ + --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + - **Verify creation confirmation:** + ```bash + $CLI token create \ + --token-name "TestCoin" \ + --symbol "TST" \ + --decimals 8 \ + --initial-supply 1000000 \ + --name e2e-m3-test 2>&1 | grep -qiE "(created|success|TestCoin|TST)" + ``` + +2. Verify the token exists by checking balance: + ```bash + $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(1000000|TestCoin|TST)" + ``` + - **Expected:** Balance shows the initial supply. + +3. Verify CIP-0112 alignment: + ```bash + $CLI token create \ + --token-name "TestCoin" \ + --symbol "TST" \ + --decimals 8 \ + --initial-supply 1000000 \ + --name e2e-m3-test 2>&1 | grep -qiE "(cip.0112|v2|token.standard)" + ``` + - **Expected:** Output references CIP-0112 / V2 path (or no V1 warnings). + +**Cleanup:** None (token persists for subsequent tests). + +--- + +### M3-TOK-002: Token mint + +**Preconditions:** Token "TestCoin" created (M3-TOK-001). +**Platforms:** All + +**Steps:** + +1. Mint additional tokens: + ```bash + $CLI token mint TestCoin 500000 --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + - **Verify mint confirmation:** + ```bash + $CLI token mint TestCoin 500000 --name e2e-m3-test 2>&1 | grep -qiE "(minted|success|500000)" + ``` + +2. Verify updated balance: + ```bash + $CLI token balance TestCoin --name e2e-m3-test + ``` + - **Expected:** Balance is now `1500000` (initial 1000000 + minted 500000). + - **Verify:** + ```bash + $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "1500000" + ``` + +3. Mint to a specific wallet: + ```bash + $CLI token mint TestCoin 100000 --to "$WALLET_B" --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + +**Cleanup:** None. + +--- + +### M3-TOK-003: Token transfer + +**Preconditions:** Token "TestCoin" minted (M3-TOK-002), multiple wallets available. +**Platforms:** All + +**Steps:** + +1. Transfer tokens between wallets: + ```bash + $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + - **Verify transfer confirmation:** + ```bash + $CLI token transfer TestCoin 250000 --to "$WALLET_B" --name e2e-m3-test 2>&1 | grep -qiE "(transferred|success|250000)" + ``` + +2. Verify sender balance decreased: + ```bash + SENDER_BALANCE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") + # Sender balance should be 1500000 - 250000 = 1250000 (or adjusted based on previous mints) + echo "Sender balance: $SENDER_BALANCE" + ``` + +3. Verify receiver balance increased: + ```bash + $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test 2>&1 + ``` + - **Expected:** Receiver has tokens from transfer + any direct mints. + +4. Attempt transfer with insufficient balance: + ```bash + $CLI token transfer TestCoin 999999999999 --to "$WALLET_B" --name e2e-m3-test + ``` + - **Expected:** Non-zero exit code, error message about insufficient balance. + +**Cleanup:** None. + +--- + +### M3-TOK-004: Token burn + +**Preconditions:** Token "TestCoin" exists with balance > 0. +**Platforms:** All + +**Steps:** + +1. Burn tokens: + ```bash + $CLI token burn TestCoin 100000 --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + - **Verify burn confirmation:** + ```bash + $CLI token burn TestCoin 100000 --name e2e-m3-test 2>&1 | grep -qiE "(burned|burnt|success|100000)" + ``` + +2. Verify balance decreased after burn: + ```bash + $CLI token balance TestCoin --name e2e-m3-test + ``` + - **Expected:** Balance reduced by 100000 from pre-burn value. + +3. Attempt to burn more than available balance: + ```bash + $CLI token burn TestCoin 999999999999 --name e2e-m3-test + ``` + - **Expected:** Non-zero exit code, error message about insufficient balance. + +**Cleanup:** None. + +--- + +### M3-TOK-005: Token balance query + +**Preconditions:** Token "TestCoin" exists. +**Platforms:** All + +**Steps:** + +1. Query balance for default wallet: + ```bash + $CLI token balance TestCoin --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + - **Verify output format:** + ```bash + $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "(TestCoin|TST|balance|[0-9]+)" + ``` + +2. Query balance for a specific wallet: + ```bash + $CLI token balance TestCoin --to "$WALLET_B" --name e2e-m3-test + ``` + - **Expected:** Exit code `0`, shows balance for wallet B. + +3. Query balance for non-existent token: + ```bash + $CLI token balance NonExistentToken --name e2e-m3-test + ``` + - **Expected:** Non-zero exit code or zero balance, with clear message. + +4. Query all token balances (if supported): + ```bash + $CLI token balance --name e2e-m3-test + ``` + - **Expected:** Exit code `0`, lists all tokens and their balances. + +**Cleanup:** None. + +--- + +### M3-TOK-006: Full flow — create, mint, transfer, burn, balance + +**Preconditions:** LocalNet `e2e-m3-test` running, clean token state preferred. +**Platforms:** All + +This test executes the complete token lifecycle in a single sequential flow, validating state after each step. + +**Steps:** + +1. **Create** a new token: + ```bash + $CLI token create \ + --token-name "E2ECoin" \ + --symbol "E2E" \ + --decimals 6 \ + --initial-supply 0 \ + --name e2e-m3-test + ``` + - **Verify:** Exit code `0`, creation confirmed. + - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `0`. + +2. **Mint** initial supply: + ```bash + $CLI token mint E2ECoin 1000000 --name e2e-m3-test + ``` + - **Verify:** Exit code `0`. + - **Assert:** `$CLI token balance E2ECoin --name e2e-m3-test` shows `1000000`. + +3. **Transfer** to another wallet: + ```bash + $CLI token transfer E2ECoin 400000 --to "$WALLET_B" --name e2e-m3-test + ``` + - **Verify:** Exit code `0`. + - **Assert sender:** Balance = `600000`. + - **Assert receiver:** Balance = `400000`. + +4. **Burn** from sender: + ```bash + $CLI token burn E2ECoin 100000 --name e2e-m3-test + ``` + - **Verify:** Exit code `0`. + - **Assert sender:** Balance = `500000`. + +5. **Final balance** check: + ```bash + $CLI token balance E2ECoin --name e2e-m3-test + ``` + - **Assert sender:** `500000`. + ```bash + $CLI token balance E2ECoin --to "$WALLET_B" --name e2e-m3-test + ``` + - **Assert receiver:** `400000`. + - **Assert total supply:** `900000` (1000000 minted - 100000 burned). + +6. **Ledger verification** — verify token operations created transactions: + ```bash + $CLI tx ls --template "E2ECoin" --name e2e-m3-test 2>&1 | wc -l + ``` + - **Expected:** At least 4 transactions (create, mint, transfer, burn). + +**Cleanup:** None (E2ECoin persists for reference). + +--- + +### M3-TOK-007: Token balance after partial burn + +**Preconditions:** Token "TestCoin" exists with known balance. +**Platforms:** All + +**Steps:** + +1. Record current balance: + ```bash + BEFORE=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") + echo "Balance before: $BEFORE" + ``` + +2. Burn a small amount: + ```bash + BURN_AMOUNT=1 + $CLI token burn TestCoin $BURN_AMOUNT --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + +3. Verify exact balance after partial burn: + ```bash + AFTER=$($CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -oE "[0-9]+") + EXPECTED=$((BEFORE - BURN_AMOUNT)) + [ "$AFTER" -eq "$EXPECTED" ] && echo "PASS: balance is $AFTER (expected $EXPECTED)" || echo "FAIL: balance is $AFTER, expected $EXPECTED" + ``` + +4. Burn all remaining balance: + ```bash + $CLI token burn TestCoin "$AFTER" --name e2e-m3-test + ``` + - **Expected:** Exit code `0`. + +5. Verify zero balance: + ```bash + $CLI token balance TestCoin --name e2e-m3-test 2>&1 | grep -qiE "^0$\|: 0\|balance.*0" + ``` + - **Expected:** Balance is exactly `0`. + +**Cleanup:** None. + +--- + +### M3-TOK-008: Web UI token toolkit — create + mint + +**Preconditions:** Web UI accessible, LocalNet running. +**Platforms:** All + +**Steps:** + +1. Verify token toolkit section exists in Web UI: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(token|faucet|mint|create.*token)" + ``` + - **Expected:** Token section found. + +2. Verify token cards are rendered: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(token.*card|TestCoin|E2ECoin|TST|E2E)" + ``` + - **Expected:** Previously created tokens appear as cards. + +3. Verify mint action UI elements: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(mint|amount|supply)" + ``` + - **Expected:** Mint controls present. + +4. Verify create token form/wizard UI elements: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(create|wizard|name|symbol|decimals)" + ``` + - **Expected:** Token creation form present. + +**Note:** Full interactive token creation and minting via the Web UI requires browser automation. The above validates UI structure and that existing tokens are reflected. + +**Cleanup:** None. + +--- + +### M3-TOK-009: Web UI token transfer + activity feed + +**Preconditions:** Web UI accessible, tokens with balance exist. +**Platforms:** All + +**Steps:** + +1. Verify transfer action UI elements: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(transfer|send|recipient|to.*wallet)" + ``` + - **Expected:** Transfer controls present. + +2. Verify recent token activity feed: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(activity|recent|history|transaction|event)" + ``` + - **Expected:** Activity feed section present. + +3. Verify token activity includes operations from CLI tests: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(mint|transfer|burn|create)" + ``` + - **Expected:** Token operations from earlier tests appear in the activity feed. + +4. Verify burn action UI elements: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(burn|destroy)" + ``` + - **Expected:** Burn controls present. + +5. Verify balance display: + ```bash + curl -sf "$WEB_UI_URL" | grep -qiE "(balance|supply|[0-9]+)" + ``` + - **Expected:** Token balances displayed. + +**Note:** Full interactive transfer and activity feed verification requires browser automation. + +**Cleanup:** None. + +--- + +### M3-TOK-010: Cross-platform regression (macOS/Linux/Windows) + +**Preconditions:** This test is a meta-test — run the full M3-TOK-001 through M3-TOK-009 suite on each platform. +**Platforms:** All (run once per platform) + +**Steps:** + +1. **Per-platform execution:** + ```bash + echo "Running on platform: $(uname -s) $(uname -m)" + ``` + +2. **Execute the full token test suite on the current platform:** + ```bash + # Run M3-TOK-001 through M3-TOK-009 and record results + PASS_COUNT=0 + FAIL_COUNT=0 + + # M3-TOK-001: Token create + $CLI token create --token-name "PlatformCoin" --symbol "PLT" --decimals 6 --initial-supply 1000 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + + # M3-TOK-002: Token mint + $CLI token mint PlatformCoin 500 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + + # M3-TOK-003: Token transfer + $CLI token transfer PlatformCoin 200 --to "$WALLET_B" --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + + # M3-TOK-004: Token burn + $CLI token burn PlatformCoin 100 --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + + # M3-TOK-005: Token balance + $CLI token balance PlatformCoin --name e2e-m3-test && PASS_COUNT=$((PASS_COUNT+1)) || FAIL_COUNT=$((FAIL_COUNT+1)) + + echo "Platform regression results: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" + [ "$FAIL_COUNT" -eq 0 ] && echo "PASS: all platform tests passed" || echo "FAIL: $FAIL_COUNT tests failed" + ``` + +3. **Verify platform-specific binary integrity:** + ```bash + case "$(uname -s)" in + Darwin) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "Mach-O" && echo "PASS: macOS binary" || echo "WARN" ;; + Linux) file $(which canton-devkit 2>/dev/null || echo ".") | grep -qiE "ELF" && echo "PASS: Linux binary" || echo "WARN" ;; + *) echo "Windows: verify .exe manually" ;; + esac + ``` + +4. **Record platform and Docker environment:** + ```bash + echo "=== Platform Info ===" + uname -a + docker version --format '{{.Server.Version}}' + docker compose version + $CLI --version + echo "====================" + ``` + +**Cleanup:** +```bash +$CLI down --name e2e-m3-test 2>/dev/null || true +$CLI clean --name e2e-m3-test --force 2>/dev/null || true +``` + +--- + +## Cross-Platform Notes + +| Platform | Special Considerations | +|---|---| +| **macOS (Apple Silicon)** | Docker Desktop required. Token operations go through Ledger API on localhost. No known arm64-specific token issues expected. | +| **Linux (amd64)** | Native Docker. Token operations may be faster due to native container performance. Ensure user is in `docker` group. | +| **Windows (amd64)** | Docker Desktop with WSL 2. Token CLI commands work via PowerShell or WSL bash. `grep` and `cut` available in WSL; use PowerShell equivalents (`Select-String`, `ConvertFrom-Json`) for native Windows testing. | + +--- + +## Test Execution Summary + +| ID | Test Name | Category | Depends On | +|---|---|---|---| +| M3-TOK-001 | Token create wizard (non-interactive) | Token Create | M1 + M2 suites | +| M3-TOK-002 | Token mint | Token Ops | M3-TOK-001 | +| M3-TOK-003 | Token transfer | Token Ops | M3-TOK-002 | +| M3-TOK-004 | Token burn | Token Ops | M3-TOK-002 | +| M3-TOK-005 | Token balance query | Token Ops | M3-TOK-001 | +| M3-TOK-006 | Full flow: create, mint, transfer, burn, balance | Token E2E | M1 + M2 suites | +| M3-TOK-007 | Token balance after partial burn | Token Edge | M3-TOK-001 | +| M3-TOK-008 | Web UI token toolkit: create + mint | Token Web UI | M2-WEB-001 | +| M3-TOK-009 | Web UI token transfer + activity feed | Token Web UI | M2-WEB-001 | +| M3-TOK-010 | Cross-platform regression | Regression | All M3 tests | + +--- + +## CIP-0112 Scope Note + +All token tests in this plan target the **CIP-0112 (Token Standard V2)** path as the default, consistent with the proposal's committed scope. CIP-56 (V1) compatibility and V1-to-V2 migration helpers are explicitly out of scope for this test plan. If CIP-56 support is added later, a supplementary test plan should be created. From 75e1b2b68ae2f4eaef73fd34a915f9d911df4ce4 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 2 Jul 2026 19:09:33 +0000 Subject: [PATCH 29/68] docs: remove resolved limitations (locking, splice version pinning) --- docs/limitations.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/docs/limitations.md b/docs/limitations.md index b543e78e..3ac3e223 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -18,26 +18,6 @@ resolved. must be torn down with that older binary and re-created under a DNS-label name. -## Concurrency / locking - -- **(resolved)** Registry locking is now a real cross-process lock on - every platform. On Windows both the fail-fast per-instance lock and - the blocking index read-modify-write lock go through - `windows.LockFileEx` (`internal/registry/lock_windows.go`, - `internal/registry/index_lock_windows.go`); Linux/macOS use - `syscall.Flock`. The OS releases the lock when the handle closes or - the process exits, so there is no stale lock file to recover. - -## Splice version pinning - -- **(resolved)** The catalogue pins (a) the git commit SHA (immutable, - content-addressable — `internal/splice/versions.json`'s `commit` - field) and (b) the ContentSHA of the extracted - `cluster/compose/localnet/` subtree (`content_sha` field). The hash - covers the extracted tree, not the gzip envelope, so a gzip-level - rewrite by GitHub (compression-level change, mtime drift) has no - effect. See [versions.md](./versions.md). - ## Container image pinning - **Splice container images are pulled by mutable ghcr tags, not From 3ef17d33d2b1280c7c13b6a73de25161db368451 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 2 Jul 2026 19:22:01 +0000 Subject: [PATCH 30/68] Remove unused document --- docs/validation-checklist.md | 60 ------------------------------------ 1 file changed, 60 deletions(-) delete mode 100644 docs/validation-checklist.md diff --git a/docs/validation-checklist.md b/docs/validation-checklist.md deleted file mode 100644 index 14d51c81..00000000 --- a/docs/validation-checklist.md +++ /dev/null @@ -1,60 +0,0 @@ -# Zero-to-LocalNet validation checklist - -The goal: **a new developer reaches a running LocalNet in under 10 -minutes.** Use this checklist to validate your installation after -installing canton-devkit, or to sanity-check a release candidate on a -fresh machine. - -## Automated harness - -```bash -# default 10-minute budget -scripts/validate-zero-to-localnet.sh - -# true cold start (clears the Splice cache first → includes the ~140 MB download) -COLD=1 scripts/validate-zero-to-localnet.sh - -# looser budget on a slow link -BUDGET_SECONDS=900 scripts/validate-zero-to-localnet.sh -``` - -Exit `0` = passed within budget · `1` = a step failed · `2` = over budget. -The harness times: binary present → `doctor` → `up` (the long pole) → -`status` healthy → teardown. - -## Manual checklist - -A first-time user with Docker installed should be able to tick every -box without reading source: - -- [ ] **Install** — one command from [getting-started.md](getting-started.md) - (DPM component, `install.sh`, or a release binary) puts - `canton-devkit` / `dpm` on `PATH`. -- [ ] **Doctor** — `localnet doctor` runs and clearly reports any host gap - (Docker down, low memory, missing compose v2) with a fix. -- [ ] **Up** — `localnet up --name demo` downloads (on first run), boots, - waits for readiness, and prints endpoints. **Wall-clock < 10 min.** -- [ ] **Status** — `localnet status --name demo` shows healthy services + - participant/UI endpoints. -- [ ] **UI** — `localnet ui` opens a dashboard at the printed URL. -- [ ] **Down** — `localnet down --name demo` stops cleanly; `localnet list` - reflects it. -- [ ] **No surprises** — no manual Docker commands, no editing config - files, no hunting for ports. - -## Reporting results - -If a step fails or blows the budget, please open an issue. These details -make a run reproducible: - -| Field | Example | -|---|---| -| Platform | macOS 14 arm64 / Ubuntu 22.04 amd64 | -| Docker memory | 8 GiB | -| Cold or warm cache | cold | -| `up` wall-clock | 6m12s | -| Result | pass / fail (step) | -| Friction notes | "doctor memory hint was clear"; "didn't know which port was the UI" | - -Successful timings are welcome too — they help track how the -zero-to-LocalNet experience holds up across platforms. From 37df194615c5e82efddc96bbb4d8418647fa74a2 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Thu, 2 Jul 2026 20:29:57 +0000 Subject: [PATCH 31/68] docs: remove validation-checklist page and its references --- README.md | 1 - website/astro.config.mjs | 1 - .../docs/reference/validation-checklist.md | 63 ------------------- 3 files changed, 65 deletions(-) delete mode 100644 website/src/content/docs/reference/validation-checklist.md diff --git a/README.md b/README.md index d1ad8627..edfe0b76 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,6 @@ Optional `--profile observability` adds **Prometheus + Grafana** with a curated > [Troubleshooting](docs/troubleshooting.md) · > [Versions](docs/versions.md) · > [Limitations](docs/limitations.md) · -> [Validation checklist](docs/validation-checklist.md) · > [Telemetry](docs/telemetry.md) > > Demo: [`scripts/demo.sh`](scripts/demo.sh) (guided tour) · diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 12d539dd..0e3aac28 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -45,7 +45,6 @@ export default defineConfig({ { slug: 'reference/telemetry' }, { slug: 'reference/limitations' }, { slug: 'reference/troubleshooting' }, - { slug: 'reference/validation-checklist' }, ], }, { diff --git a/website/src/content/docs/reference/validation-checklist.md b/website/src/content/docs/reference/validation-checklist.md deleted file mode 100644 index de60d20a..00000000 --- a/website/src/content/docs/reference/validation-checklist.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: "Zero-to-LocalNet Validation Checklist" -description: "Validate that a new developer reaches a running LocalNet in under 10 minutes — automated harness and manual checklist." ---- - -The goal: **a new developer reaches a running LocalNet in under 10 -minutes.** Use this checklist to validate your installation after -installing canton-devkit, or to sanity-check a release candidate on a -fresh machine. - -## Automated harness - -```bash -# default 10-minute budget -scripts/validate-zero-to-localnet.sh - -# true cold start (clears the Splice cache first → includes the ~140 MB download) -COLD=1 scripts/validate-zero-to-localnet.sh - -# looser budget on a slow link -BUDGET_SECONDS=900 scripts/validate-zero-to-localnet.sh -``` - -Exit `0` = passed within budget · `1` = a step failed · `2` = over budget. -The harness times: binary present → `doctor` → `up` (the long pole) → -`status` healthy → teardown. - -## Manual checklist - -A first-time user with Docker installed should be able to tick every -box without reading source: - -- [ ] **Install** — one command from [Getting started](../../getting-started/) - (DPM component, `install.sh`, or a release binary) puts - `canton-devkit` / `dpm` on `PATH`. -- [ ] **Doctor** — `localnet doctor` runs and clearly reports any host gap - (Docker down, low memory, missing compose v2) with a fix. -- [ ] **Up** — `localnet up --name demo` downloads (on first run), boots, - waits for readiness, and prints endpoints. **Wall-clock < 10 min.** -- [ ] **Status** — `localnet status --name demo` shows healthy services + - participant/UI endpoints. -- [ ] **UI** — `localnet ui` opens a dashboard at the printed URL. -- [ ] **Down** — `localnet down --name demo` stops cleanly; `localnet list` - reflects it. -- [ ] **No surprises** — no manual Docker commands, no editing config - files, no hunting for ports. - -## Reporting results - -If a step fails or blows the budget, please open an issue. These details -make a run reproducible: - -| Field | Example | -|---|---| -| Platform | macOS 14 arm64 / Ubuntu 22.04 amd64 | -| Docker memory | 8 GiB | -| Cold or warm cache | cold | -| `up` wall-clock | 6m12s | -| Result | pass / fail (step) | -| Friction notes | "doctor memory hint was clear"; "didn't know which port was the UI" | - -Successful timings are welcome too — they help track how the -zero-to-LocalNet experience holds up across platforms. From 320035a57a91e7df133ee928431bf11125046ed3 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 3 Jul 2026 07:55:57 +0000 Subject: [PATCH 32/68] ci: grant contents:read to docs deploy job Job-level permissions override the workflow-level defaults, so the deploy job lost contents:read and actions/deploy-pages failed with 'Deployment failed, try again later.' --- .github/workflows/docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 86b96568..554e36bc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,6 +53,7 @@ jobs: needs: build runs-on: [self-hosted, Linux] permissions: + contents: read pages: write id-token: write environment: From eb3f75ca68a1c423c9a1c621b8e250349b413a64 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sat, 4 Jul 2026 13:07:10 +0200 Subject: [PATCH 33/68] Remove outdated docs (#200) * Remove unused commands * docs: sync README trim with site and add Web UI guide Remove the broken Roadmap nav link, drop the hero demo from the docs homepage, sync website limitations with the repo copy, and document Web UI security/remote access in a dedicated guide linked from README. * docs: remove standalone Web UI guide pages Drop the redundant web-ui.md docs and README links; the Web UI section in README is sufficient. * chore: update .gitignore * docs: trim copy and clarify LocalNet auth model Replace marketing-heavy README/site intro with neutral operator prose, sync mirrored docs to the website (including new FAQ page), and explain why validator-backend dev JWTs enable single-token multi-party use on LocalNet but not in production. --- .gitignore | 6 + README.md | 73 ++---------- docs/explorer.md | 9 +- docs/faq.md | 17 ++- docs/getting-started.md | 9 +- docs/limitations.md | 10 +- docs/observability.md | 30 +++-- docs/tokens.md | 6 +- docs/versions.md | 11 +- website/astro.config.mjs | 1 + website/src/content/docs/getting-started.md | 9 +- website/src/content/docs/guides/explorer.md | 9 +- .../content/docs/guides/localnet-lifecycle.md | 95 +--------------- .../src/content/docs/guides/observability.md | 30 +++-- website/src/content/docs/guides/tokens.md | 6 +- website/src/content/docs/index.mdx | 87 ++------------- website/src/content/docs/reference/faq.md | 104 ++++++++++++++++++ .../src/content/docs/reference/limitations.md | 35 +----- .../src/content/docs/reference/versions.md | 11 +- 19 files changed, 214 insertions(+), 344 deletions(-) create mode 100644 website/src/content/docs/reference/faq.md diff --git a/.gitignore b/.gitignore index c7c9eef3..41c3f878 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ dist/ .idea/ .vscode/ +# AI files +.cursor/ +.claude/ +AGENTS.md +CLAUDE.md + # Vite/React build output for the embedded Web UI. The placeholder # index.html is tracked so go:embed has at least one match on a # fresh clone; `make frontend` overwrites it with the real bundle. diff --git a/README.md b/README.md index edfe0b76..e544b8ab 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # canton-devkit -### The fastest way to run a [Canton](https://canton.network/) network on your laptop. +### The easiest and fastest way to run a [Canton](https://canton.network/) LocalNet on your laptop. A single-binary toolkit for spinning up, inspecting, and tearing down a complete Canton developer stack — Canton synchronizer + participant, Splice super-validator apps, three party wallets (app-user, app-provider, SV), Scan explorer, signed JWTs — in **one command**. @@ -25,30 +25,16 @@ A single-binary toolkit for spinning up, inspecting, and tearing down a complete Web UI · Commands · Architecture · - FAQ · - Roadmap + FAQ

-
- -```sh -❯ canton-devkit localnet up demo - ✓ Splice 0.6.4 cache hit - ✓ Compose started · 12 containers - ✓ Health checks · canton · splice · postgres - ✓ JWTs signed · app-user · app-provider · super-validator - ✦ "demo" is ready · Splice 0.6.4 · ready in 1m 24s -``` - -
-
--- ## ✨ Why canton-devkit? -[Canton](https://canton.network/) is the public blockchain with built-in privacy, designed for regulated finance — but its local-dev story has historically been a multi-hour expedition: clone [Splice](https://github.com/canton-network/splice), decode docker-compose layers, hunt JWT secrets, copy-paste party IDs. `canton-devkit` collapses that into a single binary built around three convictions: +[Canton](https://canton.network/) is the public blockchain with built-in privacy, designed for regulated finance — but its local-dev story has historically been a multi-hour expedition: clone [Splice](https://github.com/canton-network/splice), decode docker-compose layers, hunt JWT secrets, copy-paste party IDs. `canton-devkit` collapses that into a single binary: @@ -97,11 +83,11 @@ Optional `--profile observability` adds **Prometheus + Grafana** with a curated ## 🎯 Who is this for? -| You are… | We've got you because… | +| You are… | What DevKit provides | |---|---| | **A Daml/Canton app developer** | Reproducible local stack, signed JWTs, party IDs auto-recorded, hot DAR upload | | **A CI engineer** | Pinned versions, `--json` everywhere, exit codes documented, snapshot/restore for fixtures | -| **An evaluator** | One command to a healthy network. Tear it down with `clean` when you're done | +| **A first-time user** | One command to a healthy network; tear it down with `clean` when done | | **A workshop facilitator** | Same demo on every laptop, regardless of OS or Apple Silicon | > [!NOTE] @@ -233,40 +219,13 @@ canton-devkit localnet restore --from demo.tgz ## 🖥️ Web UI -`canton-devkit localnet ui` launches a polished Vite/React dashboard, embedded in the binary, **loopback-only by default**. - -
- - - - -
+Use `canton-devkit localnet ui` to launch the Web UI with -**What you get** - -- 📊 **Live overview** — instance status, container health (SSE) +- 📊 **Live overview** — instance status, container health - 🔑 **Developer setup** — copy JWTs, export `.env` / `.json` / `.yaml` - 💾 **Backup & restore** — download a snapshot, drag-drop to restore - 🪵 **Per-container logs** — `docker logs --tail` in the browser - ⚡ **⌘ K palette** — fuzzy-jump between instances and routes -- 🩺 **Live preflight** — Docker, memory, disk, before every `up` - - - -**Security model** - -- Bound to `127.0.0.1` — refuses non-loopback hosts unless `--allow-non-loopback` -- CSRF: same-Origin gate on all state-changing requests -- JWTs redacted by default in responses; opt-in via explicit query flag -- Embedded SPA — no external CDN, no analytics, no phone-home - -For remote access: - -```sh -ssh -L 7777:127.0.0.1:7777 dev-host -``` - -
--- @@ -310,7 +269,7 @@ flowchart LR CLI[CLI
localnet up / status / …] Web[Web UI
localhost:7777] end - Core[internal/localnet
orchestrator] + Core[LocalNet orchestrator] Reg[Registry
~/.canton-devkit/] Splice[Splice fetch
pinned by commit SHA] Docker[Docker Compose
~12 containers] @@ -353,22 +312,6 @@ The `splice` container runs **one Java process** (`SpliceApp daemon`) that hosts --- -## 🗺️ Roadmap - -**Shipped today** - -- **LocalNet lifecycle CLI + packaging** — lifecycle commands (`up` / `down` / `restart` / `clean` / `status` / `logs`), named instances, version pinning, explicit ports, snapshot/restore, doctor/preflight, deterministic automation output, DPM component, Homebrew/APT, and standalone release artifacts -- **Web UI + observability + DAR + Explorer** — Web UI parity for LocalNet lifecycle, logs, env export, snapshots, and preflight; Prometheus/Grafana with Canton dashboard presets; `metrics`; DAR upload/list/info/download/diff/remove/build-upload/watch; ACS + transaction Explorer; optional agent skill docs -- **CIP-0112 token tooling** — token workspace for LocalNet: party aliases, `token create`, `mint`, `transfer`, `burn`, `faucet`, `balance(s)`, `summary`, and `activity`, targeting the Token Standard V2 / CIP-0112 path via the `token-standard-v2` catalogue entry and `tokens-v2` profile - -**Planned** - -- Intel Mac (`darwin_amd64`) and Linux ARM (`linux_arm64`) release artifacts - -Follow progress in [open PRs](https://github.com/bitdynamics-ab/canton-devkit/pulls), or [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new) to weigh in on direction. - ---- - ## ❓ FAQ
diff --git a/docs/explorer.md b/docs/explorer.md index 4b35d8cf..24192681 100644 --- a/docs/explorer.md +++ b/docs/explorer.md @@ -6,9 +6,8 @@ transactions from the participant's gRPC ledger API, so you can see what's on the ledger without writing a script. This guide covers what the Explorer can do today, the equivalent -CLI commands for scripted workflows, and which features are -planned but not yet shipped — so you can tell at a glance whether -the Explorer fits your task. +CLI commands for scripted workflows, and current limitations — so you can +tell at a glance whether the Explorer fits your task. --- @@ -96,7 +95,7 @@ shows: - **Payload** as pretty-printed JSON. Records, lists, optionals, primitives, parties, and contract IDs all render natively; variants/enums/maps fall back to a textual proto form (a typed - decoder is planned). + decoder using Daml-LF metadata is not yet supported). - **Signatories** and **Observers** as separate lists. - **Created** with the RFC 3339 timestamp the participant recorded and a human-readable "Xs/m/h/d ago". @@ -270,7 +269,7 @@ Things the Explorer does **not** do today: - **Variants, enums, maps fall back to a textual proto form** in the payload preview. Records, lists, primitives, parties, and contract IDs decode natively. The full typed decoder using - Daml-LF metadata is planned. + Daml-LF metadata is not yet supported in the payload preview. If the Explorer can't show what you need, the CLI usually can — or the underlying gRPC API directly via the SDK. diff --git a/docs/faq.md b/docs/faq.md index 025f022f..380d2152 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -69,11 +69,18 @@ admin), so `token burn` archives the holder's `Holding` contracts directly and returns change. Supply = sum of holdings, so this removes the burned amount from circulation. -**Why are party aliases safe here but not in production?** -On LocalNet the `unsafe` dev secret signs for every party, so "you own -all parties" is true and the god-mode workspace is appropriate. That -assumption does **not** hold on a real network — the dev JWTs are -loopback-only and must never be reused off-box. +**How does the authorization work differently in production?** +On LocalNet, token commands authenticate with the **validator-backend +dev JWT** — a static token signed with the validator node's hardcoded +development secret. That credential can be granted act-as/read-as rights +for **any** party on the node, so your application can use a single token +for every party you allocate on the LocalNet validator (`bob`, `alice`, …) +and transfer, mint, or query on behalf of all of them. + +Production networks won't expose that model: each party uses its **own** +credentials, tokens are issued per session (not static JWTs), and you +should not use backend credentials to sign for other parties on the +network. ## Operations diff --git a/docs/getting-started.md b/docs/getting-started.md index ad4b738a..51421b7a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -144,8 +144,8 @@ sudo apt install canton-devkit=0.7.0 ``` The APT repo is currently unsigned and therefore uses `trusted=yes`; -the release still publishes SHA-256 metadata. A signed repository key -is planned. Package installation records a best-effort anonymous `apt` +the release still publishes SHA-256 metadata. Repository signing has +not been added yet. Package installation records a best-effort anonymous `apt` install-surface telemetry ping — see [telemetry.md](./telemetry.md) for what is sent and how to opt out before installing. @@ -289,9 +289,8 @@ platforms. ### Splice LocalNet versions -DevKit pins a **curated** set of Splice versions in -[`internal/splice/versions.json`](../internal/splice/versions.json); -`localnet up --version ` selects one. List them at runtime: +DevKit pins a **catalogue** of tested Splice versions; `localnet up +--version ` selects one. List them at runtime: ```bash canton-devkit localnet versions diff --git a/docs/limitations.md b/docs/limitations.md index 3ac3e223..d4d5d4dd 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -113,9 +113,7 @@ for the topology. per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any platform regardless of the Linux `host-gateway` mapping. -- **Planned.** Gating the per-instance overlay off (to drop the - duplication) is deferred until the shared-only path is end-to-end - validated on a native Linux Docker host. The runtime toggle funnels - through a single neutral function - (`internal/localnet.SetObservability`), so removing the overlay is - additive rather than a rewrite of both surfaces. +- **Removal pending validation.** The per-instance overlay stays enabled + until the shared-only path is validated end-to-end on a native Linux + Docker host. When that validation completes, the overlay can be gated + off without changing the CLI or Web UI observability commands. diff --git a/docs/observability.md b/docs/observability.md index d29ca089..d74419a0 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -15,10 +15,9 @@ persisted in the registry so re-up preserves bookmarked URLs. ## Metric naming convention The live Splice 0.6.4 Prometheus surfaces **three** metric prefix -families. Earlier versions of the dashboard and `internal/metricsq` -used a `canton_*` prefix that does NOT exist upstream — those -queries silently returned no data. The audit notes below pin the -current convention so future panels stay aligned. +families. Earlier versions of the dashboard used a `canton_*` prefix that +does NOT exist upstream — those queries silently returned no data. The +audit notes below pin the current convention so future panels stay aligned. Probe used to ground-truth the names: @@ -58,20 +57,19 @@ metric is emitted by the Daml participant) or `daml_sequencer_*` / ## Smoke test (drift guard) -`internal/metricsq/smoke_test.go` (build tag `integration`) queries -every `Headline*` in `SummaryQueries` against a live Prometheus and -fails if any returns 0 results — the only way to catch silent -metric-name drift when Splice updates. +An integration test (build tag `integration`) queries every headline +metric in the CLI summary against a live Prometheus and fails if any +returns zero results — the way to catch silent metric-name drift when +Splice updates. -Run it locally: +Run it locally against a running observability-enabled instance: ``` canton-devkit localnet up --name metric-audit --profile observability PROM_PORT=$(canton-devkit localnet status --name metric-audit --format json \ | jq -r '.endpoints[] | select(.label=="prometheus_ui") | .port') METRICSQ_SMOKE_PROM=http://localhost:${PROM_PORT} \ - go test -tags=integration -run TestSummaryQueries_LiveProm \ - ./internal/metricsq/ + go test -tags=integration -run TestSummaryQueries_LiveProm ./... canton-devkit localnet clean --name metric-audit --force ``` @@ -103,8 +101,7 @@ on an already-running instance **without restarting Canton**: canton-devkit localnet observability status --name demo --format json ``` -Both surfaces call the **same** neutral orchestration -(`internal/localnet.SetObservability`) — there is no second +Both surfaces call the **same** orchestration path — there is no second docker-compose code path that could drift. With neither `--prometheus` nor `--grafana`, the verb acts on both sidecars (the legacy umbrella semantics); pass one flag to operate on a single component. @@ -150,9 +147,8 @@ deliberate, kept fallback: both the CLI and the Web UI read **shared-first** and fall back to the per-instance Prometheus when the shared stack isn't up, and the per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any -platform regardless of the Linux `host-gateway` mapping. Gating the -per-instance overlay off (to drop the duplication) is deferred until the -shared-only path can be end-to-end validated on a native Linux Docker host -— see [docs/limitations.md](limitations.md#observability-transitional-dual-stack). The +platform regardless of the Linux `host-gateway` mapping. The per-instance +overlay remains enabled until the shared-only path is validated end-to-end +on a native Linux Docker host — see [docs/limitations.md](limitations.md#observability-transitional-dual-stack). The extra resource cost (a second Prometheus+Grafana per instance) is the price of that fallback on a dev machine; it carries no correctness impact. diff --git a/docs/tokens.md b/docs/tokens.md index a4cb0f49..f920eecd 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -4,7 +4,7 @@ canton-devkit ships first-class tooling for the **Canton Token Standard V2** (the CIP-0112 path) so you can create an instrument, mint/transfer/ burn holdings, fund parties, and reconcile balances against a live LocalNet — from the CLI **or** the Web UI, by readable party alias, with -no JWTs, ports, or 130-char contract ids in your face. +without surfacing raw JWTs, ports, or full contract IDs in every command. > **Scope: V2 / CIP-0112 only.** This tooling targets the Token Standard > V2 (CIP-0112) surface. V1 / CIP-0056 is **not** supported. V2 is @@ -39,7 +39,7 @@ auto-issues a per-role dev JWT; `--role` defaults to `app-user`. On LocalNet there is **no trust boundary between parties — you own all of them** (the dev secret signs for every role). So the token tool is a -single *god-mode workspace* over the instance, not a wallet-per-party: +single operator workspace over the instance, not a wallet-per-party: - **Party aliases** — `token party new bob` allocates a party and lets you say `--to bob` everywhere instead of pasting its id. @@ -106,7 +106,7 @@ Add `--format json` to any read command (`balance`, `balances`, | `token burn` | Burn supply. The example token has no protocol burn, so this archives the holder's `Holding` contracts directly (signatory = account parties + admin, all operator-controlled on LocalNet) and returns change. | | `token faucet ` | Fund a party from a well-known source, auto-accepted. `--source` overrides the default funded party. | | `token balance` | One party's balances. | -| `token balances` | Party × instrument balance matrix (god-mode reconciliation). | +| `token balances` | Party × instrument balance matrix (cross-party reconciliation). | | `token summary` | Supply / holder count / holding-contract count + holder distribution for one instrument. | | `token activity` | Mint/transfer/burn history for one instrument, reconstructed from the ledger. | | `token party new\|ls\|rm` | Manage the party alias registry. | diff --git a/docs/versions.md b/docs/versions.md index c31690f6..535d1e6e 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -1,8 +1,7 @@ # Splice version catalogue -DevKit pins to a **curated** list of Splice versions in -[`internal/splice/versions.json`](../internal/splice/versions.json) so -`localnet up` never composes-up an untested upstream tag. +DevKit pins to a **catalogue** of tested Splice versions embedded in the +binary so `localnet up` never composes-up an untested upstream tag. ## What DevKit fetches, and from where @@ -55,7 +54,7 @@ the canonical name in code and docs. | `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. DevKit fetches via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | | `content_sha` | `scripts/compute-tree-sha.sh` | SHA-256 over the extracted `cluster/compose/localnet/` subtree (sorted by path). Stable across upstream gzip-envelope rewrites; this is the authoritative integrity check at fetch time. | | `size` | byte count of the source-tarball | Informational; used to print a hint before download and to size the in-flight body cap. | -| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major adapter in `internal/splice/v0X/`. | +| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major Splice adapter for that release line. | | `channel` *(optional)* | catalogue maintainer | `""` / `"stable"` → production-ready; `"alpha"` → opt-in pre-release (Token Standard V2 snapshot etc.). `up` prints a one-line warning when an alpha entry is selected. | | `image_repo` *(optional)* | catalogue maintainer | Overrides the default Docker image repository. Defaults to `ghcr.io/digital-asset/decentralized-canton-sync/docker`. Set to `ghcr.io/digital-asset/decentralized-canton-sync-dev/docker` for the V2 alpha track. The v06 adapter forwards this as the `IMAGE_REPO` compose env. | @@ -139,8 +138,8 @@ Three reasons the catalogue is curated: for downstream consumption. DevKit doesn't aim to support every commit that happens to land in the repo. -3. **Adapter routing.** DevKit ships per-major adapters - (`internal/splice/v05/`, `v06/`). A new major version (e.g. `0.7.x`) +3. **Adapter routing.** DevKit ships per-major adapters for each Splice + major version. A new major version (e.g. `0.7.x`) needs a corresponding adapter before it can be added — the script leaves `major` blank for non-N.N.N tags so a maintainer notices. diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 0e3aac28..122ac607 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -44,6 +44,7 @@ export default defineConfig({ { slug: 'reference/packaging' }, { slug: 'reference/telemetry' }, { slug: 'reference/limitations' }, + { slug: 'reference/faq' }, { slug: 'reference/troubleshooting' }, ], }, diff --git a/website/src/content/docs/getting-started.md b/website/src/content/docs/getting-started.md index 8f04b51c..cfd424cb 100644 --- a/website/src/content/docs/getting-started.md +++ b/website/src/content/docs/getting-started.md @@ -141,8 +141,8 @@ sudo apt install canton-devkit=0.7.0 ``` The APT repo is currently unsigned and therefore uses `trusted=yes`; -the release still publishes SHA-256 metadata. A signed repository key -is planned. Package installation records a best-effort anonymous `apt` +the release still publishes SHA-256 metadata. Repository signing has +not been added yet. Package installation records a best-effort anonymous `apt` install-surface telemetry ping — see [Telemetry](../reference/telemetry/) for what is sent and how to opt out before installing. @@ -215,9 +215,8 @@ platforms. ### Splice LocalNet versions -DevKit pins a **curated** set of Splice versions in -[`internal/splice/versions.json`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/internal/splice/versions.json); -`localnet up --version ` selects one. List them at runtime: +DevKit pins a **catalogue** of tested Splice versions; `localnet up +--version ` selects one. List them at runtime: ```bash canton-devkit localnet versions diff --git a/website/src/content/docs/guides/explorer.md b/website/src/content/docs/guides/explorer.md index 0c625d38..ee79bbe9 100644 --- a/website/src/content/docs/guides/explorer.md +++ b/website/src/content/docs/guides/explorer.md @@ -9,9 +9,8 @@ transactions from the participant's gRPC ledger API, so you can see what's on the ledger without writing a script. This guide covers what the Explorer can do today, the equivalent -CLI commands for scripted workflows, and which features are -planned but not yet shipped — so you can tell at a glance whether -the Explorer fits your task. +CLI commands for scripted workflows, and current limitations — so you can +tell at a glance whether the Explorer fits your task. --- @@ -99,7 +98,7 @@ shows: - **Payload** as pretty-printed JSON. Records, lists, optionals, primitives, parties, and contract IDs all render natively; variants/enums/maps fall back to a textual proto form (a typed - decoder is planned). + decoder using Daml-LF metadata is not yet supported). - **Signatories** and **Observers** as separate lists. - **Created** with the RFC 3339 timestamp the participant recorded and a human-readable "Xs/m/h/d ago". @@ -273,7 +272,7 @@ Things the Explorer does **not** do today: - **Variants, enums, maps fall back to a textual proto form** in the payload preview. Records, lists, primitives, parties, and contract IDs decode natively. The full typed decoder using - Daml-LF metadata is planned. + Daml-LF metadata is not yet supported in the payload preview. If the Explorer can't show what you need, the CLI usually can — or the underlying gRPC API directly via the SDK. diff --git a/website/src/content/docs/guides/localnet-lifecycle.md b/website/src/content/docs/guides/localnet-lifecycle.md index 921c107f..05a6a13e 100644 --- a/website/src/content/docs/guides/localnet-lifecycle.md +++ b/website/src/content/docs/guides/localnet-lifecycle.md @@ -100,95 +100,6 @@ sudo rm /usr/local/bin/canton-devkit ## FAQ -Common questions about canton-devkit. See also -[Troubleshooting](../../reference/troubleshooting/) for failure-mode fixes. - -### General - -**What is canton-devkit?** -A single-binary developer tool for running and operating a Canton -**LocalNet** — a full local Canton Network (sequencers, mediators, -participants, Splice apps) in Docker. It gives you a CLI -(`canton-devkit localnet …`, or `dpm localnet …` under DPM) and an -embedded Web UI for the same operations. - -**CLI or Web UI — which should I use?** -Both expose the same operations — the two surfaces are kept in parity -by design. Use the CLI for scripting/CI; `canton-devkit localnet ui` -for a dashboard, the contract explorer, DAR management, metrics, and -the token workspace. - -**Does it fork or patch Splice?** -No. It downloads the upstream `cluster/compose/localnet/` tree pinned by -immutable commit SHA and verified by SHA-256 after extraction. See the -[Splice version catalogue](../../reference/versions/). - -**Which platforms are supported?** -macOS (arm64), Linux (amd64), and Windows (amd64) are the released, -tested targets. Other OS/arch combinations may work (DevKit only -orchestrates Docker) but are untested — `localnet doctor` warns on -unsupported platforms. See the -[compatibility matrix](../../getting-started/#compatibility-matrix). - -### Versions - -**What does `--version latest` give me?** -The curated catalogue's `latest_alias` (a production-ready stable -release). `localnet versions` lists the full catalogue; `--allow-uncurated` -plus an explicit tag lets you run an upstream version not yet curated. - -**What's the difference between the curated catalogue and runtime -resolution?** -Curated entries (in `versions.json`) are tested and pinned by commit + -content SHA. Uncurated tags are resolved live against GitHub and cached -locally — handy for trying a brand-new upstream release before it's -curated. - -### Tokens (CIP-0112 / V2) - -**V1 or V2?** -This tool targets **Token Standard V2 (CIP-0112)** only. V1 / CIP-0056 is -not supported. See the [Tokens guide](../tokens/). - -**Why is V2 "alpha" and what does `--profile tokens-v2` do?** -V2 runs on a special upstream Splice build (alpha protocol 35) on the -`-dev` image repo. `--profile tokens-v2` injects the Canton config that -enables alpha-version-support + protocol 35. Without it the stack can't -run the V2 protocol; `doctor` warns. - -**Why can't I mint or burn Amulet?** -Amulet (Canton Coin) has no developer-facing mint/burn surface — those -are governance operations. The workspace observes Amulet and can transfer -it, but Mint/Burn are gated. Create your own `splice-test-token-v2` -instrument for full create → mint → transfer → burn. - -**How does burn work if the example token has no burn choice?** -Correct — `splice-test-token-v2` has no protocol-level standalone burn. -On LocalNet you control the holding's signatories (account parties + -admin), so `token burn` archives the holder's `Holding` contracts -directly and returns change. Supply = sum of holdings, so this removes -the burned amount from circulation. - -**Why are party aliases safe here but not in production?** -On LocalNet the `unsafe` dev secret signs for every party, so "you own -all parties" is true and the god-mode workspace is appropriate. That -assumption does **not** hold on a real network — the dev JWTs are -loopback-only and must never be reused off-box. - -### Operations - -**Can I run more than one instance at once?** -Yes. Each `--name` gets isolated Docker resources and a port block. -`localnet list` shows them all. - -**Where does state live?** -`~/.canton-devkit/localnet//` (per-instance registry + data) and -`~/.canton-devkit/cache/` (downloaded Splice trees). Removing the cache -is safe; it re-downloads on next `up`. - -**Snapshot / restore — is it crash-consistent?** -Snapshots capture Docker volumes + registry state. They are **not** -guaranteed application-consistent for a *running* instance — see the -warning in [Troubleshooting](../../reference/troubleshooting/#snapshot-consistency) -and `localnet snapshot --help`. Stop the instance for a fully consistent -snapshot. +See the [FAQ](../../reference/faq/) for common questions about versions, +tokens, multi-instance setups, and snapshots. For failure-mode fixes, see +[Troubleshooting](../../reference/troubleshooting/). diff --git a/website/src/content/docs/guides/observability.md b/website/src/content/docs/guides/observability.md index 4df98e3b..e9ff8d73 100644 --- a/website/src/content/docs/guides/observability.md +++ b/website/src/content/docs/guides/observability.md @@ -18,10 +18,9 @@ persisted in the registry so re-up preserves bookmarked URLs. ## Metric naming convention The live Splice 0.6.4 Prometheus surfaces **three** metric prefix -families. Earlier versions of the dashboard and `internal/metricsq` -used a `canton_*` prefix that does NOT exist upstream — those -queries silently returned no data. The audit notes below pin the -current convention so future panels stay aligned. +families. Earlier versions of the dashboard used a `canton_*` prefix that +does NOT exist upstream — those queries silently returned no data. The +audit notes below pin the current convention so future panels stay aligned. Probe used to ground-truth the names: @@ -61,20 +60,19 @@ metric is emitted by the Daml participant) or `daml_sequencer_*` / ## Smoke test (drift guard) -`internal/metricsq/smoke_test.go` (build tag `integration`) queries -every `Headline*` in `SummaryQueries` against a live Prometheus and -fails if any returns 0 results — the only way to catch silent -metric-name drift when Splice updates. +An integration test (build tag `integration`) queries every headline +metric in the CLI summary against a live Prometheus and fails if any +returns zero results — the way to catch silent metric-name drift when +Splice updates. -Run it locally: +Run it locally against a running observability-enabled instance: ``` canton-devkit localnet up --name metric-audit --profile observability PROM_PORT=$(canton-devkit localnet status --name metric-audit --format json \ | jq -r '.endpoints[] | select(.label=="prometheus_ui") | .port') METRICSQ_SMOKE_PROM=http://localhost:${PROM_PORT} \ - go test -tags=integration -run TestSummaryQueries_LiveProm \ - ./internal/metricsq/ + go test -tags=integration -run TestSummaryQueries_LiveProm ./... canton-devkit localnet clean --name metric-audit --force ``` @@ -106,8 +104,7 @@ on an already-running instance **without restarting Canton**: canton-devkit localnet observability status --name demo --format json ``` -Both surfaces call the **same** neutral orchestration -(`internal/localnet.SetObservability`) — there is no second +Both surfaces call the **same** orchestration path — there is no second docker-compose code path that could drift. With neither `--prometheus` nor `--grafana`, the verb acts on both sidecars (the legacy umbrella semantics); pass one flag to operate on a single component. @@ -153,9 +150,8 @@ deliberate, kept fallback: both the CLI and the Web UI read **shared-first** and fall back to the per-instance Prometheus when the shared stack isn't up, and the per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any -platform regardless of the Linux `host-gateway` mapping. Gating the -per-instance overlay off (to drop the duplication) is deferred until the -shared-only path can be end-to-end validated on a native Linux Docker host -— see [Known limitations](../../reference/limitations/#observability-transitional-dual-stack). The +platform regardless of the Linux `host-gateway` mapping. The per-instance +overlay remains enabled until the shared-only path is validated end-to-end +on a native Linux Docker host — see [Known limitations](../../reference/limitations/#observability-transitional-dual-stack). The extra resource cost (a second Prometheus+Grafana per instance) is the price of that fallback on a dev machine; it carries no correctness impact. diff --git a/website/src/content/docs/guides/tokens.md b/website/src/content/docs/guides/tokens.md index d6159789..d10b7d44 100644 --- a/website/src/content/docs/guides/tokens.md +++ b/website/src/content/docs/guides/tokens.md @@ -7,7 +7,7 @@ canton-devkit ships first-class tooling for the **Canton Token Standard V2** (the CIP-0112 path) so you can create an instrument, mint/transfer/ burn holdings, fund parties, and reconcile balances against a live LocalNet — from the CLI **or** the Web UI, by readable party alias, with -no JWTs, ports, or 130-char contract ids in your face. +without surfacing raw JWTs, ports, or full contract IDs in every command. > **Scope: V2 / CIP-0112 only.** This tooling targets the Token Standard > V2 (CIP-0112) surface. V1 / CIP-0056 is **not** supported. V2 is @@ -42,7 +42,7 @@ auto-issues a per-role dev JWT; `--role` defaults to `app-user`. On LocalNet there is **no trust boundary between parties — you own all of them** (the dev secret signs for every role). So the token tool is a -single *god-mode workspace* over the instance, not a wallet-per-party: +single operator workspace over the instance, not a wallet-per-party: - **Party aliases** — `token party new bob` allocates a party and lets you say `--to bob` everywhere instead of pasting its id. @@ -109,7 +109,7 @@ Add `--format json` to any read command (`balance`, `balances`, | `token burn` | Burn supply. The example token has no protocol burn, so this archives the holder's `Holding` contracts directly (signatory = account parties + admin, all operator-controlled on LocalNet) and returns change. | | `token faucet ` | Fund a party from a well-known source, auto-accepted. `--source` overrides the default funded party. | | `token balance` | One party's balances. | -| `token balances` | Party × instrument balance matrix (god-mode reconciliation). | +| `token balances` | Party × instrument balance matrix (cross-party reconciliation). | | `token summary` | Supply / holder count / holding-contract count + holder distribution for one instrument. | | `token activity` | Mint/transfer/burn history for one instrument, reconstructed from the ledger. | | `token party new\|ls\|rm` | Manage the party alias registry. | diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx index c1270d3d..7b75485f 100644 --- a/website/src/content/docs/index.mdx +++ b/website/src/content/docs/index.mdx @@ -14,88 +14,25 @@ hero: variant: minimal --- -import { Card, CardGrid } from '@astrojs/starlight/components'; - `canton-devkit` is a single-binary toolkit for spinning up, inspecting, and tearing down a complete [Canton](https://canton.network/) developer stack — Canton synchronizer + participant, Splice super-validator apps, three party wallets -(app-user, app-provider, SV), Scan explorer, signed JWTs — in **one command**. - -```sh -❯ canton-devkit localnet up demo - ✓ Splice 0.6.4 cache hit - ✓ Compose started · 12 containers - ✓ Health checks · canton · splice · postgres - ✓ JWTs signed · app-user · app-provider · super-validator - ✦ "demo" is ready · Splice 0.6.4 · ready in 1m 24s -``` - -## Why Canton DevKit? - - - - No YAML editing, no env-file shuffling. `up` downloads Splice, signs JWTs, - brings up a dozen containers, prints endpoints. Cold start ~90 s. - - - Same code, two skins: a polished CLI for terminals & CI, a real - Vite/React Web UI for browser-driven inspection. Always at parity. - - - No forks, no patches. Thin wrapper over upstream - [Splice LocalNet](https://github.com/canton-network/splice), pinned to - immutable commit SHAs and verified by content hash. - - - Save a working state to a `.tgz`, hand it to a teammate, replay it on CI. - Disaster recovery in 4 seconds. - - - `go install` or `dpm install package` — same artefact. macOS arm64, Linux - amd64, Windows amd64. No JVM, no Python, no Node at runtime. - - - Optional `--profile observability` adds Prometheus + Grafana with a curated - Canton dashboard. The CLI scrapes the same metrics. - - - -## Quick start +(app-user, app-provider, SV), Scan explorer, and signed JWTs. -```sh -# Verify your host is ready (Docker, RAM, disk, ports) -canton-devkit localnet doctor +## Getting started -# Bring up a Canton network called "demo" -canton-devkit localnet up demo - -# Inspect from the browser -canton-devkit localnet ui - -# …or stay in the terminal -canton-devkit localnet status # ports, health, uptime -canton-devkit localnet logs canton # tail Canton's logs -eval "$(canton-devkit localnet env)" # export endpoints to your shell - -# Snapshot, tear down, restore -canton-devkit localnet snapshot --to demo.tgz -canton-devkit localnet clean -canton-devkit localnet restore --from demo.tgz -``` - -:::tip -Stuck? Run `canton-devkit localnet doctor` — it tells you exactly what's missing -and how to fix it. -::: +Start with [Installation & Getting Started](getting-started/) for install paths, +Docker prerequisites, and a zero-to-running LocalNet walkthrough. -## Who is this for? +## Documentation -| You are… | We've got you because… | -| --- | --- | -| **A Daml/Canton app developer** | Reproducible local stack, signed JWTs, party IDs auto-recorded, hot DAR upload | -| **A CI engineer** | Pinned versions, `--json` everywhere, exit codes documented, snapshot/restore for fixtures | -| **An evaluator** | One command to a healthy network. Tear it down with `clean` when you're done | -| **A workshop facilitator** | Same demo on every laptop, regardless of OS or Apple Silicon | +- [LocalNet lifecycle](guides/localnet-lifecycle/) — up, inspect, multiple instances, clean up +- [Tokens (CIP-0112 / V2)](guides/tokens/) +- [Contract explorer](guides/explorer/) +- [Observability](guides/observability/) +- [FAQ](reference/faq/) +- [Troubleshooting](reference/troubleshooting/) +- [Known limitations](reference/limitations/) :::note **Not for production.** This is a developer tool. For production Canton diff --git a/website/src/content/docs/reference/faq.md b/website/src/content/docs/reference/faq.md new file mode 100644 index 00000000..a22120e3 --- /dev/null +++ b/website/src/content/docs/reference/faq.md @@ -0,0 +1,104 @@ +--- +title: FAQ +description: Common questions about canton-devkit — versions, tokens, multi-instance setups, and snapshots. +--- + +Common questions about canton-devkit. See also +[Troubleshooting](./troubleshooting/) for failure-mode fixes. + +## General + +**What is canton-devkit?** +A single-binary developer tool for running and operating a Canton +**LocalNet** — a full local Canton Network (sequencers, mediators, +participants, Splice apps) in Docker. It gives you a CLI +(`canton-devkit localnet …`, or `dpm localnet …` under DPM) and an +embedded Web UI for the same operations. + +**CLI or Web UI — which should I use?** +Both expose the same operations — the two surfaces are kept in parity +by design. Use the CLI for scripting/CI; `canton-devkit localnet ui` +for a dashboard, the contract explorer, DAR management, metrics, and +the token workspace. + +**Does it fork or patch Splice?** +No. It downloads the upstream `cluster/compose/localnet/` tree pinned by +immutable commit SHA and verified by SHA-256 after extraction. See +[Splice version catalogue](./versions/). + +**Which platforms are supported?** +macOS (arm64), Linux (amd64), and Windows (amd64) are the released, +tested targets. Other OS/arch combinations may work (DevKit only +orchestrates Docker) but are untested — `localnet doctor` warns on +unsupported platforms. See the compatibility matrix in +[Getting started](../../getting-started/#compatibility-matrix). + +## Versions + +**What does `--version latest` give me?** +The curated catalogue's `latest_alias` (a production-ready stable +release). `localnet versions` lists the full catalogue; `--allow-uncurated` +plus an explicit tag lets you run an upstream version not yet curated. + +**What's the difference between the curated catalogue and runtime +resolution?** +Curated entries (in `versions.json`) are tested and pinned by commit + +content SHA. Uncurated tags are resolved live against GitHub and cached +locally — handy for trying a brand-new upstream release before it's +curated. + +## Tokens (CIP-0112 / V2) + +**V1 or V2?** +This tool targets **Token Standard V2 (CIP-0112)** only. V1 / CIP-0056 is +not supported. See the [Tokens guide](../guides/tokens/). + +**Why is V2 "alpha" and what does `--profile tokens-v2` do?** +V2 runs on a special upstream Splice build (alpha protocol 35) on the +`-dev` image repo. `--profile tokens-v2` injects the Canton config that +enables alpha-version-support + protocol 35. Without it the stack can't +run the V2 protocol; `doctor` warns. + +**Why can't I mint or burn Amulet?** +Amulet (Canton Coin) has no developer-facing mint/burn surface — those +are governance operations. The workspace observes Amulet and can transfer +it, but Mint/Burn are gated. Create your own `splice-test-token-v2` +instrument for full create → mint → transfer → burn. + +**How does burn work if the example token has no burn choice?** +Correct — `splice-test-token-v2` has no protocol-level standalone burn. +On LocalNet you control the holding's signatories (account parties + +admin), so `token burn` archives the holder's `Holding` contracts +directly and returns change. Supply = sum of holdings, so this removes +the burned amount from circulation. + +**How does the authorization work differently in production?** +On LocalNet, token commands authenticate with the **validator-backend +dev JWT** — a static token signed with the validator node's hardcoded +development secret. That credential can be granted act-as/read-as rights +for **any** party on the node, so your application can use a single token +for every party you allocate on the LocalNet validator (`bob`, `alice`, …) +and transfer, mint, or query on behalf of all of them. + +Production networks won't expose that model: each party uses its **own** +credentials, tokens are issued per session (not static JWTs), and you +should not use backend credentials to sign for other parties on the +network. + +## Operations + +**Can I run more than one instance at once?** +Yes. Each `--name` gets isolated Docker resources and a port block. +`localnet list` shows them all. + +**Where does state live?** +`~/.canton-devkit/localnet//` (per-instance registry + data) and +`~/.canton-devkit/cache/` (downloaded Splice trees). Removing the cache +is safe; it re-downloads on next `up`. + +**Snapshot / restore — is it crash-consistent?** +Snapshots capture Docker volumes + registry state. They are **not** +guaranteed application-consistent for a *running* instance — see the +warning in [Troubleshooting](./troubleshooting/#snapshot-consistency) +and `localnet snapshot --help`. Stop the instance for a fully consistent +snapshot. diff --git a/website/src/content/docs/reference/limitations.md b/website/src/content/docs/reference/limitations.md index c2eea51e..854389e5 100644 --- a/website/src/content/docs/reference/limitations.md +++ b/website/src/content/docs/reference/limitations.md @@ -14,33 +14,12 @@ resolved. Uppercase, underscores, and leading/trailing hyphens are rejected. DNS-label form was chosen so the same name is safe to embed as a hostname in a future `{service}.{instance}.localhost` routing model - without a second translation step. The single source of truth lives in - `internal/registry/state.go` (`ValidateName`); the CLI layer delegates. + without a second translation step. *Migration:* instances created with an older release that still allowed uppercase or underscore names (e.g. `MyStack`, `my_stack`) must be torn down with that older binary and re-created under a DNS-label name. -## Concurrency / locking - -- **(resolved)** Registry locking is now a real cross-process lock on - every platform. On Windows both the fail-fast per-instance lock and - the blocking index read-modify-write lock go through - `windows.LockFileEx` (`internal/registry/lock_windows.go`, - `internal/registry/index_lock_windows.go`); Linux/macOS use - `syscall.Flock`. The OS releases the lock when the handle closes or - the process exits, so there is no stale lock file to recover. - -## Splice version pinning - -- **(resolved)** The catalogue pins (a) the git commit SHA (immutable, - content-addressable — `internal/splice/versions.json`'s `commit` - field) and (b) the ContentSHA of the extracted - `cluster/compose/localnet/` subtree (`content_sha` field). The hash - covers the extracted tree, not the gzip envelope, so a gzip-level - rewrite by GitHub (compression-level change, mtime drift) has no - effect. See [Splice version catalogue](../versions/). - ## Container image pinning - **Splice container images are pulled by mutable ghcr tags, not @@ -57,7 +36,7 @@ resolved. each running image's content digest (image ID) in `state.json` (`image_digests`) and, on a later `up`/`restart` of the SAME version, WARNs if a digest changed — i.e. a mutable ghcr tag was republished - under you. See `internal/localnet/image_digests.go`. This is a warning, + under you. This is a warning, not a gate (a digest can legitimately change if you manually re-pull), and it's best-effort (a capture failure just skips the check). True digest-pinning at pull time would need upstream Splice to expose @@ -136,9 +115,7 @@ for the topology. per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any platform regardless of the Linux `host-gateway` mapping. -- **Planned.** Gating the per-instance overlay off (to drop the - duplication) is deferred until the shared-only path is end-to-end - validated on a native Linux Docker host. The runtime toggle funnels - through a single neutral function - (`internal/localnet.SetObservability`), so removing the overlay is - additive rather than a rewrite of both surfaces. +- **Removal pending validation.** The per-instance overlay stays enabled + until the shared-only path is validated end-to-end on a native Linux + Docker host. When that validation completes, the overlay can be gated + off without changing the CLI or Web UI observability commands. diff --git a/website/src/content/docs/reference/versions.md b/website/src/content/docs/reference/versions.md index 92536afe..f8b0a783 100644 --- a/website/src/content/docs/reference/versions.md +++ b/website/src/content/docs/reference/versions.md @@ -3,9 +3,8 @@ title: "Splice Version Catalogue" description: "How DevKit pins curated Splice LocalNet versions by commit SHA and content hash, discovers upstream tags, and resolves uncurated versions on opt-in." --- -DevKit pins to a **curated** list of Splice versions in -[`internal/splice/versions.json`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/internal/splice/versions.json) so -`localnet up` never composes-up an untested upstream tag. +DevKit pins to a **catalogue** of tested Splice versions embedded in the +binary so `localnet up` never composes-up an untested upstream tag. ## What DevKit fetches, and from where @@ -58,7 +57,7 @@ the canonical name in code and docs. | `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. DevKit fetches via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | | `content_sha` | `scripts/compute-tree-sha.sh` | SHA-256 over the extracted `cluster/compose/localnet/` subtree (sorted by path). Stable across upstream gzip-envelope rewrites; this is the authoritative integrity check at fetch time. | | `size` | byte count of the source-tarball | Informational; used to print a hint before download and to size the in-flight body cap. | -| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major adapter in `internal/splice/v0X/`. | +| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major Splice adapter for that release line. | | `channel` *(optional)* | catalogue maintainer | `""` / `"stable"` → production-ready; `"alpha"` → opt-in pre-release (Token Standard V2 snapshot etc.). `up` prints a one-line warning when an alpha entry is selected. | | `image_repo` *(optional)* | catalogue maintainer | Overrides the default Docker image repository. Defaults to `ghcr.io/digital-asset/decentralized-canton-sync/docker`. Set to `ghcr.io/digital-asset/decentralized-canton-sync-dev/docker` for the V2 alpha track. The v06 adapter forwards this as the `IMAGE_REPO` compose env. | @@ -142,8 +141,8 @@ Three reasons the catalogue is curated: for downstream consumption. DevKit doesn't aim to support every commit that happens to land in the repo. -3. **Adapter routing.** DevKit ships per-major adapters - (`internal/splice/v05/`, `v06/`). A new major version (e.g. `0.7.x`) +3. **Adapter routing.** DevKit ships per-major adapters for each Splice + major version. A new major version (e.g. `0.7.x`) needs a corresponding adapter before it can be added — the script leaves `major` blank for non-N.N.N tags so a maintainer notices. From 6fa06cfd536a5314be4c4c914620606d405a3552 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sat, 4 Jul 2026 23:48:49 +0200 Subject: [PATCH 34/68] docs: add release download-stats charts to README (#203) * docs: add release download-stats charts to README Reimplements the gh-release-stats download-count logic (sum asset.download_count per release) as a dependency-free Bash + jq generator that emits static SVG charts + a markdown summary, since a GitHub README cannot execute JavaScript. - scripts/release-stats.sh: fetch releases from the public builds repo (bitdynamics-ab/homebrew-canton-devkit), classify assets into platforms by regex, and render four views: per-platform over time, per-version total over time, and all-time totals per version and per platform (bars). Checksum files are excluded from counts. - .github/workflows/release-stats.yml: regenerate + commit charts daily, on demand, and after the Release workflow. Uses GITHUB_TOKEN only; actions SHA-pinned. - README: new Adoption section embedding the charts + a live total downloads badge, linking to the interactive gh-release-stats tool. * chore: take a daily download-stats snapshot Append one row per UTC day to docs/assets/release-downloads-history.jsonl capturing current cumulative totals (overall, per platform, per version). Re-running on the same day replaces that day's row (idempotent). The existing charts are unchanged; this just starts accumulating a real download-over-time series, which the GitHub API cannot provide directly. The daily workflow now also stages/commits the history file. * Update gitignores * chore: run release-stats on self-hosted runner; drop Debian from charts --- .github/workflows/release-stats.yml | 56 +++ .gitignore | 3 + README.md | 32 +- docs/assets/release-downloads-by-platform.svg | 71 ++++ docs/assets/release-downloads-by-version.svg | 38 ++ docs/assets/release-downloads-history.jsonl | 1 + docs/assets/release-downloads-totals.svg | 45 ++ docs/assets/release-downloads.md | 27 ++ scripts/release-stats.sh | 388 ++++++++++++++++++ 9 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-stats.yml create mode 100644 docs/assets/release-downloads-by-platform.svg create mode 100644 docs/assets/release-downloads-by-version.svg create mode 100644 docs/assets/release-downloads-history.jsonl create mode 100644 docs/assets/release-downloads-totals.svg create mode 100644 docs/assets/release-downloads.md create mode 100755 scripts/release-stats.sh diff --git a/.github/workflows/release-stats.yml b/.github/workflows/release-stats.yml new file mode 100644 index 00000000..799f8c4d --- /dev/null +++ b/.github/workflows/release-stats.yml @@ -0,0 +1,56 @@ +name: Release stats + +# Regenerate the release download-statistics charts embedded in the README. +# Data source is the public builds repo (bitdynamics-ab/homebrew-canton-devkit), +# which mirrors the same release assets as this repo. Uses the built-in +# GITHUB_TOKEN only — no additional secrets required. + +on: + workflow_dispatch: + schedule: + # Daily at 06:17 UTC (off-peak; avoids the top-of-hour cron surge). + - cron: "17 6 * * *" + workflow_run: + # Refresh promptly after a release publishes new assets. + workflows: ["Release"] + types: + - completed + +permissions: + contents: write + +concurrency: + group: release-stats + cancel-in-progress: false + +jobs: + regenerate: + name: Regenerate download-stats charts + runs-on: self-hosted + timeout-minutes: 10 + steps: + - name: Check out repository + # actions/checkout@v7.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Generate charts + append daily snapshot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/release-stats.sh + + - name: Commit refreshed charts + snapshot if changed + run: | + set -euo pipefail + git add docs/assets/release-downloads-by-platform.svg \ + docs/assets/release-downloads-by-version.svg \ + docs/assets/release-downloads-totals.svg \ + docs/assets/release-downloads.md \ + docs/assets/release-downloads-history.jsonl + if git diff --cached --quiet; then + echo "Release stats already up to date." + exit 0 + fi + git -c user.name="github-actions[bot]" \ + -c user.email="github-actions[bot]@users.noreply.github.com" \ + commit -m "chore: refresh release download stats" + git push diff --git a/.gitignore b/.gitignore index 41c3f878..13d35e53 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ CLAUDE.md internal/ui/dist/* !internal/ui/dist/index.html .worktrees/ + +# worktrunk configs +.config/wt.toml diff --git a/README.md b/README.md index e544b8ab..feecfaa3 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A single-binary toolkit for spinning up, inspecting, and tearing down a complete CI Go Reference Release + Total downloads License: Apache 2.0

@@ -25,7 +26,8 @@ A single-binary toolkit for spinning up, inspecting, and tearing down a complete Web UI · Commands · Architecture · - FAQ + FAQ · + Adoption

@@ -416,6 +418,34 @@ Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first fo --- +## 📈 Adoption + +Release download statistics for the public builds repo +([`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases)), +which mirrors the same release assets published here. Charts refresh daily +via [`.github/workflows/release-stats.yml`](.github/workflows/release-stats.yml) +and are regenerated by [`scripts/release-stats.sh`](scripts/release-stats.sh). +Checksum files (`SHA256SUMS`) are excluded from the counts. + +
+ +Total downloads per release over time + +Downloads per platform per release over time + +All-time downloads per version and per platform + +
+ +Exact numbers live in +[`docs/assets/release-downloads.md`](docs/assets/release-downloads.md). For +on-demand exploration and CSV export, use the interactive +[gh-release-stats](https://ramiawar.github.io/gh-release-stats/) tool (enter +`bitdynamics-ab/homebrew-canton-devkit`) — the download-count logic here is a +static reimplementation of that tool. + +--- + ## 📦 Releasing Tagged builds (`v*`) publish: diff --git a/docs/assets/release-downloads-by-platform.svg b/docs/assets/release-downloads-by-platform.svg new file mode 100644 index 00000000..912532de --- /dev/null +++ b/docs/assets/release-downloads-by-platform.svg @@ -0,0 +1,71 @@ + + +Downloads per platform, by release + +0 + +1 + +2 + +3 + +4 + +5 + + +v0.3 +v0.4 +v0.5 +v0.6 +v0.7 +v0.8.1 +v0.9.0 +v0.10.1 +Release tag (oldest -> newest) + + + + + + + + + + +macOS (arm64) + + + + + + + + + + +Linux (amd64) + + + + + + + + + + +Windows (amd64) + + + + + + + + + + +Debian (.deb) + diff --git a/docs/assets/release-downloads-by-version.svg b/docs/assets/release-downloads-by-version.svg new file mode 100644 index 00000000..a259b0ae --- /dev/null +++ b/docs/assets/release-downloads-by-version.svg @@ -0,0 +1,38 @@ + + +Total downloads, by release + +0 + +2 + +4 + +6 + +8 + +11 + + +v0.3 +v0.4 +v0.5 +v0.6 +v0.7 +v0.8.1 +v0.9.0 +v0.10.1 +Release tag (oldest -> newest) + + + + + + + + + + +Total downloads + diff --git a/docs/assets/release-downloads-history.jsonl b/docs/assets/release-downloads-history.jsonl new file mode 100644 index 00000000..1953b969 --- /dev/null +++ b/docs/assets/release-downloads-history.jsonl @@ -0,0 +1 @@ +{"date":"2026-07-04","total":26,"byPlatform":{"macOS (arm64)":15,"Linux (amd64)":7,"Windows (amd64)":4,"Debian (.deb)":0},"byVersion":{"v0.3":1,"v0.4":3,"v0.5":4,"v0.6":1,"v0.7":1,"v0.8.1":0,"v0.9.0":5,"v0.10.1":11}} diff --git a/docs/assets/release-downloads-totals.svg b/docs/assets/release-downloads-totals.svg new file mode 100644 index 00000000..1fac045d --- /dev/null +++ b/docs/assets/release-downloads-totals.svg @@ -0,0 +1,45 @@ + + + +All-time downloads per version +v0.3 + +1 +v0.4 + +3 +v0.5 + +4 +v0.6 + +1 +v0.7 + +1 +v0.8.1 + +0 +v0.9.0 + +5 +v0.10.1 + +11 + + +All-time downloads per platform +macOS (arm64) + +15 +Linux (amd64) + +7 +Windows (amd64) + +4 +Debian (.deb) + +0 + + diff --git a/docs/assets/release-downloads.md b/docs/assets/release-downloads.md new file mode 100644 index 00000000..f8e49e79 --- /dev/null +++ b/docs/assets/release-downloads.md @@ -0,0 +1,27 @@ + + + +**Total downloads:** 26 across 8 releases. + +### Downloads per version + +| Version | Downloads | +|---|---| +| v0.10.1 | 11 | +| v0.9.0 | 5 | +| v0.8.1 | 0 | +| v0.7 | 1 | +| v0.6 | 1 | +| v0.5 | 4 | +| v0.4 | 3 | +| v0.3 | 1 | + +### Downloads per platform + +| Platform | Downloads | +|---|---| +| macOS (arm64) | 15 | +| Linux (amd64) | 7 | +| Windows (amd64) | 4 | +| Debian (.deb) | 0 | + diff --git a/scripts/release-stats.sh b/scripts/release-stats.sh new file mode 100755 index 00000000..57384b27 --- /dev/null +++ b/scripts/release-stats.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# +# release-stats.sh — generate GitHub release download-statistics charts. +# +# Reimplements the logic of https://github.com/RamiAwar/gh-release-stats +# (fetch GET /repos/{owner}/{repo}/releases, sum asset.download_count per +# release) as a dependency-free Bash + jq generator that emits static SVG +# charts + a markdown fragment, so the numbers can be embedded in a README +# that cannot execute JavaScript. +# +# It also appends a daily snapshot of the current totals to a history file +# so a download-over-time series accumulates from now on. The GitHub API only +# exposes CURRENT download counts (no history), so this persisted snapshot is +# the only way to build a real time series later. +# +# Outputs (into docs/assets/): +# release-downloads-by-platform.svg — per-platform downloads over time +# release-downloads-by-version.svg — per-version total downloads over time +# release-downloads-totals.svg — all-time totals per version + per platform (bars) +# release-downloads.md — per-version + per-platform summary tables +# release-downloads-history.jsonl — appended daily snapshot (total + per platform + per version) +# +# Environment: +# STATS_REPO target repo (default: bitdynamics-ab/homebrew-canton-devkit) +# OUT_DIR output directory (default: docs/assets) +# SNAPSHOT_DATE override snapshot date, UTC YYYY-MM-DD (default: today; for testing) +# GITHUB_TOKEN optional; raises the GitHub API rate limit when set +# +# Requires: jq, and either `gh` or `curl`. + +set -euo pipefail + +STATS_REPO="${STATS_REPO:-bitdynamics-ab/homebrew-canton-devkit}" + +# Resolve OUT_DIR relative to the repo root (parent of this script's dir), +# so the script works from any CWD. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +OUT_DIR="${OUT_DIR:-${repo_root}/docs/assets}" + +command -v jq >/dev/null || { echo "error: jq is required" >&2; exit 1; } + +mkdir -p "${OUT_DIR}" + +# --- Fetch releases ------------------------------------------------------- +# Prefer `gh` (handles auth + pagination); fall back to curl for local runs. +fetch_releases() { + if command -v gh >/dev/null 2>&1; then + gh api "repos/${STATS_REPO}/releases" --paginate 2>/dev/null && return 0 + fi + local auth=() + [ -n "${GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + curl -sSfL "${auth[@]}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${STATS_REPO}/releases?per_page=100" +} + +raw="$(fetch_releases)" + +# `gh --paginate` concatenates JSON arrays; normalise to one flat array +# (works whether the input is one array or several). +releases="$(printf '%s' "${raw}" | jq -s 'add // []')" + +count="$(printf '%s' "${releases}" | jq 'length')" +if [ "${count}" -eq 0 ]; then + echo "error: no releases found for ${STATS_REPO}" >&2 + exit 1 +fi + +# --- Classify assets into platforms + compute all views ------------------- +# Platform classification is regex-based (not literal filenames) because the +# asset naming drifted across historical releases. Checksum files +# (SHA256SUMS / checksums.txt) are excluded from platform totals. +# +# Emits a compact JSON model consumed by the SVG/markdown renderers below. +# Releases are ordered oldest -> newest (chronological), matching a time axis. +model="$(printf '%s' "${releases}" | jq ' + def platform_of($name): + if ($name | test("_darwin_arm64\\.")) then "macOS (arm64)" + elif ($name | test("_linux_amd64\\.")) then "Linux (amd64)" + elif ($name | test("_windows_amd64\\.(zip|exe)$")) then "Windows (amd64)" + elif ($name | test("_amd64\\.deb$")) then "Debian (.deb)" + elif ($name | test("(?i)^(SHA256SUMS|checksums\\.txt)$")) then null + else null + end; + + # Fixed platform order for stable legends / stable diffs. + ["macOS (arm64)", "Linux (amd64)", "Windows (amd64)", "Debian (.deb)"] as $platforms + + | [ .[] + | { tag: .tag_name, + date: (.published_at // .created_at), + # per-platform download counts for this release + byPlatform: ( + reduce (.assets[]? | { p: platform_of(.name), dl: .download_count }) + as $a ( {}; + if $a.p == null then . else .[$a.p] = ((.[$a.p] // 0) + $a.dl) end + ) + ) + } + | .total = ([ .byPlatform[] ] | add // 0) + ] + # oldest first + | sort_by(.date) + | { platforms: $platforms, + releases: ., + totalsByVersion: [ .[] | { tag, total } ], + totalsByPlatform: ( + reduce (.[] | .byPlatform | to_entries[]) as $e ( {}; + .[$e.key] = ((.[$e.key] // 0) + $e.value) ) + ), + grandTotal: ([ .[] | .total ] | add // 0) + } +')" + +# --- SVG helpers ---------------------------------------------------------- +# Fixed, colour-blind-friendly palette; indexed to keep colours stable. +PALETTE=("#2563eb" "#16a34a" "#ea580c" "#9333ea" "#0891b2" "#ca8a04") + +svg_escape() { printf '%s' "$1" | sed 's/&/\&/g; s//\>/g'; } + +# Round a float up to a "nice" axis maximum (>= 5, ceil). +nice_max() { + awk -v v="$1" 'BEGIN{ if (v < 5) v = 5; print (v == int(v)) ? v : int(v)+1 }' +} + +# ------------------------------------------------------------------------ +# Line chart renderer. +# $1 = output file +# $2 = chart title +# $3 = JSON: { labels: [..], series: [ {name, color, values:[..]} ] } +# ------------------------------------------------------------------------ +render_line_chart() { + local out="$1" title="$2" data="$3" + + local W=760 H=380 + local ml=56 mr=180 mt=48 mb=64 # margins (mr wide for legend) + local pw ph + pw=$(( W - ml - mr )) + ph=$(( H - mt - mb )) + + local labels n maxv + labels="$(printf '%s' "${data}" | jq -r '.labels | join("\u0001")')" + n="$(printf '%s' "${data}" | jq '.labels | length')" + maxv="$(printf '%s' "${data}" | jq '[.series[].values[]] | max // 0')" + maxv="$(nice_max "${maxv}")" + + { + printf '\n' \ + "$W" "$H" "$W" "$H" "$(svg_escape "${title}")" + printf '\n' "$W" "$H" + printf '%s\n' "$ml" "$(svg_escape "${title}")" + + # Y grid + labels (5 ticks) + local i gy val + for i in 0 1 2 3 4 5; do + gy=$(awk -v mt="$mt" -v ph="$ph" -v i="$i" 'BEGIN{printf "%.1f", mt + ph - (ph*i/5)}') + val=$(awk -v m="$maxv" -v i="$i" 'BEGIN{printf "%d", m*i/5}') + printf '\n' \ + "$ml" "$gy" $(( ml + pw )) "$gy" + printf '%s\n' \ + $(( ml - 8 )) "$(awk -v g="$gy" 'BEGIN{printf "%.1f", g+4}')" "$val" + done + + # Axes + printf '\n' \ + "$ml" "$mt" "$ml" $(( mt + ph )) + printf '\n' \ + "$ml" $(( mt + ph )) $(( ml + pw )) $(( mt + ph )) + + # X labels + local idx=0 lbl xx + IFS=$'\001' read -ra _labels <<< "${labels}" + for lbl in "${_labels[@]}"; do + if [ "$n" -gt 1 ]; then + xx=$(awk -v ml="$ml" -v pw="$pw" -v i="$idx" -v n="$n" 'BEGIN{printf "%.1f", ml + pw*i/(n-1)}') + else + xx=$(awk -v ml="$ml" -v pw="$pw" 'BEGIN{printf "%.1f", ml + pw/2}') + fi + printf '%s\n' \ + "$xx" $(( mt + ph + 20 )) "$(svg_escape "${lbl}")" + idx=$(( idx + 1 )) + done + printf 'Release tag (oldest -> newest)\n' \ + $(( ml + pw/2 )) $(( H - 12 )) + + # Series: polylines + points + local si=0 sname scolor + while IFS= read -r sname; do + scolor="$(printf '%s' "${data}" | jq -r --argjson i "$si" '.series[$i].color')" + # polyline points + local pts="" + local vi=0 v px py + while IFS= read -r v; do + if [ "$n" -gt 1 ]; then + px=$(awk -v ml="$ml" -v pw="$pw" -v i="$vi" -v n="$n" 'BEGIN{printf "%.1f", ml + pw*i/(n-1)}') + else + px=$(awk -v ml="$ml" -v pw="$pw" 'BEGIN{printf "%.1f", ml + pw/2}') + fi + py=$(awk -v mt="$mt" -v ph="$ph" -v val="$v" -v m="$maxv" 'BEGIN{printf "%.1f", mt + ph - (ph*val/m)}') + pts="${pts} ${px},${py}" + vi=$(( vi + 1 )) + done < <(printf '%s' "${data}" | jq -r --argjson i "$si" '.series[$i].values[]') + + printf '\n' "${pts# }" "${scolor}" + # points + for p in ${pts}; do + printf '\n' "${p%,*}" "${p#*,}" "${scolor}" + done + + # legend entry + local ly=$(( mt + si*22 )) + printf '\n' \ + $(( ml + pw + 16 )) "$ly" "${scolor}" + printf '%s\n' \ + $(( ml + pw + 36 )) $(( ly + 12 )) "$(svg_escape "${sname}")" + + si=$(( si + 1 )) + done < <(printf '%s' "${data}" | jq -r '.series[].name') + + printf '\n' + } > "${out}" + echo "wrote ${out}" +} + +# ------------------------------------------------------------------------ +# Horizontal bar chart renderer (used for all-time totals). +# $1 = output file +# $2 = chart title +# $3 = JSON: { bars: [ {label, value} ] } +# ------------------------------------------------------------------------ +render_bar_chart() { + local out="$1" title="$2" data="$3" + + local nbars maxv + nbars="$(printf '%s' "${data}" | jq '.bars | length')" + maxv="$(printf '%s' "${data}" | jq '[.bars[].value] | max // 0')" + maxv="$(nice_max "${maxv}")" + + local ml=140 mr=48 mt=48 mb=24 + local rowh=30 gap=10 + local pw=440 + local ph=$(( nbars * (rowh + gap) )) + local W=$(( ml + pw + mr )) + local H=$(( mt + ph + mb )) + + { + printf '\n' \ + "$W" "$H" "$W" "$H" "$(svg_escape "${title}")" + printf '\n' "$W" "$H" + printf '%s\n' "$(svg_escape "${title}")" + + local i=0 label value y bw color + while IFS= read -r label; do + value="$(printf '%s' "${data}" | jq -r --argjson i "$i" '.bars[$i].value')" + y=$(( mt + i*(rowh+gap) )) + bw=$(awk -v pw="$pw" -v v="$value" -v m="$maxv" 'BEGIN{printf "%.1f", (m>0)?pw*v/m:0}') + color="${PALETTE[$(( i % ${#PALETTE[@]} ))]}" + printf '%s\n' \ + $(( ml - 10 )) $(( y + rowh/2 + 4 )) "$(svg_escape "${label}")" + printf '\n' \ + "$ml" "$y" "$bw" "$rowh" "$color" + printf '%s\n' \ + "$(awk -v ml="$ml" -v bw="$bw" 'BEGIN{printf "%.1f", ml+bw+6}')" $(( y + rowh/2 + 4 )) "$value" + i=$(( i + 1 )) + done < <(printf '%s' "${data}" | jq -r '.bars[].label') + + printf '\n' + } > "${out}" + echo "wrote ${out}" +} + +# --- View 1: per-platform downloads over time ----------------------------- +by_platform_data="$(printf '%s' "${model}" | jq --argjson pal "$(printf '%s\n' "${PALETTE[@]}" | jq -R . | jq -s .)" ' + . as $m + | { labels: [ $m.releases[].tag ], + series: [ $m.platforms | to_entries[] as $p + | select($p.value != "Debian (.deb)") + | { name: $p.value, + color: ($pal[$p.key] // "#666666"), + values: [ $m.releases[] | (.byPlatform[$p.value] // 0) ] } ] + } +')" +render_line_chart "${OUT_DIR}/release-downloads-by-platform.svg" \ + "Downloads per platform, by release" "${by_platform_data}" + +# --- View 2: per-version total downloads over time ------------------------ +by_version_data="$(printf '%s' "${model}" | jq ' + { labels: [ .releases[].tag ], + series: [ { name: "Total downloads", + color: "#2563eb", + values: [ .releases[].total ] } ] + } +')" +render_line_chart "${OUT_DIR}/release-downloads-by-version.svg" \ + "Total downloads, by release" "${by_version_data}" + +# --- Views 3 & 4: all-time totals per version + per platform (bars) ------- +# Render both into a single combined-height SVG by stacking two bar charts +# vertically is complex; instead emit one bar chart that shows per-version +# totals, and per-platform totals share the same file via a divider. +totals_version_bars="$(printf '%s' "${model}" | jq ' + { bars: [ .totalsByVersion[] | { label: .tag, value: .total } ] } +')" +totals_platform_bars="$(printf '%s' "${model}" | jq ' + { bars: [ .platforms[] as $p | select($p != "Debian (.deb)") | { label: $p, value: (.totalsByPlatform[$p] // 0) } ] } +')" + +# Render two separate bar SVGs, then combine into one file so the README +# embeds a single "totals" image. +render_bar_chart "${OUT_DIR}/.totals-version.svg" \ + "All-time downloads per version" "${totals_version_bars}" +render_bar_chart "${OUT_DIR}/.totals-platform.svg" \ + "All-time downloads per platform" "${totals_platform_bars}" + +combine_svgs() { + local out="$1" top="$2" bottom="$3" + local tw th bw bh + tw="$(grep -o 'width="[0-9]*"' "${top}" | head -1 | tr -dc '0-9')" + th="$(grep -o 'height="[0-9]*"' "${top}" | head -1 | tr -dc '0-9')" + bw="$(grep -o 'width="[0-9]*"' "${bottom}" | head -1 | tr -dc '0-9')" + bh="$(grep -o 'height="[0-9]*"' "${bottom}" | head -1 | tr -dc '0-9')" + local gap=24 + local W=$(( tw > bw ? tw : bw )) + local H=$(( th + gap + bh )) + { + printf '\n' \ + "$W" "$H" "$W" "$H" + printf '\n' "$W" "$H" + printf '' + # strip the outer and from each child, wrap in + sed -e '1d' -e '$d' "${top}" + printf '\n' $(( th + gap )) + sed -e '1d' -e '$d' "${bottom}" + printf '\n\n' + } > "${out}" + echo "wrote ${out}" +} +combine_svgs "${OUT_DIR}/release-downloads-totals.svg" \ + "${OUT_DIR}/.totals-version.svg" "${OUT_DIR}/.totals-platform.svg" +rm -f "${OUT_DIR}/.totals-version.svg" "${OUT_DIR}/.totals-platform.svg" + +# --- Markdown summary tables --------------------------------------------- +{ + echo "" + echo "" + echo + printf '**Total downloads:** %s across %s releases.\n\n' \ + "$(printf '%s' "${model}" | jq -r '.grandTotal')" \ + "$(printf '%s' "${model}" | jq -r '.releases | length')" + + echo "### Downloads per version" + echo + echo "| Version | Downloads |" + echo "|---|---|" + printf '%s' "${model}" | jq -r ' + (.totalsByVersion | reverse)[] | "| \(.tag) | \(.total) |"' + echo + echo "### Downloads per platform" + echo + echo "| Platform | Downloads |" + echo "|---|---|" + printf '%s' "${model}" | jq -r ' + .platforms[] as $p | "| \($p) | \(.totalsByPlatform[$p] // 0) |"' + echo +} > "${OUT_DIR}/release-downloads.md" +echo "wrote ${OUT_DIR}/release-downloads.md" + +# --- Append today's snapshot to the history file -------------------------- +# One row per UTC day: { date, total, byPlatform, byVersion }. Re-running on +# the same day replaces that day's row (idempotent), so the file stays clean +# and ordered. This accumulates the time series the GitHub API can't provide. +HISTORY_FILE="${OUT_DIR}/release-downloads-history.jsonl" +snapshot_date="${SNAPSHOT_DATE:-$(date -u +%Y-%m-%d)}" + +today_row="$(printf '%s' "${model}" | jq -c --arg d "${snapshot_date}" ' + { date: $d, + total: .grandTotal, + byPlatform: .totalsByPlatform, + byVersion: ( [ .totalsByVersion[] | { (.tag): .total } ] | add // {} ) }')" + +touch "${HISTORY_FILE}" +{ + jq -c --arg d "${snapshot_date}" 'select(.date != $d)' "${HISTORY_FILE}" 2>/dev/null || true + printf '%s\n' "${today_row}" +} | jq -s -c 'sort_by(.date) | .[]' > "${HISTORY_FILE}.tmp" +mv "${HISTORY_FILE}.tmp" "${HISTORY_FILE}" +echo "updated ${HISTORY_FILE} (snapshot ${snapshot_date})" From 38ecbb18fe2ba757346e5c1cf352314add57d0cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 07:17:21 +0000 Subject: [PATCH 35/68] chore(deps): bump golang.org/x/net from 0.51.0 to 0.55.0 Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.55.0. - [Commits](https://github.com/golang/net/compare/v0.51.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 8 ++++---- go.sum | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 7eb60534..46648fc3 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,9 @@ require ( github.com/mattn/go-isatty v0.0.22 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 go.yaml.in/yaml/v3 v3.0.4 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.45.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 ) @@ -32,9 +33,8 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect ) diff --git a/go.sum b/go.sum index ff65a089..bc450862 100644 --- a/go.sum +++ b/go.sum @@ -76,13 +76,13 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= From 181beb3f6db76aa7691c64e53dc77569970b9525 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Sun, 5 Jul 2026 15:37:17 +0000 Subject: [PATCH 36/68] docs: make docs/ the single source for the website Regenerate the Starlight site from the repository's docs/*.md instead of maintaining a hand-edited mirror under website/src/content/docs/. This removes the drift risk between the canonical docs and the published site. - scripts/sync-docs.mjs regenerates mapped pages on predev/prebuild: extracts the H1 as the Starlight title, rewrites sibling doc links to site-relative URLs and out-of-docs links to GitHub, and prunes orphaned generated pages. Generated pages are gitignored. - Each generated page gets a per-page editUrl pointing at the canonical docs/ on GitHub (not the generated website mirror), so "edit this page" lands on the source of truth. - docs-map.mjs carries site-only metadata (dest, description), the hand-authored page list, and a doNotPublish list so internal/process docs (changes-from-proposal.md) never leak onto the grant-facing site. - sync-docs.test.mjs unit-tests the transforms (link rewriting, title extraction, editUrl emission, auto-publish, do-not-publish); run via `npm test`. - getting-started.md keeps numbered section headings (1..6); faq.md links to #4-compatibility-matrix accordingly. - Grant-facing wording cleanup: drop internal/ Go paths and marketing superlatives ("polished", "curated", "Disaster recovery in 4 seconds") from limitations.md, observability.md, getting-started.md, and index.mdx. Verified: website `npm test` (10 pass), `npm run docs:sync`, `npm run build` (16 pages), and `go build ./...` all succeed. --- docs/dashboard-customization.md | 18 +- docs/explorer.md | 8 +- docs/faq.md | 2 +- docs/getting-started.md | 123 +------ docs/limitations.md | 10 +- .../guides => docs}/localnet-lifecycle.md | 13 +- docs/observability.md | 2 +- docs/tokens.md | 4 +- docs/troubleshooting.md | 2 +- website/.gitignore | 7 + website/astro.config.mjs | 19 ++ website/docs-map.mjs | 48 +++ website/package-lock.json | 20 ++ website/package.json | 8 +- website/scripts/sync-docs.mjs | 135 ++++++++ website/scripts/sync-docs.test.mjs | 118 +++++++ website/src/content/docs/getting-started.md | 251 --------------- .../docs/guides/dashboard-customization.md | 303 ------------------ website/src/content/docs/guides/explorer.md | 301 ----------------- website/src/content/docs/guides/homebrew.md | 81 ----- .../src/content/docs/guides/observability.md | 157 --------- website/src/content/docs/guides/tokens.md | 146 --------- website/src/content/docs/index.mdx | 30 ++ website/src/content/docs/reference/faq.md | 104 ------ .../src/content/docs/reference/limitations.md | 121 ------- .../src/content/docs/reference/packaging.md | 191 ----------- .../src/content/docs/reference/telemetry.md | 143 --------- .../content/docs/reference/troubleshooting.md | 101 ------ .../src/content/docs/reference/versions.md | 156 --------- website/src/styles/custom.css | 173 ++++++++++ 30 files changed, 600 insertions(+), 2195 deletions(-) rename {website/src/content/docs/guides => docs}/localnet-lifecycle.md (87%) create mode 100644 website/docs-map.mjs create mode 100644 website/scripts/sync-docs.mjs create mode 100644 website/scripts/sync-docs.test.mjs delete mode 100644 website/src/content/docs/getting-started.md delete mode 100644 website/src/content/docs/guides/dashboard-customization.md delete mode 100644 website/src/content/docs/guides/explorer.md delete mode 100644 website/src/content/docs/guides/homebrew.md delete mode 100644 website/src/content/docs/guides/observability.md delete mode 100644 website/src/content/docs/guides/tokens.md delete mode 100644 website/src/content/docs/reference/faq.md delete mode 100644 website/src/content/docs/reference/limitations.md delete mode 100644 website/src/content/docs/reference/packaging.md delete mode 100644 website/src/content/docs/reference/telemetry.md delete mode 100644 website/src/content/docs/reference/troubleshooting.md delete mode 100644 website/src/content/docs/reference/versions.md create mode 100644 website/src/styles/custom.css diff --git a/docs/dashboard-customization.md b/docs/dashboard-customization.md index 4231e055..ca716b6f 100644 --- a/docs/dashboard-customization.md +++ b/docs/dashboard-customization.md @@ -24,7 +24,7 @@ are printed in the same output. ## 1. What ships out of the box The bundled dashboard lives at -[`assets/grafana/dashboards/canton-localnet.json`](../assets/grafana/dashboards/canton-localnet.json) +[`assets/grafana/dashboards/canton-localnet.json`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/dashboards/canton-localnet.json) and is titled **Canton LocalNet — DApp Developer Overview**. It refreshes every 10s, defaults to a 15-minute window, and exposes a single `$instance` template variable backed by @@ -34,7 +34,7 @@ participant. The current bundle ships 10 panels (`id` 1-15 with gaps reserved for future inserts). The metric names match the live `daml_*`, `jvm_*`, and `db_client_*` families audited in -[docs/observability.md](observability.md) — not the older +[Observability](observability.md) — not the older non-existent `canton_*` names. | Panel | Type | PromQL | What it tells you | @@ -51,7 +51,7 @@ non-existent `canton_*` names. | Top 10 gRPC Methods by Throughput | bar gauge | `topk(10, sum by (grpc_method_name) (rate(daml_grpc_server_handled_total{instance=~"$instance"}[5m])))` | API throughput by live gRPC method. Stock Splice 0.6.4 does not expose template-grain submission counters. | For the full metric-family audit and substitution table, see -[docs/observability.md](observability.md). +[Observability](observability.md). --- @@ -59,7 +59,7 @@ For the full metric-family audit and substitution table, see The dashboard JSON is mounted into the Grafana container by the provisioner configured in -[`assets/grafana/provisioning/dashboards/canton.yaml`](../assets/grafana/provisioning/dashboards/canton.yaml). +[`assets/grafana/provisioning/dashboards/canton.yaml`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/provisioning/dashboards/canton.yaml). Grafana re-scans this directory every 30 seconds, so edits take effect without a container restart: @@ -231,7 +231,7 @@ If a panel renders as "No data", the fastest debug is to open Prometheus directly, type the metric name, and see whether the instance is producing it at all. For the full audited substitution table from the earlier `canton_*` placeholders to the live names, see -[docs/observability.md](observability.md). +[Observability](observability.md). --- @@ -289,12 +289,12 @@ pool rather than the ledger itself. ## 8. See also -- [docs/getting-started.md](getting-started.md) — installing DevKit +- [Getting started](getting-started.md) — installing DevKit and starting LocalNet with the observability overlay. -- [docs/observability.md](observability.md) — audited metric families +- [Observability](observability.md) — audited metric families and the `canton_*` → `daml_*` substitution table. -- [docs/telemetry.md](telemetry.md) — the anonymous usage counters +- [Telemetry](telemetry.md) — the anonymous usage counters the DevKit CLI itself records (separate from Canton's Prometheus metrics). -- [docs/troubleshooting.md](troubleshooting.md) — common Grafana / +- [Troubleshooting](troubleshooting.md) — common Grafana / Prometheus startup issues. diff --git a/docs/explorer.md b/docs/explorer.md index 24192681..c924f7b7 100644 --- a/docs/explorer.md +++ b/docs/explorer.md @@ -1,4 +1,4 @@ -# Explorer Usage +# Explorer The Explorer is the Web UI's window into a running LocalNet's ledger. It reads the Active Contract Set (ACS) and recent @@ -289,10 +289,10 @@ or the underlying gRPC API directly via the SDK. ## 11. See also -- [docs/getting-started.md](getting-started.md) — starting a +- [Getting started](getting-started.md) — starting a LocalNet and finding its participant ports. -- [docs/tokens.md](tokens.md) — driving CIP-0112 token flows from +- [Tokens](tokens.md) — driving CIP-0112 token flows from the CLI; useful to populate the ACS with realistic contracts while you explore. -- [docs/troubleshooting.md](troubleshooting.md) — port-recapture +- [Troubleshooting](troubleshooting.md) — port-recapture and JWT-related fixes. diff --git a/docs/faq.md b/docs/faq.md index 380d2152..7e0661f1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -28,7 +28,7 @@ macOS (arm64), Linux (amd64), and Windows (amd64) are the released, tested targets. Other OS/arch combinations may work (DevKit only orchestrates Docker) but are untested — `localnet doctor` warns on unsupported platforms. See the compatibility matrix in -[getting-started.md](getting-started.md#5-compatibility-matrix). +[getting-started.md](getting-started.md#4-compatibility-matrix). ## Versions diff --git a/docs/getting-started.md b/docs/getting-started.md index 51421b7a..b2aea106 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,8 +19,6 @@ tree**. Throughout the docs, `dpm localnet ` and > never changes host permissions. It orchestrates the existing Splice > LocalNet container stack. ---- - ## 1. Prerequisites | Requirement | Why | Check | @@ -41,8 +39,6 @@ when a check fails, printing copy-pasteable remediation. It's the same preflight `localnet up` runs, so a green `doctor` means `up` will pass preflight. ---- - ## 2. Install — DPM component (primary) DevKit is published as a native DPM component to an OCI registry. Add @@ -70,8 +66,6 @@ subcommand (`up`, `down`, `status`, `dar …`, `contracts …`, `token …`, `metrics`, `doctor`, …) lives under it. This keeps the DPM surface minimal and conflict-free. ---- - ## 3. Install — standalone binary Download the binary for your platform from the @@ -146,7 +140,7 @@ sudo apt install canton-devkit=0.7.0 The APT repo is currently unsigned and therefore uses `trusted=yes`; the release still publishes SHA-256 metadata. Repository signing has not been added yet. Package installation records a best-effort anonymous `apt` -install-surface telemetry ping — see [telemetry.md](./telemetry.md) +install-surface telemetry ping — see [Telemetry](telemetry.md) for what is sent and how to opt out before installing. Direct `.deb` install also works: @@ -192,8 +186,9 @@ brew tap bitdynamics-ab/canton-devkit brew install canton-devkit ``` -> See [homebrew.md](./homebrew.md) for the direct-formula install, -> the tap layout, and how the formula is kept in sync on each release. +> See the [Homebrew guide](homebrew.md) for the direct-formula +> install, the tap layout, and how the formula is kept in sync on each +> release. ### From source (Go toolchain) @@ -201,79 +196,7 @@ brew install canton-devkit go install github.com/bitdynamics-ab/canton-devkit/cmd/canton-devkit@latest ``` ---- - -## 4. Zero to running LocalNet - -```bash -# 1. Check the host (no changes made) -canton-devkit localnet doctor - -# 2. Start a named LocalNet (downloads Splice on first run; waits for readiness) -canton-devkit localnet up --name demo - -# 3. Inspect it — endpoints, health, credentials -canton-devkit localnet status --name demo - -# 4. Export endpoints for your app/tests -eval "$(canton-devkit localnet env --name demo)" - -# 5. Upload a DAR -canton-devkit localnet dar upload ./my-app.dar --instance demo - -# 6. Watch live contracts. The participant gRPC endpoint isn't -# host-published by default, so pass --endpoint host:port -# (auto-discovery from --name is not yet supported). Find the -# port under "participant_ledger_app-user" in `status` output. -canton-devkit localnet contracts watch --name demo --endpoint localhost: - -# 7. Tear it down -canton-devkit localnet down --name demo -``` - -Replace `canton-devkit` with `dpm` if you installed via the DPM -component. `up` waits for the stack to become healthy (Splice -onboarding can take several minutes on a cold start) and prints the -service endpoints and credential locations when ready. - -### Running two LocalNets at once - -```bash -canton-devkit localnet up --name alpha -canton-devkit localnet up --name beta -canton-devkit localnet list # both instances + their state -``` - -Each named instance gets its own deterministic compose project, -network, and host ports, so they don't collide. - -#### Explicit, deterministic ports (`--port-base`) - -By default DevKit **auto-allocates** host ports — the simplest path, and -it never conflicts because the kernel hands out free ports. When you need -a **fixed, predictable** port map instead — reproducible CI layouts, or -multiple instances at known offsets — pin a base: - -```bash -canton-devkit localnet up --name alpha --port-base 20000 # services at 20000+N -canton-devkit localnet up --name beta --port-base 30000 # services at 30000+N -``` - -Each service lands on `base + N`, identically across runs and machines. -Every derived port must be free or `up` fails fast (no silent fallback) — -so the layout you asked for is the layout you get. Pre-flight a base -before bringing anything up: - -```bash -canton-devkit localnet doctor --port-base 20000 # are 20000..20000+services free? -``` - -The same control is available in the Web UI's **New instance** dialog -under *Advanced → Fixed port base*. - ---- - -## 5. Compatibility matrix +## 4. Compatibility matrix ### Platforms (released, tested) @@ -289,27 +212,25 @@ platforms. ### Splice LocalNet versions -DevKit pins a **catalogue** of tested Splice versions; `localnet up +DevKit pins a catalogue of tested Splice versions; `localnet up --version ` selects one. List them at runtime: ```bash canton-devkit localnet versions ``` -See [docs/versions.md](./versions.md) for how the catalogue is fetched -and verified. Uncurated upstream tags can be used at your own risk via -`up --version --allow-uncurated`. - ---- +See the [Splice version catalogue](versions.md) for how the +catalogue is fetched and verified. Uncurated upstream tags can be used +at your own risk via `up --version --allow-uncurated`. -## 6. Troubleshooting +## 5. Troubleshooting the install | Symptom | Cause | Fix | |---|---|---| | `doctor` says **Docker daemon** ✗ | Docker not running | Start Docker Desktop / `sudo systemctl start docker` | | `doctor` says **Compose v2** ✗ | Only Compose v1 present | Upgrade to Docker Compose v2 (`docker compose`, not `docker-compose`) | | `up` fails **PORTS_IN_USE** | Another process holds a port | Stop the conflicting process, or use a different `--name` | -| `up` hangs at "waiting for healthy" | Insufficient Docker memory | Raise Docker memory to ≥ 8 GB; see [docs/limitations.md](./limitations.md) | +| `up` hangs at "waiting for healthy" | Insufficient Docker memory | Raise Docker memory to ≥ 8 GB; see [Known limitations](limitations.md) | | Linux: `permission denied` on the Docker socket | User not in `docker` group | `sudo usermod -aG docker $USER` then re-login | | macOS: "cannot be opened because the developer cannot be verified" | Gatekeeper quarantine | `xattr -d com.apple.quarantine $(which canton-devkit)` | | Web UI / Explorer shows stale ports after a restart | Docker re-assigned ephemeral ports | DevKit re-captures them within ~15 s; or run `localnet restart --name ` | @@ -318,20 +239,10 @@ For anything else, attach the full `localnet doctor` output to a [GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues) — it includes OS/arch, Docker/Compose versions, and the check results. ---- - -## 7. Uninstall / clean up - -```bash -# stop + remove a single instance's containers, volumes, and state -canton-devkit localnet clean --name demo - -# remove every DevKit-managed instance -canton-devkit localnet clean --all - -# remove the standalone binary -sudo rm /usr/local/bin/canton-devkit -``` +## 6. Next steps -`clean` refuses to touch a running instance unless you pass `--force` -(which tears it down first). Use `--dry-run` to preview. +- [LocalNet lifecycle](localnet-lifecycle.md) — zero to a running + LocalNet, multiple instances, deterministic ports, and clean-up. +- [Tokens](tokens.md) — CIP-0112 token flows on LocalNet. +- [Explorer](explorer.md) — browse the Active Contract Set and + recent transactions from the Web UI. diff --git a/docs/limitations.md b/docs/limitations.md index d4d5d4dd..1e472657 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -11,8 +11,8 @@ resolved. Uppercase, underscores, and leading/trailing hyphens are rejected. DNS-label form was chosen so the same name is safe to embed as a hostname in a future `{service}.{instance}.localhost` routing model - without a second translation step. The single source of truth lives in - `internal/registry/state.go` (`ValidateName`); the CLI layer delegates. + without a second translation step. Name validation is centralized so + every surface enforces the same rule. *Migration:* instances created with an older release that still allowed uppercase or underscore names (e.g. `MyStack`, `my_stack`) must be torn down with that older binary and re-created under a @@ -34,7 +34,7 @@ resolved. each running image's content digest (image ID) in `state.json` (`image_digests`) and, on a later `up`/`restart` of the SAME version, WARNs if a digest changed — i.e. a mutable ghcr tag was republished - under you. See `internal/localnet/image_digests.go`. This is a warning, + under you. This is a warning, not a gate (a digest can legitimately change if you manually re-pull), and it's best-effort (a capture failure just skips the check). True digest-pinning at pull time would need upstream Splice to expose @@ -94,12 +94,12 @@ resolved. rather than DPM until the Windows `.exe` path through DPM is verified. -## Observability: transitional dual stack +## Observability: transitional dual stack DevKit runs a host-level shared Prometheus + Grafana stack — one stack serves every running LocalNet via file-based service discovery, refcounted by target file. See -[docs/observability.md](observability.md#stack-topology--host-shared-with-a-transitional-per-instance-overlay) +[Observability](observability.md#stack-topology--host-shared-with-a-transitional-per-instance-overlay) for the topology. - **Each observability-enabled instance still *also* runs a diff --git a/website/src/content/docs/guides/localnet-lifecycle.md b/docs/localnet-lifecycle.md similarity index 87% rename from website/src/content/docs/guides/localnet-lifecycle.md rename to docs/localnet-lifecycle.md index 05a6a13e..3324594b 100644 --- a/website/src/content/docs/guides/localnet-lifecycle.md +++ b/docs/localnet-lifecycle.md @@ -1,7 +1,4 @@ ---- -title: LocalNet Lifecycle -description: Zero to a running Canton LocalNet — start, inspect, run multiple instances, pin ports, tear down, and answers to common questions. ---- +# LocalNet Lifecycle Canton DevKit is a single-binary developer tool for running and operating a Canton **LocalNet** — a full local Canton Network (sequencers, mediators, @@ -11,7 +8,7 @@ embedded Web UI for the same operations. This guide walks the full lifecycle: bring an instance up, inspect it, run several at once, and clean up. See -[Installation & Getting Started](../../getting-started/) first if you +[Installation & Getting Started](getting-started.md) first if you haven't installed DevKit yet. ## Zero to running LocalNet @@ -98,8 +95,4 @@ sudo rm /usr/local/bin/canton-devkit `clean` refuses to touch a running instance unless you pass `--force` (which tears it down first). Use `--dry-run` to preview. -## FAQ - -See the [FAQ](../../reference/faq/) for common questions about versions, -tokens, multi-instance setups, and snapshots. For failure-mode fixes, see -[Troubleshooting](../../reference/troubleshooting/). +For common questions, see the [FAQ](faq.md). diff --git a/docs/observability.md b/docs/observability.md index d74419a0..e62d4211 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -149,6 +149,6 @@ up, and the per-instance scrape uses in-network service DNS (`canton:10013`) rather than `host.docker.internal`, so it works on any platform regardless of the Linux `host-gateway` mapping. The per-instance overlay remains enabled until the shared-only path is validated end-to-end -on a native Linux Docker host — see [docs/limitations.md](limitations.md#observability-transitional-dual-stack). The +on a native Linux Docker host — see [Known limitations](limitations.md#observability-transitional-dual-stack). The extra resource cost (a second Prometheus+Grafana per instance) is the price of that fallback on a dev machine; it carries no correctness impact. diff --git a/docs/tokens.md b/docs/tokens.md index f920eecd..38e4d78d 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -119,7 +119,7 @@ V2 runs only on the upstream **alpha** Splice build (snapshot image on the `-dev` ghcr repo, `initial-protocol-version=35`). Consequences to know: - **The upstream V2 DevNet resets periodically.** The catalogue entry may - need refreshing each release cycle — see [docs/versions.md](versions.md). + need refreshing each release cycle — see [Splice version catalogue](versions.md). - **Use `--profile tokens-v2`.** Selecting the alpha version without it brings up a stack that can't run the V2 protocol; `doctor` warns. - **Loopback-only dev auth.** Per-role JWTs are signed with a literal @@ -138,6 +138,6 @@ V2 runs only on the upstream **alpha** Splice build (snapshot image on the create → mint → transfer → burn, all on-ledger, no scan registry dependency (its `TokenRules` *is* the registry). -See also: [getting-started.md](getting-started.md) · +See also: [Getting started](getting-started.md) · [FAQ](faq.md) · [troubleshooting](troubleshooting.md) · [versions](versions.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8cf84bf7..190abfe8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -71,7 +71,7 @@ no mint/burn surface — create your own token to exercise them. re-issues a dev token from the project's env files. The token commands also auto-issue per-role tokens when `--token` is empty. -## Snapshot consistency +## Snapshot consistency `localnet snapshot` captures Docker volumes + registry state. For a **running** instance this is a crash-consistent (not diff --git a/website/.gitignore b/website/.gitignore index 63b09bde..2d51391a 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -13,3 +13,10 @@ node_modules/ # macOS .DS_Store + +# Generated from ../docs by scripts/sync-docs.mjs (predev/prebuild). +# Edit the repo docs, not these pages. Hand-authored pages (index.mdx, +# 404.md, operations/) stay tracked. +src/content/docs/getting-started.md +src/content/docs/guides/ +src/content/docs/reference/ diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 122ac607..a4dd7861 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -11,6 +11,25 @@ export default defineConfig({ title: 'Canton DevKit', description: 'One command to a full Canton LocalNet — spin up, inspect, and tear down a complete Canton developer stack.', + customCss: [ + // Self-hosted fonts (Fontsource) + typography tuning. + '@fontsource-variable/inter', + '@fontsource-variable/jetbrains-mono', + './src/styles/custom.css', + ], + expressiveCode: { + // Code-block typography is resolved at build time by + // Expressive Code — plain CSS overrides don't reach the + // inner
, so set it here (this fixes the browser
+				// falling back to its default monospace inside frames).
+				styleOverrides: {
+					codeFontFamily: "'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace",
+					codeFontSize: '0.875rem',
+					codeLineHeight: '1.7',
+					uiFontFamily: "'Inter Variable', ui-sans-serif, system-ui, sans-serif",
+					borderRadius: '0.5rem',
+				},
+			},
 			social: [
 				{
 					icon: 'github',
diff --git a/website/docs-map.mjs b/website/docs-map.mjs
new file mode 100644
index 00000000..d64d1365
--- /dev/null
+++ b/website/docs-map.mjs
@@ -0,0 +1,48 @@
+// Single source of truth: the repository's docs/*.md files.
+//
+// scripts/sync-docs.mjs regenerates the mapped site pages from docs/ on
+// every `npm run dev` / `npm run build` (predev/prebuild hooks). Edit the
+// repo docs, not the generated pages — generated pages are gitignored.
+//
+// `dest` is the site path (no extension) under src/content/docs/.
+// `description` is site-only metadata (Starlight frontmatter + SEO);
+// it lives here so the repo docs stay plain markdown.
+//
+// A docs/*.md file that is NOT listed here is auto-published under
+// reference/ with a build warning — new docs never silently
+// disappear from the site. Add an entry to place (and describe) it
+// properly.
+
+export const docsMap = [
+  { src: 'getting-started.md', dest: 'getting-started', description: 'Install Canton DevKit as a DPM component or standalone binary on macOS, Linux, and Windows, and verify your host is ready for LocalNet.' },
+  { src: 'localnet-lifecycle.md', dest: 'guides/localnet-lifecycle', description: 'Zero to a running Canton LocalNet — start, inspect, run multiple instances, pin ports, tear down, and answers to common questions.' },
+  { src: 'explorer.md', dest: 'guides/explorer', description: 'Browse the Active Contract Set, recent transactions, and a ledger timeline of a running LocalNet from the Web UI — with CLI equivalents for scripting.' },
+  { src: 'observability.md', dest: 'guides/observability', description: 'Enable the observability profile for Prometheus + Grafana, understand the live Splice metric naming convention, and toggle the sidecars at runtime.' },
+  { src: 'dashboard-customization.md', dest: 'guides/dashboard-customization', description: 'Extend, replace, or restore the bundled Grafana dashboard for a running LocalNet — panels, template variables, and persistence across down/up cycles.' },
+  { src: 'tokens.md', dest: 'guides/tokens', description: 'Create, mint, transfer, and burn CIP-0112 (Token Standard V2) instruments against a live LocalNet from the CLI or the Web UI.' },
+  { src: 'homebrew.md', dest: 'guides/homebrew', description: 'Install canton-devkit via the Homebrew tap or direct formula, and how the formula is kept in sync on every release.' },
+  { src: 'faq.md', dest: 'reference/faq', description: 'Common questions about canton-devkit: what it is, how the CLI and Web UI relate, versions, and day-to-day usage.' },
+  { src: 'versions.md', dest: 'reference/versions', description: 'How DevKit pins tested Splice LocalNet versions by commit SHA and content hash, discovers upstream tags, and resolves uncurated versions on opt-in.' },
+  { src: 'packaging.md', dest: 'reference/packaging', description: 'How canton-devkit ships — standalone binaries, the DPM component, the Debian/APT package — and the current supply-chain integrity story.' },
+  { src: 'telemetry.md', dest: 'reference/telemetry', description: "The complete reference for canton-devkit's anonymous, aggregate usage counters — what is collected, what is never collected, and how to inspect or disable it." },
+  { src: 'limitations.md', dest: 'reference/limitations', description: 'Things DevKit does not (yet) do well, with the rationale and workarounds where applicable.' },
+  { src: 'troubleshooting.md', dest: 'reference/troubleshooting', description: 'Failure modes and fixes for LocalNet bring-up, ports, V2 token instances, credentials, and snapshots.' },
+];
+
+// Hand-authored pages the sync must never touch or prune.
+export const handAuthored = ['index.mdx', '404.md', 'operations/telemetry-collector.md'];
+
+// Internal / process docs that live in docs/ but must NOT be published to
+// the grant-facing site (see AGENTS.md "Grant-facing documentation" —
+// out-of-scope files). Listed by docs/ filename; the sync skips them
+// instead of auto-publishing them under reference/.
+export const doNotPublish = ['changes-from-proposal.md', 'original-devkit-proposal.md'];
+
+// Repo root on GitHub, for links that point outside docs/ (e.g. the
+// telemetry collector runbook) so they resolve from the published site.
+export const repoBlobBase = 'https://github.com/bitdynamics-ab/canton-devkit/blob/main/';
+
+// GitHub "edit this page" base for the canonical docs/ sources. Generated
+// pages point their editUrl here (at docs/), not at the generated
+// mirror under website/, so the edit link lands on the source of truth.
+export const docsEditBase = 'https://github.com/bitdynamics-ab/canton-devkit/edit/main/docs/';
diff --git a/website/package-lock.json b/website/package-lock.json
index 88a5dca0..a375af9e 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -9,6 +9,8 @@
       "version": "0.0.1",
       "dependencies": {
         "@astrojs/starlight": "^0.41.2",
+        "@fontsource-variable/inter": "^5.2.8",
+        "@fontsource-variable/jetbrains-mono": "^5.2.8",
         "astro": "^7.0.5",
         "sharp": "^0.35.3"
       }
@@ -1061,6 +1063,24 @@
         "@expressive-code/core": "^0.44.0"
       }
     },
+    "node_modules/@fontsource-variable/inter": {
+      "version": "5.2.8",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
+      "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
+    "node_modules/@fontsource-variable/jetbrains-mono": {
+      "version": "5.2.8",
+      "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
+      "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
     "node_modules/@img/colour": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
diff --git a/website/package.json b/website/package.json
index 3cf3ed56..cd6fe6ef 100644
--- a/website/package.json
+++ b/website/package.json
@@ -8,10 +8,16 @@
     "start": "astro dev",
     "build": "astro build",
     "preview": "astro preview",
-    "astro": "astro"
+    "astro": "astro",
+    "test": "node --test \"scripts/**/*.test.mjs\"",
+    "docs:sync": "node scripts/sync-docs.mjs",
+    "predev": "node scripts/sync-docs.mjs",
+    "prebuild": "node scripts/sync-docs.mjs"
   },
   "dependencies": {
     "@astrojs/starlight": "^0.41.2",
+    "@fontsource-variable/inter": "^5.2.8",
+    "@fontsource-variable/jetbrains-mono": "^5.2.8",
     "astro": "^7.0.5",
     "sharp": "^0.35.3"
   }
diff --git a/website/scripts/sync-docs.mjs b/website/scripts/sync-docs.mjs
new file mode 100644
index 00000000..3fc98d81
--- /dev/null
+++ b/website/scripts/sync-docs.mjs
@@ -0,0 +1,135 @@
+#!/usr/bin/env node
+// Regenerates the site's doc pages from the repository's docs/*.md.
+//
+// docs/ is the single source of truth for documentation content. This
+// script runs automatically before `astro dev` and `astro build`
+// (predev/prebuild), so the site can never drift from the repo docs.
+//
+// Transform per page:
+//   - first `# H1` becomes the Starlight frontmatter title (removed from body)
+//   - `description` comes from docs-map.mjs (site-only metadata)
+//   - `editUrl` points at the canonical docs/ on GitHub, not the
+//     generated mirror under website/
+//   - links to sibling docs (`other.md`, `other.md#anchor`) are rewritten
+//     to relative site URLs; links to other repo files become GitHub URLs
+//   - output written to src/content/docs/.md
+//
+// Unmapped docs/*.md are published under reference/ with a warning
+// (except internal/process docs in doNotPublish, which are skipped).
+// Generated pages that no longer correspond to a source are pruned
+// (hand-authored pages listed in docs-map.mjs are never touched).
+//
+// The transform helpers are exported for unit testing; the filesystem
+// generation only runs when this file is invoked directly as a CLI.
+
+import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
+import { dirname, join, posix, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { docsMap, handAuthored, doNotPublish, repoBlobBase, docsEditBase } from '../docs-map.mjs';
+
+// Build the src→dest lookup, auto-mapping any docs/*.md not in the map so
+// new docs always publish. Internal/process docs (doNotPublish) are
+// skipped so they never leak onto the grant-facing site.
+export function buildEntries(sources, map = docsMap, skip = doNotPublish) {
+  const bySrc = new Map(map.map(e => [e.src, e]));
+  const warnings = [];
+  for (const f of sources) {
+    if (skip.includes(f)) continue;
+    if (!bySrc.has(f)) {
+      const dest = 'reference/' + f.replace(/\.md$/, '');
+      warnings.push(`sync-docs: docs/${f} is not in docs-map.mjs — auto-publishing at ${dest}/ (add a map entry to place it properly)`);
+      bySrc.set(f, { src: f, dest, description: '' });
+    }
+  }
+  return { bySrc, warnings };
+}
+
+export function siteLink(fromDest, target, destBySrc) {
+  const [file, anchor] = target.split('#');
+  const dest = destBySrc.get(file);
+  if (dest === undefined) return null;
+  // Starlight page URLs end in a slash, so the browser resolves relative
+  // links against the page path itself (e.g. /reference/telemetry/) —
+  // compute relative to fromDest, not its parent directory.
+  let rel = posix.relative(fromDest, dest);
+  if (!rel.startsWith('.')) rel = './' + rel;
+  return `${rel}/${anchor ? '#' + anchor : ''}`;
+}
+
+export function transformLinks(body, fromDest, destBySrc, blobBase = repoBlobBase) {
+  return body.replace(/\]\(([^)\s]+)\)/g, (whole, target) => {
+    if (/^(https?:|mailto:|#)/.test(target)) return whole;
+    if (/^[\w./-]+\.md(#[\w-]*)?$/.test(target) && !target.includes('/')) {
+      const link = siteLink(fromDest, target, destBySrc);
+      if (link) return `](${link})`;
+    }
+    // Relative link out of docs/ (repo file or directory) → GitHub.
+    const resolved = posix.normalize(posix.join('docs', target));
+    return `](${blobBase}${resolved.replace(/^(\.\.\/)+/, '')})`;
+  });
+}
+
+export function escapeYaml(s) {
+  return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+}
+
+// Turn one docs/*.md source into the generated Starlight page string.
+export function renderPage(entry, raw, destBySrc, opts = {}) {
+  const blobBase = opts.blobBase ?? repoBlobBase;
+  const editBase = opts.editBase ?? docsEditBase;
+  const m = raw.match(/^#\s+(.+)\n/);
+  const title = m ? m[1].trim() : entry.src.replace(/\.md$/, '');
+  const body = m ? raw.slice(m[0].length) : raw;
+  const fm = [
+    '---',
+    `title: ${escapeYaml(title)}`,
+    ...(entry.description ? [`description: ${escapeYaml(entry.description)}`] : []),
+    `editUrl: ${escapeYaml(editBase + entry.src)}`,
+    '---',
+    '',
+  ].join('\n');
+  return fm + transformLinks(body, entry.dest, destBySrc, blobBase).replace(/^\n+/, '');
+}
+
+function generate() {
+  const here = dirname(fileURLToPath(import.meta.url));
+  const websiteDir = join(here, '..');
+  const repoRoot = join(websiteDir, '..');
+  const docsDir = join(repoRoot, 'docs');
+  const outDir = join(websiteDir, 'src', 'content', 'docs');
+
+  const sources = readdirSync(docsDir).filter(f => f.endsWith('.md'));
+  const { bySrc, warnings } = buildEntries(sources);
+  for (const w of warnings) console.warn(w);
+  const destBySrc = new Map([...bySrc.values()].map(e => [e.src, e.dest]));
+
+  const produced = new Set(handAuthored);
+  for (const entry of bySrc.values()) {
+    const raw = readFileSync(join(docsDir, entry.src), 'utf8');
+    const outPath = join(outDir, entry.dest + '.md');
+    mkdirSync(dirname(outPath), { recursive: true });
+    writeFileSync(outPath, renderPage(entry, raw, destBySrc));
+    produced.add(entry.dest + '.md');
+  }
+
+  // Prune orphans: generated pages whose source disappeared.
+  const walk = dir => readdirSync(dir, { withFileTypes: true }).flatMap(d => {
+    const p = join(dir, d.name);
+    return d.isDirectory() ? walk(p) : [p];
+  });
+  for (const p of walk(outDir)) {
+    const rel = relative(outDir, p).split('\\').join('/');
+    if (!produced.has(rel)) {
+      console.warn(`sync-docs: pruning orphan page ${rel}`);
+      rmSync(p);
+    }
+  }
+
+  console.log(`sync-docs: generated ${bySrc.size} pages from docs/ (${handAuthored.length} hand-authored pages untouched)`);
+}
+
+// Only regenerate files when run as a CLI, so tests can import the pure
+// helpers without touching the filesystem.
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+  generate();
+}
diff --git a/website/scripts/sync-docs.test.mjs b/website/scripts/sync-docs.test.mjs
new file mode 100644
index 00000000..e76ae540
--- /dev/null
+++ b/website/scripts/sync-docs.test.mjs
@@ -0,0 +1,118 @@
+// Unit tests for the docs→site sync transforms (scripts/sync-docs.mjs).
+// Run with: npm test  (node --test)
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+  buildEntries,
+  siteLink,
+  transformLinks,
+  escapeYaml,
+  renderPage,
+} from './sync-docs.mjs';
+
+const MAP = [
+  { src: 'getting-started.md', dest: 'getting-started', description: 'Install and verify.' },
+  { src: 'faq.md', dest: 'reference/faq', description: 'Common questions.' },
+  { src: 'tokens.md', dest: 'guides/tokens', description: '' },
+];
+const destBySrc = new Map(MAP.map(e => [e.src, e.dest]));
+
+test('buildEntries keeps mapped sources and auto-publishes unmapped ones', () => {
+  const { bySrc, warnings } = buildEntries(
+    ['getting-started.md', 'faq.md', 'tokens.md', 'newdoc.md'],
+    MAP,
+    [],
+  );
+  assert.equal(bySrc.get('newdoc.md').dest, 'reference/newdoc');
+  assert.equal(bySrc.get('getting-started.md').dest, 'getting-started');
+  assert.equal(warnings.length, 1);
+  assert.match(warnings[0], /newdoc\.md is not in docs-map/);
+});
+
+test('buildEntries skips do-not-publish (internal/process) docs', () => {
+  const { bySrc, warnings } = buildEntries(
+    ['faq.md', 'changes-from-proposal.md'],
+    MAP,
+    ['changes-from-proposal.md'],
+  );
+  assert.ok(!bySrc.has('changes-from-proposal.md'));
+  assert.equal(warnings.length, 0);
+});
+
+test('siteLink rewrites sibling docs to relative site URLs', () => {
+  // From reference/faq to getting-started (a top-level page): two levels up.
+  assert.equal(
+    siteLink('reference/faq', 'getting-started.md', destBySrc),
+    '../../getting-started/',
+  );
+  // Preserves anchors.
+  assert.equal(
+    siteLink('reference/faq', 'getting-started.md#4-compatibility-matrix', destBySrc),
+    '../../getting-started/#4-compatibility-matrix',
+  );
+});
+
+test('siteLink returns null for unknown targets', () => {
+  assert.equal(siteLink('reference/faq', 'nope.md', destBySrc), null);
+});
+
+test('transformLinks rewrites sibling .md links and keeps external/anchor links', () => {
+  const body = [
+    'See [FAQ](faq.md) and [matrix](getting-started.md#4-compatibility-matrix).',
+    'External [Canton](https://canton.network/) stays.',
+    'In-page [top](#intro) stays.',
+  ].join('\n');
+  const out = transformLinks(body, 'guides/tokens', destBySrc);
+  assert.match(out, /\[FAQ\]\(\.\.\/\.\.\/reference\/faq\/\)/);
+  assert.match(out, /\[matrix\]\(\.\.\/\.\.\/getting-started\/#4-compatibility-matrix\)/);
+  assert.match(out, /\[Canton\]\(https:\/\/canton\.network\/\)/);
+  assert.match(out, /\[top\]\(#intro\)/);
+});
+
+test('transformLinks sends out-of-docs relative links to GitHub blob base', () => {
+  const out = transformLinks(
+    'See [proposal](../README.md) for details.',
+    'getting-started',
+    destBySrc,
+    'https://github.com/o/r/blob/main/',
+  );
+  assert.match(out, /\[proposal\]\(https:\/\/github\.com\/o\/r\/blob\/main\/README\.md\)/);
+});
+
+test('escapeYaml quotes and escapes double quotes and backslashes', () => {
+  assert.equal(escapeYaml('plain'), '"plain"');
+  assert.equal(escapeYaml('has "quote"'), '"has \\"quote\\""');
+  assert.equal(escapeYaml('back\\slash'), '"back\\\\slash"');
+});
+
+test('renderPage extracts H1 into title, emits editUrl, drops H1 from body', () => {
+  const raw = '# Frequently Asked Questions\n\nSome body text.\n';
+  const out = renderPage(MAP[1], raw, destBySrc, {
+    editBase: 'https://github.com/o/r/edit/main/docs/',
+  });
+  assert.match(out, /^---\n/);
+  assert.match(out, /title: "Frequently Asked Questions"/);
+  assert.match(out, /description: "Common questions\."/);
+  assert.match(out, /editUrl: "https:\/\/github\.com\/o\/r\/edit\/main\/docs\/faq\.md"/);
+  assert.ok(!out.includes('# Frequently Asked Questions'));
+  assert.match(out, /Some body text\./);
+});
+
+test('renderPage omits description when empty but always emits editUrl', () => {
+  const raw = '# Tokens\n\nBody.\n';
+  const out = renderPage(MAP[2], raw, destBySrc, {
+    editBase: 'https://github.com/o/r/edit/main/docs/',
+  });
+  assert.ok(!out.includes('description:'));
+  assert.match(out, /editUrl: "https:\/\/github\.com\/o\/r\/edit\/main\/docs\/tokens\.md"/);
+});
+
+test('renderPage falls back to filename-derived title when no H1', () => {
+  const raw = 'No heading here.\n';
+  const out = renderPage(MAP[2], raw, destBySrc, {
+    editBase: 'https://github.com/o/r/edit/main/docs/',
+  });
+  assert.match(out, /title: "tokens"/);
+});
diff --git a/website/src/content/docs/getting-started.md b/website/src/content/docs/getting-started.md
deleted file mode 100644
index cfd424cb..00000000
--- a/website/src/content/docs/getting-started.md
+++ /dev/null
@@ -1,251 +0,0 @@
----
-title: Installation & Getting Started
-description: Install Canton DevKit as a DPM component or standalone binary on macOS, Linux, and Windows, and verify your host is ready for LocalNet.
----
-
-Canton DevKit is a single Go binary that orchestrates the Splice
-LocalNet Docker stack. It ships two ways:
-
-1. **DPM component** (primary) — install through the Daml Package
-   Manager and invoke as `dpm localnet …`.
-2. **Standalone binary** (`canton-devkit`) — a self-contained
-   executable for users who don't run DPM (CI, DevOps, workshop
-   facilitators), shipped as release archives plus APT convenience
-   packages for Debian/Ubuntu hosts. Invoke as `canton-devkit localnet …`.
-
-Both paths ship the **same binary** and expose the **same command
-tree**. Throughout the docs, `dpm localnet ` and
-`canton-devkit localnet ` are interchangeable.
-
-> **The only system prerequisite is a working Docker runtime.** DevKit
-> never installs Docker, never edits the Docker daemon config, and
-> never changes host permissions. It orchestrates the existing Splice
-> LocalNet container stack.
-
-## Prerequisites
-
-| Requirement | Why | Check |
-|---|---|---|
-| Docker Engine / Desktop | DevKit runs LocalNet as containers | `docker version` |
-| Docker Compose **v2** | LocalNet is a compose project | `docker compose version` |
-| ~8 GB free RAM for Docker | Splice stack is memory-hungry | Docker Desktop → Settings → Resources |
-| ~20 GB free disk | Splice images + volumes | `df -h` |
-
-Run the built-in host check at any time — it never modifies anything:
-
-```bash
-dpm localnet doctor          # or: canton-devkit localnet doctor
-```
-
-`doctor` exits `0` when the host is ready (warnings allowed) and `2`
-when a check fails, printing copy-pasteable remediation. It's the same
-preflight `localnet up` runs, so a green `doctor` means `up` will pass
-preflight.
-
-## Install — DPM component (primary)
-
-DevKit is published as a native DPM component to an OCI registry. Add
-it to your project's `daml.yaml` (or `multi-package.yaml`) `components`
-list and install:
-
-```yaml
-# daml.yaml
-sdk-version: 
-name: my-app
-version: 0.1.0
-source: .
-dependencies: []
-components:
-  - oci://ghcr.io/bitdynamics-ab/canton-devkit:
-```
-
-```bash
-dpm install package
-dpm localnet --help          # confirms the component loaded
-```
-
-DPM registers a single top-level `localnet` command; every DevKit
-subcommand (`up`, `down`, `status`, `dar …`, `contracts …`, `token …`,
-`metrics`, `doctor`, …) lives under it. This keeps the DPM surface
-minimal and conflict-free.
-
-## Install — standalone binary
-
-Download the binary for your platform from the
-[Releases page](https://github.com/bitdynamics-ab/canton-devkit/releases),
-verify its checksum, mark it executable, and put it on your `PATH`.
-
-Release assets are versioned archives named
-`canton-devkit___.tar.gz` (`.zip` on Windows) — each
-contains the `canton-devkit` binary plus `LICENSE` and `README.md`. Every
-release also publishes a single `SHA256SUMS` file covering all archives;
-the examples below verify against it.
-
-### macOS (Apple Silicon)
-
-```bash
-VERSION=v0.7   # replace with the latest release tag
-ASSET="canton-devkit_${VERSION}_darwin_arm64.tar.gz"
-base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}"
-curl -fLO "${base}/${ASSET}"
-curl -fLO "${base}/SHA256SUMS"
-# verify against the release checksums (recommended)
-grep " ${ASSET}\$" SHA256SUMS | shasum -a 256 -c - || { echo "checksum mismatch"; exit 1; }
-tar -xzf "${ASSET}"             # → canton-devkit, LICENSE, README.md
-chmod +x canton-devkit
-sudo mv canton-devkit /usr/local/bin/
-# Gatekeeper: first run may need this once
-xattr -d com.apple.quarantine /usr/local/bin/canton-devkit 2>/dev/null || true
-canton-devkit version
-```
-
-### Linux (amd64)
-
-```bash
-VERSION=v0.7
-ASSET="canton-devkit_${VERSION}_linux_amd64.tar.gz"
-base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}"
-curl -fLO "${base}/${ASSET}"
-curl -fLO "${base}/SHA256SUMS"
-grep " ${ASSET}\$" SHA256SUMS | sha256sum -c - || { echo "checksum mismatch"; exit 1; }
-tar -xzf "${ASSET}"             # → canton-devkit, LICENSE, README.md
-chmod +x canton-devkit
-sudo mv canton-devkit /usr/local/bin/
-canton-devkit version
-```
-
-### APT — Debian / Ubuntu (amd64)
-
-Tagged releases update a static APT repository hosted from the public
-builds repo. Add it once, then install or upgrade with normal APT:
-
-```bash
-echo "deb [trusted=yes arch=amd64] https://raw.githubusercontent.com/bitdynamics-ab/homebrew-canton-devkit/main/apt stable main" \
-  | sudo tee /etc/apt/sources.list.d/canton-devkit.list
-sudo apt update
-sudo apt install canton-devkit
-canton-devkit version
-```
-
-List available versions:
-
-```bash
-apt list -a canton-devkit
-apt policy canton-devkit
-```
-
-Install a specific version:
-
-```bash
-sudo apt install canton-devkit=0.7.0
-```
-
-The APT repo is currently unsigned and therefore uses `trusted=yes`;
-the release still publishes SHA-256 metadata. Repository signing has
-not been added yet. Package installation records a best-effort anonymous `apt`
-install-surface telemetry ping — see [Telemetry](../reference/telemetry/)
-for what is sent and how to opt out before installing.
-
-Direct `.deb` install also works:
-
-```bash
-VERSION=v0.7
-DEB_VERSION="${VERSION#v}"
-ASSET="canton-devkit_${DEB_VERSION}_amd64.deb"
-base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}"
-curl -fLO "${base}/${ASSET}"
-curl -fLO "${base}/SHA256SUMS"
-grep " ${ASSET}\$" SHA256SUMS | sha256sum -c - || { echo "checksum mismatch"; exit 1; }
-sudo apt install "./${ASSET}"
-canton-devkit version
-```
-
-The Debian package installs `/usr/bin/canton-devkit`. It does not install
-Docker; run `canton-devkit localnet doctor` after installation to verify
-Docker CLI, Compose v2, ports, disk, memory, and host prerequisites.
-
-### Windows (amd64, PowerShell)
-
-```powershell
-$Version = "v0.7"
-$Asset = "canton-devkit_${Version}_windows_amd64.zip"
-$base = "https://github.com/bitdynamics-ab/canton-devkit/releases/download/$Version"
-Invoke-WebRequest -Uri "$base/$Asset" -OutFile $Asset
-Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile SHA256SUMS
-# verify against the release checksums
-$expected = ((Get-Content SHA256SUMS | Select-String -SimpleMatch $Asset) -split '\s+')[0]
-$actual = (Get-FileHash $Asset -Algorithm SHA256).Hash.ToLower()
-if ($expected -ne $actual) { throw "checksum mismatch" }
-Expand-Archive -Path $Asset -DestinationPath canton-devkit-dist -Force
-# put it somewhere on PATH, e.g. a tools dir you've added to PATH
-Move-Item canton-devkit-dist\canton-devkit.exe "$env:USERPROFILE\bin\canton-devkit.exe"
-canton-devkit version
-```
-
-### Homebrew (macOS arm64 / Linux amd64)
-
-```bash
-brew tap bitdynamics-ab/canton-devkit
-brew install canton-devkit
-```
-
-> See the [Homebrew guide](../guides/homebrew/) for the direct-formula
-> install, the tap layout, and how the formula is kept in sync on each
-> release.
-
-### From source (Go toolchain)
-
-```bash
-go install github.com/bitdynamics-ab/canton-devkit/cmd/canton-devkit@latest
-```
-
-## Compatibility matrix
-
-### Platforms (released, tested)
-
-| OS | Arch | Status |
-|---|---|---|
-| macOS | arm64 (Apple Silicon) | ✅ Supported |
-| Linux | amd64 | ✅ Supported |
-| Windows | amd64 | ✅ Supported |
-
-Other OS/arch combinations may work (DevKit only orchestrates Docker)
-but are untested — `localnet doctor` prints a warning on unsupported
-platforms.
-
-### Splice LocalNet versions
-
-DevKit pins a **catalogue** of tested Splice versions; `localnet up
---version ` selects one. List them at runtime:
-
-```bash
-canton-devkit localnet versions
-```
-
-See the [Splice version catalogue](../reference/versions/) for how the
-catalogue is fetched and verified. Uncurated upstream tags can be used
-at your own risk via `up --version  --allow-uncurated`.
-
-## Troubleshooting the install
-
-| Symptom | Cause | Fix |
-|---|---|---|
-| `doctor` says **Docker daemon** ✗ | Docker not running | Start Docker Desktop / `sudo systemctl start docker` |
-| `doctor` says **Compose v2** ✗ | Only Compose v1 present | Upgrade to Docker Compose v2 (`docker compose`, not `docker-compose`) |
-| `up` fails **PORTS_IN_USE** | Another process holds a port | Stop the conflicting process, or use a different `--name` |
-| `up` hangs at "waiting for healthy" | Insufficient Docker memory | Raise Docker memory to ≥ 8 GB; see [Known limitations](../reference/limitations/) |
-| Linux: `permission denied` on the Docker socket | User not in `docker` group | `sudo usermod -aG docker $USER` then re-login |
-| macOS: "cannot be opened because the developer cannot be verified" | Gatekeeper quarantine | `xattr -d com.apple.quarantine $(which canton-devkit)` |
-| Web UI / Explorer shows stale ports after a restart | Docker re-assigned ephemeral ports | DevKit re-captures them within ~15 s; or run `localnet restart --name ` |
-
-For anything else, attach the full `localnet doctor` output to a
-[GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues) —
-it includes OS/arch, Docker/Compose versions, and the check results.
-
-## Next steps
-
-- [LocalNet lifecycle](../guides/localnet-lifecycle/) — zero to a running
-  LocalNet, multiple instances, deterministic ports, and clean-up.
-- [Tokens](../guides/tokens/) — CIP-0112 token flows on LocalNet.
-- [Explorer](../guides/explorer/) — browse the Active Contract Set and
-  recent transactions from the Web UI.
diff --git a/website/src/content/docs/guides/dashboard-customization.md b/website/src/content/docs/guides/dashboard-customization.md
deleted file mode 100644
index 9305c303..00000000
--- a/website/src/content/docs/guides/dashboard-customization.md
+++ /dev/null
@@ -1,303 +0,0 @@
----
-title: "Dashboard Customization"
-description: "Extend, replace, or restore the bundled Grafana dashboard for a running LocalNet — panels, template variables, and persistence across down/up cycles."
----
-
-Canton DevKit ships a Grafana dashboard that gives you a one-screen
-overview of a running LocalNet. The dashboard is provisioned from a
-JSON file on disk, so you can extend it, replace it, or restore it
-with normal file edits and a stack restart. This guide covers what
-ships out of the box, how to add panels, and how to persist your
-changes across `localnet down`/`up`.
-
-The dashboard is wired up by the optional **observability** overlay.
-Start LocalNet with the overlay enabled to get Prometheus + Grafana
-on the side:
-
-```bash
-canton-devkit localnet up --name demo --profile observability
-```
-
-Grafana then runs at `http://localhost:` (see
-`localnet status --name demo` for the port). The default credentials
-are printed in the same output.
-
----
-
-## 1. What ships out of the box
-
-The bundled dashboard lives at
-[`assets/grafana/dashboards/canton-localnet.json`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/dashboards/canton-localnet.json)
-and is titled **Canton LocalNet — DApp Developer Overview**. It
-refreshes every 10s, defaults to a 15-minute window, and exposes a
-single `$instance` template variable backed by
-`label_values(up, instance)` so you can scope every panel to one
-participant.
-
-The current bundle ships 10 panels (`id` 1-15 with gaps reserved for
-future inserts). The metric names match the live `daml_*`, `jvm_*`,
-and `db_client_*` families audited in
-[Observability](../observability/) — not the older
-non-existent `canton_*` names.
-
-| Panel | Type | PromQL | What it tells you |
-|---|---|---|---|
-| Ledger TPS (5m avg) | stat | `sum(rate(daml_participant_api_indexer_updates{instance=~"$instance"}[5m])) or vector(0)` | Steady-state ledger throughput. Drops here usually point at participant or sequencer back-pressure. |
-| Active Participants | stat | `count(up{component="canton", instance=~"$instance"} == 1)` | How many Canton nodes Prometheus can scrape right now. Anything less than expected means a node is unscrapeable. |
-| Submission Sequencing Latency (p95) | stat | `histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance=~"$instance"}[5m])) by (le))` | Tail latency from client submit to sequenced commit. This is the closest audited “command completion” latency on stock Splice 0.6.4. |
-| DB Connections In Use | stat | `sum(db_client_connections_usage{state="used", instance=~"$instance"})` | Active DB pool usage across the stack. A creeping value here is the early signal for connection-pool pressure. |
-| Transactions per Second | timeseries | `rate(daml_participant_api_indexer_updates{instance=~"$instance"}[1m]) or vector(0)` | Same signal as the TPS stat, broken out over time so you can see bursts and stalls. |
-| JVM Heap Used (per node) | timeseries | `jvm_memory_used_bytes{jvm_memory_type="heap", instance=~"$instance"}` | Heap pressure per component. A sawtooth rising baseline is the classic memory-leak shape. |
-| Sequencer Block Event Rate | timeseries | `rate(daml_sequencer_block_events_total{instance=~"$instance"}[1m])` | Sequencer-level event rate. Useful for separating ledger-layer slowness from transport-layer stalls. |
-| Submission Latency by Component | timeseries | p50 + p95 of `daml_sequencer_client_submissions_sequencing_duration_seconds_bucket` grouped by `component` | Shows whether latency is isolated to one node or systemic. Diverging p50/p95 is the early sign of queueing or retries. |
-| ACS Lookup Buffer Length | stat | `sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length{instance=~"$instance"})` | ACS-related index lookup buffer length. Stock Splice 0.6.4 does not expose total active-contract cardinality as a Prometheus metric; use the Explorer / JSON API ACS lookup for exact counts. |
-| Top 10 gRPC Methods by Throughput | bar gauge | `topk(10, sum by (grpc_method_name) (rate(daml_grpc_server_handled_total{instance=~"$instance"}[5m])))` | API throughput by live gRPC method. Stock Splice 0.6.4 does not expose template-grain submission counters. |
-
-For the full metric-family audit and substitution table, see
-[Observability](../observability/).
-
----
-
-## 2. Editing the JSON directly
-
-The dashboard JSON is mounted into the Grafana container by the
-provisioner configured in
-[`assets/grafana/provisioning/dashboards/canton.yaml`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/provisioning/dashboards/canton.yaml).
-Grafana re-scans this directory every 30 seconds, so edits take
-effect without a container restart:
-
-```bash
-# 1. Edit the JSON
-$EDITOR assets/grafana/dashboards/canton-localnet.json
-
-# 2. Wait up to 30 seconds, then refresh the Grafana tab.
-#    No restart needed.
-```
-
-If you want the change to apply instantly, restart only the Grafana
-container:
-
-```bash
-docker compose -p canton-devkit-demo restart grafana
-```
-
-`canton-devkit` does not own the Grafana container lifecycle beyond
-the overlay; `docker compose restart` against the project name is the
-direct path.
-
-### Adding a panel
-
-Append a new entry to the `panels` array. The minimum a panel needs
-is an `id` (unique within the dashboard), a `type`, a `title`, a
-`gridPos`, and at least one Prometheus `target`. Here is a panel
-that surfaces idle DB pool capacity:
-
-```json
-{
-  "id": 20,
-  "type": "timeseries",
-  "title": "DB Connections Idle",
-  "datasource": "Prometheus",
-  "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 },
-  "targets": [
-    {
-      "expr": "sum by (pool) (db_client_connections_usage{state=\"idle\", instance=~\"$instance\"})",
-      "legendFormat": "{{pool}}"
-    }
-  ]
-}
-```
-
-Pick an `id` higher than any existing one (the bundled dashboard
-goes up to 15). Place the panel below the existing rows by setting
-`y` past the last occupied row.
-
----
-
-## 3. UI edits vs. JSON edits
-
-Grafana lets you edit panels from the browser (the pencil icon on
-each panel). With the bundled provisioning config, those UI edits are
-**ephemeral by default**: as soon as the provisioner re-syncs from
-disk it will overwrite anything you did in the UI.
-
-This is intentional. It keeps the on-disk JSON the source of truth
-and avoids the "what's actually deployed?" question that crops up
-once people start clicking around in production Grafanas.
-
-If you want persistent UI edits — for exploratory work, or if you
-prefer Grafana's panel editor over hand-editing JSON — flip
-`allowUiUpdates` to `true` in the provisioner config:
-
-```yaml
-# assets/grafana/provisioning/dashboards/canton.yaml
-providers:
-  - name: canton-localnet
-    type: file
-    disableDeletion: true
-    updateIntervalSeconds: 30
-    allowUiUpdates: true   # was false
-    options:
-      path: /var/lib/grafana/dashboards
-```
-
-With `allowUiUpdates: true`, Grafana writes UI edits back into its
-own database. The on-disk JSON is still loaded on startup as the
-initial state, but subsequent UI changes survive across reloads
-until you reset to defaults.
-
-Pick one mode and stick with it. Mixing edits across both surfaces
-is how teams end up with two slightly different dashboards and no
-clear answer for which is canonical.
-
----
-
-## 4. Persisting across `down` and `up`
-
-The provisioning directory is bind-mounted into the Grafana
-container from the repo, so the JSON survives every `localnet down`
-and `localnet up` cycle automatically — the file is on your disk,
-not in a container volume.
-
-Two things to know:
-
-1. **The JSON path on the host is the source of truth.** Edit
-   `assets/grafana/dashboards/canton-localnet.json` (or drop a new
-   `.json` file next to it — the provisioner picks up every JSON in
-   that directory). Changes persist with the repo.
-2. **UI edits live in a Grafana volume.** When `allowUiUpdates` is
-   on, the Grafana SQLite database holds your edits. `localnet down`
-   keeps the volume around; `localnet clean --name ` removes it.
-   If you want UI edits to survive across instances, export them via
-   **Dashboard settings → JSON Model → Save** and check the JSON
-   into the repo.
-
-### Dropping in your own dashboard
-
-The provisioner loads every `*.json` in
-`assets/grafana/dashboards/`. To add a second dashboard alongside
-the default, drop a new JSON file next to it:
-
-```bash
-cp my-team-dashboard.json assets/grafana/dashboards/
-# wait ~30s; refresh Grafana → Dashboards → Browse
-```
-
-Each dashboard needs a unique `uid`. The bundled one uses
-`canton-localnet-v1`; pick a different value for yours.
-
----
-
-## 5. Resetting to defaults
-
-Because the on-disk JSON is the source of truth, restoring defaults
-is a `git checkout`:
-
-```bash
-git checkout -- assets/grafana/dashboards/canton-localnet.json
-```
-
-If you had `allowUiUpdates: true` and made UI edits, also wipe the
-Grafana volume so its database doesn't override the file on startup:
-
-```bash
-docker compose -p canton-devkit- stop grafana
-docker volume rm canton-devkit-_grafana-data
-canton-devkit localnet restart --name 
-```
-
-`docker volume ls` will show you the exact volume name for your
-instance; the prefix is the compose project name printed by
-`localnet status`.
-
----
-
-## 6. Where the metrics come from
-
-Prometheus scrapes the LocalNet nodes directly. The targets are
-declared in the observability overlay's Prometheus config; you can
-list them at runtime by browsing to
-`http://localhost:/targets`.
-
-Metric name conventions used by the bundled dashboard:
-
-| Prefix | Source | Notes |
-|---|---|---|
-| `daml_*` | Daml participant / sequencer / mediator OTel reporter | Ledger updates, submission sequencing latency, block events. |
-| `jvm_*` | JVM runtime instrumentation | Heap, GC, thread counts. Available on every JVM-based node. |
-| `db_client_*` | HikariCP pool stats from the JVM apps | In-use / idle / pending DB pool counts. |
-| `cn_*` / `sv_*` | Scan and super-validator business metrics | App-specific counters and histograms beyond the core Canton flow. |
-| `splice_*` | Splice triggers and domain params | Trigger latencies and control-plane metrics. |
-| `up` | Prometheus self | Whether each scrape target is reachable. The `instance` label is the source for the `$instance` template variable. |
-
-If a panel renders as "No data", the fastest debug is to open
-Prometheus directly, type the metric name, and see whether the
-instance is producing it at all. For the full audited substitution
-table from the earlier `canton_*` placeholders to the live names, see
-[Observability](../observability/).
-
----
-
-## 7. Common customizations
-
-### Alert when TPS drops to zero
-
-Edit the **Ledger TPS (5m avg)** stat panel and add an alert rule
-(Grafana 9+ alerting). The expression is the same one the panel
-uses:
-
-```
-sum(rate(daml_participant_api_indexer_updates{instance=~"$instance"}[5m])) or vector(0)
-```
-
-Fire when the value is below `0.01` for 5 minutes. The alert lives
-inside the dashboard JSON under the panel's `alert` field, so it
-persists like any other panel edit.
-
-### Filter every panel to a single participant
-
-The bundled dashboard already exposes `$instance` as a template
-variable in the top bar — pick one from the dropdown and every panel
-scopes to that participant. If you want a second variable (e.g. one
-that filters to a specific `component`), add it to
-`templating.list`:
-
-```json
-{
-  "name": "component",
-  "type": "query",
-  "datasource": "Prometheus",
-  "query": "label_values(up{instance=~\"$instance\"}, component)",
-  "refresh": 1,
-  "includeAll": true,
-  "multi": true
-}
-```
-
-Then reference it in panel queries with `component=~"$component"`.
-
-### Track DB pool pressure
-
-Add a stat panel with:
-
-```
-sum(db_client_connections_usage{state="pending", instance=~"$instance"})
-```
-
-Useful when you're load-testing or chasing slow commits: a sustained
-non-zero pending count usually means callers are waiting on the DB
-pool rather than the ledger itself.
-
----
-
-## 8. See also
-
-- [Getting started](../../getting-started/) — installing DevKit
-  and starting LocalNet with the observability overlay.
-- [Observability](../observability/) — audited metric families
-  and the `canton_*` → `daml_*` substitution table.
-- [Telemetry](../../reference/telemetry/) — the anonymous usage counters
-  the DevKit CLI itself records (separate from Canton's Prometheus
-  metrics).
-- [Troubleshooting](../../reference/troubleshooting/) — common Grafana /
-  Prometheus startup issues.
diff --git a/website/src/content/docs/guides/explorer.md b/website/src/content/docs/guides/explorer.md
deleted file mode 100644
index ee79bbe9..00000000
--- a/website/src/content/docs/guides/explorer.md
+++ /dev/null
@@ -1,301 +0,0 @@
----
-title: "Explorer"
-description: "Browse the Active Contract Set, recent transactions, and a ledger timeline of a running LocalNet from the Web UI — with CLI equivalents for scripting."
----
-
-The Explorer is the Web UI's window into a running LocalNet's
-ledger. It reads the Active Contract Set (ACS) and recent
-transactions from the participant's gRPC ledger API, so you can
-see what's on the ledger without writing a script.
-
-This guide covers what the Explorer can do today, the equivalent
-CLI commands for scripted workflows, and current limitations — so you can
-tell at a glance whether the Explorer fits your task.
-
----
-
-## 1. What you see
-
-Open the Web UI (`canton-devkit localnet ui --name ` or whatever
-port the bundled UI is running on) and switch to the **Explorer**
-tab. The screen has three views, selectable from the toggle in the
-top bar:
-
-| View | What it shows |
-|---|---|
-| **Contracts** | The Active Contract Set as a live table — an initial snapshot kept current by a server-sent-events delta stream — with a left-hand facet sidebar (templates, parties, manual refresh) and a right-hand detail drawer. |
-| **Transactions** | Recent ledger updates from the participant's `UpdateService`, with party / template / offset-range filters (mirroring the CLI `tx ls`). Each row expands inline to show its event tree (creates, archives, exercises) and can be **replayed** as a per-party visibility projection. |
-| **Timeline** | A density strip and per-update glyph row. Hover a glyph to preview, click to pin a selection. Useful for "what happened in the last minute". |
-
-All three views project through the **same instance** and **the
-same role**, picked from the top-bar selector.
-
----
-
-## 2. Choosing the instance and role
-
-The top-bar **Projecting through** widget controls two things:
-
-1. **Instance** — which LocalNet's participant the Explorer talks
-   to. The selection comes from the global instance picker and
-   sticks to the URL's `?instance=` query parameter, so it
-   survives navigating between tabs and reloading the page.
-2. **Role** — which Splice JWT to authenticate with. The choices
-   are `app-user`, `app-provider`, and `sv`. Each role is a
-   different user in the participant's user management; the
-   contracts you see are the ones that role's user has rights to
-   read.
-
-Switching either control re-runs the ACS snapshot. There is no
-client-side filtering across roles — you get a fresh view from the
-participant each time.
-
-If the Web UI shows **"No instance selected"**, create one from the
-Dashboard tab or pass `?instance=` in the URL.
-
----
-
-## 3. Filtering
-
-The Contracts view has three filter surfaces, all client-side
-against the (live) snapshot already loaded:
-
-- **Templates sidebar.** Click a template chip to restrict the
-  table to that template. Click again to clear. Multiple chips
-  combine as OR. Templates are rendered as `Module:Entity`; hover
-  to see the fully-qualified `package_id:Module:Entity` form.
-- **Parties sidebar.** Same pattern, but filters to contracts where
-  the chosen party appears as signatory or observer.
-- **Search box** (top-right of the table, focus with `/`). Free-text
-  search across template name, contract ID, payload JSON, and the
-  party lists. Useful when you know a substring of the contract ID
-  or a value in the payload.
-
-Filters compose: template chip AND party chip AND search needle
-all have to match.
-
-These are client-side facets over the loaded ACS. The
-**Transactions** view, by contrast, filters **server-side** over the
-participant's offset window — see §5 — so a transaction outside the
-loaded row cap can still be found by narrowing the query.
-
-A few keyboard shortcuts inside the Contracts view:
-
-- `/` focuses the search box.
-- `Esc` clears the selected row in the detail drawer.
-
----
-
-## 4. Inspecting a contract
-
-Click any row in the ACS table. The right-hand **detail drawer**
-shows:
-
-- **Template** in long form (`Module:Entity` from the template ID),
-  plus the originating `package_name` if the participant reported
-  one.
-- **Contract ID** (full, monospace, copyable).
-- **Payload** as pretty-printed JSON. Records, lists, optionals,
-  primitives, parties, and contract IDs all render natively;
-  variants/enums/maps fall back to a textual proto form (a typed
-  decoder using Daml-LF metadata is not yet supported).
-- **Signatories** and **Observers** as separate lists.
-- **Created** with the RFC 3339 timestamp the participant recorded
-  and a human-readable "Xs/m/h/d ago".
-
-The detail drawer is read-only — there is no "exercise choice" UI in
-the Explorer. Exercising choices is a CLI / SDK action; see
-`canton-devkit localnet token …` for the prebuilt CIP-0112 flows or
-build a regular Daml/SDK client.
-
----
-
-## 5. Transactions view
-
-Switching to **Transactions** runs a recent-updates query against
-the participant's `UpdateService`. Each row shows:
-
-- **Kind** — `transaction`, `reassignment`, or `topology`.
-- **Offset** — the participant's ledger offset.
-- **Command ID / Update ID** — whichever is present.
-- **Workflow ID** or synchronizer, when populated.
-- **Record time** — `HH:MM:SS` of when the participant recorded
-  the update.
-- **Event count** — number of events in the update.
-
-Click a row to expand its event tree (create / archive / exercise
-nodes with template + contract ID).
-
-### Filters
-
-The filter bar above the table mirrors the CLI `tx ls` flags and is
-applied **server-side** over the participant's offset window:
-
-- **party** — comma-separate to project through specific parties.
-  Omit to project through the role JWT's own parties.
-- **template** — `Module:Entity` or `pkg:Module:Entity`,
-  comma-separated for multiple.
-- **from / to** — bound the scanned ledger-offset window (`from` is
-  exclusive, `to` inclusive). Leave blank for a generous recent
-  window.
-
-Press **Apply** (or Enter in any field) to re-query; **Clear** resets
-to the default window. The header shows the scanned offset range and
-flags a **partial window** when the scan hit its cap before draining
-the window — the rows are then the newest of a clipped scan.
-
-### Replay (per-party visibility projection)
-
-Each `transaction` row has a **replay** button. It opens a drawer
-that re-fetches that transaction with the `LEDGER_EFFECTS` shape
-(exercised choices, not just the ACS delta) projected through a party
-set. The **visible to** selector lets you ask "what did party *P* see
-in this transaction?" — the same id projected through different
-parties yields different event sets. This is the Web UI counterpart of
-`canton-devkit localnet tx replay --id `.
-
-The Transactions view pulls up to 200 recent updates by default.
-
----
-
-## 6. Timeline view
-
-The Timeline groups the same updates into 60 time buckets and
-renders two strips:
-
-- A **density strip** with bar height proportional to the bucket's
-  update count.
-- A **glyph row** with one coloured cell per update (green =
-  transaction, blue = reassignment, purple = topology).
-
-Hover a glyph to preview the update in the side panel; click to
-pin the selection so you can read its event tree without keeping
-the cursor over the strip. `Esc` clears the pinned selection.
-
-The Timeline is the fastest way to answer "did anything just
-happen?" and "where in the last few minutes was the spike?".
-
----
-
-## 7. Snapshot vs. live
-
-The **Contracts** view is live:
-
-1. It first calls `StateService.GetActiveContracts` at the
-   participant's current ledger end (the snapshot).
-2. It then opens a server-sent-events stream
-   (`GET .../contracts/stream`) resuming from the snapshot's
-   `ledger_end`, applying create/archive deltas in place. The
-   handoff is a single atomic offset boundary, so no event between
-   the snapshot and the stream is missed.
-3. A 30-second timer re-snapshots quietly to reconcile any drift
-   (a suspended laptop, a dropped connection, a backend restart),
-   and the **Refresh snapshot** button in the sidebar forces one
-   immediately.
-
-The stream-status pill in the top bar and the table sub-header
-report the real connection state — `live`, `reconnecting`,
-`truncated` (the backend capped the stream; reconciliation takes
-over), or `idle`. The label tracks the actual stream state, not a
-hard-coded value.
-
-The **Transactions** and **Timeline** views are still snapshots —
-they call `UpdateService` for the most recent N updates. Re-apply
-the filters (or switch tabs back) to pull a fresh window.
-
----
-
-## 8. Using the CLI side-by-side
-
-Every view in the Explorer has a CLI equivalent that returns the
-same data as JSON, suitable for `jq` and scripting:
-
-```bash
-# Snapshot the ACS. --party is repeatable; --template accepts
-# Module:Entity or pkg:Module:Entity.
-canton-devkit localnet contracts ls \
-  --name demo \
-  --endpoint localhost: \
-  --party alice \
-  --template Token:Holding
-
-# Stream ACS changes from the current ledger end.
-canton-devkit localnet contracts watch \
-  --name demo \
-  --endpoint localhost:
-
-# Recent transactions. --party / --template / --from / --to are the
-# same filters the Web UI Transactions view exposes.
-canton-devkit localnet tx ls \
-  --name demo \
-  --endpoint localhost: \
-  --party alice \
-  --template Token:Holding
-
-# Replay one transaction's per-party visibility projection — the
-# CLI mirror of the Transactions view's "replay" button.
-canton-devkit localnet tx replay \
-  --name demo \
-  --id  \
-  --party alice
-```
-
-The `contracts ls --format json` output includes the decoded
-contract `payload` (the same field the Web UI drawer shows), so a
-`jq` consumer can read field values, not just contract IDs.
-
-A typical workflow: use the Explorer to navigate, pick out a
-template ID or contract ID, then drop into the CLI to pipe the
-data into a script. The CLI accepts the same role-scoped JWTs as
-the Explorer.
-
-The participant ledger port isn't host-published by default for
-every Splice profile — `localnet status --name ` lists the
-exposed ports under entries like `participant_ledger_app-user`.
-
----
-
-## 9. Known limits
-
-Things the Explorer does **not** do today:
-
-- **Transactions / Timeline are not live.** Only the Contracts view
-  streams (snapshot + SSE deltas). The Transactions and Timeline
-  views read a bounded snapshot; re-apply the filters to refresh.
-- **No exercise/create UI.** The drawer is read-only. Use the CLI
-  or your app to write to the ledger.
-- **No cross-instance comparison.** One instance at a time.
-- **Reassignments are skipped in the ACS view.** In-flight
-  reassignments are filtered out of the Contracts table; they
-  still appear in Transactions and Timeline as their own update
-  kind.
-- **Variants, enums, maps fall back to a textual proto form** in
-  the payload preview. Records, lists, primitives, parties, and
-  contract IDs decode natively. The full typed decoder using
-  Daml-LF metadata is not yet supported in the payload preview.
-
-If the Explorer can't show what you need, the CLI usually can —
-or the underlying gRPC API directly via the SDK.
-
----
-
-## 10. Error states you might see
-
-| What you see | What it means | What to do |
-|---|---|---|
-| **"Participant ports not recorded"** | The instance was started by an older DevKit version that didn't capture the participant ledger port. | `canton-devkit localnet down --name ` then `up --name ` again. The newer `up` flow records all Canton API ports. |
-| **"JWT lacks party-rights for ACS"** | The role's JWT is a user-id token (Splice default) whose user has no `actAs` / `readAs` rights on any party. | Grant rights via `UserManagementService`, or use a different role whose user already has them. |
-| **"No instance selected"** | The Web UI doesn't have an instance picked yet. | Create one from the Dashboard or set `?instance=` in the URL. |
-| **"No contracts match the current filters"** | The snapshot loaded fine but every contract was filtered out. | Clear template/party chips or empty the search box. |
-
----
-
-## 11. See also
-
-- [Getting started](../../getting-started/) — starting a
-  LocalNet and finding its participant ports.
-- [Tokens](../tokens/) — driving CIP-0112 token flows from
-  the CLI; useful to populate the ACS with realistic contracts
-  while you explore.
-- [Troubleshooting](../../reference/troubleshooting/) — port-recapture
-  and JWT-related fixes.
diff --git a/website/src/content/docs/guides/homebrew.md b/website/src/content/docs/guides/homebrew.md
deleted file mode 100644
index ac0361e6..00000000
--- a/website/src/content/docs/guides/homebrew.md
+++ /dev/null
@@ -1,81 +0,0 @@
----
-title: "Homebrew Install"
-description: "Install canton-devkit via the Homebrew tap or direct formula, and how the formula is kept in sync on every release."
----
-
-`canton-devkit` ships a Homebrew formula for macOS (Apple Silicon) and
-Linux (x86_64). The formula and downloadable build artifacts live in the
-dedicated tap repository
-[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit),
-following the standard Homebrew tap layout.
-
-This source repository does not keep a `Formula/` directory. Homebrew
-distribution files are maintained in `homebrew-canton-devkit`; this repository only
-keeps the release helper script and docs that describe the process.
-
-## Install (direct, no tap)
-
-```sh
-brew install --formula \
-  https://raw.githubusercontent.com/bitdynamics-ab/homebrew-canton-devkit/main/Formula/canton-devkit.rb
-```
-
-> Note: the formula's `url` + `sha256` are rewritten automatically by
-> the release workflow on every release tag (see below), so the direct
-> formula always points at the latest published release. There is no
-> `--HEAD` install path — the formula installs prebuilt release
-> artifacts only.
-
-## Install (via tap)
-
-```sh
-brew tap bitdynamics-ab/canton-devkit
-brew install canton-devkit
-```
-
-## How the formula stays in sync
-
-This is **automatic** on every release tag (`v*`). `.github/workflows/release.yml`:
-
-1. Builds and publishes the per-platform tarballs and a single GNU
-   `sha256sum` manifest named `SHA256SUMS` to a public GitHub Release in
-   `bitdynamics-ab/homebrew-canton-devkit`.
-2. Reads the `darwin_arm64` and `linux_amd64` digests out of
-   `dist/SHA256SUMS`, rewrites the `version` + two `sha256` fields of the
-   public builds repo's `Formula/canton-devkit.rb`, and commits the
-   change back via the GitHub contents API (commit message
-   `chore: bump Homebrew formula to `).
-
-No maintainer action is required for a normal release.
-
-### Manual / break-glass: `scripts/update-homebrew-formula.sh`
-
-`scripts/update-homebrew-formula.sh v0.1.0 [path/to/homebrew-canton-devkit]`
-does the same rewrite locally against a checked-out public builds repo.
-Use it only when the automated step failed, or to re-pin an existing
-tag. It downloads `SHA256SUMS` from the public release, extracts the two
-digests, rewrites the formula in place, and prints a diff — it does
-**not** commit or push, so review the diff and commit by hand. This
-keeps a human in the loop when the release tarballs themselves might be
-wrong.
-
-## Smoke test
-
-```sh
-brew install --formula ../homebrew-canton-devkit/Formula/canton-devkit.rb
-brew test  canton-devkit                          # invokes `localnet --help`
-canton-devkit localnet --help
-```
-
-`brew test` is also exercised by the formula's `test do …` block, which
-runs `canton-devkit localnet --help` and asserts the LocalNet command
-tree is reachable.
-
-## What's not supported (yet)
-
-- **Windows** — Homebrew doesn't target Windows. Use the standalone
-  artifact from the [public builds release page](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases).
-- **Linux ARM** — not in the release matrix. Could be added in a
-  follow-up if there's demand.
-- **macOS Intel** — same; the project's compatibility matrix is
-  Apple Silicon only.
diff --git a/website/src/content/docs/guides/observability.md b/website/src/content/docs/guides/observability.md
deleted file mode 100644
index e9ff8d73..00000000
--- a/website/src/content/docs/guides/observability.md
+++ /dev/null
@@ -1,157 +0,0 @@
----
-title: "Observability — Prometheus + Grafana"
-description: "Enable the observability profile for Prometheus + Grafana, understand the live Splice metric naming convention, and toggle the sidecars at runtime."
----
-
-The `--profile observability` flag on `canton-devkit localnet up`
-adds two containers to the compose project:
-
-- **Prometheus** scrapes `canton:10013` and `splice:10013` (both
-  containers ship `/app/monitoring.conf` enabling the built-in
-  OTel reporter — see `assets/compose/prometheus.yml`).
-- **Grafana** auto-provisions the `canton-localnet` dashboard from
-  `assets/grafana/dashboards/canton-localnet.json`.
-
-The host ports for both UIs are allocated at `up` time and
-persisted in the registry so re-up preserves bookmarked URLs.
-
-## Metric naming convention
-
-The live Splice 0.6.4 Prometheus surfaces **three** metric prefix
-families. Earlier versions of the dashboard used a `canton_*` prefix that
-does NOT exist upstream — those queries silently returned no data. The
-audit notes below pin the current convention so future panels stay aligned.
-
-Probe used to ground-truth the names:
-
-```
-PROM_PORT=$(canton-devkit localnet status --name  --format json \
-  | jq -r '.endpoints[] | select(.label=="prometheus_ui") | .port')
-curl -s "http://localhost:${PROM_PORT}/api/v1/label/__name__/values" \
-  | jq -r '.data[]' > /tmp/all-metrics.txt
-```
-
-597 metric names total on a healthy obs-enabled LocalNet. Prefix
-families:
-
-| Prefix              | Source                                          | Example                                                            |
-| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------ |
-| `daml_*`            | Daml participant / mediator / sequencer (OTel)  | `daml_participant_api_indexer_updates`                             |
-| `jvm_*`             | OTel JVM runtime instrumentation                | `jvm_memory_used_bytes{jvm_memory_type="heap"}`                    |
-| `db_client_*`       | HikariCP pool stats from the JVM apps           | `db_client_connections_usage{state="used"}`                        |
-| `cn_*` / `sv_*`     | Splice super-validator + Scan business metrics  | `cn_db_storage_general_executor_exectime_duration_seconds_bucket`  |
-| `splice_*`          | Splice triggers + domain params                 | `splice_trigger_latency_duration_seconds_bucket`                   |
-
-**There is no `canton_*` prefix.** Panels that need a Canton-level
-synchronizer / participant metric should use `daml_*` (where the
-metric is emitted by the Daml participant) or `daml_sequencer_*` /
-`daml_mediator_*` (where it comes from the protocol layer).
-
-### Substitute mapping (what changed)
-
-| Old (non-existent)                              | Replacement                                                              | Notes                                                                                 |
-| ----------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
-| `canton_participant_transactions_total`         | `daml_participant_api_indexer_updates`                                   | Counter of ledger updates ingested by the indexer.                                    |
-| `canton_mediator_approval_duration_bucket`      | `daml_sequencer_client_submissions_sequencing_duration_seconds_bucket`   | No mediator-approval histogram is exposed; sequencer-client send→sequenced is the closest end-to-end latency. |
-| `canton_sequencer_messages_total`               | `daml_sequencer_block_events_total`                                      | Counter of block events emitted by the sequencer.                                     |
-| `canton_participant_transaction_duration_bucket`| `daml_sequencer_client_submissions_sequencing_duration_seconds_bucket`   | Same substitute as the mediator one.                                                  |
-| `jvm_memory_used_bytes{area="heap"}`            | `jvm_memory_used_bytes{jvm_memory_type="heap"}`                          | OTel JVM instrumentation uses `jvm_memory_type`, not the legacy micrometer `area`.    |
-| `pg_stat_activity_count`                        | `db_client_connections_usage{state="used"}`                              | No postgres exporter is scraped; HikariCP pool usage is the apps'-side view of the same number. |
-
-## Smoke test (drift guard)
-
-An integration test (build tag `integration`) queries every headline
-metric in the CLI summary against a live Prometheus and fails if any
-returns zero results — the way to catch silent metric-name drift when
-Splice updates.
-
-Run it locally against a running observability-enabled instance:
-
-```
-canton-devkit localnet up --name metric-audit --profile observability
-PROM_PORT=$(canton-devkit localnet status --name metric-audit --format json \
-  | jq -r '.endpoints[] | select(.label=="prometheus_ui") | .port')
-METRICSQ_SMOKE_PROM=http://localhost:${PROM_PORT} \
-  go test -tags=integration -run TestSummaryQueries_LiveProm ./...
-canton-devkit localnet clean --name metric-audit --force
-```
-
-The test is **skipped** when `METRICSQ_SMOKE_PROM` is unset, so
-`go test -tags=integration ./...` on a box with no LocalNet stays
-green. Set the env var in CI to fail-fast.
-
-## Toggling observability on a running instance
-
-You don't have to decide about metrics at `up` time. Both surfaces
-expose a runtime toggle that brings Prometheus + Grafana up (or down)
-on an already-running instance **without restarting Canton**:
-
-- **Web UI** — the Metrics screen's "Enable observability now" button
-  (`POST /api/instances/{name}/observability`).
-- **CLI** — `dpm localnet observability enable|disable|status`:
-
-  ```
-  # Turn both sidecars on for a running instance
-  canton-devkit localnet observability enable --name demo
-
-  # Just Prometheus (no Grafana image / RAM)
-  canton-devkit localnet observability enable --name demo --prometheus
-
-  # Turn Grafana back off, leave Prometheus scraping
-  canton-devkit localnet observability disable --name demo --grafana
-
-  # Report what's running + the dashboard URL (works while stopped too)
-  canton-devkit localnet observability status --name demo --format json
-  ```
-
-Both surfaces call the **same** orchestration path — there is no second
-docker-compose code path that could drift. With neither `--prometheus`
-nor `--grafana`, the verb acts on both sidecars (the legacy umbrella
-semantics); pass one flag to operate on a single component.
-
-## Survives a down → up cycle
-
-The profile set an instance was brought up with — whether via
-`--profile observability` at create time or via the runtime toggle — is
-persisted in the registry (`state.json`'s `profiles` field). A later
-`down` + `up` (or the Web UI **Restart**) **re-enables the same
-profiles automatically**; you do not have to re-pass `--profile`. An
-explicit `--profile` on the re-up still wins (replaces, doesn't merge),
-so you can deliberately drop observability. (Earlier releases did not
-persist profiles, so Prometheus/Grafana silently vanished on every
-restart even though the stable-port contract kept the bookmarked Grafana
-URL alive.)
-
-## Stack topology — host-shared, with a transitional per-instance overlay
-
-A single **host-level** Prometheus + Grafana serves every running
-LocalNet. It runs as its own
-compose project (`canton-devkit-observability`), independent of any
-instance's lifecycle. Each observability-enabled instance publishes its
-canton/splice `:10013` metrics ports on `127.0.0.1:` and writes
-a Prometheus **file_sd** target file (`host.docker.internal:`,
-labelled `instance` + `component`); the shared Prometheus discovers
-instances from those files. The **number of target files is the refcount**:
-the stack starts on the first instance's `up` and is torn down when the
-last instance's `down`/`clean` removes its target file. Register+ensure and
-deregister+teardown each run under a dedicated **shared-stack lock** so a
-concurrent `up` and `down` of different instances can't race the stack into
-a "registered but torn down" state, and orphaned target files (left by a
-crash) are reconciled against the registry index. On native Linux the
-Prometheus service carries `extra_hosts: ["host.docker.internal:host-gateway"]`
-so the loopback-published ports resolve; on Docker Desktop the name is
-auto-provided.
-
-**Transitional dual stack (known trade-off).** Each observability-enabled
-instance currently *also* still runs its own per-instance Prometheus +
-Grafana overlay alongside the shared stack — so while running, an obs
-instance has **two** Prometheus and **two** Grafana containers. This is a
-deliberate, kept fallback: both the CLI and the Web UI read **shared-first**
-and fall back to the per-instance Prometheus when the shared stack isn't
-up, and the per-instance scrape uses in-network service DNS
-(`canton:10013`) rather than `host.docker.internal`, so it works on any
-platform regardless of the Linux `host-gateway` mapping. The per-instance
-overlay remains enabled until the shared-only path is validated end-to-end
-on a native Linux Docker host — see [Known limitations](../../reference/limitations/#observability-transitional-dual-stack). The
-extra resource cost (a second Prometheus+Grafana per instance) is the price
-of that fallback on a dev machine; it carries no correctness impact.
diff --git a/website/src/content/docs/guides/tokens.md b/website/src/content/docs/guides/tokens.md
deleted file mode 100644
index d10b7d44..00000000
--- a/website/src/content/docs/guides/tokens.md
+++ /dev/null
@@ -1,146 +0,0 @@
----
-title: "Tokens — Canton Token Standard V2 on LocalNet"
-description: "Create, mint, transfer, and burn CIP-0112 (Token Standard V2) instruments against a live LocalNet from the CLI or the Web UI."
----
-
-canton-devkit ships first-class tooling for the **Canton Token Standard
-V2** (the CIP-0112 path) so you can create an instrument, mint/transfer/
-burn holdings, fund parties, and reconcile balances against a live
-LocalNet — from the CLI **or** the Web UI, by readable party alias, with
-without surfacing raw JWTs, ports, or full contract IDs in every command.
-
-> **Scope: V2 / CIP-0112 only.** This tooling targets the Token Standard
-> V2 (CIP-0112) surface. V1 / CIP-0056 is **not** supported. V2 is
-> currently an opt-in *alpha* track (see [the alpha caveat](#the-v2-alpha-caveat));
-> it will be promoted to the default channel once V2 lands in mainline Splice.
-
----
-
-## Prerequisites — bring up a V2 LocalNet
-
-V2 needs a special Splice build (alpha protocol 35) and a profile overlay:
-
-```bash
-# list versions — the V2 entry is tagged channel: alpha
-canton-devkit localnet versions
-
-# bring up a V2-capable instance
-canton-devkit localnet up --name v2 --version token-standard-v2 --profile tokens-v2
-
-# confirm health (doctor warns if the alpha profile is missing)
-canton-devkit localnet doctor --name v2
-```
-
-All token subcommands take `--instance ` and, for on-ledger
-actions, `--endpoint ` (the participant ledger
-gRPC port — `localnet status --name v2` prints it). Empty `--token`
-auto-issues a per-role dev JWT; `--role` defaults to `app-user`.
-
----
-
-## The workspace model
-
-On LocalNet there is **no trust boundary between parties — you own all of
-them** (the dev secret signs for every role). So the token tool is a
-single operator workspace over the instance, not a wallet-per-party:
-
-- **Party aliases** — `token party new bob` allocates a party and lets
-  you say `--to bob` everywhere instead of pasting its id.
-- **Balance matrix** — `token balances` shows every party's balance of
-  every instrument in one scan.
-- **Activity feed** — `token activity` reconstructs an instrument's
-  mint/transfer/burn history from the ledger.
-- **Faucet** — `token faucet bob 100 --instrument Amulet` funds a party
-  in one auto-accepted step.
-
-Every command lands identically on the CLI and the Web UI **Tokens**
-screen (CLI ↔ UI parity).
-
----
-
-## Workflow
-
-```bash
-INST=v2
-EP=localhost:63340   # participant ledger port from `localnet status`
-
-# 1. Name a couple of parties (allocates + grants rights, records alias)
-canton-devkit localnet token party new alice --instance $INST --endpoint $EP --role app-provider
-canton-devkit localnet token party new bob   --instance $INST --endpoint $EP --role app-user
-canton-devkit localnet token party ls        --instance $INST --endpoint $EP
-
-# 2. Create your own native V2 instrument (auto-uploads the test-token DARs)
-canton-devkit localnet token create --instance $INST --endpoint $EP --non-interactive \
-  --name "Retail Token" --symbol RTK --decimals 6 --initial-supply 1000000 --issuer alice
-
-# 3. Mint supply to a party
-canton-devkit localnet token mint --instance $INST --endpoint $EP \
-  --instrument RTK --to bob --amount 1000
-
-# 4. See everyone's balances at a glance
-canton-devkit localnet token balances --instance $INST --endpoint $EP
-
-# 5. Transfer (--auto-accept settles in one step on LocalNet)
-canton-devkit localnet token transfer --instance $INST --endpoint $EP \
-  --instrument RTK --from bob --to alice --amount 250 --auto-accept
-
-# 6. Inspect one instrument
-canton-devkit localnet token summary  --instance $INST --endpoint $EP --instrument RTK
-canton-devkit localnet token activity --instance $INST --endpoint $EP --instrument RTK
-
-# 7. Burn supply (archives the holder's holdings; returns change)
-canton-devkit localnet token burn --instance $INST --endpoint $EP \
-  --instrument RTK --from bob --amount 100
-```
-
-Add `--format json` to any read command (`balance`, `balances`,
-`summary`, `activity`, `party ls`) for machine-readable output.
-
----
-
-## Command reference
-
-| Command | What it does |
-|---|---|
-| `token create` | Create an on-ledger V2 instrument (TokenRules) for an issuer. Auto-uploads the bundled `splice-test-token-v2` DARs if not vetted. `--non-interactive` for CI; otherwise a wizard. |
-| `token mint` | Mint new supply to a party (`TokenRules_OfferMint`, controller = issuer). Native CIP-0112 v2 instruments only. |
-| `token transfer` | Sender-initiated transfer. `--auto-accept` chains the receiver-side accept (LocalNet default convenience); `--no-wait` returns the instruction id to hand off. |
-| `token transfer accept` | Receiver accepts a pending `TransferInstruction` by id. |
-| `token burn` | Burn supply. The example token has no protocol burn, so this archives the holder's `Holding` contracts directly (signatory = account parties + admin, all operator-controlled on LocalNet) and returns change. |
-| `token faucet  ` | Fund a party from a well-known source, auto-accepted. `--source` overrides the default funded party. |
-| `token balance` | One party's balances. |
-| `token balances` | Party × instrument balance matrix (cross-party reconciliation). |
-| `token summary` | Supply / holder count / holding-contract count + holder distribution for one instrument. |
-| `token activity` | Mint/transfer/burn history for one instrument, reconstructed from the ledger. |
-| `token party new\|ls\|rm` | Manage the party alias registry. |
-
----
-
-## The V2 alpha caveat
-
-V2 runs only on the upstream **alpha** Splice build (snapshot image on the
-`-dev` ghcr repo, `initial-protocol-version=35`). Consequences to know:
-
-- **The upstream V2 DevNet resets periodically.** The catalogue entry may
-  need refreshing each release cycle — see [Splice version catalogue](../../reference/versions/).
-- **Use `--profile tokens-v2`.** Selecting the alpha version without it
-  brings up a stack that can't run the V2 protocol; `doctor` warns.
-- **Loopback-only dev auth.** Per-role JWTs are signed with a literal
-  `unsafe` dev secret. They are valid only against your local stack —
-  never reuse them against DevNet/TestNet/MainNet.
-
----
-
-## Amulet vs. your own token
-
-- **Amulet** (Canton Coin) is dual-implemented (V1 + V2). The workspace
-  *observes* it — balances, matrix, activity — and can transfer it via
-  the off-ledger scan registry, but it has **no mint or burn** surface
-  (those are governance operations). The UI gates Mint/Burn accordingly.
-- **Your own `splice-test-token-v2` instrument** is fully operable:
-  create → mint → transfer → burn, all on-ledger, no scan registry
-  dependency (its `TokenRules` *is* the registry).
-
-See also: [Getting started](../../getting-started/) ·
-[FAQ](../localnet-lifecycle/#faq) · [troubleshooting](../../reference/troubleshooting/) ·
-[versions](../../reference/versions/).
diff --git a/website/src/content/docs/index.mdx b/website/src/content/docs/index.mdx
index 7b75485f..1bd073c9 100644
--- a/website/src/content/docs/index.mdx
+++ b/website/src/content/docs/index.mdx
@@ -14,6 +14,8 @@ hero:
       variant: minimal
 ---
 
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
 `canton-devkit` is a single-binary toolkit for spinning up, inspecting, and tearing
 down a complete [Canton](https://canton.network/) developer stack — Canton
 synchronizer + participant, Splice super-validator apps, three party wallets
@@ -24,6 +26,34 @@ synchronizer + participant, Splice super-validator apps, three party wallets
 Start with [Installation & Getting Started](getting-started/) for install paths,
 Docker prerequisites, and a zero-to-running LocalNet walkthrough.
 
+
+	
+		No YAML editing, no env-file shuffling. `up` downloads Splice, signs JWTs,
+		brings up a dozen containers, prints endpoints. Cold start ~90 s.
+	
+	
+		Same code, two skins: a CLI for terminals & CI, a
+		Vite/React Web UI for browser-driven inspection. Always at parity.
+	
+	
+		No forks, no patches. Thin wrapper over upstream
+		[Splice LocalNet](https://github.com/canton-network/splice), pinned to
+		immutable commit SHAs and verified by content hash.
+	
+	
+		Save a working state to a `.tgz`, hand it to a teammate, replay it on CI.
+		Restore a captured state in seconds.
+	
+	
+		`go install` or `dpm install package` — same artefact. macOS arm64, Linux
+		amd64, Windows amd64. No JVM, no Python, no Node at runtime.
+	
+	
+		Optional `--profile observability` adds Prometheus + Grafana with a
+		Canton dashboard. The CLI scrapes the same metrics.
+	
+
+
 ## Documentation
 
 - [LocalNet lifecycle](guides/localnet-lifecycle/) — up, inspect, multiple instances, clean up
diff --git a/website/src/content/docs/reference/faq.md b/website/src/content/docs/reference/faq.md
deleted file mode 100644
index a22120e3..00000000
--- a/website/src/content/docs/reference/faq.md
+++ /dev/null
@@ -1,104 +0,0 @@
----
-title: FAQ
-description: Common questions about canton-devkit — versions, tokens, multi-instance setups, and snapshots.
----
-
-Common questions about canton-devkit. See also
-[Troubleshooting](./troubleshooting/) for failure-mode fixes.
-
-## General
-
-**What is canton-devkit?**
-A single-binary developer tool for running and operating a Canton
-**LocalNet** — a full local Canton Network (sequencers, mediators,
-participants, Splice apps) in Docker. It gives you a CLI
-(`canton-devkit localnet …`, or `dpm localnet …` under DPM) and an
-embedded Web UI for the same operations.
-
-**CLI or Web UI — which should I use?**
-Both expose the same operations — the two surfaces are kept in parity
-by design. Use the CLI for scripting/CI; `canton-devkit localnet ui`
-for a dashboard, the contract explorer, DAR management, metrics, and
-the token workspace.
-
-**Does it fork or patch Splice?**
-No. It downloads the upstream `cluster/compose/localnet/` tree pinned by
-immutable commit SHA and verified by SHA-256 after extraction. See
-[Splice version catalogue](./versions/).
-
-**Which platforms are supported?**
-macOS (arm64), Linux (amd64), and Windows (amd64) are the released,
-tested targets. Other OS/arch combinations may work (DevKit only
-orchestrates Docker) but are untested — `localnet doctor` warns on
-unsupported platforms. See the compatibility matrix in
-[Getting started](../../getting-started/#compatibility-matrix).
-
-## Versions
-
-**What does `--version latest` give me?**
-The curated catalogue's `latest_alias` (a production-ready stable
-release). `localnet versions` lists the full catalogue; `--allow-uncurated`
-plus an explicit tag lets you run an upstream version not yet curated.
-
-**What's the difference between the curated catalogue and runtime
-resolution?**
-Curated entries (in `versions.json`) are tested and pinned by commit +
-content SHA. Uncurated tags are resolved live against GitHub and cached
-locally — handy for trying a brand-new upstream release before it's
-curated.
-
-## Tokens (CIP-0112 / V2)
-
-**V1 or V2?**
-This tool targets **Token Standard V2 (CIP-0112)** only. V1 / CIP-0056 is
-not supported. See the [Tokens guide](../guides/tokens/).
-
-**Why is V2 "alpha" and what does `--profile tokens-v2` do?**
-V2 runs on a special upstream Splice build (alpha protocol 35) on the
-`-dev` image repo. `--profile tokens-v2` injects the Canton config that
-enables alpha-version-support + protocol 35. Without it the stack can't
-run the V2 protocol; `doctor` warns.
-
-**Why can't I mint or burn Amulet?**
-Amulet (Canton Coin) has no developer-facing mint/burn surface — those
-are governance operations. The workspace observes Amulet and can transfer
-it, but Mint/Burn are gated. Create your own `splice-test-token-v2`
-instrument for full create → mint → transfer → burn.
-
-**How does burn work if the example token has no burn choice?**
-Correct — `splice-test-token-v2` has no protocol-level standalone burn.
-On LocalNet you control the holding's signatories (account parties +
-admin), so `token burn` archives the holder's `Holding` contracts
-directly and returns change. Supply = sum of holdings, so this removes
-the burned amount from circulation.
-
-**How does the authorization work differently in production?**
-On LocalNet, token commands authenticate with the **validator-backend
-dev JWT** — a static token signed with the validator node's hardcoded
-development secret. That credential can be granted act-as/read-as rights
-for **any** party on the node, so your application can use a single token
-for every party you allocate on the LocalNet validator (`bob`, `alice`, …)
-and transfer, mint, or query on behalf of all of them.
-
-Production networks won't expose that model: each party uses its **own**
-credentials, tokens are issued per session (not static JWTs), and you
-should not use backend credentials to sign for other parties on the
-network.
-
-## Operations
-
-**Can I run more than one instance at once?**
-Yes. Each `--name` gets isolated Docker resources and a port block.
-`localnet list` shows them all.
-
-**Where does state live?**
-`~/.canton-devkit/localnet//` (per-instance registry + data) and
-`~/.canton-devkit/cache/` (downloaded Splice trees). Removing the cache
-is safe; it re-downloads on next `up`.
-
-**Snapshot / restore — is it crash-consistent?**
-Snapshots capture Docker volumes + registry state. They are **not**
-guaranteed application-consistent for a *running* instance — see the
-warning in [Troubleshooting](./troubleshooting/#snapshot-consistency)
-and `localnet snapshot --help`. Stop the instance for a fully consistent
-snapshot.
diff --git a/website/src/content/docs/reference/limitations.md b/website/src/content/docs/reference/limitations.md
deleted file mode 100644
index 854389e5..00000000
--- a/website/src/content/docs/reference/limitations.md
+++ /dev/null
@@ -1,121 +0,0 @@
----
-title: "Known Limitations"
-description: "Things DevKit does not (yet) do well, with the rationale and workarounds where applicable."
----
-
-Things DevKit does not (yet) do well, with the rationale and
-workarounds where applicable. This list is updated as limitations are
-resolved.
-
-## Instance naming
-
-- **`--name` must be a DNS label.** Names are validated against RFC 1123:
-  1-63 chars of lowercase `[a-z0-9-]`, must start and end with `[a-z0-9]`.
-  Uppercase, underscores, and leading/trailing hyphens are rejected.
-  DNS-label form was chosen so the same name is safe to embed as a
-  hostname in a future `{service}.{instance}.localhost` routing model
-  without a second translation step.
-  *Migration:* instances created with an older release that still
-  allowed uppercase or underscore names (e.g. `MyStack`, `my_stack`)
-  must be torn down with that older binary and re-created under a
-  DNS-label name.
-
-## Container image pinning
-
-- **Splice container images are pulled by mutable ghcr tags, not
-  digests.** The catalogue pins the source TREE (commit SHA +
-  post-extract ContentSHA), but Splice's compose references every image
-  through a single shared `IMAGE_TAG` variable
-  (`image: "${IMAGE_REPO}canton:${IMAGE_TAG}"`,
-  `${IMAGE_REPO}splice-app:${IMAGE_TAG}`, the web UIs, …). Because one
-  variable addresses ~6 distinct images, per-image `@sha256:` digests
-  cannot be injected via the compose env — a single digest can't pin
-  six different images.
-
-  Instead DevKit VERIFIES post-up: after services are healthy it records
-  each running image's content digest (image ID) in `state.json`
-  (`image_digests`) and, on a later `up`/`restart` of the SAME version,
-  WARNs if a digest changed — i.e. a mutable ghcr tag was republished
-  under you. This is a warning,
-  not a gate (a digest can legitimately change if you manually re-pull),
-  and it's best-effort (a capture failure just skips the check). True
-  digest-pinning at pull time would need upstream Splice to expose
-  per-image digest variables in its compose.
-
-## Compose env reconstruction
-
-- **`composeContext` rebuilds env from registry state.**
-  `down` / `logs` / `creds` need the env that was passed to `up`.
-  DevKit reconstructs it from `state.json` so a fresh shell can still
-  operate the instance. Any new env var a future Splice release adds
-  that is not captured in state will silently break operations from a
-  fresh shell.
-
-## Integration testing
-
-- **No CI integration test for `localnet up` against real Splice.**
-  Unit tests cover parsers and orchestration well, but the actual
-  bring-up flow is not yet exercised end-to-end in CI, so drift in the
-  upstream Splice compose contract may first surface at runtime rather
-  than in CI.
-
-## Memory requirements
-
-- **Splice's full stack wants ~12 GB of Docker memory.**
-  `cluster/compose/localnet/resource-constraints.yaml` (from
-  [canton-network/splice](https://github.com/canton-network/splice))
-  sums to canton 4 GB + splice 3 GB +
-  postgres 2 GB + console 2 GB + 7 UI services @ 256-512 MB ≈ 12 GB.
-  In practice a single instance runs on 7-8 GB because most of those
-  limits are headroom. But:
-
-  - **Two concurrent instances exceed 8 GB Docker** → splice in one of
-    them gets OOM-restarted by docker, never reaches healthy, and
-    `WaitForHealthy` times out at 15 min.
-  - **GitHub `ubuntu-latest` runners have 7 GB RAM** — enough for
-    `up` to start but Splice's onboarding may not complete. Use a
-    larger runner class or a self-hosted runner for CI jobs that
-    bring up LocalNet.
-  - **Docker Desktop default on macOS is 8 GB.** Bump via Settings →
-    Resources before running multi-instance scenarios.
-
-  The preflight check enforces a 4 GB hard floor; the 12 GB
-  recommendation is documentation, not a gate — single-instance
-  setups on 7-8 GB work fine for most users.
-
-  On timeout, `WaitForHealthy` now dumps the last `docker compose ps`
-  snapshot in its error so the stuck service + state are visible
-  without re-running anything.
-
-## Platform parity
-
-- **Homebrew formula targets macOS arm64 and Linux x86_64 only.**
-  Matches the release matrix. macOS Intel, Linux ARM, and Windows are
-  intentionally out of scope.
-- **Windows users**: use the standalone zip from GitHub Releases
-  rather than DPM until the Windows `.exe` path through DPM is
-  verified.
-
-## Observability: transitional dual stack
-
-DevKit runs a host-level shared Prometheus + Grafana stack — one
-stack serves every running LocalNet via file-based service discovery,
-refcounted by target file. See
-[Observability](../../guides/observability/#stack-topology--host-shared-with-a-transitional-per-instance-overlay)
-for the topology.
-
-- **Each observability-enabled instance still *also* runs a
-  per-instance Prometheus + Grafana overlay** alongside the shared
-  stack, so while running it has **two** Prometheus and **two** Grafana
-  containers — roughly **~600 MiB** of duplicated overhead per extra
-  environment.
-- **Why it's kept (for now).** The per-instance overlay is a deliberate
-  fallback: both the CLI and the Web UI read shared-first and fall back
-  to the per-instance Prometheus when the shared stack isn't up, and the
-  per-instance scrape uses in-network service DNS (`canton:10013`)
-  rather than `host.docker.internal`, so it works on any platform
-  regardless of the Linux `host-gateway` mapping.
-- **Removal pending validation.** The per-instance overlay stays enabled
-  until the shared-only path is validated end-to-end on a native Linux
-  Docker host. When that validation completes, the overlay can be gated
-  off without changing the CLI or Web UI observability commands.
diff --git a/website/src/content/docs/reference/packaging.md b/website/src/content/docs/reference/packaging.md
deleted file mode 100644
index 437141e0..00000000
--- a/website/src/content/docs/reference/packaging.md
+++ /dev/null
@@ -1,191 +0,0 @@
----
-title: "Packaging & Distribution"
-description: "How canton-devkit ships — standalone binaries, the DPM component, the Debian/APT package — and the current supply-chain integrity story."
----
-
-`canton-devkit` ships through complementary channels:
-
-1. **DPM component** (primary) — installed via `dpm install package`.
-2. **Standalone Go binary** (additional) — direct download / install.
-3. **Package-manager convenience** — a hosted APT repo wraps the same
-   standalone Linux binary for Debian/Ubuntu workflows.
-
-All release artifacts are produced by the same release workflow
-([`.github/workflows/release.yml`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/.github/workflows/release.yml))
-from the same Go source. The DPM component and Debian package both wrap
-the standalone binary for their respective ecosystems.
-
-## Standalone binary
-
-Tagged releases (`v*`) publish per-platform standalone artifacts to
-GitHub Releases:
-
-| File | Platform |
-|---|---|
-| `canton-devkit__linux_amd64.tar.gz` | Linux x86_64 |
-| `canton-devkit__darwin_arm64.tar.gz` | macOS Apple Silicon |
-| `canton-devkit__windows_amd64.zip` | Windows x86_64 |
-| `canton-devkit__amd64.deb` | Debian/Ubuntu x86_64 |
-| `SHA256SUMS` | GNU `sha256sum --check`-compatible manifest |
-
-Each tarball/zip contains the binary, `LICENSE`, and `README.md`; the
-Debian package installs the same binary plus docs under standard Linux
-paths. Verify before unpacking/installing:
-
-```sh
-sha256sum --check SHA256SUMS
-tar -xzf canton-devkit_v0.7.0_linux_amd64.tar.gz
-./canton-devkit localnet --help
-```
-
-> **Version-string asymmetry:** the standalone archive filenames keep the
-> `v` prefix (`canton-devkit_v0.7.0_…`), matching the git tag, while the
-> DPM/OCI tag strips it (`…:0.7.0`) because DPM requires a bare-semver
-> tag. Same release, two conventions — chosen to match each ecosystem's
-> norm.
-
-## DPM component
-
-The DPM component is published to GitHub Container Registry on every
-tagged release at `ghcr.io/bitdynamics-ab/canton-devkit:`.
-Install via:
-
-```sh
-dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit:
-dpm localnet --help
-```
-
-`` follows semver (no `v` prefix); tag `latest` always points
-at the newest published release.
-
-### Manifest
-
-The component registers a single top-level command `localnet` that
-delegates the rest of the DevKit CLI surface to the binary's own argv
-parser. See [`packaging/component.yaml.tmpl`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/packaging/component.yaml.tmpl).
-
-DPM does NOT pass the registered command name into the binary's argv —
-only `exec-args` + user args reach it. `exec-args: ["localnet"]` is
-therefore required so the binary always dispatches into its `localnet`
-subtree regardless of how DPM invoked the component. A contract test
-(`TestRunIsArgvOnly`) locks this invariant.
-
-The manifest lives as a template with a `@@BINARY_PATH@@` token: the
-release workflow substitutes `bin/canton-devkit` on Unix platforms and
-`bin/canton-devkit.exe` on Windows. DPM does NOT auto-append `.exe`
-on Windows — empirically verified against DPM 1.0.16, which fails
-manifest validation with `stat ...: no such file or directory` when
-the path doesn't include the extension.
-
-### Why a single top-level command?
-
-DPM components register top-level commands into a flat namespace shared
-with DPM builtins and every other component. DevKit deliberately
-registers only `localnet` to:
-
-- Avoid collisions with DPM builtins (`install`, `publish`, `versions`,
-  `bootstrap`, …) or with future first-party components.
-- Keep the DPM surface minimal — `dpm localnet up`, `dpm localnet dar
-  upload`, `dpm localnet contracts ls`, etc. nest naturally.
-
-All DevKit subcommands live inside the binary's own Cobra tree, not in
-the DPM manifest.
-
-## Local validation
-
-Before pushing a release-affecting change, validate the manifest
-end-to-end against a real DPM CLI:
-
-```sh
-# 1. Build a host-platform binary into the expected layout.
-mkdir -p /tmp/cdk-component/bin
-go build -o /tmp/cdk-component/bin/canton-devkit ./cmd/canton-devkit
-
-# 2. Render the manifest from the template for this platform.
-sed "s|@@BINARY_PATH@@|bin/canton-devkit|" \
-    packaging/component.yaml.tmpl > /tmp/cdk-component/component.yaml
-cp LICENSE /tmp/cdk-component/LICENSE
-
-# 3. Run dpm publish --dry-run; it validates the manifest schema and
-#    reports the OCI layout that would be pushed.
-dpm publish component oci://localhost:5000/canton-devkit:0.0.1-dryrun \
-    --dry-run \
-    --platform darwin/arm64=/tmp/cdk-component
-```
-
-`✅ Component manifest is valid` confirms the manifest schema. CI runs
-the same `--dry-run` on every push and the real publish only on `v*`
-tags.
-
-## Debian / APT package
-
-The release workflow builds a Debian package from the exact same
-`linux/amd64` binary used in the standalone tarball and DPM component.
-It publishes the `.deb` as a release asset and updates a static APT repo
-in the public builds repository:
-
-```sh
-echo "deb [trusted=yes arch=amd64] https://raw.githubusercontent.com/bitdynamics-ab/homebrew-canton-devkit/main/apt stable main" \
-  | sudo tee /etc/apt/sources.list.d/canton-devkit.list
-sudo apt update
-sudo apt install canton-devkit
-```
-
-Available versions can be inspected with:
-
-```sh
-apt list -a canton-devkit
-apt policy canton-devkit
-```
-
-Direct artifact install remains available:
-
-```sh
-sudo apt install ./canton-devkit_0.7.0_amd64.deb
-canton-devkit version
-```
-
-The package installs:
-
-```text
-/usr/bin/canton-devkit
-/usr/share/doc/canton-devkit/LICENSE
-/usr/share/doc/canton-devkit/README.md
-```
-
-It deliberately does **not** depend on or install Docker. DevKit's
-runtime preflight remains `canton-devkit localnet doctor`, which checks
-Docker CLI availability, daemon connectivity, Compose v2, ports, disk,
-memory, and host-specific prerequisites.
-
-The Debian `postinst` script calls
-`canton-devkit telemetry _record-install-surface apt` as a best-effort
-hook. The binary owns opt-out precedence, local spooling, and uploader
-timeouts, so package installation never fails if telemetry is disabled or
-the collector is unreachable.
-
-The hosted repo is generated on every release by preserving all existing
-`apt/pool/main/c/canton-devkit/*.deb` files in
-`bitdynamics-ab/homebrew-canton-devkit`, adding the new version, and
-rewriting `Packages`, `Packages.gz`, and `Release` metadata under
-`apt/dists/stable/main/binary-amd64/`.
-
-**Known limitation:** the APT repo is unsigned and documented with
-`trusted=yes`. The repository is backed by HTTPS and release checksums,
-but a GPG-signed `InRelease` file and install instructions using
-`signed-by=` are planned hardening steps.
-
-## Supply-chain integrity
-
-Today's integrity story is **SHA-256 checksums** (`SHA256SUMS`, verifiable
-with `sha256sum --check`) plus the immutability of the GHCR OCI digest.
-The CI pipeline also pins every GitHub Action and the DPM CLI tarball by
-SHA.
-
-**Known limitation:** the release artifacts are **not yet
-cryptographically signed**. There are no [cosign](https://github.com/sigstore/cosign)/Sigstore
-signatures on `SHA256SUMS` or on the OCI artifact, so consumers can
-verify *integrity* (the bytes match the checksum) but not *provenance*
-(the bytes were produced by the project's release pipeline). Keyless
-cosign signing plus a published verification step is a planned
-hardening item.
diff --git a/website/src/content/docs/reference/telemetry.md b/website/src/content/docs/reference/telemetry.md
deleted file mode 100644
index c509022d..00000000
--- a/website/src/content/docs/reference/telemetry.md
+++ /dev/null
@@ -1,143 +0,0 @@
----
-title: "Telemetry"
-description: "The complete reference for canton-devkit's anonymous, aggregate usage counters — what is collected, what is never collected, and how to inspect or disable it."
----
-
-canton-devkit records **anonymous, aggregate usage counters** — merged
-into a daily total with **no per-invocation rows** — to help maintainers
-see what's used and what breaks. The only identifier sent is a single
-**anonymous random install token** (a UUID, not derived from any hardware
-detail) used purely to count *distinct* installs; it never tags an
-individual counter. This page is the complete reference for what is
-collected, what is never collected, and how to inspect or disable it.
-
-Inspect exactly what's queued any time:
-
-```bash
-canton-devkit telemetry preview
-```
-
-## On by default (opt-out)
-
-Telemetry is **on by default**. The first time you run an operational
-command in an interactive terminal, a one-time notice explains this. The
-Debian package also records a one-time `apt` install-surface ping during
-package installation; because that path is non-interactive, opt out
-**before** install with `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1` if you do
-not want it. Turn telemetry off any time — your choice persists:
-
-```bash
-canton-devkit telemetry off      # disable (persists)
-canton-devkit telemetry on       # re-enable
-
-# or, per-invocation / environment-wide, without writing config:
-export DPM_TELEMETRY=off          # also: on
-export DO_NOT_TRACK=1             # the community standard — always wins
-```
-
-Precedence (highest first): `DO_NOT_TRACK` → `DPM_TELEMETRY` → config file
-→ default on.
-
-## What is collected — counters only
-
-A closed, compile-time-enforced allow-list of fourteen counters. Each is a
-`chart` with a small set of `buckets`; we keep daily **counts** per
-bucket and nothing else:
-
-| Counter | Buckets |
-|---|---|
-| `dpm/install` | `linux` `darwin` `windows` — **once per machine** on the first non-CI run (a device-count proxy; no identifier) |
-| `dpm/install_surface` | `apt` — **once per machine** when Debian/Ubuntu package installation finishes |
-| `dpm/command` | the localnet verb (`up`, `down`, `dar`, `token`, …) |
-| `dpm/command_exit` | `/ok` or `/fail` |
-| `dpm/token_action` | the token subcommand (`create` `mint` `transfer` `burn` `balance` …) — CIP-0112 flow visibility |
-| `dpm/ui_feature` | Web UI screen touched per session (`dar` `explorer` `metrics` `tokens` `skills` `backup` `instances`) |
-| `dpm/channel` | `stable` `nightly` `dev` |
-| `dpm/os` | `linux` `darwin` `windows` |
-| `dpm/arch` | `amd64` `arm64` |
-| `dpm/ci` | `true` `false` |
-| `dpm/llm_agent` | `claude` `copilot` `cursor` `gemini` `none` |
-| `dpm/docker_engine` | `docker` `colima` `orbstack` `podman` `other` |
-| `dpm/compose_version_bucket` | `v2.20-` `v2.20-v2.27` `v2.28+` |
-| `dpm/doctor_fail` | failing `doctor` check ids (only on `doctor` failure) |
-
-A period file is literally:
-
-```json
-{
-  "schema_version": 2,
-  "period": "2026-06-21",
-  "granularity": "daily",
-  "counters": {
-    "dpm/command": {"up": 5, "down": 3},
-    "dpm/os": {"darwin": 8}
-  }
-}
-```
-
-We learn *"this day saw 5 `up` invocations on darwin/arm64"* — and
-nothing else.
-
-## The anonymous install token
-
-One value is sent that *can* distinguish installs: a random **UUIDv4**
-minted on first upload and stored in your telemetry config. It exists for
-exactly one reason — so the collector can answer *"how many distinct
-installs?"* (the one number pure counters can't give). What it is
-**not**:
-
-- **Not derived from your machine** — no hostname, MAC, serial, or
-  hardware fingerprint feeds it. It's pure random bytes.
-- **Not linked to your usage** — the collector stores it alone, as
-  `(token, active-date)`, never beside a counter. We can count installs;
-  we can't see what any one install did.
-- **Per-environment, not per-person** — it lives in the config file, so a
-  fresh container, VM, or reinstall mints a new one by design. It counts
-  *environments*, not people.
-- **Suppressed in CI** and **rotatable anytime** with
-  `canton-devkit telemetry reset-id` (or cleared entirely when you
-  `telemetry off`).
-
-## What is **never** collected
-
-No machine id, no hashed hardware id, no IP retention. And by
-construction — the model is counters, not events — no:
-
-- instance / project / compose names, party ids, contract ids
-- DAR names/hashes, package/module names
-- JWT audiences/issuers/fingerprints, ports, endpoints, file paths
-- command arguments beyond the verb, error messages, stack traces
-- timestamps finer than the ISO week, environment variables, hostnames
-
-There is no per-invocation row to profile, and the one token we send
-correlates only to itself (an install count) — never to your usage.
-
-## How it works
-
-- Counters accumulate in memory during a run and merge into the current
-  day's local file (`/canton-devkit/telemetry/.json`)
-  on exit. Recording never blocks or fails a command.
-- The Debian package install hook uses the same spool/uploader path: it
-  records the install-surface counter locally first, then does a
-  best-effort flush so the install path is visible even if the user never
-  runs an operational command later.
-- A **completed** past period is uploaded once (a single POST), then its
-  file is deleted. On the first upload failure the period is marked deferred
-  and retried at the next window; after a second miss it is dropped.
-  Retrying an aggregate is privacy-safe; retrying individual events is
-  not, so we don't keep events.
-- With **no collector configured** (the default in a source build),
-  nothing ever leaves the machine. Release binaries may bake an endpoint;
-  `telemetry status` shows whether one is set.
-
-## Audit it
-
-```bash
-canton-devkit telemetry status              # on/off, the rule that decided it, channel, collector
-canton-devkit telemetry preview             # this period's counters (exactly what would be sent)
-canton-devkit telemetry preview --format json
-canton-devkit telemetry flush               # send all queued counters now (skip the daily window)
-DPM_TELEMETRY_DEBUG=1 canton-devkit localnet status   # print the would-send JSON to stderr, send nothing
-```
-
-See also: [FAQ](../../guides/localnet-lifecycle/#faq) · [Getting started](../../getting-started/).
diff --git a/website/src/content/docs/reference/troubleshooting.md b/website/src/content/docs/reference/troubleshooting.md
deleted file mode 100644
index 2d25168b..00000000
--- a/website/src/content/docs/reference/troubleshooting.md
+++ /dev/null
@@ -1,101 +0,0 @@
----
-title: "Troubleshooting"
-description: "Failure modes and fixes for LocalNet bring-up, ports, V2 token instances, credentials, and snapshots."
----
-
-Failure modes and fixes. Start with `canton-devkit localnet doctor
---name ` — it checks Docker, memory, ports, version channel,
-and the alpha-profile requirement, and prints targeted remediation.
-
-## `localnet up` fails or containers OOM-loop
-
-**Symptom:** Canton container restarts repeatedly; `up` times out.
-
-**Cause:** Docker memory below the version's floor. Splice 0.6.x needs
-≈8 GiB; the V2 alpha similar. The default Docker Desktop allocation
-(4 GiB) is too low.
-
-**Fix:** Raise Docker memory to the recommended value (`doctor` prints
-it), then `localnet up` again. The per-version preflight gate surfaces
-this before the stack starts.
-
-## Port already in use
-
-**Symptom:** `PORTS_IN_USE` error envelope on `up`.
-
-**Fix:** Another instance (or a stale container) holds the port block.
-`localnet list` to find it, `localnet down --name ` to free it, or
-pass a different `--name` (each name gets its own block). Note: Docker may
-reassign ephemeral host ports across a restart — re-read them from
-`localnet status` rather than caching old values.
-
-## V2 instance: ledger port refused / scan registry 502
-
-**Symptom:** token commands fail with `connection refused` on the
-participant port, or Amulet transfers return `HTTP 502` from nginx.
-
-**Cause:** The V2 alpha image ships a broken in-container healthcheck, so
-the Splice container can read `health: starting` for a long time even
-when functional — and the off-ledger scan registry (behind nginx) isn't
-ready until the Splice app fully boots.
-
-**Fix:**
-- Give the stack more time; `doctor` / `status` reflect real readiness
-  via the readyz fallback, not just the container healthcheck.
-- The **native test-token** path (your own `splice-test-token-v2`
-  instrument) needs **no scan registry** — its `TokenRules` is the
-  registry — so create/mint/transfer/burn of your own token work even
-  while the scan app is still coming up. Only **Amulet** transfers depend
-  on the scan registry.
-- If the participant port is genuinely down, `localnet status` will show
-  it; restart with `localnet restart --name `.
-
-## Token: "package not vetted" / manual DAR upload
-
-**Symptom:** `token create` errors that `splice-test-token-v2` isn't
-vetted.
-
-**Fix:** `token create --endpoint …` auto-fetches and uploads the
-test-token + burn-mint DARs (pinned to the instance's Splice commit). If
-you're offline or the fetch fails, upload them manually with
-`localnet dar upload ` and retry.
-
-## Token: mint/burn disabled in the Web UI
-
-Mint and Burn are gated to **native CIP-0112 v2 instruments created on
-this instance**. Amulet and registry-only ("recorded") instruments have
-no mint/burn surface — create your own token to exercise them.
-
-## Credentials lost after a failed `up`
-
-**Symptom:** token/ledger commands can't find a JWT for a role.
-
-**Fix:** `localnet creds --name  --role  --format raw`
-re-issues a dev token from the project's env files. The token commands
-also auto-issue per-role tokens when `--token` is empty.
-
-## Snapshot consistency
-
-`localnet snapshot` captures Docker volumes + registry state. For a
-**running** instance this is a crash-consistent (not
-application-consistent) copy: in-flight transactions or unflushed
-database writes may not be fully captured. For a guaranteed-consistent
-snapshot, `localnet down --name ` first, then snapshot. `snapshot`
-warns when run against a running instance.
-
-## Still stuck?
-
-- `localnet logs --name  [service]` — tail container logs.
-- `localnet doctor --name ` — host + instance diagnostics.
-- File a [GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues)
-  with the `doctor` output and the failing command.
-
-## Log lookup implementation note
-
-`localnet logs` intentionally asks Docker Compose for logs by project
-label only (`docker compose -p  logs`). It does not replay the
-cached `compose.yaml` files, generated overlays, or `--env-file` list
-from registry state. Logs are read from already-created containers, so
-rebuilding the active Compose model is unnecessary and can hide
-profile-gated services unless the exact profile set from `localnet up`
-is replayed.
diff --git a/website/src/content/docs/reference/versions.md b/website/src/content/docs/reference/versions.md
deleted file mode 100644
index f8b0a783..00000000
--- a/website/src/content/docs/reference/versions.md
+++ /dev/null
@@ -1,156 +0,0 @@
----
-title: "Splice Version Catalogue"
-description: "How DevKit pins curated Splice LocalNet versions by commit SHA and content hash, discovers upstream tags, and resolves uncurated versions on opt-in."
----
-
-DevKit pins to a **catalogue** of tested Splice versions embedded in the
-binary so `localnet up` never composes-up an untested upstream tag.
-
-## What DevKit fetches, and from where
-
-> **Upstream repo:** [`canton-network/splice`](https://github.com/canton-network/splice)
-> **Subtree extracted:** `cluster/compose/localnet/`
-> **Fetch URL:** `https://github.com/canton-network/splice/archive/.tar.gz`
-
-That subtree is the canonical Splice LocalNet definition — `compose.yaml`,
-`compose.env`, `resource-constraints.yaml`, `conf/`, `docker/`, `env/`.
-DevKit downloads only that subtree on cache-miss and verifies it against
-the catalogue's `content_sha`; the rest of the Splice source tree is
-discarded.
-
-### Not to be confused with `cn-quickstart`
-
-[`digital-asset/cn-quickstart`](https://github.com/digital-asset/cn-quickstart)
-is a separate repo that *builds on top of* the same Splice LocalNet to
-provide an App-Provider quickstart with a backend service, frontend,
-Daml workflows, etc. — see its README for context. DevKit deliberately
-fetches the bare LocalNet base from `canton-network/splice` rather than
-the App-Provider layer from cn-quickstart, because the lifecycle
-commands (`up` / `down` / `status` / `creds` / `logs`) only need the
-minimal infrastructure surface. App-Provider workflows are out of scope for DevKit;
-users who want them can run cn-quickstart's `make start` on top of a
-DevKit-managed LocalNet.
-
-### A note on the upstream URL
-
-GitHub may surface this repo as `hyperledger-labs/splice` in older
-documentation (e.g. cn-quickstart's README still uses that name).
-That URL redirects to `canton-network/splice` — GitHub's API resolves
-both to the same canonical `full_name`, and tag SHAs match. DevKit uses
-the canonical name in code and docs.
-
-## Anatomy of a catalogue entry
-
-```json
-{
-  "tag": "0.6.4",
-  "commit": "578b7822d62947763a48334d556aefebc7ffacec",
-  "content_sha": "db1e1336dc4e33abe7011a0df29e5becd141d11c84cdf42849e48bf2106066af",
-  "size": 137576613,
-  "major": "0.6"
-}
-```
-
-| Field | Source of truth | Why it's pinned |
-|---|---|---|
-| `tag` | Upstream git tag (or branch label for pre-releases) | User-facing identifier; what `--version` accepts. |
-| `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. DevKit fetches via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. |
-| `content_sha` | `scripts/compute-tree-sha.sh` | SHA-256 over the extracted `cluster/compose/localnet/` subtree (sorted by path). Stable across upstream gzip-envelope rewrites; this is the authoritative integrity check at fetch time. |
-| `size` | byte count of the source-tarball | Informational; used to print a hint before download and to size the in-flight body cap. |
-| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major Splice adapter for that release line. |
-| `channel` *(optional)* | catalogue maintainer | `""` / `"stable"` → production-ready; `"alpha"` → opt-in pre-release (Token Standard V2 snapshot etc.). `up` prints a one-line warning when an alpha entry is selected. |
-| `image_repo` *(optional)* | catalogue maintainer | Overrides the default Docker image repository. Defaults to `ghcr.io/digital-asset/decentralized-canton-sync/docker`. Set to `ghcr.io/digital-asset/decentralized-canton-sync-dev/docker` for the V2 alpha track. The v06 adapter forwards this as the `IMAGE_REPO` compose env. |
-
-### The alpha channel
-
-DevKit's first alpha entry is the **Token Standard V2** snapshot pointed at by [`token-standard-v2-upcoming`](https://github.com/canton-network/splice/tree/token-standard-v2-upcoming). V2 publishes images to a separate `-dev` ghcr registry (hence the `image_repo` override) and runs only on Canton's *alpha* protocol version (initial protocol 35, alpha-version-support flags). The Canton config side of that requirement is delivered by a separate `--profile tokens-v2` overlay; selecting the alpha catalogue entry without the profile is supported but will not bring up a healthy stack.
-
-**Stability caveat:** the upstream V2 DevNet [is reset and upgraded on a weekly cadence](https://github.com/canton-network/splice/blob/token-standard-v2-upcoming/token-standard/TOKEN_STANDARD_V2_DEVNET.md), so the V2 entry's `commit` will rotate more often than a stable release. Refresh via `scripts/add-splice-version.sh` (modify the script to pass `--ref token-standard-v2-upcoming` for branch-tracking).
-
-## Discovering versions
-
-```sh
-dpm localnet versions             # supported + available upstream
-dpm localnet versions --offline   # supported only (no network)
-dpm localnet versions --format=json
-```
-
-Status flags per row:
-
-| Status | Meaning |
-|---|---|
-| `supported` | Catalogued; upstream pin matches. Safe to use. |
-| `drifted` | Catalogued; upstream tag has been force-moved to a different commit. **Security signal** — re-review the catalogue entry before trusting. |
-| `available` | Upstream has the tag; not yet in the catalogue. A maintainer can add it via the helper below. |
-| `catalogued-only` | In the catalogue, but the online tag listing does not contain the same label. For stable entries this usually means the upstream tag was deleted and should be investigated before removal; branch-backed alpha entries such as `token-standard-v2` can also appear this way until branch/ref-aware status is added. |
-
-## Adding a new version (maintainer flow)
-
-```sh
-scripts/add-splice-version.sh 0.6.5
-```
-
-The script:
-1. Resolves `0.6.5` → commit SHA via the GitHub REST API.
-2. Downloads the archive at that commit.
-3. Extracts the `cluster/compose/localnet/` subtree.
-4. Computes `ContentSHA` via `scripts/compute-tree-sha.sh`.
-5. Inserts a new entry into `versions.json` (sorted by tag).
-6. Prints the diff. **Does not commit.**
-
-A maintainer then:
-- Reviews the diff.
-- Bumps `latest_alias` if the new tag should become the default
-  `--version latest`.
-- Optionally runs the integration test against the new entry before
-  merging.
-- Commits + pushes.
-
-## Two-layer resolution
-
-DevKit exposes the catalogue as the *default* tier of a two-layer
-version model — the curated path stays audited, and an explicit
-opt-in unlocks arbitrary upstream tags for prerelease testing.
-
-| Layer | Trigger | Source | ContentSHA | Notes |
-|-------|---------|--------|------------|-------|
-| 1 — Curated | `--version ` for any tag in `versions.json` (or `--version latest`) | Embedded catalogue | Pinned at catalogue time, verified post-extract | Default. Audited. Offline. |
-| 2 — Upstream | `--version  --allow-uncurated` for any tag not in the catalogue | `api.github.com/repos/canton-network/splice/git/refs/tags/` | Computed on first extract, recorded for future runs | Requires explicit opt-in. Network on first call. Cached at `~/.canton-devkit/cache/resolved-versions.json`. |
-
-Layer 2 trades audit for flexibility: it lets a user spin up a
-`0.7.0-alpha.4` LocalNet without waiting for a catalogue PR, but
-DevKit can't promise the bits were tested against this release.
-Orchestrators print a one-line "Using uncurated Splice tag" warning
-on the layer-2 path so the user is never surprised.
-
-Because layer 2 covers the prerelease use case, the catalogue is
-strictly a curated-by-humans surface — entries are only added by a
-maintainer, never by automation.
-
-## Why not just point at the latest tag?
-
-Three reasons the catalogue is curated:
-
-1. **Reproducibility.** A user running `localnet up --version 0.6.4`
-   today must get exactly the bits that were tested when the entry
-   was added. Tag-only resolution would let `0.6.4` quietly point at
-   different code if the upstream tag is moved.
-
-2. **Surface area control.** Splice ships pre-release tags
-   (`next-cilr`, etc.) and partial-release tags that aren't intended
-   for downstream consumption. DevKit doesn't aim to support every
-   commit that happens to land in the repo.
-
-3. **Adapter routing.** DevKit ships per-major adapters for each Splice
-   major version. A new major version (e.g. `0.7.x`)
-   needs a corresponding adapter before it can be added — the script
-   leaves `major` blank for non-N.N.N tags so a maintainer notices.
-
-## Why the content SHA, not the tarball hash
-
-Pinning the gzip-tarball SHA would be brittle: GitHub regenerates
-source-tarballs lazily and the gzip metadata can drift, so the same
-source tree can yield different tarball hashes over time. The catalogue
-therefore pins the commit SHA in the URL plus a ContentSHA over the
-extracted tree — a complete integrity check that is stable across gzip
-envelope rewrites.
diff --git a/website/src/styles/custom.css b/website/src/styles/custom.css
new file mode 100644
index 00000000..f4ea6389
--- /dev/null
+++ b/website/src/styles/custom.css
@@ -0,0 +1,173 @@
+/* Canton DevKit docs — typography & brand.
+ *
+ * Fonts are self-hosted via Fontsource (no CDN): Inter Variable for
+ * body/UI, JetBrains Mono Variable for code. Code-block internals are
+ * configured in astro.config.mjs (expressiveCode.styleOverrides) because
+ * Expressive Code resolves them at build time.
+ *
+ * The accent scale is the product's brand teal (frontend/src/tokens.ts:
+ * brand #5BD7C5 on #0B0E13) so the docs and the Web UI share one
+ * identity: bright teal in dark mode, deepened for contrast on white. */
+
+:root {
+	--sl-font: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, sans-serif;
+	--sl-font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+
+	/* Light theme accent — brand teal darkened for WCAG AA on white. */
+	--sl-color-accent-low: #d5f2ec;
+	--sl-color-accent: #0e7c6f;
+	--sl-color-accent-high: #0b4f47;
+}
+
+:root[data-theme='dark'] {
+	/* Dark theme accent — the product's brand teal, verbatim. */
+	--sl-color-accent-low: #16403c;
+	--sl-color-accent: #5bd7c5;
+	--sl-color-accent-high: #7fe9d9;
+}
+
+/* ---------------------------------------------------------------- *
+ * Base rendering
+ * ---------------------------------------------------------------- */
+
+body {
+	-webkit-font-smoothing: antialiased;
+	text-rendering: optimizeLegibility;
+	font-feature-settings: 'kern' 1, 'liga' 1, 'calt' 1;
+}
+
+/* ---------------------------------------------------------------- *
+ * Headings — Inter tightens as sizes grow; 600 beats 700 at display
+ * sizes. Docs-page h1 steps down from Starlight's 40px default.
+ * ---------------------------------------------------------------- */
+
+.sl-markdown-content :is(h1, h2, h3, h4) {
+	font-weight: 600;
+	letter-spacing: -0.02em;
+}
+h1[data-page-title] {
+	font-size: clamp(1.75rem, 1.35rem + 1.4vw, 2.25rem);
+	font-weight: 650;
+	letter-spacing: -0.022em;
+	line-height: 1.2;
+}
+.sl-markdown-content h2 {
+	font-size: 1.4375rem;
+	margin-top: 2.25em;
+}
+.sl-markdown-content h3 {
+	font-size: 1.125rem;
+}
+
+/* Prose rhythm: technical docs with heavy inline code read better a
+ * touch tighter than Starlight's 1.8 default. */
+.sl-markdown-content {
+	line-height: 1.7;
+}
+
+/* ---------------------------------------------------------------- *
+ * Code — blocks are handled by Expressive Code (see astro.config);
+ * this covers inline code so it sits inside the line rhythm.
+ * ---------------------------------------------------------------- */
+
+/* The frame's 
 itself never gets a family from Expressive Code —
+ * the UA default (Courier) leaks into prompt glyphs and copy affordances. */
+.expressive-code pre {
+	font-family: var(--sl-font-mono);
+	font-size: 0.875rem;
+}
+
+.sl-markdown-content code:not(pre code) {
+	font-size: 0.855em;
+	font-variant-ligatures: none;
+	padding: 0.125rem 0.375rem;
+	border-radius: 0.25rem;
+}
+
+/* ---------------------------------------------------------------- *
+ * Tables — dense reference content: smaller size, tabular numerals
+ * so ports and versions align in columns.
+ * ---------------------------------------------------------------- */
+
+.sl-markdown-content table {
+	font-size: 0.9rem;
+	font-variant-numeric: tabular-nums;
+}
+.sl-markdown-content th {
+	font-weight: 600;
+	letter-spacing: 0.01em;
+}
+
+/* ---------------------------------------------------------------- *
+ * Landing page (splash template)
+ * ---------------------------------------------------------------- */
+
+/* Readable measure: without this the splash prose runs ~1080px wide
+ * (~140 chars/line). Cap each block (not the container — Starlight's
+ * splash layout collapses a constrained container to zero width); the
+ * card grid gets to breathe a little wider. */
+[data-has-hero] .sl-markdown-content > * {
+	max-width: 46rem;
+	margin-inline: auto;
+}
+[data-has-hero] .sl-markdown-content > .card-grid {
+	max-width: 52rem;
+}
+
+/* Hero: bring the 64px flat-black title down to a designed scale with
+ * a brand gradient; give the tagline real presence. */
+.hero {
+	padding-block: 2.5rem 1.5rem;
+}
+.hero h1 {
+	font-size: clamp(2.5rem, 1.9rem + 2.6vw, 3.5rem);
+	font-weight: 650;
+	letter-spacing: -0.028em;
+	line-height: 1.08;
+	background: linear-gradient(115deg, var(--sl-color-text) 30%, var(--sl-color-accent) 100%);
+	-webkit-background-clip: text;
+	background-clip: text;
+	-webkit-text-fill-color: transparent;
+}
+.hero .tagline {
+	font-size: clamp(1.125rem, 1rem + 0.6vw, 1.375rem);
+	line-height: 1.5;
+	letter-spacing: -0.012em;
+	max-width: 34ch;
+}
+
+/* ---------------------------------------------------------------- *
+ * Cards — Starlight's defaults set 24px titles inside 210px cards;
+ * rebalance toward the content.
+ * ---------------------------------------------------------------- */
+
+article.card {
+	border-radius: 0.75rem;
+}
+article.card .title {
+	font-size: 1.0625rem;
+	font-weight: 600;
+	letter-spacing: -0.01em;
+}
+article.card .body {
+	font-size: 0.9375rem;
+	line-height: 1.6;
+	color: var(--sl-color-gray-2);
+}
+/* Starlight 0.41 tints each card icon with a rotating pastel background
+ * (cream/lavender/mint/pink) — charming for a blog, noise for a brand.
+ * One identity: accent glyph on the accent-low tint. */
+article.card svg.icon {
+	color: var(--sl-color-accent);
+	background-color: var(--sl-color-accent-low) !important;
+	border-color: color-mix(in srgb, var(--sl-color-accent) 35%, transparent) !important;
+}
+
+/* ---------------------------------------------------------------- *
+ * Chrome — sidebar and page-nav UI text sits smaller and calmer.
+ * ---------------------------------------------------------------- */
+
+nav.sidebar summary,
+.sidebar-content a {
+	letter-spacing: -0.005em;
+}

From b17560bdbf3462f33b6db4262b262227052f27b8 Mon Sep 17 00:00:00 2001
From: Zhe Li 
Date: Sun, 5 Jul 2026 22:46:54 +0200
Subject: [PATCH 37/68] feat(localnet): standalone stop/start commands +
 unpause alias (#201)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(localnet): add standalone stop/start commands and unpause alias

Split `start`/`stop` out of the up/down aliases into first-class
lifecycle commands that sit between pause/resume and down/up:

  - `localnet stop`  → docker compose stop (containers kept on disk)
  - `localnet start` → docker compose start, falling back to a full
    `up` (reusing the recorded version + profiles) when the containers
    were already removed or the instance isn't registered
  - `unpause` added as an alias of `resume`

Full CLI ↔ Web UI parity: the instance detail card gains Stop and
Start actions (and a distinct Down button), backed by new
/stop (sync 204) and /start (204 fast / 202 up-fallback) handlers.

Docker layer gains `-p`-only StopContainers/Start; shared logic lives
in internal/localnet/stopstart.go. Reconciler now preserves the
stopped status when no container is running.

Docs updated: pause-vs-stop-vs-down comparison table + guidance in the
lifecycle guide, corrected alias claims + new verbs in the agent skill
doc, and a new stop/start section in changes-from-proposal.

BEHAVIOUR CHANGE: `localnet stop` no longer removes containers (was an
alias for `down`) and `localnet start` no longer unconditionally
recreates the stack (was an alias for `up`); it converges to running,
recreating only when containers are gone. Use `down`/`up` explicitly
for the old behaviour.

* fix(localnet): preserve profile recovery on start fallback

* test(e2e): cover standalone stop/start + unpause in milestone 1

Reflect PR #201 (standalone stop/start commands + unpause alias) in the
Milestone 1 E2E assets:

- e2e-milestone1.sh: expand the M1-INST-003 --help surface check to
  assert start/stop/pause/resume are listed (they are now first-class
  lifecycle commands, no longer up/down aliases); verify the unpause
  alias resolves. Add M1-STP-001, exercising stop (containers kept) then
  start (fast compose-start path back to healthy).
- e2e-test-milestone-1.md / .html: add the M1-STP-001 case, expand the
  help command list, bump the test count to 19, and note the stop!=down
  behaviour change.
---
 docs/changes-from-proposal.md                |  25 +-
 docs/localnet-lifecycle.md                   |  32 ++
 docs/tests/e2e-test-milestone-1.html         |  76 ++++-
 docs/tests/e2e-test-milestone-1.md           |  63 +++-
 frontend/src/api.ts                          |  52 +++-
 frontend/src/screens/InstanceDetail.test.tsx |  99 +++++++
 frontend/src/screens/InstanceDetail.tsx      | 124 ++++----
 internal/cli/help.go                         |   4 +-
 internal/cli/help_test.go                    |   2 +-
 internal/cli/localnet/down.go                |  14 +-
 internal/cli/localnet/down_test.go           |  12 +-
 internal/cli/localnet/localnet.go            |   6 +-
 internal/cli/localnet/pause.go               |   5 +-
 internal/cli/localnet/start.go               |  49 ++++
 internal/cli/localnet/stop.go                |  40 +++
 internal/cli/localnet/up.go                  |   5 +-
 internal/cli/localnet/up_test.go             |  12 +-
 internal/docker/compose.go                   |  48 +++
 internal/docker/compose_test.go              |  41 +++
 internal/localnet/stopstart.go               | 293 +++++++++++++++++++
 internal/localnet/stopstart_test.go          | 213 ++++++++++++++
 internal/skills/docs/localnet-lifecycle.md   |  24 +-
 internal/ui/handlers/instances.go            | 174 +++++++++++
 internal/ui/handlers/reconciler.go           |  20 ++
 internal/ui/handlers/reconciler_test.go      |  17 ++
 internal/ui/handlers/stopstart_test.go       | 134 +++++++++
 scripts/e2e-milestone1.sh                    |  54 +++-
 27 files changed, 1527 insertions(+), 111 deletions(-)
 create mode 100644 internal/cli/localnet/start.go
 create mode 100644 internal/cli/localnet/stop.go
 create mode 100644 internal/localnet/stopstart.go
 create mode 100644 internal/localnet/stopstart_test.go
 create mode 100644 internal/ui/handlers/stopstart_test.go

diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md
index 11f13d24..07cf8998 100644
--- a/docs/changes-from-proposal.md
+++ b/docs/changes-from-proposal.md
@@ -19,6 +19,7 @@ Every deviation listed here is **intentional**, not an oversight or implementati
   - [`--profile` flag (new)](#--profile-flag-new)
   - [`--port-base` flag (new)](#--port-base-flag-new)
 - [`localnet pause` / `resume` (new)](#localnet-pause--resume-new)
+- [`localnet stop` / `start` (new)](#localnet-stop--start-new)
 - [`localnet creds` (new)](#localnet-creds-new)
 - [`localnet versions` (new)](#localnet-versions-new)
 - [`localnet ui` (new)](#localnet-ui-new)
@@ -74,8 +75,7 @@ The following aliases are not in the proposal but are shipped:
 
 | Canonical command | Alias(es) | Notes |
 |---|---|---|
-| `localnet up` | `start` | More intuitive for new users |
-| `localnet down` | `stop` | Pair with `start` |
+| `localnet resume` | `unpause` | Matches `docker compose unpause` terminology |
 | `localnet observability` | `obs` | Shorter for interactive use |
 | `localnet container list` | `ls`, `ps` | Matches Docker CLI conventions |
 | `localnet token party ls` | `list` | Consistency within party subcommand |
@@ -83,6 +83,8 @@ The following aliases are not in the proposal but are shipped:
 
 `localnet list` has **no** `ls` alias despite the pattern above — adding it would shadow `localnet logs` with a common prefix, increasing ambiguity in tab-completion.
 
+**Behaviour change (removed aliases):** earlier builds shipped `start` as an alias for `up` and `stop` as an alias for `down`. These aliases have been **removed** — `start` and `stop` are now standalone commands with distinct behaviour (see [`localnet stop` / `start`](#localnet-stop--start-new)). `localnet stop` no longer removes containers (use `down` for that), and `localnet start` no longer unconditionally recreates the stack (though it converges to a running instance, falling back to `up` when containers are gone).
+
 ---
 
 ## `localnet up`
@@ -123,12 +125,29 @@ The following aliases are not in the proposal but are shipped:
 
 **Shipped:** `dpm localnet pause ` and `dpm localnet resume `.
 
-`pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT. No readiness wait is performed on resume.
+`pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT (alias `unpause`, matching `docker compose unpause`). No readiness wait is performed on resume.
 
 **Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Frees CPU and reduces resource consumption without discarding ledger state. Required for CLI ↔ Web UI parity (the UI exposes a pause/resume action on the instance card).
 
 ---
 
+## `localnet stop` / `start` (new)
+
+**Proposal said:** not mentioned as standalone commands. Earlier DevKit builds shipped `stop` and `start` only as aliases for `down` and `up`.
+
+**Shipped:** `dpm localnet stop ` and `dpm localnet start ` are now first-class lifecycle commands sitting between pause/resume and down/up:
+
+- `stop` gracefully stops the instance's containers (`docker compose stop`) but **keeps** them on disk. CPU and the container runtime are freed; ledger state and the containers themselves survive.
+- `start` starts a stopped instance's containers (`docker compose start`), skipping image pulls and stack recreation. If the containers have already been removed (e.g. the instance was `down`ed, or containers were pruned externally), `start` transparently falls back to a full `up` — reusing the recorded Splice version and profiles — with no extra flag or confirmation. `start` accepts `--no-wait` to skip the readiness wait.
+
+The teardown/bring-up ladder is therefore: `pause`/`resume` (freeze, RAM held) → `stop`/`start` (stop containers, kept on disk) → `down`/`up` (remove and recreate containers) → `clean` (remove data volumes and state).
+
+**Why:** `stop`/`start` fill the gap between the instant-but-RAM-heavy pause and the slow-but-clean down: they free container resources while avoiding the cost of recreating the stack on the next start. Making them standalone commands (rather than aliases) gives users the full Docker Compose lifecycle vocabulary. The intelligent `start` fallback means users never have to remember whether an instance was stopped or downed — `start` always converges to a running instance. Required for CLI ↔ Web UI parity (the UI exposes Stop and Start actions on the instance card).
+
+**Behaviour change:** because `stop`/`start` are no longer aliases, `localnet stop` no longer removes containers and `localnet start` no longer unconditionally recreates the stack. Users who relied on the old alias behaviour should use `down`/`up` explicitly.
+
+---
+
 ## `localnet creds` (new)
 
 **Proposal said:** not mentioned as a standalone command. `env` was the credential/config export surface.
diff --git a/docs/localnet-lifecycle.md b/docs/localnet-lifecycle.md
index 3324594b..17ae464a 100644
--- a/docs/localnet-lifecycle.md
+++ b/docs/localnet-lifecycle.md
@@ -79,6 +79,38 @@ canton-devkit localnet doctor --port-base 20000   # are 20000..20000+services fr
 The same control is available in the Web UI's **New instance** dialog
 under *Advanced → Fixed port base*.
 
+## Pause, stop, or tear down
+
+DevKit gives you three ways to make an instance stop doing work, each
+trading resource savings against restart cost. All three have symmetric
+"undo" commands and identical Web UI buttons on the instance detail card.
+
+| Command | What it does | Containers | Volumes/state | Resume with | Restart cost |
+| --- | --- | --- | --- | --- | --- |
+| `localnet pause` | Freezes containers in place (`docker compose pause`) | Kept, paused | Kept | `localnet resume` (alias `unpause`) | Instant — processes thaw |
+| `localnet stop` | Gracefully stops containers (`docker compose stop`) | Kept, stopped | Kept | `localnet start` | Fast — containers restart |
+| `localnet down` | Stops **and removes** containers (`docker compose down`) | Removed | Kept | `localnet up` | Slow — recreates the stack |
+
+Notes:
+
+- **Pause** holds RAM (containers still resident) but frees CPU — best
+  for a short break where you want to jump straight back in.
+- **Stop** releases both CPU and the container runtime while keeping the
+  containers on disk, so `start` skips image pulls and stack recreation.
+- **Down** frees everything except your data volumes; `up` rebuilds the
+  stack from the recorded version and profiles. `localnet start` on an
+  instance whose containers are already gone transparently falls back to
+  a full `up` for you.
+- `localnet clean` (below) is the only command that removes **data
+  volumes and registry state** — it is not part of the reversible set.
+
+**Most common choices:**
+
+- Stepping away for a few minutes → `pause` / `resume`.
+- Done for the day, want a fast start tomorrow → `stop` / `start`.
+- Freeing the machine or resetting the containers → `down` / `up`.
+- Throwing the instance away entirely → `clean`.
+
 ## Uninstall / clean up
 
 ```bash
diff --git a/docs/tests/e2e-test-milestone-1.html b/docs/tests/e2e-test-milestone-1.html
index 97f834fa..bd2b1012 100644
--- a/docs/tests/e2e-test-milestone-1.html
+++ b/docs/tests/e2e-test-milestone-1.html
@@ -307,7 +307,7 @@
 
-

E2E Test Plan — Milestone 1: LocalNet Management CLI 18 Tests

+

E2E Test Plan — Milestone 1: LocalNet Management CLI 19 Tests

Proposal: original-devkit-proposal.md, Milestone 1 Delivery: Month 3 @@ -321,7 +321,7 @@

E2E Test Plan — Milestone 1: LocalNet Management CLI - Scope. 18 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, down, restart, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised. + Scope. 19 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, start, stop, down, restart, pause, resume, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised.

@@ -374,7 +374,7 @@

Test Cases

dpm install package canton-devkit
Expected: Exit code 0.

Verify dpm localnet --help exits 0 and output matches:

-
dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
dpm localnet --help 2>&1 | grep -qE "(up|start|stop|down|restart|pause|resume|clean|status|logs|snapshot|restore)"
@@ -429,7 +429,7 @@

Test Cases

Step 2. Verify the binary runs:

./canton-devkit localnet --help
Expected: Exit code 0, output matches:
-
./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
./canton-devkit localnet --help 2>&1 | grep -qE "(up|start|stop|down|restart|pause|resume|clean|status|logs|snapshot|restore)"
@@ -476,15 +476,19 @@

Test Cases

Step 2. Check help output includes all Milestone 1 commands:

$CLI --help 2>&1 | grep -qE "up"
+$CLI --help 2>&1 | grep -qE "start"
+$CLI --help 2>&1 | grep -qE "stop"
 $CLI --help 2>&1 | grep -qE "down"
 $CLI --help 2>&1 | grep -qE "restart"
+$CLI --help 2>&1 | grep -qE "pause"
+$CLI --help 2>&1 | grep -qE "resume"
 $CLI --help 2>&1 | grep -qE "clean"
 $CLI --help 2>&1 | grep -qE "status"
 $CLI --help 2>&1 | grep -qE "logs"
 $CLI --help 2>&1 | grep -qE "snapshot"
 $CLI --help 2>&1 | grep -qE "restore"
 $CLI --help 2>&1 | grep -qE "doctor"
-
Expected: All grep commands exit 0.
+
Expected: All grep commands exit 0. start/stop are first-class lifecycle commands (no longer aliases of up/down), and pause/resume are listed too.
@@ -909,6 +913,67 @@

Test Cases

+ +
+ + + M1-STP-001 + Stop keeps containers; start restores them + Lifecycle + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All + Timeout: 300s +
+ +

stop/start are first-class lifecycle commands (they were previously aliases for down/up). stop runs docker compose stop — the containers are stopped but kept on disk — and start runs docker compose start, reusing the existing containers without recreating the stack. When the containers have already been removed (e.g. after down), start transparently falls back to a full up.

+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Stop the instance:

+
$CLI stop --name e2e-test-default
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 3. Verify containers are stopped but not removed:

+
# Containers still exist (stopped state)
+docker ps -a --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default"
+# ...but none are running
+docker ps --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS"
+
Expected: Containers present in docker ps -a, absent from docker ps.
+
+
+ +
+ +
+

Step 4. Start the instance again:

+
$CLI start --name e2e-test-default
+
Expected: Exit code 0, no image pull / stack recreate (fast compose-start path).
+

Verify readiness after start:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
Cleanup: $CLI clean --name e2e-test-default --force 2>/dev/null || true
+
+
+
@@ -1331,6 +1396,7 @@

Test Execution Summary

M1-STS-001Status shows healthy servicesStatusM1-UP-001 M1-LOG-001Logs — full and service-filteredLogsM1-UP-001 M1-RST-001Restart full + single serviceLifecycleM1-UP-001 + M1-STP-001Stop keeps containers; start restores themLifecycleM1-UP-001 M1-DWN-001Down stops instance cleanlyLifecycleM1-UP-001 M1-CLN-001Clean removes all resourcesLifecycleM1-DWN-001 M1-SNP-001Snapshot and restoreStateM1-UP-001 diff --git a/docs/tests/e2e-test-milestone-1.md b/docs/tests/e2e-test-milestone-1.md index e535ff65..a9682f26 100644 --- a/docs/tests/e2e-test-milestone-1.md +++ b/docs/tests/e2e-test-milestone-1.md @@ -2,7 +2,7 @@ > **Proposal Reference:** `original-devkit-proposal.md`, Milestone 1 (Lines 230–247) > **Estimated Delivery:** Month 3 -> **Total Tests:** 18 +> **Total Tests:** 19 > **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) --- @@ -131,8 +131,12 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true 2. Check help output includes all Milestone 1 commands: ```bash $CLI --help 2>&1 | grep -qE "up" + $CLI --help 2>&1 | grep -qE "start" + $CLI --help 2>&1 | grep -qE "stop" $CLI --help 2>&1 | grep -qE "down" $CLI --help 2>&1 | grep -qE "restart" + $CLI --help 2>&1 | grep -qE "pause" + $CLI --help 2>&1 | grep -qE "resume" $CLI --help 2>&1 | grep -qE "clean" $CLI --help 2>&1 | grep -qE "status" $CLI --help 2>&1 | grep -qE "logs" @@ -140,7 +144,7 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true $CLI --help 2>&1 | grep -qE "restore" $CLI --help 2>&1 | grep -qE "doctor" ``` - - **Expected:** All grep commands exit `0`. + - **Expected:** All grep commands exit `0`. (`start`/`stop` are first-class lifecycle commands — no longer aliases of `up`/`down` — and `pause`/`resume` are listed too.) 3. Verify no runtime dependencies required (no Go, Node, Python, Rust): ```bash @@ -443,6 +447,55 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true --- +### M1-STP-001: Stop keeps containers; start restores them + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All +**Timeout:** 300 seconds + +`stop`/`start` are first-class lifecycle commands (they were previously +aliases for `down`/`up`). `stop` runs `docker compose stop` — the +containers are stopped but **kept on disk** — and `start` runs +`docker compose start`, reusing the existing containers without +recreating the stack. When the containers have already been removed +(e.g. after `down`), `start` transparently falls back to a full `up`. + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Stop the instance: + ```bash + $CLI stop --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + +3. Verify containers are stopped but **not** removed: + ```bash + # Containers still exist (stopped state) + docker ps -a --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" + # ...but none are running + docker ps --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS" + ``` + - **Expected:** Containers present in `docker ps -a`, absent from `docker ps`. + +4. Start the instance again: + ```bash + $CLI start --name e2e-test-default + ``` + - **Expected:** Exit code `0`, no image pull / stack recreate (fast compose-start path). + - **Verify readiness after start:** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +**Cleanup:** `$CLI clean --name e2e-test-default --force 2>/dev/null || true` + +--- + ### M1-DWN-001: Down stops instance cleanly **Preconditions:** LocalNet `e2e-test-default` running. @@ -751,6 +804,7 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true | M1-STS-001 | Status shows healthy services | Status | M1-UP-001 | | M1-LOG-001 | Logs — full and service-filtered | Logs | M1-UP-001 | | M1-RST-001 | Restart full + single service | Lifecycle | M1-UP-001 | +| M1-STP-001 | Stop keeps containers; start restores them | Lifecycle | M1-UP-001 | | M1-DWN-001 | Down stops instance cleanly | Lifecycle | M1-UP-001 | | M1-CLN-001 | Clean removes all resources | Lifecycle | M1-DWN-001 | | M1-SNP-001 | Snapshot and restore | State | M1-UP-001 | @@ -781,7 +835,7 @@ The test plan assumes command syntax that differs from the actual CLI implementa | `$CLI snapshot --name X` | `$CLI snapshot --name X --to ` | `--to` is required — output path | | `$CLI restore --name X` | `$CLI restore --name X --from ` | `--from` is required — input path | | `$CLI --version` → semver | `$CDK --version` → `canton-devkit version dev` | Version is top-level, may be `dev` in local builds | -| `$CLI --help` shows `clean`, `restart` | Hidden commands; not in `--help` output | Exist and work via `--help` on each subcommand | +| `$CLI --help` shows lifecycle commands | `up`, `start`, `stop`, `down`, `restart`, `pause`, `resume`, `clean` all listed | `start`/`stop` are now standalone commands (no longer `up`/`down` aliases); `resume` has an `unpause` alias | | Docker label `canton-devkit` | `com.docker.compose.project=canton-` | Docker compose project label, not a custom label | | `$CLI down` then `$CLI clean` | `$CLI clean --force` on running instance | `down` deregisters the instance; `clean` can't find it after. Use `clean --force` directly | @@ -798,7 +852,7 @@ The test plan assumes command syntax that differs from the actual CLI implementa | ID | Result | Duration | Notes | |---|---|---|---| -| M1-INST-003 | **PASS** | <1s | Version (`dev`), help (10 visible + 2 hidden commands), Mach-O arm64 | +| M1-INST-003 | **PASS** | <1s | Version (`dev`), help lists all lifecycle commands (`up`/`start`/`stop`/`down`/`restart`/`pause`/`resume`/`clean`), Mach-O arm64 | | M1-DOC-001 | **PASS** | <2s | 0 issues, 1 warning (memory 8.84/12 GB). Exit 0. | | M1-DOC-002 | **PASS** | <2s | Exit 2 when Docker hidden from PATH. Remediation: "Install Docker Desktop for Mac" | | M1-UP-001 | **PASS** | ~2-4 min | Splice 0.6.4, cached images. Status: healthy. Docker compose project verified. | @@ -806,6 +860,7 @@ The test plan assumes command syntax that differs from the actual CLI implementa | M1-LOG-001 | **PASS** | <10s | Full logs: 308 lines. Service-filtered (`canton`): 20 lines. | | M1-ENV-001 | **PASS** | <1s | `export CANTON_*` format. Contains JWT (redacted), audience, port variables. | | M1-RST-001 | **PASS** | ~5-8 min | Full restart + single-service (`--service canton`) restart. Readiness wait is slow post-restart. | +| M1-STP-001 | **NOT RUN** | — | Added after this run (PR #201 standalone `stop`/`start`). Covered by `scripts/e2e-milestone1.sh`. | | M1-SNP-001 | **PASS*** | ~10 min | Snapshot: 78 MB .tgz. Restore + re-up works but splice re-sync can exceed 5 min (crash-consistent, not app-consistent). | | M1-DWN-001 | **PASS** | ~5s | Containers stopped, non-devkit containers unaffected. | | M1-CLN-001 | **PASS*** | ~10 min | See finding below. `clean --force` on running instance removes all resources (containers, volumes, networks). | diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 29e3cff9..f47eab45 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1300,11 +1300,11 @@ export const fetchMetricsRange = ( ); }; -// stopInstance invokes POST /api/instances/{name}/down — runs -// `docker compose down`, preserving Docker volumes and the registry -// entry (status=stopped). Synchronous on the wire: blocks until the -// server returns 204 or 5xx. -export async function stopInstance(name: string): Promise { +// downInstance invokes POST /api/instances/{name}/down — runs +// `docker compose down`, REMOVING containers and networks while +// preserving Docker volumes and the registry entry (status=stopped). +// Synchronous on the wire: blocks until the server returns 204 or 5xx. +export async function downInstance(name: string): Promise { const resp = await fetch( `/api/instances/${encodeURIComponent(name)}/down`, { method: "POST" }, @@ -1321,6 +1321,48 @@ export async function stopInstance(name: string): Promise { } } +// stopInstance invokes POST /api/instances/{name}/stop — runs +// `docker compose stop`: a graceful halt that KEEPS the containers in +// place for a fast `startInstance`. Distinct from downInstance (which +// removes them). Synchronous: 204 on success. Valid only when running +// or paused. +export async function stopInstance(name: string): Promise { + await postInstanceAction(name, "stop"); +} + +// startInstance invokes POST /api/instances/{name}/start — the +// intelligent "get it running" verb. The backend returns: +// +// - 204 — fast `docker compose start` completed (containers were +// present); the caller just refetches. +// - 202 + events_url — the containers were gone, so the backend fell +// back to a full bring-up; the caller hands events_url to the +// progress modal (same shape as resumeInstance). +// +// Returns the accepted response on 202, or null on 204. +export async function startInstance( + name: string, +): Promise { + const resp = await fetch( + `/api/instances/${encodeURIComponent(name)}/start`, + { method: "POST" }, + ); + if (!resp.ok) { + const text = await resp.text(); + let body: ApiErrorBody = { code: "UNKNOWN", error: resp.statusText }; + try { + body = JSON.parse(text); + } catch { + /* non-JSON; keep default */ + } + throw new ApiError(resp.status, body); + } + if (resp.status === 202) { + return (await resp.json()) as ResumeAcceptedResponse; + } + return null; +} + // pauseInstance / resumeInstance invoke POST /api/instances/{name}/pause // | /resume — docker compose pause/unpause. Near-instant; 204 // on success. Pause is valid only when running, resume only when paused. diff --git a/frontend/src/screens/InstanceDetail.test.tsx b/frontend/src/screens/InstanceDetail.test.tsx index 3da96e13..00ff00af 100644 --- a/frontend/src/screens/InstanceDetail.test.tsx +++ b/frontend/src/screens/InstanceDetail.test.tsx @@ -180,6 +180,105 @@ describe("InstanceDetail", () => { }); }); + it("posts to /stop (not /down) when the Stop button is clicked on a running instance", async () => { + // Gentle Stop = docker compose stop, containers kept. Distinct + // from the Down button (docker compose down, removes containers). + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/stop")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "running", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const onChanged = vi.fn(); + render( + , + ); + + const stopBtn = await screen.findByRole("button", { name: /^⏹ Stop$/ }); + fireEvent.click(stopBtn); + + await waitFor(() => { + const calls = fetchMock.mock.calls.map((c) => c[0]); + expect( + calls.some( + (u: string) => + typeof u === "string" && u.endsWith("/api/instances/demo/stop"), + ), + ).toBe(true); + // Must NOT have hit /down. + expect( + calls.some( + (u: string) => typeof u === "string" && u.endsWith("/down"), + ), + ).toBe(false); + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it("posts to /start when the Start button is clicked on a stopped instance", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/start")) { + // 204 fast-start path. + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "stopped", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const onChanged = vi.fn(); + render( + , + ); + + const startBtn = await screen.findByRole("button", { name: /start/i }); + fireEvent.click(startBtn); + + await waitFor(() => { + const calls = fetchMock.mock.calls.map((c) => c[0]); + expect( + calls.some( + (u: string) => + typeof u === "string" && u.endsWith("/api/instances/demo/start"), + ), + ).toBe(true); + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + it("re-fetches when the name prop changes", async () => { // The Dashboard hands a new name when the user switches // instances. Without the useEffect dep on `name`, the diff --git a/frontend/src/screens/InstanceDetail.tsx b/frontend/src/screens/InstanceDetail.tsx index abc593cb..2a0887ef 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react"; import { ApiError, type Instance, + downInstance, fetchInstance, pauseInstance, recreateInstance, - resumeInstance, scrubInstance, + startInstance, stopInstance, unpauseInstance, } from "../api"; @@ -46,19 +47,36 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { >({ kind: "idle" }); async function onStop() { - if (!confirm(`Stop instance ${name}? Containers will be brought down via docker compose. Data volumes are preserved.`)) { + // Gentle stop: `docker compose stop` keeps containers around for a + // fast Start. No destructive confirm needed — nothing is removed. + setStopping({ kind: "running" }); + try { + await stopInstance(name); + setStopping({ kind: "idle" }); + setRefetchTick((n) => n + 1); + onChanged?.(); + } catch (e) { + const msg = e instanceof ApiError ? e.message : "failed to stop"; + setStopping({ kind: "err", message: msg }); + setRefetchTick((n) => n + 1); + onChanged?.(); + } + } + + async function onDown() { + if (!confirm(`Tear down instance ${name}? Containers will be removed via docker compose down. Data volumes are preserved.`)) { return; } setStopping({ kind: "running" }); try { - await stopInstance(name); + await downInstance(name); setStopping({ kind: "idle" }); // Refetch our own status, then notify the parent so the // dashboard's row + ActionButton catch up too. setRefetchTick((n) => n + 1); onChanged?.(); } catch (e) { - const msg = e instanceof ApiError ? e.message : "failed to stop"; + const msg = e instanceof ApiError ? e.message : "failed to tear down"; setStopping({ kind: "err", message: msg }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -122,9 +140,11 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onStart() { setStopping({ kind: "running" }); try { - await resumeInstance(name); - // 202 — bring-up is in progress. Refresh both surfaces eagerly - // so the user sees "creating" before the dashboard's next poll. + // 204 → fast `docker compose start` done; 202 → full bring-up in + // progress (containers had been removed). Either way, refresh + // both surfaces so the user sees the transitional status before + // the dashboard's next poll. + await startInstance(name); setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -219,6 +239,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { busy={stopping.kind === "running"} onStart={onStart} onStop={onStop} + onDown={onDown} onPause={onPause} onResume={onResume} onRemove={onRemove} @@ -297,21 +318,23 @@ function DetailGrid({ instance }: { instance: Instance }) { // ActionButton dispatches the right verb(s) per instance status. // Registry status alone isn't enough — docker truth may diverge: // -// - running/paused → Pause/Resume + Recreate + Stop -// - failed/partial → Recreate + Stop + Remove (containers MAY still +// - running/paused → Pause/Resume + Recreate + Stop + Down +// - failed/partial → Recreate + Down + Remove (containers MAY still // be up even though the orchestrator gave up; // compose down no-ops cleanly if not) -// - stopped → Start + Remove +// - stopped → Start + Down + Remove // - creating/other → no button (CreatingPanel owns that surface) // -// The failed/partial Stop is labeled "Stop containers" (distinct from -// "Stop" on running) to signal a force-cleanup rather than a graceful -// shutdown of a healthy instance. +// Stop (docker compose stop) is the gentle halt — containers are kept +// so Start is fast. Down (docker compose down) removes containers; a +// following Start recreates them via up. On failed/partial, Down is +// labeled "Down containers" to signal a force-cleanup. function ActionButton({ status, busy, onStart, onStop, + onDown, onPause, onResume, onRemove, @@ -321,22 +344,34 @@ function ActionButton({ busy: boolean; onStart: () => void; onStop: () => void; + onDown: () => void; onPause: () => void; onResume: () => void; onRemove: () => void; onRecreate: () => void; }) { - if (status === "running") { + if (status === "running" || status === "paused") { return (
- + {status === "running" ? ( + + ) : ( + + )} -
- ); - } - if (status === "paused") { - return ( -
- -
); @@ -401,17 +414,17 @@ function ActionButton({ {busy ? "…" : "↻ Recreate"} + + ); +} diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 60814616..500f2ce3 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -1,6 +1,7 @@ import type { Point } from "./charts/types"; import { Sparkline } from "./charts/Sparkline"; -import { W, wMono } from "../tokens"; +import { IcArrowUp } from "./icons"; +import { W, wMono, wideCaps } from "../tokens"; // MetricCard — the 4-up strip at the top of the Metrics screen. // One headline number + a delta vs the prior window + an inline @@ -35,7 +36,7 @@ export function MetricCard({ delta, deltaPolarity = "up-is-good", sparkline, - sparklineColor = "#7CB5F7", + sparklineColor = "#8FA3EE", error, format = defaultFormat, }: MetricCardProps) { @@ -47,7 +48,7 @@ export function MetricCard({ const good = (deltaSign > 0 && deltaPolarity === "up-is-good") || (deltaSign < 0 && deltaPolarity === "down-is-good"); - deltaColor = good ? "#62E2A0" : "#F08FB5"; + deltaColor = good ? W.ok : W.err; } return ( @@ -55,29 +56,28 @@ export function MetricCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 14, display: "flex", flexDirection: "column", - gap: 6, minWidth: 0, }} > + {/* Stat label row — label left, delta chip right (>=8px apart). */}
{title} @@ -89,9 +89,18 @@ export function MetricCard({ fontSize: 11, color: deltaColor, fontWeight: 600, + display: "inline-flex", + alignItems: "center", + gap: 4, }} > - {deltaSign > 0 ? "▲" : deltaSign < 0 ? "▼" : "—"}{" "} + {deltaSign > 0 ? ( + + ) : deltaSign < 0 ? ( + + ) : ( + "—" + )} {format(Math.abs(delta))} {unit && {" " + unit}} @@ -99,7 +108,7 @@ export function MetricCard({
{error ? ( -
+
{error}
) : ( @@ -119,7 +128,7 @@ export function MetricCard({ style={{ color: W.text, fontSize: 26, - fontWeight: 700, + fontWeight: 600, lineHeight: 1.1, fontFamily: wMono, }} @@ -145,7 +154,7 @@ export function MetricCard({ )}
-
+
{sparkline ? ( ) : ( @@ -172,7 +181,7 @@ function Skeleton({ width, height, background: W.border, - borderRadius: 4, + borderRadius: 2, opacity: 0.4, }} /> diff --git a/frontend/src/components/charts/AreaChart.tsx b/frontend/src/components/charts/AreaChart.tsx index aa3089dc..238faa9a 100644 --- a/frontend/src/components/charts/AreaChart.tsx +++ b/frontend/src/components/charts/AreaChart.tsx @@ -224,7 +224,7 @@ export function AreaChart({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: 2, padding: "3px 7px", fontFamily: wMono, fontSize: 10.5, diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx index cec83615..87f6de89 100644 --- a/frontend/src/components/charts/BarChart.tsx +++ b/frontend/src/components/charts/BarChart.tsx @@ -26,7 +26,7 @@ export function BarChart({ bars, width = 320, height, - defaultColor = "#7CB5F7", + defaultColor = "#8FA3EE", format = (v) => (Math.abs(v) >= 1000 ? v.toFixed(0) : v.toFixed(1)), }: Props) { // Default height grows with bar count so dense lists don't squish. diff --git a/frontend/src/components/charts/Heatmap.tsx b/frontend/src/components/charts/Heatmap.tsx index ef1c72dd..db0909e3 100644 --- a/frontend/src/components/charts/Heatmap.tsx +++ b/frontend/src/components/charts/Heatmap.tsx @@ -35,7 +35,7 @@ export function Heatmap({ colLabels, width = 320, height = 160, - color = "#7CB5F7", + color = "#8FA3EE", }: Props) { const innerW = Math.max(1, width - PADDING.left - PADDING.right); const innerH = Math.max(1, height - PADDING.top - PADDING.bottom); diff --git a/frontend/src/components/charts/charts.test.tsx b/frontend/src/components/charts/charts.test.tsx index 0ccc04aa..1979be66 100644 --- a/frontend/src/components/charts/charts.test.tsx +++ b/frontend/src/components/charts/charts.test.tsx @@ -129,7 +129,7 @@ describe("AreaChart", () => { { it("renders empty-state message when there are no points", () => { render( , ); expect(screen.getByText(/no data in this window/i)).toBeTruthy(); @@ -167,12 +167,12 @@ describe("MultiLine", () => { series={[ { label: "median", - color: "#5BD7C5", + color: "#6480E6", points: [{ t: 1, v: 100 }, { t: 2, v: 110 }], }, { label: "p99", - color: "#F5BF55", + color: "#DDB25E", points: [{ t: 1, v: 200 }, { t: 2, v: 220 }], }, ]} @@ -237,7 +237,7 @@ describe("Heatmap", () => { describe("Sparkline", () => { it("renders an SVG with width/height even when there's no data", () => { - const { container } = render(); + const { container } = render(); const svg = container.querySelector("svg"); expect(svg).toBeTruthy(); expect(svg?.getAttribute("width")).toBe("120"); @@ -251,7 +251,7 @@ describe("Sparkline", () => { { t: 2, v: 2 }, { t: 3, v: 3 }, ]} - color="#7CB5F7" + color="#8FA3EE" />, ); const paths = container.querySelectorAll("svg path"); diff --git a/frontend/src/components/charts/types.ts b/frontend/src/components/charts/types.ts index 60ec7585..775e2730 100644 --- a/frontend/src/components/charts/types.ts +++ b/frontend/src/components/charts/types.ts @@ -73,15 +73,16 @@ export function decodePrometheusRange( })); } -// Curated chart palette. Keeps a chart with 6+ series readable — -// neighbouring lines never share the same hue family. Picked to be -// accessible on the project's dark surface tokens. +// Curated chart palette — the design system's dataviz ramp (cobalt +// first, teal second, then supporting hues). Keeps a chart with 6+ +// series readable: neighbouring lines never share the same hue +// family, and every stop is legible on the dark surface tokens. export const CHART_PALETTE = [ - "#7CB5F7", // blue - "#5BD7C5", // teal - "#C4A8F5", // purple - "#F5BF55", // amber - "#F08FB5", // pink - "#62E2A0", // green - "#E8A14E", // orange + "#6480E6", // cobalt + "#7BD2C6", // teal + "#93A7F0", // cobalt-light + "#DDB25E", // amber + "#189E8C", // teal-deep + "#7CC89A", // green + "#C8971F", // amber-deep ]; diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 00000000..874a7022 --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,178 @@ +// The single icon system for the Web UI — 16×16 stroke glyphs drawn +// with currentColor so they inherit the text color of whatever they +// sit in. Replaces the mixed emoji/unicode controls (⚡ 🔥 ⏸ ↻ …) +// that read as prototype polish. +// +// Usage: inside a Button icon slot, or standalone with +// size/style overrides. All icons are aria-hidden decoration; the +// accessible name belongs to the surrounding control. + +import type { CSSProperties, ReactNode } from "react"; + +export interface IconProps { + /** Rendered box in px. Defaults to 14 (button-slot size). */ + size?: number; + style?: CSSProperties; +} + +function I({ size = 14, style, children }: IconProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +export const IcPlay = (p: IconProps) => ( + + + +); + +export const IcPause = (p: IconProps) => ( + + + +); + +export const IcStop = (p: IconProps) => ( + + + +); + +export const IcEject = (p: IconProps) => ( + + + + +); + +export const IcRefresh = (p: IconProps) => ( + + + + +); + +export const IcCheck = (p: IconProps) => ( + + + +); + +export const IcX = (p: IconProps) => ( + + + +); + +export const IcAlert = (p: IconProps) => ( + + + + + +); + +export const IcDownload = (p: IconProps) => ( + + + + +); + +export const IcUpload = (p: IconProps) => ( + + + + +); + +export const IcArrowUp = (p: IconProps) => ( + + + +); + +export const IcArrowRight = (p: IconProps) => ( + + + +); + +export const IcChevronDown = (p: IconProps) => ( + + + +); + +export const IcChevronRight = (p: IconProps) => ( + + + +); + +export const IcPlus = (p: IconProps) => ( + + + +); + +export const IcBolt = (p: IconProps) => ( + + + +); + +export const IcFlame = (p: IconProps) => ( + + + +); + +export const IcDroplet = (p: IconProps) => ( + + + +); + +/** Status dot — the only full-radius element in the system. */ +export function Dot({ + color, + size = 6, + pulse = false, + style, +}: { + color: string; + size?: number; + pulse?: boolean; + style?: CSSProperties; +}) { + return ( + + ); +} diff --git a/frontend/src/fonts/OFL-Archivo.txt b/frontend/src/fonts/OFL-Archivo.txt new file mode 100644 index 00000000..8597481e --- /dev/null +++ b/frontend/src/fonts/OFL-Archivo.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/fonts/OFL-JetBrainsMono.txt b/frontend/src/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/frontend/src/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/fonts/archivo-italic.woff2 b/frontend/src/fonts/archivo-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..625819b55a0ecbe5fcd45fe28c076e0f3e7e1188 GIT binary patch literal 101888 zcmV)3K+C^(Pew8T0RR910geCw6951J1G0<&0ga3R0RR9100000000000000000000 z0000QpI007033>NKS)+VQj-z}U_Vn-K~#beCp-X!E-!&L5eN!_?r4FxMhk>q05FS< zNC7qiBm;>!1Rw>D6bFobTS1d^1E&&c%ENRDvs0pyZ4e!JcjF!fa{{R2elZK30f-|{tZ7GV1y6t5@A;Do3VMb&{ zDRsu&;NF+OSleY;QW#VmDr1Jju$0$rqd+e=Q3!Wj?i9lIxRN2=TZN#^1rPC{1)}a; zVA`<_TWM;$QABqL*R(MFe2ukqyjvF9L>757Qj=}nb)$d<8>UDF)URF<**rI-P)oTX z#1%qnL$uOGAtao_P9kAnDOO{2D%ESLOEyH?JBIi|Rs5tC>*b_x{`sYU=i5iSeO`2& z-G>f-KBW!6{;b9!BughqC;ZXt#WQJQ;2P2&`~R1T2Wg^wiSAhOvFYN8Ej%@B?1JNP zKuA(L~>meQ(dX?-NoI4FHtIzift51 z)~P)M{M!6?#T{JcyNKq3i{y^TBT6_b1Poe0w5Jnf#$2ZRy3}5#i*)5m{mv;Rarfbx zKL;!y21#Wr*H==pRLt#*$$ha@EENl4spQ&jCCDHPx6lXU_-5XIF#S`cE0F*tDWr?( zTDax}V29qR{r}jOZCN~{kw%D?WrD9+a0wU={0Ga;rhIhUzTZjRuALCu#20DkYx&Ob1(F!W8cz6_J`1SjFm1>q2L z$pP@^Bm4te$8*ek3!LokWRd~2WpS(3YUXM}iEZg8+^gO@&`JFD$@Fap+$L$4c5S*0 zfjdIk9s+bL0EWOoA0@tf@I-6nSN8AZ!>0o2u#^ee1RFBeSZy z`=G#*X^1Zn&h9=S{zT~0J@WuRewTpe4$!qN+ma<6Xe>yMjLgXV$!7Kc9Slwo6*Ugu zH=o%9Tms8k9w}><&?UoM?*sF_JlxYzASodth=Rl1?~DSs)RCfMZ0wTB%m9=}|B zv)S;$N7_zZjp|gpcd0NEnEXeb?~b0g|KBf5>D+&+)%%r4t>hR78jvjmWMpgtCn21 z0Z5+zIW4jFQgEw?XF`!zPu91rEv;z7(&>I%+peKB7T?l)IqFSv--w0iRbe#cpJCa6ZAX<}rrkq03?r27GHnU+k zD|AmGz6(>CG)0;Qu>@{frAtw!bmc)i17l$&vX3Q^QD4An6-p{lY7*NzYCI4Hewk-@ltP=SAc z_dMvtvI;#7EpZOs-TIk)aKd@aQ%Wup;sJ+DIFyD5uq<%8xncGq59yoWoQTabaa%Pf z%P3y)xY?D#6jP)$dcQtImpPFv$HdTr!HL{`JB-a5PA|&8YikxLs7NcA4}DMm>?`6X zLM1Ro$~>3AAU>x1Kg&7$*^ND8dpTIL$dp5owfmLEGw0jqj@;|W9wCHagfOnFHNqIz z1ixdiw4Y7Drbh&9lMo?voBT~oEo7=Xh?=N4p>un!e}-)|VgynauJuEG`zRs~E|0W& zz3l9$0~V5OboEE(dh_7u@(=tS*WVkmkS7y=JGZ)2R;0j_y=fJ zPI)(BTYUov#Wmmk^lS(+C=he7T96tO+Diq!o?tM@291`AYneL*h#3KZ6aaJxWTK#9 z0nkx{m&3;6fVBWj*~cmXgu!G3GA+ z@&#}ASbK6tZ@S1wdfXY6_j+7mDe2c2_WPMn9rPlQ7Zg~#&ZtY0Ucs^;0-@gj+h>qq zf*F7-tuz6$RCPZQoZu1N-3MPO*oWpXUSR@E`vZgAXM`UF)zXa%wQ+LRCwh$dP-JBv zE3L+iVBH^IlpUrP4I_pxAC;79`k1Nh^`nQ$j@@zMg;R}emss}kw>yIm7MNe;GP|{s zw+x6Jl=wi_ag=~}a1V%nU{1oj3Mm2>0-yl}yCDPN9sRw?=YAHj`VXSS%IL^Wn?Z9f$+pYo_ox@ zCDk;&cZraU-Z^*-Zh>Eya)H2_0D;IQ0|fFXid)%7D*?#iujW`2+mQ%B?hXh{O*$HF zfSYWG-%tlKeqi!la{#9U*X7_;?!Aoru$9SnJ!2V{;W!VRdyta;kZOWwwWFrB=XthB zfvt6X*JBD^haC;!o@Z}frU62=V+E~+om+g<3;(%iwlb3SCEvXI{M%bp)^~sZ$oGA) zalV1?{dd<_eXwbzn-Baq^*R4K)H|r@%>1D){Gxv`Mq4lJ9x?vMoHks|qso3b4Sj2* z{~IVOzyOFCX0?{Q{!st_MT(Z3hx;`BwRVKLP`;lE0FVXh6aezpZ+)V!>x=gfKA|t8 z#4*lJ^MCw|##uW@DYcqe>j(-q|Kw?dv0XdO|H(5(syWX8=~L`-9pLG=pRT^Jr8b@a z)AhKiT|F*rtv-JpzW8U4J+{C*wdJj6W?yKouz-K@1gpGs_4v*+)~YdT8l@-ho?p4Ye7e9&=6sq~*51z%cr=s~<`6=SG&&;=;`Q}Z!Vwvw4 zR-f-=7yCy(d@hUMC!`t*w?cdhbz{o?DNc?sr!BU@+EWaFY)^2H+V|ea9_{gTTDm$@%7X1 zeIH9>xPJDXS85-}y_3(q3mPgt|KfSVG@M+&@caXA)=cM%=j3vo{Gz{sm1Cmsg>|H4 zh|2X#W}mP7imw>FwX^;5`pswF!o13MPd#>>CokV0H!mA=>!*jn{pNTPUitaS&3A6U4mdeIk#BTSyUzX`q;}0a|y@Op&UwCZg`>go!|5#OjgV^!7|2{VdQa}5C;pLy2o;Jp6{;wbXqKMUppZ#|^YCVn1&;6?qKOI*37yhN+TzAC! zr~c%v4|=C^Klb07VNFq{{?TVXMmnAzfAAj-m(_dx^vC{tGFeyi`Nw|k6gv2 zaj!18v{i6+r+06c*X^-1noB#Lk-LdBD&4!mcy6E6~ zi!IN@#bFa0PL z;ifUAgdmCA!T~n{zIxb{;myv(OYNE8?at(;a|;<6YbpY`1-d6 z4zG8o|6a=wUo*Ss{H=|>({lJ*Wag3QF^vJZh9qhU(B!7emjJqtt*l`=2UA!>@=oLa z@Ff*W2fHBxaBqeVMxjsZ}$-@&y zknP2m`W44TZLiuP=ViQgMQ?InY94bvsWIo_SA^tmy2Q~L@0r|Ot>V#<2O7-K5-!u0 zs=Q^xzI&Cz1qK?Dz~eZiW1ZjfNHcX|^MLs*+gT1tTi5r^IO3Og>PtQKE-|eKeuFRr zL04nce4heyNGb3u!+buq(v(WivgBJa`IJZ8KokOXjzoeQf4mUY!$e?E8bBj(A;ZWZ z8(?Fhp(}`VPL4ea8C*Do2&vlR4MGNLj7y=yNI)tP7E+=A&S9L&p+xzbSoOVH~lO9L)Kw;h$6w3DreMurz0aMGZQjm;|d}&D`!GE z3eT+e!MKwmm|5fU$C^{ZWm<83c%}U_jk-G~u${7X1foK$?sb_jniC2lbE8r!6of?) z>H?9FNm5?@&S;Egs({F9$D1BiapGe`p3nGkgoRI~M+=XZV|4ih$*@HeG~5=lokmAZ zjEY+lrIbDYNA^80d7b7; zsG*R6^dk#PG`f&2s5+8V^O0xx-3FGkB0wTt-3%es7Rzv*gfM#i7TOKlNxPomGN1BM z-Zf<_U8XT6;mXnm^xg$Yj*%rvYym4*CyH4O9M_14&WQOajg(Rq1I@dJ9;?nE;HYD7S?$Ak~fSVo9l z2}o!xArz%QqlgZu>tT+8d$4ZE#{gjzDTF;Pwo$`2>X3>xyFvQ7S6{cmgq*vrK{QF? zIRjpe>aPO;-!o9gF!@vFB>0Y0nph`LQ|kFzDbF4 z){(kB0{{i`^mUzC5)A{tX66V&`mM40-aPjfnaEkOsSnx&>~3? zi@Z((ASm8xk=B>_0J{KS{kOEgmeGFld4Jujp7B8509QKGaoa8LW>kA*{&#^#tq25o z=FB;Uv`;0_{@l|F$(jOmw?lyW&t)NRtIUrKE-9G21hQxhy1GRt7VTZMqCgeoK^*vj z75tH~6z=_hS8J}}{i@5y9Sjs?W**npl}!y4B*gqYOOqI|+iOzCdIbpq4g+@jIvIy^ zbyLT-OheaHK!6DIU|~XrrbxtrV<}_E9I*4?zAuoeb6v-QPCb$QEm}B2*?LGuAAiHOlLBE zsDUs&Dc-+|ue?o7NUXs_T{ZyfukiY5^dRf5aLOaoSendk(ntjx1!k4pn<98&MOllO zyp77nON94~_7DtlJSn)mu-`GPX9V@4XxfhodErCLku=D7?IR{yRsy0OT+@ zg;zfjRo@9S@KABvy)c5Vq|^Rb*;-Lb;!h6ex#Q@?tyYJT!t&h5 z@FI_x>et~qQ{-8fx6>D-R?3;}Hk1d(4E?Osw>6C_T0PHAVO32g*x4jkY);+mN9;nU zJ^}V72gVZ}pPGnMs?Bpo?10!(+a{p>4SDB2faM2fCYieQG0!EVpCVKe`f&8JERmI0np*$oLr_}DYPX5v+z4zk5a-aQ#ZFHihN;BdF1 z+IWPz%ac@?g7AeG!{_2J<81@AMRii=S%pDT3uja?a6NX)JABP)R-VL*s4Zg z*oJe2gPO_Fa3oqdCS__pQck@&NNz`qBOzDv#h#gZh@mC=zK+NG+uv$j&=~9Dqn#s6 zo(K2tP5HdS(22{Fqk*-4m`*t?k1&Udvr@4u%IpD2U-M)pXiZFISKT8ii3xX5;4WTx zDm>z6QNUCz^D{*Ju_NUk^gHKG%GvSt97a*{TnKYxCOHe;1LV=RExSJ0;#0g-?2I^o z`4*P>L#PGr1T9O@RW9i-@^JS;hI=Qn-S*lIdr>_>p2_h2xj^UuM5nI zDZSUn5Fw!5m*iT;vhdq?A^5y&=V6bS{zAV1vyE#bMgt536?HxZC|Fl;i?;&MW4N@5 zy_2DHgRnT2VYy*YqX3566_5ZJ)L2Utsu%9y3ILq$xY{oJznDFO_(B4J17AWwjcZ>v zuwSJGfVY%#?V@vans%4fn9P0$jDZ8O?v~4>PVLqjEl`OvKZ%3ds$-CVSGiwpPf>v+ zeZA~2#^ull@@ddR6ui<^TzU7}Q+A-V`!SY68z2R97&Q@H3*J*f!Ml%^UBmU@fs!a9 zS3y|ND`%#2Ri?XZBst)>DqdwE;rcATV(-yjpm2Hn!|OjoHtq%=Kz`S&_b_8>`m^6e z7?+KEl$o@Lo5V3n4Y@CApghHPv=8*5;6-crc-G?lI2ry-{vd+VKY9oZ(gkLeIxg5 z@VT<2@IsSRtL5*(^aYWl1-QK0zM-aE}iM{B|I`h$d8F?2fAahicYfb3qe9ToLL^+$6_A%KHIwHAklQf*os%`9d(1w`EjXi9-R41x= zauHX2ZNHal5jc9zaH*nI#Lt0UjP;kz^^KcK^?BVi+HaKvk8}D*r)@u`gFPjXMSB<2 z(Rl2Rwjzv?m*ljc_4+u}i;L)?gPB9 zzGV|=9gMZ6H1O_c*k%m2d7Md(`eDrQrq{V#hOrqSo$A$I)?E5T@v%|uQrqtN>IyAJBFVQm z8fKl3QD@Hzp`Y%azuckBc4xY_!pJXUi;ABX+9TfdPB8Eekk0(iwdrVfsS<7BcP#9@ z^ojP$Nh^OXmtHR|`b1+5StC@_!=~kx2`A0 zWbaj;aB6Q>!;99#X&T*3tv-)5w4>L?-)^x*?&edUze*82kf2?MtfeYA6LHf@R($!f z79@hL6eOqrEIC}{$BlfUJ3oSudkzrtpQ3*iQ@r(cz!<7hf0jwN}WUJz2afNhfjuIn4i9i9$ zm_rZ}XU7zcLkii|gRTK(mKrDvrH8VuLa_Idc|5^5Q)!R@o`PFv52Qt>h}!T$%di$k zW%k^x!AoM*a)9MPDI~RVRr#Nu5Z%v0uP~;fPzXZ-?+jY81o0ry00e8O4X@JN!SVn* z0Sz5Opb*k1qF@4H4q<`D@{h%e9U^-U=%%)uXvC2d3g_encp==4#yt3d{340Ja8Lw> zNNiDyB*nCvr<(~gOEze>FgZwaL3uFwnC2uYM*MNiQv_A41Xrm~6J;t;qsEu7N)1$P ze=Igtr)b31Vqvseb4A>fdRW)f|HZ7%`kSnLS~;oKtC1Ec`0cE3Bjc~NR^HyyRNg;DNRekQ70xZc^6!hP zdJP$N&(rXb@aS(bqs@#l&IFT8bCOe><_u>!$Jcp;AMzu9%uo0!KjY{8f?xAn{_mDP z@p*(}TSWj)c&QoKn-qe8LVySc2{M#fk2Yna#1k*P@xh+}0tq6R5JCwfd^56-N(Ivg zE6)HkJ&ScJNF$vLGRY#_vxCTW-kSW`%DyiF)cU8tH^trMMJTepb#2t7>z$mEojVeC z(zXH^u;9Q$pa$Zk+w3>%LvV=AT?_Px{VUeip;HU&aG8BS^6KX(^8DfEThoNq{T{+l0$p#%_3C;Pwi( zFN#&PQ>~rQ`#_Mp-#K9bnlQj83IMQIvk(mcbc;M4M(=&OcSkrfTD5h^vI^m>Ex|dE zeqIQX54oJ@+;&&{b?H%37|FfUNAB;BijL;r6w;<(4T+B(g31Stp!Z{ApfBUbi=<~taWpg@Ky;Ufx*|%Yj z#-FnUJQ;+X|7Gi$st?;-Cq#nD_rSz2xw<)$_&yl|AB z)R0*po65UzRi#=Dh&UYFw@kST%`PG+Q47J>9*3|NwINpKA_Nacz9S@<$dPVZl~lKm z+PX{vj3rEWV8@p`Q90XsD6KnrLcQ zD&ma*Hf10J05$~wJ3t)(h<St+P@~d5e0QNpIfW3Bko&pe;ItjnP2WW8U&rbLa zoP`!T!P5tDL_z-RZr`snzdn6l|(N#=O>asGcL8)@pO{H?RXzV~)EvHcCUf<6SG zx!Yw-x!{G-`yAsvQvGE$eVFuhIK-X*>HzmWOc8WyG_#WW5q13~oOXM@3$1J>TDS7e z9&6P|2HA3}oS~EBLsN}ykDfr*r1Xdu*@_(BuXR-1f>_tnT%(=IMMRw+*-MJx62w**!zeAEYPd!*TQC+OBLp zcyLaS-rGo*zW5f%&Si1;uDAZsLj=3$<`}xK{@17afNZXaTiaYg4hYVyDxy#PkMUa{Jl>$mJ5T>;exF+1#6tVr zFVvU#$r$JK`JWCKl-WiS7hnF5-dney-GsvO`fst@%wm%nCtm;8dw=;PR?{t*Z~U_H z3QeJ}5O>3Hu-^O?@iIHE^!6|DSGjuq_qR+g|FC_8I9|kiU@x38>dGM zVZ(_-Up+@04q6-#p09pG%T2hc4Ts;Cu-mKyDEKfq?QotdOXnYACLKe zemnC9s%Vw}r#fu_6E|g%^e0sMGgRJa;?BSSDQV!Sx(>G-R{#6+{`WT4hfoS_o!e^m zJDZxAt>Ql%RM<@<7VCuH2U^TV7##!Ws{to&Nb!D{@#l?;3mdAK;rE-D<~Gde9^3qS z>L4s~*JSFYGzvG2xH+}?W2%hS*C8u#4?jq{Yr~Bf3p)MPQaav{;_-s{f86_d{%ohm zwZ+~3tfR^sZ$y`$`-|(|Rn|Z6`;7;A&Dn2%0qB5}e>^|*^?&#=rS~771IGVyw|;M{ z9B7Imc`o!PzI|8f?9>Yz#q&q9UEqfgrbMsY57xu&@V@oF6T0{A?fky8(+J;p_f~q3 zrt`Z|7yi#|mv8*LxcUL?hU*`OXNxoK{gOBT>yw{+_?7&l-+ssK53hln(oe0+ zF5N8~oc_63=j}an;F7h{Ltl6;aRZm z{qW^d(L?LB_e=j88s4-X==L3x&)d&hg4fP1{7(M32EO*li>F2&F#J9E!KO#@ zbK)ZS1wo&(Ug`5NJ^SDPW>iI<7C+o8W_HdWe`o$p^^uw^{~u#{_9mN~j%8Y#mR|}ymj>-iLJ-)JNxEa@;6>(SD95|IHl%1pBb zZ2=H2_UKjspQQ&`j2x!pO-rSS%0cE%#vJnKrFQ-t1c1_8^X+p0fX}=oaSj0x{gI=N zd>n}GY()<(WaJfBcHRBPV-T*44I;x|zt<{)b*v`dzZ!{%^vAAc-ZcQOqZ*)py#(Oa z4fMmkQFB1a83tQsjLL1@?2q;1w|YH6~1D7H2|_=7Jy`mASk7n`TDp0yQK|m z_nSUgC|s9q;no|7|Gzf7DpnE28Q>We5?X@v~J<+G% zMIUro)2SLPF){Exqdq(9^Pt3BGz=_!0zx9vu=d=#a0;f(O<}>3RjPo5x!avxqlI4g z!<}$(=f_{5U=bokjdf_c1}EQ)6qzkIVJjtlUMc_s%6|G>2@0aOt;roC(F? zj2ReV#Evx^ww#!m!;1$cAD+DV3J@+ts4$6Q#fcYWrVOdlWJ&i)rXuqdC{!*-1ryo9 zKqkZ>3*2?jfFZ*M!4Wb!L@1cB6W|Cm1PWpa2{>@4AnMhh>Ig(Wl)%FU5N!kfXV=#r zr_kkg+`PgZ|IjTcs^fN4RJ89&Hg2gxJATFmE3M__xlx62oG=;N#1<~7^{(X5=W@X> zbU`_=X8D{EMV7J~*;?cWtVm?$wiBR9>%vm}QH91Q0*>;rw6FlqnAb}t$ljI_{7_Wh zMo7F#R=7joP4ksXm|{gXSP+?Z($G9P_nRiLA2%p}ejtig>v;;jcAS*n_lvXHN)a)1 z9yJzGg!CY#tyaf*v66V1VF!J}OBAJX9J6(s(y!B8tz%TDG`+|*QeD?nOSL*7I!I9t zqS7_T6c~>&t;4vg&&*ucX`Q0tx|9iw$;|O6hwFu=5^Hn}Qj}xxLNv9jLYhzS_lZFY zJ?2!aM?|+$wDTSNP!>dw(^u1xDwx;dXylsTWyh%$2AR)!&FE30f{+5I+V``Q|-LbA}aNRSMk|H=ia z42nA9PghJWoC5W}@nu7T@{mPgdw_a9dNrCkYSmJ75J}Y0Op#Dr5tPt0o7Nl628F4L zVg4J3Wddym5|)IPM@w8CMCrLX%j)b^qbFBK8b!r~rPz)`Zc-l{Qv0vo1B&{+rJU-I zsZnKE%*1@&B?-;pCo%~OOi#$)ThxgLn5wzMKGcYuKik1g!}1Jak%zmXE07Q zcWiC^a*ev}%Q?~NlU536p709+ir6ipQ00#Wz|>mHf2U`lK{+?`2f+yM?u=()=@Z5L&wgVh} z!YmH|c}d@!;5QB*v=hbS1#orua!y!%W<}W$)3Z|iv+Ty=&z$%BFTw*QDKUqN%`O`8)3Joaz!4Mw49pLS(4#?iL)%W8WbR>= zH4Z@*V-=#P`(imxMT2+zME+!(w=v&}){>qSQAL7>J)kPsUMzd8j8rmukt}D*6_p4w zv&csL6eqtM)I{W)OL;2K(Elqq`2ks%+rB}nEpWo;o3 z6y>&^rn1jW6rMArxr3_-H^N4!tYlI;m&o$%;|h-hhoJ8INzGuPo(2R3nDz=9HS~RJ0Y+M{|QveJ=PtQ;_-+pi2z4#>(Qvr*c z?U(3kE30IhRXA`4id?Fo7C`hY{10R>2H77Y_PWTJVJoL#<@2?QR2r)RnV~!Cbc@j3 z6qR3MFwk%NwXln<(F<%AX**T}E!!04_xduVy``p20-p;K;!~`eW727xl*SvOk>|r% z*b0nRy~5V=Q;Jsbkg8RI$&{7IH0qWVm@YetrkqXxt%Y7QnSw>z0r$0o=!Z0*Xo|+$ zR-~e9Q}>a!8PY+2YUeME##of`v)?HuR3x42#MX*fa`-r%dZapVw0#@rZlr8CX1kIN zkyV&ZLL{Z*_fT|4x_o|v=!ZxW0;G3anq(|YiTpK!{71ioF$3nVrsiS7TW11%FNT+2 zXvb5Qg$9W`>R!qZHN8!*j1#I_ zZdyQ*T^7yK!;IjAQ6XlSUZQK=CU)A5R1WR@Ww5hre3;L&Vzx(%3aY`ri|DUD#H^18yiA#X3wvNR(%X??QVq}GJ`d# zSL7+;S-Lh2^Qkj7tU_MRGVGA@_3#??I>XoKu3XB1NJmEtDK(0CbnY#4#%?1z6o%_* z?GwX`SEANRz7gZMz>>*jk-iVFh`gJVNT*1YYdxf%_ibum$l5nZrzwvRs3Fbne-Ua? z42w`zU6B-21Md4;p6rI4{DR~rz@pUiN2aKv)Zek6x|7B2$Yp&GWle7PrS&X)POmfA z4em(UzlqwmuI*7n4kEZ~_w8m!_0zd82o8tRTUoz2>}1i(&OJp=g326f12^rBlfQTF zh()3@h!q2gjIks5ffY8=h%UebCOvDOtObtb>W$|W$k9f(U4;KACvPXAnT`6!{;8LXZ8obNr8PvfvQJpECcYtweDy17+ykCXNI&ewuwa5L3nO>J$b|loKX97GC#jofVMiWsm z2LC-TepJHSm(J%vki^S|x|s@F^l=!kMG%-Tws(V-*r=vm1uuCck+lJVIq~J`SgBYR z(vaj>ZYo)hX(ykI>vAC-AnGHB_o$;AHDhb2(ZBP} zoM^7sbpJSjG&R>-NTUOqRYY339c2kHCYK_#BO$kd0mIeKO|-o-pUCt>e6?Q;+qkAh z&B&?Rv!(-u{cay=|FnyxEHLHiuNH+mB8Z(FZQ+!HhCy!|yACE?cAHew_GwTo7jb5~ z6f34{dJ5shu9Bipt*SYufADeWuA?}j3k-*Qp?8e!@Q(jpR9$}i8-CWIJZm)crcFI3 z6D*=d<&emQ)bEtKR;ip`Z8ekW8bsmuMPk@#>I$?c%T{ZyNWi2ww`LiWqsy@- z@q&+(7@ERHHl}YGnn5$|N})Pb5`#4eI1-<`$vFNOe%F8E4`jq+T9ZouAWpm^+qofK zVdgRFALlN-rs+q*RtK;8Qz?LdLMcaqT>SEgL*^0>qHnmS>_L-2&pNGou>&B-eUGA) z`yrouF?B~N1D3O4iW&hPo!kulS4k4(;JM$KZ1`i3EeYL5l(CY>k%}<~p92hNKqh@` zzf^Up!SnN6ggoUest9 z8QbD^(OeHLm?B4`q%LpXo_lxQw;hh>DN_oAYz9Qs46>Qv>!emb)izPV9=*ge(8rPt zYDZ2W0v(nipG14eC1rICizuR^EkBT?Z}^F2VoRf#QV+}6sEqKBHU_(nNNpI#5c6)3 zZhdI6^c*W4U~f4N7n^4}f#F_ko=GCgI)^4@mW!n=M#HdfqR22h)D}_K0D$8?tqW?O z!9jc1=`fJC>F?Wm8_8Rv*Ie=9)_X~>uw$2j5xbPPehekpal03 z9WJ5wZh$6DVjc|X3;C4HK^PI_C#NH zaouqJdg>j8-^|5T7yE6A{F1d5lHuTqvvB=HG zyqt|9EtAbp%YLd+ewo>2QDYQ^7lcPeG0i2JdF{03^~y|b@~IwHnA<9|ff;$NC0LX7 zsY+=os~PVm(gcg+q`X>$VgmYfUL32 zY^^AG)9%gJ^SOd)CO%?!*S+pqMcsLW*Sb<&Iu709*h*@$L|G>h!1hL{wYcj#yZs9~ z{t^-I4w-?w6fTOrv-ZWG<*d6WMA)q2p!gG?70d&@46LUZu5DBjSeEzu zas~+gHYn`OBEt4=s9U1tu@V{@Kk?p>rcdbJX(;4eG0vYfWiik;NhEvGx*x!q5R)79 z0937)Cupr1ytmp>FMJyDU^gbNO`%HEDZ*R8f=V**JyEH*xB^?k3@GLQTBKAk;+2oYW~7UV|uEm39i?vC_AjnBtuYRX?E^P zruFeE1qJxKBWDFtXu}9&E)9sg!bezpX&~K|c0f2T4G@|b5wA-FVv2D_eUi`_E{*2{ zl(C1Gs-PgwR!Qbymn}4uEA{t%S#w|`!O%Oe(Chsm-{Z75=&HY`2yV(lK zJLW$E8_m^1$yKU+8eG0|D)p?C&B`0kR;POx0-}8Fxq@Ewr-9RvH z%kK8pTYO#i6O%ztrP(u_L02F*Dc{VCf8Av8aeR4U=OdY=LX4|&SAf#H?Yyc|!>@7> zvLB)_!+&l=UzLZ7o8X~FpB)89J21%q_OO3NUu~6r&jIpno*NOpnhsS6i2S$PjZeI~ zN9(|e5Z||XZ}tS8d9~K~+)>Fz2AtyS>z7UjpS=i#?j6T{GyOjj&pz2Ik6lQenHooY zI}Tp&qg`0DV6g&uRk1(s9?l&$LnjN%#zW`8Szb5w`SY5&@ok|29a^OMWw#bZ)NFyK zRpp}?fhGukR5OKO*U6o(0*O4^hSv*}3zNki6c?pZ>vd6uq4-(;E0e)5;46ySQ#*{m zn^0HH<^R2_Z|HFA2sAVSlv-1+N?GcC5VPZ}gPrtgmT>?1e$ur}`a~-(4;`HK!EtAv z09$;plEq3sVozX^%!>+0D|@cuOfz2&Lxov~1nP%H(I*5>YP#`fi0`&SZ#LIYpyMjY zzyH#{Ik71yc+=v|*;-nV$LVRPyHime>bkVRyRGD#`6}q3ZR_>_XtlmJ5=ixH1_@w@ zpTJyVcYe(xg^Bpluh_&yV?iBYmU)r~d+9iBaVX}ZWAp!c(pSszp`MOX8YH3Mb`7&H z!`pr-qi%SN@}GnBtj*B$>i1^_8KP?NR5veS>{$po z4b-n^r=-=I?55=>b`JOx!FBw?-Yr>Yqu6Ltss_70T^M zYN%=Qs}*WU+B|hUFz0mGecu^<8#XI#t7_ez7Db;hDyiv0$4es4T*bj=odTL3cn$#= z+9Qn{`ikJos*Z0`6s45yj$rMwA!Si@|F+k40C^Rd|rDYg@1{7Lh$A+am9NfO^?3cEqC>9%4W}F(h{RoS` z+;sV)QzLeJK3KSECl!5|yxeNq7ITogsyGtKECiAVeyJ?HpDiW=B!m4nrNJ+igvNCa zL`Jk;2GD4awQiUyg3rscZ~DEZil-zJW8Ql{?i?|N-E&gL?u8P2Z**7e3X7n3=ap6t z$$w=?iU(!aCx04^;vw2~X2n1h546Sp${2i$(Z^X|?5<~)80q&|3O)`YCUdU)i~jM~ zJTp#v+;VRn|47vNe3|@tet&i20r;k42!O~LT$jatixw6D} zS8Abc9WSqujPoP^9IGN!Ztih4AS}G2SSNv?y!fn8{)wTle=ZY}?%Ll3WaU|80Mp~2 z;Hv_x-3C(Fk{K8}b%1s>6TQ-63%7k8ZD0bG(;xH?-S7WxdiBjA-3=iBgot^J^2T=? z&ZD`UwQFaRdU-%=gFQAZP5;XM4_87qZIR@lpY-8k#4L&Q^2c#d6BI36cS-Gw#Az*Um4BEV)ID@(T z0fkqb-#_3F9o>=)hMQYl=dJu;rA^+4!oev-v3=3im``F(W^LLqmIv==TJTjlR^L{f zo8&|HZPrtYSv^}`a?dN4#SR%1u=Hr%f=oXr%YH)OAa~Ei9(wJNZ=Q8UM6#=Fe$0yq zYgYzZP>Kjb`SSu*L?rRKopDcLD&o^VDG;pxZUy`aGOB!swc4!@`?(jl#^N{&%C;A+;ESZAJwPQr#89>?hO`A}%fJSxCHjrgt8q za8}Y00@}ffQ`5-#)7$gZ%2E$S9mQ9Mt%S`k{iLlAlmESoqCOxBA2hjX8N%3OLd8pS z4QGe7<{hd&6v}U^c{ZLUT6cVYMt^0`x7SNwl0^@0n?5N2TUX_@N|e{vaPYksDd3gF zXKfFnBmVuPq4@GQrgsFSml{ZYQl24sPl*-{T(7kileeCbVM5f+FgBEa`l>c{J7^=3 z68V3>rmOIyLvoeKw^AZB=yC`>KS&7`kj-#p4ce+fcIyLg(x{1Ztanp?JgjGyMS>e; zWPq2L`mCbJe_Fw|TZ^eje>74Ega4hOimdq_lcYH8O!D28gk;Y4ZI*z$_ZvKi)E-%= z{!btUYz?07Z`LjWkF~06?i>^kY0`KIN&oJ2J#)H>kgVFHKqXLufULt6&%FH#oNJXK zO(9NL?1AA^Curk1%XU&|xLTh~#$2(Gqf&(nQG8czrL-2J&aS*3r7Q$!izdn69|WO4 z1Usy}h7XLaA+~Q^zoP1zS{DoSdSC~s|Dpv9B>daM^EHkCvK8IL2_*>lmza(9t^YFg z2RqKj;x!bWdi0y$-!T6cAo4x;|<@Vn<s*8iWK zeCCj{_XaTu+)Fr?G2x0dqIwe)QH4P{RrUWl-Qp#);Wq=BpE;;QE5Xdj(6Ir@MKU#N z2J6?<^Eo=gP~k7KDk7!&4g|k#cZ!SX(Sg`1(aW#n;JkTjKO!*d{*3>iLlkDeW=Qo1 z-J;?{IVihDwMdGOG8n()eiY8iJFujf47IMGZSS8hC->4i{Jy(fZPBm=4M>*+(XHXQof`ss0*3Or>Ovbq}l2qYqonvD8lfbj5;v9nA z;R`3!@#wlCC&!E5)e5F`@$5B&UwyrnCBUO%s7STEo-BZ*M=)S5daA{S%KV3#WX_#d z^UiR-GH3QxFQ0h|9%L28tF`Qm9P7B@KJrtbFSv>&xZdY2Otq!ZN+(b#93+&I~4&1YeX+AzdmbVa51Q>joB-i z`l5<`QLfS~DshEkXSs&3=CMQIyc4+#KxmINubN`U42F4xPfC+T4ASotHT=({U?!o_ z#rP?#Zl2lQ{ZweZldC&oJ{2m>?_~LEmy8j!Q4(6MMW1&F*+ZeUbqwffAJ*WLMql~I z@0Eicnj`IlmEsU>Eo|&Y?-#z;Y^g?NtQl43;<}~#&t`c9Zlzqp}0t}au#HX zcJ4FNttyzu?U(1_YKA|K)XkSa@r@^&YtECX^IYYTaSsI=?MlO{rM6(Urs(nB!iV~U zEE!&tM|D^QIKm>Lq+z+?ML|8r|J0u22Ht{AX}&c}-WaB$E15}YANq}B5{{yUFX~|Y z_S4}wcL3jK76-n#dZulnIQM?{RYv}iM4i7xwhuJsyEcxIw?EY@{u= z2cJHaNZvLf{;_!xV*sfW>`YWRdY?@ zY)#Rly+sf82cI{R-Wzo6;7cYj{dtaW27q8~g7kw3yFAF34ZMz!c){OBSXW3S0luV~^pgB|D9y{w;rJ z>$SDCAkfmf|8I=ov2qzGDD0&XpIId1KaR!nGJGt;DH(~FPsEo0=8={Q=H>S0E!WKH zkgo&a75&e|#aF@MN%rA;!?JqW-lAX9^L2X8<{#P11&7*4COo^#3#Gd-ObgDeJoXzr_M^LW)uNz(8CcvpKJ&n7`Iwlx)W4{8 z5Tt47#-p&bDl||eFYS9WpKhI#t0dmQqRdBm<1?4iK&5{AiA&iGIILFH`IPq`r3KTr zvLpE?)rXJ*I#AcTizFfo_s)L}Vc-WNOOcqndLd?_Ny~iCb>P2DVgrr*+}~VcgYiD* zaT-BSCU(7wCwPGxnu925+UX-Nzh2i7T-1>0%2p@-1QhqC@Zt!UvYAGCW0Xf}0J^8g zZZ84R!GUm(4nB&mPVd(jj|U%l=lA$1xir1^`O(1#JGaM2lIhw<$*Dc|lGHUC9~lu{ z9|eyCoTMnuqTPQfSwo=cchBi{WK706k;oUuj+HRnTlbkGE#|BJTJP_h#=_#aRD0@W=!p*LCzG|CC(>^vF9q6@70$B({G&d}pKPCsM)ci(sZ zyX|L67;vlAaxOw*&BG7EyHZU%|9_NI9)2d^)p<6K-a%l#f<*rsVJiAOa+vd z4~%p?zu-_)jF&#+yr9}{qk$WTU+K*6gJX~~YmBjn6w1|a)$-$qAL2(AFf_7qHQh|l^)PFw8;Zv}T_Dp)wRG5*v$YHPFUeU;qxDKJ43pA0srvs2*) zedFD9#k*ySGOR)x63?dc8dA$Ao18ZICTyn;9#aQ_nSvQYRj3ba-4Ol-yEq9Dd6K(F zs1>kGu<_6OjaS)3jL+0GJKvgrvLNRFzruyiX~Cf!2fmlv;sOSq$^AZe;@5+%aiDIQ zT0r>33g3U`wMrBi;^8ZVe@6-~9f_C6)4W6hM-^n^Z5v$g+ia8@c%+z>TG zFokE5sQ*;y`!FN*=i~P?auo_j{@$k4`@a#)l*@CNukO%f+@j|z6!hE&%08U^eJD#I z&trYRN~DcyRk<3CDxSSFYuOg!DECI-KT`fKqV(^L{j;-_rd!7>$b8_PKQNLJ&mx#T zPE0)bG*Fwg}= zI8DB}Q~CI0!Tm0D&{bBu?#Zg)>bxc*-8jes(_sX}Gr>1Hf~dm}5Rwn7PuYk~KAd;#_tWENF`=`B-&X_l(eAmF za_BAaL+?i?IrXoJKF(MyPjTu`i9Z67o1AsuI4huWRaf@$>5~01zqCHT(pt}y?2G%Y zPXeZIumm+toyb)d7x?i!RZ;(&*?9}DHs60 z(JMVMzVarycjokdW`N?^dd=e8aJXyWzGi<*L|3`pKy34@K6vwMBFNwNOe1)`k^?zQ z=i|~x_H*j4S-^l^gUbtZ%ieFYvyQR=#);~;E37+iOEFiMypu*Nof8pX{DN_Ns$^?p18N~I&E3zHNe4Ja9I`W?O;N~BZ6p^cmpO%43t(g8&Qe?Rb8iY%v)l2Xfg2l1 z_Iu5r#eFvayQSsXH^E!lf+ObYb!(G(r7w58I?Lqd;cjN(x=&Ljiw-u%_Q*+Pb%2g- zczEZ>7{P-%GNm>wmlnDya?_}=#Ow<%YlPg~x3DLsQ#Ouc^G*+1p;(j5Q}?q2*j#x| zr228r=EC=QZC)m|JXPX-*&Y>#n>Ap=m5dV^@tZU+oQtFTKH9!~c!3R0pfq#np3@Jv zUMjVbw0VFrEx&)o6SLYi`8fygqFWPsF^NekN*?^F0UqOtxV&~A=l}W)<&@8 zf`j|48})`(34{Nf15XlGal*Qfr05FRC>&XZ^lFauj|c+whUC;a@fCgM$~V)SQ%{OS zo1Z!pfK%v7(TC1#O8uHdcX4D_zE3_iJAK^vD+9*LR}-63Ul51~o;l->%MESr`HaVY z>Du}y+Q%;Q*>8VZ2ZU|zgt@l%Xd*WkaN=H@b0pJq_Pg`clIkdp7^`#JLt=i^8W&x& z6Ik^Af^%ky_rr^d%@}?}#fycu?1{uXIq~-#~d)dIiYK4dV%E#?s59JUu7 zs#k3fSO9n6q&7)bv!x&jmemYImQ9R&&q({SVC!`z#z@$D&?kI@?dYevw8Hf2T*F?is6R zAgqVmKX|{t8!1E_9zC`F%0!(jpiFMm*f?l5H;f3kk+FfA1a zM|}=kWccg@=Rf#J;W2u!Q$0ym>-j>B{?y89@jvc=r~Y6MgUH09?gWm|d_*Tu_)es4 z{qQlO<Sx@Esond_n;V0Px#T`{0IuA@J%10E!=?51A=_1N0E=eZ(k~b!hLf=)fkz zqa+B~8uBxg&Qv2bJLwek3T9&#KbD)U``J3#`(^IrOy^p}?au4NA0+fac8lCPg$I@1 zS8-EKr-pGY3T?O!FFodZqihPZ+1Mc8aDq`L{T9^$>aI}#vxPm2cD8tHO{Zx-OY53e zE^F<=Ha?~89=kqtU{OcMIuCW`8a)TO{GO{jyS24}JKQ_Uqsr5l{QrhG@B46*Puu(U zEID1aTA!?Q zatq8YSGb}qM`gPjue$GO9JJ^KO}i}ZwEPJx|7DHehWj=NXPaF&?Ni!++o4O^>vRn2 zddlgbvqyA)qs#xtm4D;v-?;JJ?(7=S4S&-86AypH6U2+xyn4v%x4m_JyyVj#`SJwc zf9Xfh?|1rly9v6{d>m->!fYT_XlG%s;q1megSQ?3PKC-W5`9b?sLYsCbpM6FTs>|c ztiJwO7>F4jWlUg}$6`0j%F{||UZ}M-7iv?G-`S;&14jyH78ftqMsD5Q2kIfsCCY2{ zd@%p7mupwTcTM0yVK0cfTWmuU__<%=3zCA8tEKdr7VC6Mem2u6wYh2BqhuDGd3vWa zPb&IQ`H0Glsk7jB7JsJsi!Oa4%We5nHnl!w*)J>~vf^PYzur|(uy#k+?~1=0 zf05U0Dt7Y|dBc{pHoNVAv7^S$b-Sc?TkU~oZ}-u1!2!8LYKL1LdBxFuk9{Y1wXf=! z)v0dRt}Ht7BWHg1xF{Y=#uz;=EUVMLeGC9kqTPRdNW`cBAP~|(!5}x$f*ZnPy~{=W zrOsbt%F7F10gsfoz-!A>@CG@6N9754NBKGo`QP1^sW$bd+q9Z`Q*Y`G{Y4R49{+in+%juWwO~}%J0r|7`mr~sShDag&-K=Sr z=YSB2qBN?%i2$qgXD^Qg!m${VDI^(a{r@k<-KtaO`JVXB1X)|xWXR$PxMHI(4AAQO z9z+2|^`1u{aHhe6Vs5r!zQK(zDxi>{BU)fzaWri-%DGfJ9#w)tpk?8G7;0ogb7c+= zbxf0*O50Vqx;OWNyfxkZG)QTzWh9etcRPaT%1)ZKC$+OkaZgz7DNJG(p2sk@P? zbyKJQ#V>T7+SSEvnr=t0E?zbd0r5@ylf3$*9O8Q;S{J7hKT`Imd-Z(zK;MdTT^!q{ z?TOzAv=07WAN0DN=FuYW#&L4o zBs5wy+EU?_OBhP$iIf|xbiU?PqpI0*H$Aq*h|)rw&~JkvR82#7(|kHfR01{HpN8dj z9b*JV$N>*cG&!egSDSnTl5>8Ag2ABIYh^sfv?)tngE&ly>Ts>m{y%C6v1GZl&Xk-2 zNkL{!(+TSIYiuP`<3cRRo(G=NMGW2zzVn3;&G=}x6XZJ1!1~4j9;uz_ZL9L zWlXQEDqz%n9z=)8@r%{!?l-1tY>8K6{RMi^6O|pcA%D%m@_WUOx7F)ARojPazF&$~ zZJZd#c-naNP9J8j>*dwP`>Ij$E1W}M9@5iQRo&^8D!=oy{EFH*F}D7+@#>u_U+;%a zYE`3T;O!8sO=MTsBV9T3ffK&L9{;DZZ(m9@+F&jnL*`D00L(3TlXQ{pGtppWHq2^97N^B+5%$(6IBw}BusFMQ_z z%F+-%pNmg(juPa9FSs$G`vf<(j(4@TG&gVC%Ns|s9W{X}PxQc$m1IpPvcNMm=c#eq zJ=yc<(y6^Kn$@Am@8Wzyx$qZGb{gV?5!#Flf+Vs^3>P?-sjHYMs%BWPnryo5o-Cc* zFO3aNZ$(SF`e|^+n5BPw#`-|Ak1QFn_{tA0od@)iou!P@5m#HAhLo|6JXq2 z$cy7&cnMURVMO}TDb14V+N}jNiAhaI+BOCEsxJ$-RTN(bY3=zcP|@|l*dOkU_1yT| zu^JgQ1Gp99x%HO;WSY#5q)D(!?@VgAqnDse>JZSX1;DN@>R&h+BRGVmNzBt#gqovu z&js|B#LNJ%E@kYtN|#oEkabFjz;1g4C3zt@dO8~)`;rGKunq>t>Nb`(t=6KL4wfVt zjs-ubMUWw<8xku}42MRCOih8-7@MOckYkiPpYxo3(f#bA_laCiZWHy#JD17gqL8ue z@St)pg!#>KvXT>$U<)CKCoZOavjMf;UmHf2h=!edg@|!VUr$4Y%8*oK8TRP{wZ?3J zDnOY6XAFiziBSx~CceR-x*LU=Hnw{1r$#hfTuJsxP~nqPJg)z*M!~$Q`z7(XA6rIq zGchU(>57snsok5b*5-2A7e`uC#oJT}A^xQ~SKj#OORZ@_FYz#2ZGs$z#RF0T9l&WoQMX@oj~|}qp&X-(~I86995DUqZ(+DR!}&V zPgb%XM55z%s;GBnc4Lk7zC??+*f1p((no=+iirk|FP)}d6paQ6pz$kOXk*jTfP@1y zFf<`vEJdcfsWfZfHox)v{m&g=yZg8B=kQ-eK1|(6yxXM@=AAKWns!Vh2cXq-f`{ib z_H+0zHuvit?ciUz&32`x-Q6Cy6&!5UTzZPP+mqS7g4p^)>6=rWKW0fbPdrltEeC`6 z;a`tECVbuhPKV?An>hHgTsNt#0R-_IL`(+o)suufb**oFPCIWMZZw3Qv{p7CS-vj( z;u(ix`(BMDKA}v6b9{d^QQ7V;0+0ycI0dzm_@(Q*1Onf46|Pq)4ZSaz7%2W`1EQ7z}qWFi_o^DW?QbWe`ztGP|XeQBeC2F|~2$z6{< zPq}5$F}9B8f3bmLSG4c(mhVNa;hl2Nm6^Fz@N)FGBZ*AIfK&BRlnh`u`#>Yb?vI!Q? zFG_F;NeqU-ov7h?A-F_B=jb({NG4S&=dUfa1p=W+ED=b}-T;^hsyZ{$&MF2|E@lM6 zudLzSMzz|Ozb?=75;djwwKYF?w$yv-lnTp}b;tk&y<;>69}3FydF(Xt?`fI@DWB^* zi*?5-E(aB3DqNh{vL%s7BrMA+Le&!TV?gbuF$$?Uq<>S}KAF>r@rT{B{xUYMRW69u0BAm2EQ3K#)Ww3P1S?aQl9n&xZ|>uqCgNZ#Qo{ceK9HTYYy`0U*WoCPJx&%y9PU0 zu3pgrZs&>#N@u4*-ssS^Y?C*%r_6Xu`+N+t?q!PqBf@5~S); zI?tsn<4ovlBhN@ui6+N8vxM$ial$D^2IM$Y32!LrvuR_E1*FupE6y>K`oDR@sv%G$ zRkdtN=5>AE7f37fzHyp}(y`6&0vT{`9&BzA3x>|(ITDz2F&k&V*6fmX%xuKhvKM^^ zD(}GXq(V9)Ue7Ow*A+uo zk+yIg>EP4NcOKm70VkN)YjHu8O58B(KK9*~gkvb{uFmR)RT5-EI|Umgrb(DZ(xS*a zRF{+R-dE-4^w2iETjeC2viKAs)|_pgQGDOgRmB%R_yT`ghtC-&8L0@Xc)hI9-q+2( zJ>4+?!0E`GW|qxomF_u!*6A9$$M=qpsc<5OV3H|r8<%(=QQf-|DV0qgn1af#S#cAQ z3o_FJ<@E}FD9_tPC~SI=Y*E1Dk(4lRL7JkY#q)tMZ0x?Cw3%znSQOOdd5F`>S|v~N zYt%Tz+_hJZrAW;}Kiju%hy4_!xz%Y*f*>MctSM5NS!6H_7od#xIA*r0%3TucfqRn7R$<< z&WS~>qYqn^c4c}T51|(X(6Km|8|6uw=Z-C#Kmy^bMX+e}(3C0ETr$lX^16s}(_9Er zGEr&3U>VWidbpM)bni^MTK!>Xnb#~vBK!4_=C#red57EU2^2}H8ZU!m(B6bUbjWV9 z6^g7rrRc1UQb?X9Hw4)R5H0n z9Vi`NBBL{m^W*P1zxJT~sU7rTV$ht)78U&G>RwB*f~&iB5U@^B>(06+P6TqB*rI!m z!lqk6{L@Zk;N%Gv4eY^L^#hU*TXoLuN$*iLllK!fmLi$e=K&{;4Np}qq%|@bQ>vm5 zGg+;~0x0m)j>-W|ZxY+OVE=`3=_{4nJTY3W^x?L&GtSjD&8Z{<0>>9VgM0#i27Xj2 z9U*|x=i)6yTJqCmivbbWEz=#-a3Al;J%Gmutg(~dsVku|xiqPcnFE)oYmqt5|EmTP z-rbnm^V5!+0YF%r;R^rr^-cq}wxm?VAec1gMyD8!vY;ltE90Ub{XI?_omD?gE@!~< zRwL}-oo~I#cmHx#BI5_ausJs@`#jDwg~$RLQPm(eOMP%Jjc;%yn5HU*8)MmEVa^(9=rwaix_yT&U11n{q`;q8=9y0 zTZZlLUhUzS=<=cV$hz^U-wCxZUL@yt0xL+&c&Dd-6h=T%Zu_A~mc9LArCpEboyEHX zM#TYoHTcJqDmLYFW+J`=&CI+0$7fjl?7I4k|32zS94KeG;hijw!fv%0GFDLogZ#vjm7ml^C#BEx=CD2 z^0SM^G{wEVU*DiESZj(M^P(|rRD(X?k-X3vTUgwRg6X?-n}G!}EE!f*y}OQ2=UzS8 zkf?h}KWfV&TvY)TzgL0>TisB3_wIO~FB4bf5!a&+AQ>N)@QE)OMdsLPa_omiDz4nE zR;HSGo}RZ-)2(z_f4ot$w=P5;q0;^kDC5ISPAO?TZi(}C^i>_sI40|Hacf(L-Ad2e zr$gwuu5D=wk2q;H!3}_;esT@IJoOSW?qV8;%BRLdM5C9FsH8ekQBl#K2tevA6RT!2 z`I`46wUAIXFLZk8xXp)~-vtzV;i2CB+cXPq7+O70?V)OT)xmVr>qf0S0Oe3~d$dY~d|LjB~^QLi(`IOFRClq3cqv4ND7 zKp+sfp)Z6TQycY{yIao9jV~o+41BQ5dfxw6zCcmsr~9{zgZ$|vY6r$6S?Gzf8oa}9 zHysq)4@cp%$7W^Q(}hLsx&`gY zmQnc+`(gP1^@q8^Q75KSzuShTtYs(Uymi+FPizvMSGrA?6hPE=|970FO>OB)jzZ}+ zUBXFIeS+0}zaoqP7rTB@)(JR1`B-V0Qr5C(asvIFzAi$mbeqN><4LpUIh(Cqv#Z@k={3@N2C=3RkTQHn^=pIXkMod*>{{@+uc$t?tl>> zjs+ehg}ZGi&*ZPj#O8Xz{6)CXthomLtbM|0_V_nnGS)tX3pvdr2u-oNh;|dLnlw#g zfnP{rI+UhSs%h$!d0`Y{D7Tezmw~{U&qpC9j7{S`aZQAo) z+YLD~Pn`QTg2BCegoZWhD3{9@N=Pq{XP>UtSJ=nw&^x5gCINT^;SI8qiokgsaLct= z)FYaDHeU;tU2$l%ZJrHUBWCvSN9%qtEpdyXizr9TAF!KlVnaRY4zcwr9#7h4dj5zS z)iq6L!yxGj9^TmYbMbuQDp+)g3}Be5*A8T-R%Jp~(9big`os~4p(yLDPHUFh(CN=m zC-i8V?n}owiDhkm9C?>zrS*T4b2SUuit?nA>X7zN!&D(Czfj;N7>8PKS-A z1WEE9S>iO+Mdn$~U8ndr>fcCMotg-Xh0M4v&2v{uDwu9b^^L6T=(O8Kr?m8PVZy@} zX?X>^Aq2{KEJS7JJf`&5& zY?v#GoQPX%b&1Zn2&xW#v{|7m5OIc-a+#)$A@l>UXS!=e9^ONVY^y}d#LTC`8e~=3 z$MT&gE6;_x-p{~&$)9{6m64laXDEN`AHl#&8x;uwYMgUSCZwi}M7P1&YKN&MYO7fexHSb=|{DP{t||HcmEGA zuNPGSl-1ZyWy0!=waukKvooG0YO9O2t?aQdayws+VkC9cyC{dMZi#K0%8PbVY}}ya z547@(w<6o=F*Gd+s?kRf-UA|tq?`F*dXq}H55ONiYn>G_L6o!iA_iR0J6b;GnQ8Kk zCA$0NTEP8)2_VQOATvlTf2;Fds}J=r9RBjJV2_@tqs9Sh)_rC9uSRdzz9w@G`wRts z3c^{;I8_>+LS$Y@w4)VHz0Jy*f5V*hvE^uwwe&_4|Cc63SnK&`7al!a1X8dP;KYAL*x&o4F zOfYPo@yKWY_~Nxt4HS@6Q-Se3y9%LHLxAzyjPBWa+_$rGrhue2{29+P z&J5*ifAi-(3P@^0pRz4u-Dj#*Zr7UtNNU5L(yff?r+YZX4!-+s&W8L*?QOA1+;yJm ze7j)$2g4DJ6L-2rp6sgsTgIk$gHTV@Wjxt4+wO&LQD5??jc^&Fv${pS!Bt%Tt$LD8 zH%Vam{1mJ0JV8D2mGNW?yG_o(DXAyXGDNk$aZ7dM<{FpcDX#6>+`>7kC&>k3{_69! z@$W0EU%VB6>w`oFHgK#C_Aw_=0vVsz{y%Wu)IL z`xPZLEzXb?@o-9w8Wb<&$C)FFYWcop=_e|d&(JM+8I@gVx706cWnHKLI+(lGksLM+!J{=#whHK6E&bg8^k~?=|rT* zPSMRnLz?!T!rw&cQh+f4MUC>ByFeIEV#0zS@HcM3g40|_VR~ zrOIVUM>JD=14QL#_D}h}QhXWQSI)gmcQO6Qn>yeLz9Y4??Dw;uVuJUv;Rv1a+oWie zaPHIutt)zRa-HcE&v$4k9+csjIlSxOkI>JoxwT^gGkHP7mpS=~utt$58~u zJkHth6W{*E(X~02c$U;0(J>vZ_ORw*d+3pfFF^p(YS2q0`M0y=J9SADwQIquHhEjQg(U$?FzN7Yt2t?ew!hnb`e zl!ZJq$&4gWc&|n`4S{@&94m^TJnAyKDR}kal?#8ku=~g4{`ZQ=vmC<;{NJZi{!kzi zi^Wsq%Q^`4_1fqf82Bc_IJ;yP%=AupArlJuiV?x*Mj%Rx}UZXs{4lg?vWjF=) z!zB-z5--viGGMMJ7qQ`pz;Rl^dD-BlILWgt&GNDbHzq0hnaV4!YiO1$khZurRW&FE z3r6?uq~bigixYWlH$mh(uQY3t$XWx`kQB?7d0y1c1y|)0)YIN|LU5Q${z0QEdY-K6 ztcZ_}&qDXf0kzUql4p#gV_e=w{5$~92QSbmq<8U}hzcBq^}4Owsq{O_UCer`jC_Pk zh2&o#xIYUA+f)Vecvr_@0^i-Zyzx+auM>CK>Ut@_PR?l5X0zE08!X`upWPnDaTxvO z&3BSt{nJ+t^gQ1uHHB}R8hBR7!~#BF$Wt@Mf`d`lj*bRi0R8_Y*2MbfC`MFt%d%7I zU!Gaj^+w~#WIDT{Nf;&yVE>&+lpN$9O3PglaUnWVs-(aEz)b=AFA!iepH>js& zGm3^twmgam?x~An91OfZ9SKab6cyH%B(FT7Q?|kTxLjZ8Oakpf&xKxU9Y4#A&Htis zWs~95&v4`)vzi}-S&6-84Fe8dS#R;e+oMu3Z({}H9v z%=TbiPD`#X9P!?5DfRxw;Bah2@ci(0KJjK8crJvl4=#zDR}vDvTP+W$OUU%J$m8@p z8*$w&TW&;>6iF*8lh;VWyWrpoar2-1O=o@T7q8!7;6h0#9(@+zoV>}Y*4EbMGeu_n z&VEWtcp;p?3-R7)6ol;v0-HbxJFeMMwa^Q~*$8dEbN*x6w0Z5_BhIHsiv@#0j>QBE zoYE<<-5Jm$q8OglFMF}WY%*v!s00vrLZ5Z`v`qy~;{k)OFk9SyA_lfQ1G+{O!_fK# zev`}Y{HmZ@42US1xp@TmSxE5*wGE61h1>gG76)n_fesVSM(ln;YmJ&lSJfm*b!-^) zy{dAoSg}6fIRhOhE|J_{91gj`Y|;ny)57|F3}a=oRw>_?}mNY|Y#-)IZL!`ekSp%0l7i$`N8dzWf%lNd{JQ(%0a-;#72xgMr zpr+q1b2r8JuX&o~1poMO4r)HITfsE z&kdu+!0mAcbz{al)6KTg*mS9&+IW2S9`k9YeX?9dtZudHoEN;iEyJzR={qZvR0hS$ zm(3wvMN1Oj)YSU0`=17Ab#V_358uc6qG=rf2zE_7d_FmdojN$?>BojU%bvzMPSAFI z>I8-Km|(K9fEAT((0NS%DuSe;YL6~t+bsV{4jFN5Wu)eu)D zNt)Ng3@E}1R0+<0fR^Jrwr7bSyOn34#6&^r=Bg5!AOK^k9ZTmzP*`nMsbXnz&PHIv zxQmg{cg=*x{U?H`AUAY!>#}Bln4mqQ8)5DB^_6v26WC9` zW}C~v)Th~sv=-!A+Thb<8pC;Km1$9xR8ttj{K9+oukY{gKQoH$h|+lv!iCy{|KJE+ z$H#yb^#BdY)s$q{Qcajh=)`UjFX{mr3X}=3&LS&Op;$&`jCvqC>7@7w%H|7R*K0xJ z?T=2&Dz=CER;)iTE`W`6C>r)s>?m(@o{~g`IO>7u&r-3Y(9KA9>=o>&2MU6FxTyU8 z!<&W%2Q?{nBF|>qi1u__8O6t?A76FG@c4%>X8@hf;*}D*Nkx*Lk2RWjO*radmJpDr z1Ud^oqq9>-Ktxc_>OgpaIuhS5NAdAX&b74r=SW;_9PR-c%ABb((_XhJt& zF(uKoU~3HtH!N8t)#xl;yHx%M?S!U!Xcx+vrOIlj)9|zu8;=e~hy#I)q|*x2!z@Xs z`0(k0{GOa7ar|z@B6yx;?54M>PCX_cg*$s^4s`eR_MRQcwu(Q>?!FX# z5B~kb3mQAyT#Y)qRdKO9pFBA8mk)oth`|%UppI@uvpLN|e-l;@vU?{r?)wOJwBmF@ zi`IX^22St^-oA<#ofd(Elh8xDzd|}_H29bCYW8@+=`mbM9d(rc6cx{o3sO>A)TpEM zXGo851nz~m;Q}0nV{k7VM)rJj-ZT`JG0SrT&l%{m$7*_}#mrUwp>OcT<`fH6u-Lq; z+N_(p*O1w zD%CO?IF@835=Up{UlLp8>?baYu!EVde7P z8LjSgLs4|--m@G@lW|D`HMQ`lc9F+SYZVJE`|95`WY#m%KJjha_s?}FHlOVj)Ajj= zb=f{znG{j6P`wvCFG_%PZyOSYv)nz0?562birr91h)T6gZ;qW?#X=t~5}zgaSc93U zBivie!6w)Q_rtxg88*Xa*j(nI=vUmm>Uq?0bo1^{9$fk4hnA0}lvi8%$Yq*dVf4+T z$Y{_*mnwH&f)-=l^nTCFkJ&jjrs}G_qyzNoq|T3lKm;VcCiC};-D4W>b(RQs(7g2$ z*}ko7iV$+{>BY0vQ(KIBnpE?L02x88-0@5bt5W{v-q;p)QL;XPuVm243s0)6xZ~m? zEAv*FTRR>tO+q8)QZ8p42Ym9+!TvynVh0De`>&z@uI|C<_l5qqy4&jC82ZoZB@vCV z_jIFkpJ%)-AST^{40mgu(JgCX=-DPpLmCI40cihEeGRSsCj%-yC#H|VBNci#GN2e;0WjxMKKjW{%eWfU^qYVH_HggNcs=HaBe zj~1A-XY^huaMeHm%^!BTpTBqT45VbA3{a;V$2Pf|Q?j|W zKBj}}$iso@5V+-FsDzTI2c4yPMG{3p0ey=RE}IJOF#yVGf@?qsq;)=YfIPS!@!{Lf ztdacZx!?|8VtdKc9D_2^!f7*CwyEiAZLc+uaQ5f@X(8rCFT{IGh&ctNPtkN4W9h69 z$*&W=Z9~ONemYOY(#LzhYSxjr`OV^rjm}B!VIDN|7Kq8N6pcuH>Yv$7NXKc?#;n#j z7ebZU4y~(%uLKodPfD#00r;Kq-l`~#wTPp&)98B`0}hZ5+;$<`K{-I7oLPT)u74Uy z&cJxteDjxw$BVA@m|K1J>p%Cfes3%S%h4VCnT9dve8_2kZ7q;#k!^Qmv4bd5tId(D zXX+A5;*^JukE*U87ZViVuRJ;q3@!(1)o4HQC2i`#*5BOTi3*I9wJC5ODQq|+oiZTy z_@#|{;ma*fp-(n!vgYPlttKn0EVHPN^)uB)C$_;K4tr1MVw6;q&dXr3Tr?X^Kgrv< z7Wjv(-pGf0ots#de}_E8#%$i~BQ;t$ zIH`nXUxWu_4n*_$rY!!rwCH!AbS43Dv3|y;*_yQ`qR0?LTjb7Ue&h3bco&r8JRYjK z#Oa>GBP>?U`oR&XDM<;@<06VO1H8$uElVH`FHUPI3ru3AW<{;yIQ;(GjS%y+;hp;h zu3ZpKjtq7UYj1fdoYg)qL$ZL9c^b00V{FHN=sk*M9Q`GtVBh7qkr}p}6cpOH9S*Mw zDT+PM#;N~DdX_hqhNhw$+Ac;<*ejnJ?r1a%6=7NRd94Kl)WsCP94GGY8IuTR`!HEh z;4<%dU9dCUv7rpsALwtTw#OB?tYlhof5x=US`0Y z)jtc{LAF!s0d3RqP3JblS}z2XV+J?;;_GlLeJuG6D5zu@u6b5b9zj@;H6CgUxD~{c|bD7Zr&$?UFoy)vNAEkGjdoB z=~!I40I-$5UnW^GW^{b{oh*@6J0S739Ml@^-bfg{Iu@QS=n*w$Q{gBi{+ZEHlT0ju z3Zoj@t_I1}HIIPG-#@0Og#b}NuD_t%AxO$?7}*XvI|>_dUjFsxvRZ6_nv@sA9Km90 z!vHuuD&(x1`YKv5B-(>%-*vPO>CkFgkjMCY0X!-R*jT()m_bU1sgO z7QPXAciX|6eQ4M*hV+!S=%r;6_FwO-F0=Msi_+&H?`ZWNQYs9aEeTy%_+Z@w5LMNW zl}5}puZ>XNtA#_9KrMr%n0wn|>-0TI)}H8=!Mc*{9Cd76z7hE!)R0dS#6cKmS!rD@=xxxR zouw9WZI{N1`E6hCWV!( zeQfW^!BxrpLDb))Hf3G@Nsgg#9A}B&6~B7ZxL@sUIbK};QFtim7+yt2BwX67A$UZ> zBpa1w0UH*7n!?AWo5^=PB4XQte7<#B&}aPND2H+A!gy>F=S46cc(M?Y$B$l4Nwj0i z(^|?+sHBzS941~m?%)}pYOMAY#S!>NdXit(mzAoHRppH2MOP%8| zPfw*i4^TR$>#?la5ru;+C9snL^p8#-8*%{$5(ou@QRZPDA&TygRRF*<)32U9c<`yJklXSGM!EvaYp8}B1a5Uk zrenECGhE0rHxf=0r!prBLi^MFlNtg@sKi8RKm4r^2;jM=x zWO`#ocvb33y7Q4&HLZemgh@8D>!LB_(>js$5CFAJw^>rEHZY3lj9SV-@@FFmt2A9& zIw#WIxH_sXk6w>TDjT|vE-`c1DPAJH#lYI;c#pTJ&t0cvrx>i3BkpwL_c6Q6dYnU| zP;$zJ#=e(+xr3A>Gwe^2+p-Sab@A5v#=EZtMN%CXY3T-(pet(agD6Ap1i6S&4XuVF z;JTpI(;$tvkp+7>My`8HidJ(M7(oRX)rLe~@K&T=!zs|sSG4!%4K@ZwP-%fEmr7Cu zWN92l3CXhai*u4XV;#5a9#L|~7wTpS4(0@+(E+5)A4RQ=XjtZ()>~mfN-Z20NlHv4 zUc^8-+qBeVqx7k1o1ns@i**y@iQv{N;8EW^G0H|pe=P`Q%oS==Z(rg~Nz)ynq{Z;% zXC5Dpq&9l~CY8d&<}!^@=r}rsCapvd^?@6kR!HKba@9e!!84V4SkZ39>U3tkgJiKW z^2JSiN;v5wk%Qq(2PWLuu^faUP&L|#<{t7K z=8=w++N>{Eq{&@E>T-u%-GTdsWDo3*X3msSoS#D-RCvaT=*R_0Z-~;$Q zP|Z>G`U=iex%C)Z@&Z2fCvhX*MBuZV!3?`f?ePGDd5m#SUVm;ob|1Df$ zK?BY(IKz}!i#O_BEzf`mXPUUYYX(-Zf`6pn2_SdX9I?JRy>4h))*p<9*m?ZbtIK`t zzcOzO)Bv#E%6BOurlYAvvylqfKH4QK>(V(+9fZ9knG8=_NAm75u=F*RitYjOGtokB zP=-O3PLbtwXFMg>D)12d ze3n4a&i*;neKh!_QzPf=1RT;L#zf`T436t0VO(c8_7-jxI>L>m?Ru$UmbLQrOgLIi zCU$7$Z%nIP5;aNEhJBQCKu3aOuW^~5hZ9fQ!I;?kqAvS~tgDey)9d>ZUnqqk$l#+W zQo0B}U!a-h%qbXimF=W8hWcgyt+C=KMq!MyP-iimU}!>cV$$*!OISYF3NS~_b6|?E z@uS<4u61PIR_|N;%7qhf+>?+fl?@Cjh95tV~`$!OBS)td@5TBql&B;;om#BwKxkE1wN% z1XL9D?0yES92R=T*$#@u1 zsaLhbuQN_T=#$nJqJcn&@#5#vi^NsvAAj6$Rw)$1N!|O=)-^sQPBDayWrK1_C#p@V z%Jw@~BPh5?jzjG#@P+kQJQhtC5irPVbU6l1gVl3F~2 z_ml$p2ys`wAI@(nskfXW2`_}ujg93b+IT;~h7foOsg$ zv-CSpkx_p^)14sBD=ZNF{}17eAZezbW}7#E^|8-_GkQ#1U6Yd&6hkN888xrKaJ-CB z`(JO8sN&QCQ6cUw4>6B12~GxHFMp$*Yj1EBry?SmZ<9vL`O#c{hF z?N#6rHr=J2=k3AsM#T4lN|7Y%l+~JH583Hl?j0Z~Mo@HG1W^ndKk&9QC=3=m?S{AH z8QbcS;h^8|S_#P3vji%`* z3RAu~$y{p9DVLM)Cf0^R>nfAA9@jF@U##%Ky+i%rG;hHKgIlE-vxOx5 zYakruf>l0JUWSTsaXENVJ zkB_X|bhacXKTx0jgLV^8K!6;fuIldts==oFc9UfoMYU#6m9Fl4PJ3MYL8N+hhK=;IVo$(;s)%*ZHn8R^q<&#p zU`iAV@icsE6~xaAFTMx@V1aO0)_l$$RN@v!npdhF@X2yRlR4E|N`VkXEE@&*jUlXw zaOzY8|6Jm(B_<*!KGP+H;igaAEFAbwKGk48OTM|~0R$+qef(VVq~9}-iNN{3G*p=3Z=AKhuwQ-^rb5q7QtQLq-YHGP zYo~GJ_n`-9yiCWI-kZ{rkkVhfvD}yeEjYkvX~l{q$Mhf!V$V-ZUN=rY(WtWs=d_P) zX>V_B*PNPvm__He{`@V4rj4Lpyw{)3Cp}kIVJi+Cbbt9y&SV`Wl=qMrhb6McR>!Nr>h)10`hbM`nt5-T@rN|S!pSZaEr*Vx;Yoj;^#rq zK5Kck#?|jsX*!J8-j^>}k-4T|e(gs>Hr@F zi$GE}K2dL17*=jlj+v}dHWbvybQTgTrVj*O5Vzp8t*POhn&~;W1xvwr?Y`3YAR8jr zILq{x&zC5uXAu$+;AX{JR-QV??;g(cI*VF`+)I9_v|tWzlrVsSaZs=w{%^v90{nuot~F+n5*5Gg^c}JvQ2Bw~c+H zji*p`W~69V|MloK&k|^l{y*lXoqel?K#F=W)*jUWs;RWTWx~>#NeDoTdN3ZgmXGfG z{f}_rmcDqRl1SxX5Qz$ z&uht?v^jZw_^_g`nr_TYrfcLoaZBlANVg8C+to5h-;i79L?MLUa}h<;VP!QE5RrpX z>6aW< z2@QTexFiRRQg1v70Ar{FQvi2>gMY!6eZe@kFbM@%7{I~uwOuy0R7tP&CzJ6Aq^1hw zz95Rz493aH(02C_gO=uz!12Z_CAAtOq<(Q7>q9k`Mmp3GK>cKJieQx&eW0>Y?6YWl z!zSz?;-tIEY$anhqYuve^CmbSVq~qdwGh;pL+TWH-pGv3d0$+tf^Mf^&Wg1e>Hv)y z`@su^2y^2bDOJql!Ity(OjtdatJ=yuu}l@1H#YzM)h`ibwO4U}=W&!(w>*e~QU!fD z!i;S*fWp2{axFP3DUdg86w2O&Sj@9ywT6kIGiN!vpuKjiL&y^(tk`$ozu*owDKSk` zaUYLUsjTYABJG&~0i6qErCKdgRGT>Jm56i1OjqJSB{~&St&W`Q;W38cI%dRZ6>}9w zx>Kr?7xh7MQUL&qDKR0iatGjeWjP6!Ktm%$K1&B5j>;S(u-L9)w9i+ya%ih+l~l?! z%Ar-l(ITc+h{Tw*i&krW^b}1jkp9dwRDz`o!g3yx$@0%C*t+D(YI6{6%)KW{xz6jP zudmEepp@DR zN-hPJehNJ1{WyqM@C>Fgg98d#mRu#;- zEPCf_LZO{vq~_fMfR8!CDvx1v{#?XHGty3vOv?sKG*}Vcer!&4jnrC6j>;}-8%`p> zLXYez-$>273+Q7G=ORvz_^99mP z2?YtOzXq$%iPN4&Z97uGmAQ14y~pky+yvd>F}8cstawOmisFa@_Bc=Ybt+JC*3CH5 zlGVj5Gy-tFKisD?r)*SsY&J35O;=V^cNS?ieI zAMB!-q}00bJR|8GUC0)R^!Vkld= z1)s(ppEG+~$Dv%J;C1TQ(??7T*%0L-$$W&1F8JLy&bjE^)Gv5ed*1Sk#XGZwrZ}b; z&`-ne)q2~=h3u@bO{oRjG@SsE`Tzo8l9>gUst>mm!H(`~Pp_m+7@I9$t;9M@E^{v+E;!@(J&O9+2 zqLNC&cs8Fy-i&Pr^Bw9C==QXC@7lUz#ofH&1F*M-FOf!LHU*(MMoh-sG)>atGCrK2 zxz>zi)Jp>>PFX=D%H1P9j*xZ^oOp#bLcWEKXGWyzvFc{zvs8rBPPyz$uQ~0tslVze zRc|?E96T>BhVt;UXF1W16VGxq^WfsY(9xXAPWf%kM9oFed_VAArr8MY@Vx5&{io%{ zg+EwdqWr-`BH*=kOd-e|Z%8x$jdUqvSIg;$vfkQJGGAeE=iFKKO+%x*M{&R1W;SxT z0*%ujFV1xb*bQmspCy@NbG2!)yT*=g+BA*`YyA;imYcY@xMh~w;|T(4(fA$lhMd`1(_8D3@jNu(!bMZntrTpS_GxQh3CAD8kp|) zy3_f4*@VpP^RG;x|Lyi*AE#SiVA9~!Y37`re!_ihcv)_yg`3y(r<5g|b!Al1Dx ztY;O8<#Fj%C}=DoMfF1smrmyhvoCp4!+#npF23T&Ilf;2UKnOb>an^|eT2Z0(-Nyi zW}di53L%(n+t4u+qL~HTU>s!iVm^*|FZnjY)lPMlrHUVOxMMOh=ggR<_j%c2tl_}P zCxb+TG^1AL`8I1h@$CAGrpCm2mASZt1zy1tJGx;s4MV8Q-}d?S6manwEJVpfTvm1? z@2oKZhuBzMXA-nv2BCQ;3(-PYIm78xg_Fy|tn19w_|0p)&gGMJ6xb%5{xg@fuF7|A zf~K@9tx6M)2=Gg&pYZoY8CdC1@3fh zEYpg;h z|HFCe>U;ObM*d(y0#~Y1J>B!Xl=}Cta+j-u8B_XHua~h}oHH<9TC&k=8ykfnmQwB1 zX$6@9T8?;UGQJovBT?acaLO?EkXNufs9~bt^D1^K)V(i6{HEeW}zz5xS^Y~f|$uIZ%1JWw8C }W19f`!;Uc_ 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

vv@(L}CAa>B#FCkOzQ-!l8I?X^%}AHP>*xb; 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 I82|tP0EePNvj6}9 literal 0 HcmV?d00001 diff --git a/frontend/src/fonts/archivo.woff2 b/frontend/src/fonts/archivo.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..7af3f7db56708079e71cc2c265b3889d7f4649a6 GIT binary patch literal 90096 zcmV(?K-a%_Pew8T0RR910blR{6951J15Stl0bhIo0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/frontend/src/fonts/jetbrains-mono-italic.woff2 b/frontend/src/fonts/jetbrains-mono-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4d3d6ca30f17f13f3086882305e59ede58717fbd GIT binary patch literal 42928 zcmV)CK*GOwPew8T0RR910H?436951J0fU$T0H-_v0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/frontend/src/index.css b/frontend/src/index.css index 17738210..72cca064 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,13 +1,53 @@ -/* Base styles. The mockup JSX inlines styles per element; we - lift only the body/layout-defaults here and let components - own their own styling via the tokens. This keeps the - import-graph honest: tokens.ts is the single source of truth, - global CSS is just zero-out. */ +/* Base styles. Components own their styling via tokens.ts; this file + holds only what CSS must own: font-face, body defaults, focus + rings, hover states, and keyframes. tokens.ts stays the single + source of truth for the palette. */ + +/* Canton Infrastructure Design System typefaces, self-hosted so the + UI renders identically offline (licenses in src/fonts/). Archivo + variable: body at 100% width, wide structural caps at 118%. + JetBrains Mono for code and all data values. */ +@font-face { + font-family: "Archivo"; + src: url("./fonts/archivo.woff2") format("woff2-variations"); + font-weight: 100 900; + font-stretch: 62% 125%; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Archivo"; + src: url("./fonts/archivo-italic.woff2") format("woff2-variations"); + font-weight: 100 900; + font-stretch: 62% 125%; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./fonts/jetbrains-mono.woff2") format("woff2-variations"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./fonts/jetbrains-mono-italic.woff2") format("woff2-variations"); + font-weight: 100 800; + font-style: italic; + font-display: swap; +} :root { color-scheme: dark; - --bg: #0B0E13; - --text: #E6E9EE; + --bg: #0b0f1a; + --text: #e9ecf4; + /* Motion — quick, damped, no bounce. */ + --ease-out: cubic-bezier(0.2, 0.6, 0.2, 1); + --duration-fast: 120ms; } * { @@ -22,9 +62,11 @@ body, height: 100%; background: var(--bg); color: var(--text); - font-family: "IBM Plex Sans", "Inter Tight", system-ui, sans-serif; + font-family: "Archivo", -apple-system, "Segoe UI", "Helvetica Neue", + Arial, sans-serif; font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; } button { @@ -43,34 +85,154 @@ a { * arrow keys) or programmatic .focus(). Mouse clicks don't paint * the ring, matching what sighted users expect from native UI. * - * The 2px brand-coloured outline is high-contrast against every - * surface in the dark palette. Offset 2px so it doesn't merge - * into the element's own border. */ + * 2px cobalt outline (blue-500 — identical in light and dark per + * the design system), offset 2px so it doesn't merge into the + * element's own border. */ :focus { outline: none; } :focus-visible { - outline: 2px solid #5BD7C5; + outline: 2px solid #3d5bdc; outline-offset: 2px; - border-radius: 4px; + border-radius: 2px; +} + +/* Sidebar nav items (shell/Shell.tsx::Sidebar). Hover/active tints + * live here because inline style objects can't express :hover. + * Active = accent-subtle fill + accent text at 2px radius — the + * design system's "current item" signature. */ +.side-nav-link { + display: block; + padding: 7px 10px; + margin: 1px 0; + border-radius: 2px; + font-size: 13px; + color: #a9b2c6; + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.side-nav-link:hover { + background: #171e2c; + color: #e9ecf4; +} + +.side-nav-link.active, +.side-nav-link.active:hover { + background: #141c36; + color: #93a7f0; + font-weight: 500; +} + +/* Button system (components/Button.tsx). Hover/active tints live + * here because inline style objects can't express :hover. Values + * are the dark-console tokens from tokens.ts. */ +.bd-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: inherit; + font-weight: 500; + border-radius: 2px; + border: 1px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.bd-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.bd-btn--sm { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + +.bd-btn--md { + height: 36px; + padding: 0 14px; + font-size: 13px; +} + +.bd-btn--full { + width: 100%; +} + +.bd-btn--primary { + background: #6480e6; + color: #0b0f1a; +} +.bd-btn--primary:hover:not(:disabled) { + background: #7b93ec; +} +.bd-btn--primary:active:not(:disabled) { + background: #93a7f0; +} + +.bd-btn--secondary { + background: #10151f; + border-color: #232b3d; + color: #e9ecf4; +} +.bd-btn--secondary:hover:not(:disabled) { + background: #171e2c; + border-color: #313b52; +} +.bd-btn--secondary:active:not(:disabled) { + background: #1e2637; +} + +.bd-btn--ghost { + background: transparent; + color: #a9b2c6; +} +.bd-btn--ghost:hover:not(:disabled) { + background: #171e2c; + color: #e9ecf4; +} +.bd-btn--ghost:active:not(:disabled) { + background: #1e2637; +} + +.bd-btn--danger { + background: #d2604b; + color: #fff; +} +.bd-btn--danger:hover:not(:disabled) { + background: #e08d7d; +} +.bd-btn--danger:active:not(:disabled) { + background: #ba3a29; +} + +.bd-btn__icon { + display: inline-flex; + flex: none; } /* Skip-to-content link (shell/Shell.tsx::SkipLink). Visually * hidden until focused via Tab — first focusable element on the * page so a keyboard user can jump past the sidebar to the main - * content. The .focus state pulls it on-screen as a pill. */ + * content. The .focus state pulls it on-screen. */ .skip-link { position: absolute; top: -100px; left: 8px; - background: #5BD7C5; - color: #082018; + background: #6480e6; + color: #0b0f1a; padding: 8px 14px; font-weight: 600; - border-radius: 6px; + border-radius: 2px; z-index: 1000; - transition: top 0.15s ease-out; + transition: top var(--duration-fast) var(--ease-out); } .skip-link:focus, diff --git a/frontend/src/screens/AgentSkillsScreen.tsx b/frontend/src/screens/AgentSkillsScreen.tsx index 55764a4d..586b952f 100644 --- a/frontend/src/screens/AgentSkillsScreen.tsx +++ b/frontend/src/screens/AgentSkillsScreen.tsx @@ -6,6 +6,8 @@ import { type Skill, } from "../api"; import { W, wMono } from "../tokens"; +import { Button } from "../components/Button"; +import { IcAlert, IcCheck, IcX } from "../components/icons"; // AgentSkillsScreen browses the bundled AI-agent skill docs (served by // /api/skills — the same embedded markdown the CLI `localnet skills` @@ -109,7 +111,7 @@ export function AgentSkillsScreen() { gap: 12, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: "10px 14px", flexWrap: "wrap", }} @@ -128,8 +130,17 @@ export function AgentSkillsScreen() { onClick={() => doInstall("codex")} /> {install.kind === "done" && ( - - ✓ {install.count} installed → {install.dir} + + {install.count} installed → {install.dir} )} {install.kind === "done" && install.skipped.length > 0 && ( @@ -144,30 +155,28 @@ export function AgentSkillsScreen() { fontFamily: wMono, }} > - ⚠ {install.skipped.length} preserved (locally modified):{" "} - {install.skipped.join(", ")} - + )} {install.kind === "err" && ( - - ✗ {install.message} + + {install.message} )}

@@ -186,7 +195,7 @@ export function AgentSkillsScreen() { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "auto", }} > @@ -221,7 +230,7 @@ export function AgentSkillsScreen() { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "auto", padding: "16px 20px", }} @@ -259,7 +268,7 @@ function Header() { color: W.dim, border: `1px solid ${W.border}`, padding: "1px 7px", - borderRadius: 4, + borderRadius: 2, fontSize: 10.5, fontFamily: wMono, }} @@ -286,23 +295,13 @@ function InstallButton({ onClick: () => void; }) { return ( - + ); } diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index 5f92e988..1a87fe53 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -6,6 +6,8 @@ import { type RestoreResponse, } from "../api"; import { W, wMono } from "../tokens"; +import { Button } from "../components/Button"; +import { IcCheck, IcDownload } from "../components/icons"; // Backup & restore card. Two actions: // 1. Download snapshot — POST /api/instances/:name/snapshot; the @@ -107,7 +109,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 16, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 16, }} aria-label="Backup and restore" @@ -130,13 +132,14 @@ export function BackupRestore({ instanceName }: Props) { {/* Download row */}
- + {downloading ? "Preparing…" : "Download snapshot"} + mirrors{" "} @@ -154,7 +157,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${W.err}10`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: 2, padding: "8px 12px", fontSize: 12, color: W.err, @@ -195,7 +198,7 @@ export function BackupRestore({ instanceName }: Props) { style={{ border: `1.5px dashed ${dragOver ? W.brand : W.border}`, background: dragOver ? `${W.brand}10` : "transparent", - borderRadius: 8, + borderRadius: 4, padding: "14px 16px", cursor: "pointer", color: W.dim, @@ -252,7 +255,7 @@ export function BackupRestore({ instanceName }: Props) { background: "transparent", color: W.text, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: 2, padding: "2px 6px", fontSize: 12, fontFamily: wMono, @@ -290,13 +293,17 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${W.brand}10`, border: `1px solid ${W.brand}`, - borderRadius: 6, + borderRadius: 2, padding: "8px 12px", fontSize: 12, color: W.text2, }} > - ✓ Restored{" "} + + Restored + {" "} {restore.response.name} @@ -314,7 +321,7 @@ export function BackupRestore({ instanceName }: Props) { marginTop: 10, background: `${W.err}10`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: 2, padding: "8px 12px", fontSize: 12, color: W.err, @@ -345,7 +352,7 @@ function UploadProgress({ style={{ height: 6, background: W.border, - borderRadius: 3, + borderRadius: 2, overflow: "hidden", }} > @@ -361,16 +368,3 @@ function UploadProgress({
); } - -function btn(accent: string, busy: boolean): React.CSSProperties { - return { - background: "transparent", - color: busy ? W.dim : accent, - border: `1px solid ${busy ? W.dim : accent}`, - borderRadius: 6, - padding: "5px 14px", - fontSize: 12, - fontWeight: 600, - cursor: busy ? "wait" : "pointer", - }; -} diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index f08ffc80..dcb53085 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -5,7 +5,9 @@ import { fetchContainers, restartContainer, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcRefresh } from "../components/icons"; import { ContainerLogsModal } from "./ContainerLogsModal"; // ContainerHealth — live per-container status panel. Polls @@ -96,7 +98,7 @@ export function ContainerHealth({ name }: { name: string }) { marginTop: 16, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 14, }} > @@ -131,7 +133,7 @@ export function ContainerHealth({ name }: { name: string }) { color: W.err, background: `${W.err}10`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: 2, padding: "6px 10px", fontSize: 12, }} @@ -147,7 +149,7 @@ export function ContainerHealth({ name }: { name: string }) { color: W.err, background: `${W.err}10`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: 2, padding: "6px 10px", fontSize: 12, marginBottom: 8, @@ -176,6 +178,16 @@ export function ContainerHealth({ name }: { name: string }) { ); } +// Dense-panel micro-labels: sentence case, muted — wide caps are +// reserved for real table/card headers. +// Table column headers use the quiet caps cut — match every other +// table in the app. +const colHeader: React.CSSProperties = { + ...tableCaps, + color: W.dim, + fontSize: 11, +}; + function ContainersTable({ containers, onPickLogs, @@ -207,23 +219,13 @@ function ContainersTable({ alignItems: "center", }} > -
- ● -
-
- service -
-
- state -
-
- status -
-
- actions -
+
+
Service
+
State
+
Status
+
Actions
{sorted.map((c) => { - const { color, glyph } = signalFor(c); + const color = signalFor(c); const onLogs = (e: React.MouseEvent) => { // Don't let the opening click double as a backdrop click on // the modal overlay (which would close it immediately). @@ -248,7 +250,17 @@ function ContainersTable({ style={{ display: "contents" }} title={`Click columns to view logs for ${c.name}`} > -
{glyph}
+
+ +
{c.service}
@@ -259,25 +271,17 @@ function ContainersTable({ )}
{c.status}
-
- + {isRestarting ? "restarting…" : "restart"} +
); @@ -303,7 +307,7 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { key={label} style={{ padding: "2px 8px", - borderRadius: 999, + borderRadius: 2, border: `1px solid ${color}`, background: `${color}1A`, color, @@ -329,17 +333,16 @@ function severity(c: { state: string; health?: string }): number { return 5; // healthy / running with no healthcheck } -function signalFor(c: { state: string; health?: string }): { - color: string; - glyph: string; -} { - if (c.state === "restarting") return { color: W.warn, glyph: "↻" }; - if (c.state === "dead" || c.state === "exited") return { color: W.err, glyph: "✕" }; - if (c.state === "paused") return { color: W.dim, glyph: "⏸" }; - if (c.health === "unhealthy") return { color: W.err, glyph: "⊗" }; - if (c.health === "starting") return { color: W.brand, glyph: "●" }; - if (c.health === "healthy") return { color: W.ok, glyph: "✓" }; +// signalFor maps a container's docker state/health to its status-dot +// color (the state word next to it carries the same color). +function signalFor(c: { state: string; health?: string }): string { + if (c.state === "restarting") return W.warn; + if (c.state === "dead" || c.state === "exited") return W.err; + if (c.state === "paused") return W.dim; + if (c.health === "unhealthy") return W.err; + if (c.health === "starting") return W.brand; + if (c.health === "healthy") return W.ok; // running with no healthcheck - if (c.state === "running") return { color: W.ok, glyph: "●" }; - return { color: W.dim, glyph: "·" }; + if (c.state === "running") return W.ok; + return W.dim; } diff --git a/frontend/src/screens/ContainerLogsModal.tsx b/frontend/src/screens/ContainerLogsModal.tsx index fe59a59f..f0fcfea5 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { ApiError, fetchContainerLogs } from "../api"; import { W, wMono, wSans } from "../tokens"; +import { Button } from "../components/Button"; +import { IcX } from "../components/icons"; // ContainerLogsModal — opens when the user clicks a row in // ContainerHealth. Polls docker logs for the selected container at @@ -118,9 +120,13 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props since={since} setSince={setSince} /> - + + title="Close (esc)" + onClick={onClose} + />
{detail.contract_id} - +
{state.kind === "loading" && (
@@ -358,9 +351,7 @@ function Section({ style={{ color: W.dim, fontSize: 10.5, - letterSpacing: 1.4, - textTransform: "uppercase", - fontWeight: 600, + ...wideCaps, marginBottom: 6, }} > @@ -385,7 +376,7 @@ function Pill({ border: `1px solid ${color}44`, color, padding: "1px 8px", - borderRadius: 4, + borderRadius: 2, fontSize: 10.5, fontWeight: 600, fontFamily: wMono, @@ -405,7 +396,7 @@ function Hint({ children }: { children: React.ReactNode }) { } function PartyChip({ party, kind }: { party: string; kind: "sig" | "obs" }) { - const color = kind === "sig" ? "#5BD7C5" : "#7CB5F7"; + const color = kind === "sig" ? "#6480E6" : "#8FA3EE"; return (
)} {isRunning ? ( - + ) : stage.kind === "form" ? ( <> - - + ) : ( - + )} ); @@ -505,7 +500,7 @@ function FormBody({ return (
0 @@ -524,7 +519,7 @@ function FormBody({ /> - +
@@ -776,7 +771,7 @@ function FormBody({ marginTop: 4, padding: "8px 10px", background: W.surface2, - borderRadius: 7, + borderRadius: 2, fontFamily: wMono, }} > @@ -831,13 +826,15 @@ function ProgressBody({ background: `${W.warn}1A`, border: `1px solid ${W.warn}44`, color: W.warn, - borderRadius: 6, + borderRadius: 2, padding: "6px 10px", fontSize: 11.5, margin: "6px 0", }} > - ⚠ {m} + + {m} +
))}
@@ -859,7 +856,7 @@ function ProgressBody({ margin: "8px 0 0", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, padding: "10px 12px", fontFamily: wMono, fontSize: 10.5, @@ -941,12 +938,14 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { background: `${W.brand}10`, color: W.brand, border: `1px solid ${W.brand}44`, - borderRadius: 7, + borderRadius: 2, fontSize: 12, fontFamily: wMono, }} > - ● streaming step events + + streaming step events +
); } @@ -958,12 +957,14 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { background: `${W.ok}1A`, color: W.ok, border: `1px solid ${W.ok}`, - borderRadius: 7, + borderRadius: 2, fontSize: 13, fontWeight: 600, }} > - ✓ {banner.detail || "ready"} + + {banner.detail || "ready"} +
); } @@ -976,11 +977,15 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { background: `${W.err}10`, color: W.err, border: `1px solid ${W.err}`, - borderRadius: 7, + borderRadius: 2, fontSize: 12.5, }} > - ✗ {banner.summary ?? "failed"} + + {banner.summary ?? "failed"} + {banner.cause && (
{banner.cause} @@ -992,7 +997,7 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { marginTop: 8, padding: "8px 10px", background: W.surface2, - borderRadius: 6, + borderRadius: 2, color: W.text2, fontSize: 11.5, borderLeft: `3px solid ${W.warn}`, @@ -1017,11 +1022,13 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { background: `${W.warn}1A`, color: W.warn, border: `1px solid ${W.warn}`, - borderRadius: 7, + borderRadius: 2, fontSize: 12.5, }} > - ⏹ cancelled{banner.reason ? ` — ${banner.reason}` : ""} + + cancelled{banner.reason ? ` — ${banner.reason}` : ""} +
); } @@ -1030,13 +1037,13 @@ function StepRow({ label, state }: { label: string; state: StepState }) { const icon = (() => { switch (state.status) { case "done": - return ; + return ; case "active": - return ; + return ; case "fail": - return ; + return ; default: - return ; + return ; } })(); const color = @@ -1057,7 +1064,18 @@ function StepRow({ label, state }: { label: string; state: StepState }) { fontSize: 12.5, }} > - {icon} + + {icon} +
{label}
{(state.detail || state.summary) && ( @@ -1126,7 +1144,7 @@ export function VersionPicker({ // endless "Loading…". let placeholder = "No curated versions available"; if (loading) placeholder = "Loading curated versions…"; - else if (error) placeholder = `⚠ Couldn't load versions — ${error}`; + else if (error) placeholder = `Couldn't load versions — ${error}`; return ( in every state — disabled placeholder while loading/on -// error, populated when versions arrive. A free-text fallback would let -// arbitrary tags route silently to the upstream-resolution path the -// curated dropdown exists to prevent. -// -// Sort order: "latest" first, then descending semver, so the newest -// catalogued releases sit near the top. -// -// Exported so VersionPicker.test.tsx can render the component in -// isolation and pin the "always a , never free text: a textbox would route arbitrary +// tags to the upstream-resolution path the curated dropdown prevents. +// Sorted "latest" first, then descending semver. export function VersionPicker({ versions, selected, @@ -1140,11 +1080,9 @@ export function VersionPicker({ error?: string | null; }) { if (versions.length === 0) { - // Distinct placeholders so a failed fetch doesn't render as an - // endless "Loading…". let placeholder = "No curated versions available"; if (loading) placeholder = "Loading curated versions…"; - else if (error) placeholder = `Couldn't load versions — ${error}`; + else if (error) placeholder = `Couldn't load versions: ${error}`; return ( ); } -// compareSpliceTags orders two Splice version tags like a localeCompare -// (negative ⇒ a is older/lower than b), but semver-aware so a final -// release outranks its own pre-release. localeCompare(…, {numeric:true}) -// gets this wrong: "0.6.4" is a prefix of "0.6.4-rc.1", so string -// collation sorts the rc AFTER the release — inverting semver -// precedence. Non-semver tags ("token-standard-v2") have no precedence -// to reason about and fall back to numeric localeCompare. Exported for -// the regression test. +// Semver-aware ordering (negative ⇒ a older than b) so a release +// outranks its own pre-release; plain localeCompare sorts "0.6.4-rc.1" +// after "0.6.4". Non-semver tags fall back to numeric localeCompare. export function compareSpliceTags(a: string, b: string): number { const pa = parseSemverTag(a); const pb = parseSemverTag(b); @@ -1199,7 +1132,7 @@ export function compareSpliceTags(a: string, b: string): number { for (let i = 0; i < 3; i++) { if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]; } - // Same x.y.z: a release (no pre-release) is newer than any pre-release. + // Same x.y.z: a release is newer than any pre-release. if (pa.pre === null && pb.pre === null) return 0; if (pa.pre === null) return 1; if (pb.pre === null) return -1; @@ -1212,10 +1145,8 @@ function parseSemverTag(tag: string): { core: [number, number, number]; pre: str return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null }; } -// comparePrerelease applies the semver pre-release precedence rules: -// dot-separated identifiers compared left-to-right; numeric identifiers -// numerically and ranked below alphanumerics; a shorter run loses to a -// longer one when otherwise equal. +// Semver pre-release precedence: dot-separated identifiers left-to-right, +// numeric ranked below alphanumeric, shorter run loses when otherwise equal. function comparePrerelease(a: string, b: string): number { const as = a.split("."); const bs = b.split("."); @@ -1238,32 +1169,24 @@ function comparePrerelease(a: string, b: string): number { return 0; } -// selectStyle is inlined rather than spread from `inputStyle`, which is -// declared further down — referencing it here would hit the ES-module -// TDZ ("used before declaration") under Vite/SWC. Visual parity with -// inputStyle is intentional. `appearance: "auto"` keeps the native OS -// dropdown caret, which some browsers drop when a custom -// borderRadius/background is applied — leaving the field looking like a -// disabled text input. +// Inlined, not spread from inputStyle (declared below): referencing it +// here would hit the ES-module TDZ. appearance:"auto" keeps the native +// dropdown caret some browsers drop with a custom border/background. const selectStyle: React.CSSProperties = { width: "100%", background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "7px 10px", fontSize: 13, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", cursor: "pointer", appearance: "auto", }; -// PreflightPanel renders the system-requirements check inline in the -// form: nothing when idle, a pill while loading, a neutral non-blocking -// note on probe error (the server-side gate still runs on submit), a -// compact green pill on pass, an amber box for warnings (submit still -// allowed), and a red box with per-check remediation when blocked. function PreflightPanel({ state }: { state: PreflightState }) { if (state.kind === "idle") return null; if (state.kind === "loading") { @@ -1323,17 +1246,16 @@ function PreflightPanel({ state }: { state: PreflightState }) { const heading = blocked ? "Host doesn't meet this version's requirements" : warns.length > 0 - ? "Host meets minimums — but raise resources for headroom" + ? "Host meets minimums. Raise resources for headroom." : "Host is ready for this version"; if (!blocked && warns.length === 0) { - // Compact success pill — don't clutter the form. return (
@@ -1472,7 +1394,6 @@ function Field({ } function Elapsed({ startedAt }: { startedAt: number }) { - // Tick once per second to keep the counter live; cleared on unmount. const [, force] = useState(0); useEffect(() => { const t = setInterval(() => force((n) => n + 1), 1000); @@ -1484,8 +1405,6 @@ function Elapsed({ startedAt }: { startedAt: number }) { return <>{m}:{String(s).padStart(2, "0")} elapsed; } -// ── styles ──────────────────────────────────────────────────────── - const overlayStyle: React.CSSProperties = { position: "fixed", inset: 0, @@ -1502,8 +1421,8 @@ const modalStyle: React.CSSProperties = { width: "min(680px, 92vw)", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 8, - boxShadow: "0 24px 64px rgba(0,0,0,0.6)", + borderRadius: R.dialog, + boxShadow: "0 10px 32px rgba(0,0,0,0.24)", overflow: "hidden", }; @@ -1512,9 +1431,10 @@ const inputStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "7px 10px", fontSize: 13, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", }; diff --git a/frontend/src/screens/CreatingPanel.tsx b/frontend/src/screens/CreatingPanel.tsx index 1c5ad801..53e5fdbf 100644 --- a/frontend/src/screens/CreatingPanel.tsx +++ b/frontend/src/screens/CreatingPanel.tsx @@ -6,34 +6,24 @@ import { scrubInstance, type StepName, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; import { type ProgressState, type StepState, useCreateProgress, } from "./useCreateProgress"; -// CreatingPanel — shown above the InstanceDetail/DeveloperSetup cards -// when the selected instance is status="creating". Subscribes to -// /api/instances/{name}/events and renders the same step rows as the -// create modal (both consume the shared useCreateProgress state). -// -// Two scenarios: -// 1. Live bring-up: the SSE stream replays buffered events + live -// ones — real-time progress just like the modal. -// 2. Zombie creating: the registry says creating but no goroutine is -// publishing (e.g. a server restart killed it mid-flight). No -// events arrive; after a grace period the panel surfaces a "looks -// stalled" hint with a cleanup CTA. +// Shown when the selected instance is status="creating". Renders live +// SSE bring-up progress, or — if no event arrives within ZOMBIE_GRACE_MS +// (e.g. a server restart orphaned the entry) — a stalled hint + cleanup. -const ZOMBIE_GRACE_MS = 3000; // wait this long before showing "stalled" hint +const ZOMBIE_GRACE_MS = 3000; interface Props { name: string; - // Called after a cancel or stalled-state cleanup so the Dashboard - // re-fetches and the row's status updates. onRefresh: () => void; } @@ -41,13 +31,9 @@ export function CreatingPanel({ name, onRefresh }: Props) { const eventsUrl = `/api/instances/${encodeURIComponent(name)}/events`; const progress = useCreateProgress(eventsUrl); - // Zombie detection: no event by ZOMBIE_GRACE_MS surfaces the - // "stalled" affordance. Derived freshly on every render rather than - // via setTimeout — a timeout closure would capture progress.startedAt - // at setup time and never re-check it, so events arriving late (slow - // network, slow first publish) would leave the panel permanently - // "stalled". mountedAtRef pegs the start time per name; the 1s ticker - // below keeps the derived check current. + // Derived per render, not via setTimeout: a timeout closure would + // capture startedAt once and never re-check, wedging late events as + // "stalled". mountedAtRef pegs the start; the 1s ticker below refreshes. const mountedAtRef = useRef(Date.now()); useEffect(() => { mountedAtRef.current = Date.now(); @@ -61,9 +47,8 @@ export function CreatingPanel({ name, onRefresh }: Props) { progress.startedAt === null && Date.now() - mountedAtRef.current > ZOMBIE_GRACE_MS; - // Live path: ask the goroutine to stop. The backend publishes - // kind=cancelled, then the goroutine sees ctx.Done() and writes - // status=failed via its existing path. + // Live path: ask the goroutine to stop; it publishes kind=cancelled + // then writes status=failed. async function onCancelLive() { try { await cancelInstanceUp(name); @@ -74,14 +59,12 @@ export function CreatingPanel({ name, onRefresh }: Props) { } // Zombie path: no live goroutine, so /up cancel would 404 — scrub the - // registry entry instead so the row disappears from the list. + // registry entry instead. async function onScrub() { try { await scrubInstance(name); onRefresh(); } catch { - // Even if scrub fails (e.g. 409 because the entry is now - // running), refresh so the user sees current state. onRefresh(); } } @@ -92,7 +75,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -125,10 +108,10 @@ export function CreatingPanel({ name, onRefresh }: Props) {
@@ -277,7 +260,7 @@ function StepRow({ label, state }: { label: string; state: StepState }) { marginTop: 4, height: 4, background: W.surface2, - borderRadius: 2, + borderRadius: R.control, overflow: "hidden", }} > @@ -304,41 +287,20 @@ function BannerPill({ zombie: boolean; }) { if (zombie) { - return looks stalled; + return ; } switch (banner.kind) { case "done": - return ready; + return ; case "failed": - return failed; + return ; case "cancelled": - return cancelled; + return ; default: - return streaming; + return ; } } -function Pill({ color, children }: { color: string; children: React.ReactNode }) { - return ( - - {children} - - ); -} - function ZombieHint({ name, onScrub, @@ -350,10 +312,11 @@ function ZombieHint({ }) { return (
    -
  • The bring-up finished after the page loaded — refresh to pick up the new state.
  • +
  • The bring-up finished after the page loaded. Refresh to pick up the new state.
  • The server was restarted mid-bring-up, orphaning the entry. Click Remove entry to scrub it from the diff --git a/frontend/src/screens/DARDiff.tsx b/frontend/src/screens/DARDiff.tsx index 47cbfb20..685ef2da 100644 --- a/frontend/src/screens/DARDiff.tsx +++ b/frontend/src/screens/DARDiff.tsx @@ -1,16 +1,13 @@ -// DAR structural diff viewer. Renders /api/instances/:name/dar/diff -// between two DARs as expandable sections: modules / templates / -// interfaces added/removed/changed. No third-party diff library — the -// JSON shape is small enough that a hand-rolled list-with-colour reads -// cleanly. Embedded as a drawer inside DARScreen when the user picks -// two DARs to compare. +// Structural diff between two DARs, as expandable added/removed/changed +// sections for modules, templates, and interfaces. import { useEffect, useState } from "react"; import { fetchDARDiff, type DARDiffResponse, type Role, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, R, tint } from "../tokens"; +import { MonoId } from "../components/MonoId"; import { IcArrowRight, IcChevronDown, @@ -201,7 +198,7 @@ export function DARDiff({ instance, a, b, role }: Props) { const paneStyle: React.CSSProperties = { background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 12, fontSize: 12, maxHeight: "60vh", @@ -229,14 +226,14 @@ function Side({ ); } return ( - - {label}: + + {label}: {side.name}@{side.version} - - {side.main.slice(0, 8)}… - + ); } @@ -246,13 +243,13 @@ type Tone = "add" | "rm" | "chg" | "info"; function toneColour(t: Tone): { bg: string; fg: string } { switch (t) { case "add": - return { bg: "#7CC89A22", fg: "#7CC89A" }; + return { bg: tint(W.ok, 13), fg: W.ok }; case "rm": - return { bg: `${W.err}22`, fg: W.err }; + return { bg: `${tint(W.err, 13)}`, fg: W.err }; case "chg": - return { bg: `${W.warn}22`, fg: W.warn }; + return { bg: `${tint(W.warn, 13)}`, fg: W.warn }; case "info": - return { bg: `${W.brand}1A`, fg: W.brand }; + return { bg: `${tint(W.brand, 10)}`, fg: W.brand }; } } @@ -281,10 +278,9 @@ function Section({ border: "none", color: c.fg, fontSize: 11.5, - fontWeight: 600, cursor: "pointer", padding: "2px 0", - letterSpacing: 0.6, + ...tableCaps, display: "inline-flex", alignItems: "center", gap: 6, @@ -337,7 +333,7 @@ function ChipGroup({ key={l} style={{ padding: "0 5px", - borderRadius: 2, + borderRadius: R.control, background: c.bg, color: c.fg, fontSize: 10.5, diff --git a/frontend/src/screens/DARPackageTree.tsx b/frontend/src/screens/DARPackageTree.tsx index 122ed609..1b0d48fb 100644 --- a/frontend/src/screens/DARPackageTree.tsx +++ b/frontend/src/screens/DARPackageTree.tsx @@ -1,8 +1,5 @@ -// DAR package-tree explorer. Renders a /api/instances/:name/dar/:id/ -// inspect response as an expandable tree: package → module → (template -// | interface | data type), with choices and methods as inline chips. -// Self-contained — fetches its own data and owns its expand/collapse -// state. Embedded as a drawer inside DARScreen. +// Expandable package → module → (template | interface | data type) tree +// for a DAR inspect response, with choices and methods as inline chips. import { useEffect, useState } from "react"; import { fetchDARInspect, @@ -11,9 +8,17 @@ import { type DARPackageInspect, type Role, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, R, tint } from "../tokens"; +import { MonoId } from "../components/MonoId"; import { IcChevronDown, IcChevronRight } from "../components/icons"; +// Middle-truncate for ids inside a toggle button, where a MonoId (itself +// a button) would nest interactive elements. +function midId(s: string, head = 10, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + interface Props { instance: string; mainID: string; @@ -38,8 +43,6 @@ export function DARPackageTree({ instance, mainID, role }: Props) { .then((data) => { if (cancelled) return; setState({ kind: "ok", data }); - // Auto-expand the main package so the most useful tree is - // visible on first render. const main = data.packages.find((p) => p.is_main); if (main) setExpandedPkgs(new Set([main.package_id])); }) @@ -85,12 +88,21 @@ export function DARPackageTree({ instance, mainID, role }: Props) { return (
    -
    - {state.data.packages.length} package - {state.data.packages.length === 1 ? "" : "s"} · sha256{" "} - - {state.data.sha256.slice(0, 12)}… - +
    + + {state.data.packages.length} package + {state.data.packages.length === 1 ? "" : "s"} · sha256 + +
    {state.data.packages.map((pkg) => ( )} - - {pkg.name || pkg.package_id.slice(0, 12)} + + {pkg.name || midId(pkg.package_id)} {pkg.version && ( - + {pkg.version} )} - - {pkg.lf_version} · {pkg.package_id.slice(0, 10)}… + + {pkg.lf_version} · {midId(pkg.package_id)} {expanded && @@ -228,7 +259,7 @@ function ModuleNode({
    {(mod.templates ?? []).map((t) => (
    - template{" "} + template{" "} {t.name} {t.choices && t.choices.length > 0 && ( @@ -241,7 +272,7 @@ function ModuleNode({ ))} {(mod.interfaces ?? []).map((i) => (
    - interface{" "} + interface{" "} {i.name} {i.choices && i.choices.length > 0 && ( @@ -261,7 +292,7 @@ function ModuleNode({ ))} {(mod.data_types ?? []).map((dt) => (
    - data{" "} + data{" "} {dt}
    ))} @@ -301,7 +332,9 @@ function Chip({ kind: "choice" | "method"; }) { const tone = - kind === "choice" ? { bg: `${W.brand}1A`, fg: W.brand } : { bg: "#8FA3EE22", fg: "#8FA3EE" }; + kind === "choice" + ? { bg: tint(W.brand, 10), fg: W.brand } + : { bg: tint(W.mag, 13), fg: W.mag }; return ( ("app-user"); - // Which participants an upload fans out to (the backend dials each in - // parallel). Default ON for all three so "vet everywhere" is one - // drag-and-drop. Orthogonal to `role`, which drives the package LIST: - // the user can read one participant's packages while uploading to a - // different subset. + // Participants an upload fans out to (parallel, backend-side). + // Orthogonal to `role`, which drives only the package LIST. const [vetTargets, setVetTargets] = useState>({ "app-user": true, "app-provider": true, @@ -74,17 +63,13 @@ export function DARScreen() { | { kind: "err"; error: string } >({ kind: "loading" }); const [selectedHash, setSelectedHash] = useState(null); - // Diff mode: a picked "compare with" target flips the right drawer - // from the inspect tree to DARDiff. Kept separate from selectedHash - // so the user can toggle the comparison off without losing their + // Separate from selectedHash so toggling the comparison off keeps the // primary selection. const [compareHash, setCompareHash] = useState(null); const [upload, setUpload] = useState({ kind: "idle" }); const [dragOver, setDragOver] = useState(false); const [filter, setFilter] = useState<"all" | "app">("all"); const [tick, setTick] = useState(0); // bump to refetch after upload - // Per-participant vetting per listed DAR, keyed by main package id; - // populated lazily by the batch-fetch effect below. const [vetting, setVetting] = useState>({}); const fileInputRef = useRef(null); @@ -139,9 +124,7 @@ export function DARScreen() { }); return; } - // Mirrors the backend's multipart cap (darUploadMax = 64 MiB in - // internal/ui/handlers/dar.go); reject client-side so an oversized - // DAR doesn't upload just to fail server-side. + // Mirrors the backend multipart cap (darUploadMax, dar.go). const MAX_DAR_BYTES = 64 * 1024 * 1024; const tooBig = arr.find((f) => f.size > MAX_DAR_BYTES); if (tooBig) { @@ -181,7 +164,6 @@ export function DARScreen() { if (state.kind !== "ok") return [] as DARRow[]; let list = state.data.dars; if (filter === "app") { - // Hide the canton/splice/daml system packages. list = list.filter( (d) => !d.name.startsWith("canton-builtin-") && @@ -192,17 +174,12 @@ export function DARScreen() { return list; }, [state, filter]); - // Reset the vetting cache when the instance changes or the list is - // refetched. Keyed by main id, so a role switch — same DARs, - // different participant's list — reuses already-fetched verdicts. useEffect(() => { setVetting({}); }, [name, tick]); - // Lazily fetch real per-participant vetting for each visible row (the - // endpoint fans out to all three participants server-side) so the - // list column reflects ledger state. Rows are marked "loading" in one - // batch before dispatch so re-renders never double-fetch. + // Lazily fetch per-participant vetting for each visible row; rows are + // marked "loading" in one batch so re-renders never double-fetch. const visibleMains = useMemo(() => rows.map((d) => d.main).join(","), [rows]); useEffect(() => { if (!name || state.kind !== "ok") return; @@ -231,8 +208,7 @@ export function DARScreen() { return () => { cancelled = true; }; - // visibleMains captures the row-set identity; vetting is read via - // the functional updater so it isn't a dependency (would loop). + // vetting read via functional updater to keep it out of the deps (would loop). // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, state.kind, visibleMains]); @@ -279,7 +255,7 @@ export function DARScreen() { - {state.kind === "loading" && Loading DAR list…} + {state.kind === "loading" && } {state.kind === "err" && } {state.kind === "port-missing" && ( - {/* LEFT — upload + vetting + watch mode */}
    {upload.kind === "uploading" ? ( @@ -359,7 +332,7 @@ export function DARScreen() { Drop DAR here
    - or click to browse · multi-file ok + or click to browse · multiple .dar accepted
    )} @@ -448,7 +421,6 @@ export function DARScreen() {
    - {/* MIDDLE — package list */}
    Packages on {role} participant
    @@ -492,7 +464,6 @@ export function DARScreen() {
    - {/* Column header */}
    - {/* RIGHT — inspect drawer / diff viewer */} (null); const [active, setActive] = useState(false); - // Re-render every 10s so the "ago" label stays fresh. const [, setNow] = useState(Date.now()); useEffect(() => { @@ -598,14 +565,18 @@ function WatchModeCard({ instance }: { instance: string }) {
    + {active ? "Watching" : "Idle"} {last && ( @@ -639,8 +610,6 @@ function WatchModeCard({ instance }: { instance: string }) { ); } -// formatAgo renders a "X ago" label for a unix-second delta; bands -// finer than 5s read as noise on this card. function formatAgo(deltaSec: number): string { if (deltaSec < 5) return "just now"; if (deltaSec < 60) return `${Math.floor(deltaSec)}s ago`; @@ -649,8 +618,7 @@ function formatAgo(deltaSec: number): string { return `${Math.floor(deltaSec / 86400)}d ago`; } -// VetState is the per-row vetting cell state; undefined means not yet -// requested. +// undefined means not yet requested. type VetState = | { kind: "loading" } | { kind: "ok"; rows: DARVettingRow[] } @@ -676,11 +644,10 @@ function PkgRow({ gap: 14, padding: "10px 14px", alignItems: "center", - background: active ? `${W.brand}10` : "transparent", - borderLeft: active ? `2px solid ${W.brand}` : "2px solid transparent", - paddingLeft: active ? 12 : 14, + background: active ? tint(W.brand, 12) : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", + transition: "background-color 120ms", }} > {row.name} - - {row.version} - - {row.main.slice(0, 12)}…{row.main.slice(-6)} + {row.version} +
    ); } -// VettingCell renders per-participant vetting for one DAR as a compact -// "U P S" trio of dots — green vetted, grey unvetted, amber "?" when -// that participant couldn't be probed. Matches the CLI `dar list -// --vetting` column and the inspect-drawer toggles. +// Per-participant vetting as a "U P S" dot trio: green vetted, grey +// unvetted, amber "?" when a participant couldn't be probed. function VettingCell({ vet }: { vet: VetState | undefined }) { if (!vet || vet.kind === "loading") { return ( @@ -751,7 +711,7 @@ function VettingCell({ vet }: { vet: VetState | undefined }) { {vet.rows.map((r) => { const abbr = r.role === "app-user" ? "U" : r.role === "app-provider" ? "P" : "S"; - const color = r.error ? W.warn : r.vetted ? "#7CC89A" : W.dim; + const color = r.error ? W.warn : r.vetted ? W.ok : W.dim; const title = r.error ? `${r.role}: ${r.error}` : `${r.role}: ${r.vetted ? "vetted" : "not vetted"}`; @@ -794,14 +754,16 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - padding: 32, - textAlign: "center", + borderRadius: R.card, + padding: 14, + textAlign: "left", color: W.dim, fontSize: 13, + lineHeight: 1.5, }} > - Select a package to inspect. + Select a package to inspect its tree, per-participant vetting, and + structural diff.
    ); } @@ -816,7 +778,7 @@ function InspectDrawer({ >
    - + {row.name} @@ -830,7 +792,18 @@ function InspectDrawer({ )}
    - +
    + pkg-id + +
    {row.description && ( @@ -873,9 +846,7 @@ function InspectDrawer({ ); } -// CompareSelector renders a small "compare with…" dropdown of every -// DAR currently visible in the list (excluding the active one). -// Picking a target flips the drawer into diff mode. +// "compare with…" dropdown; picking a target flips the drawer to diff mode. function CompareSelector({ allRows, currentMain, @@ -919,10 +890,8 @@ function CompareSelector({ ); } -// VettingPanel renders the per-participant vetting state for one -// DAR and lets the user toggle each. Loads on mount, refetches after -// every successful toggle so the UI never shows a stale "vetted=true" -// after an UnvetDar succeeded. +// Per-participant vetting toggles; refetches after each successful +// toggle so state never goes stale. function VettingPanel({ instance, mainID, @@ -1014,14 +983,14 @@ function VettingPanel({ border: "none", padding: 0, cursor: pending === r.role ? "wait" : "pointer", - color: r.vetted ? "#7CC89A" : W.dim, + color: r.vetted ? W.ok : W.dim, }} > onChange(r)} style={{ - background: active ? W.surface : "transparent", - color: active ? W.text : W.dim, + background: active ? tint(W.brand, 16) : "transparent", + color: active ? W.brand : W.dim, border: "none", - borderRadius: 2, + borderRadius: R.control, padding: "5px 12px", fontSize: 12, fontFamily: wMono, fontWeight: active ? 600 : 500, cursor: active ? "default" : "pointer", - boxShadow: active ? `0 0 0 1px ${W.brand}` : "none", + transition: "background-color 120ms", }} > {r} @@ -1267,7 +1234,7 @@ function VetToggle({ style={{ width: 24, height: 14, - background: on ? W.brand : "#313B52", + background: on ? W.brand : W.borderHi, borderRadius: 999, position: "relative", flexShrink: 0, @@ -1309,7 +1276,7 @@ function FilterBtn({ fontSize: 11.5, borderRadius: 2, border: `1px solid ${active ? W.brand : W.border}`, - background: active ? `${W.brand}1A` : "transparent", + background: active ? `${tint(W.brand, 10)}` : "transparent", color: active ? W.brand : W.dim, cursor: "pointer", fontFamily: wMono, @@ -1321,8 +1288,6 @@ function FilterBtn({ ); } -// ─── Tiny shared primitives ───────────────────────────────── - function Card({ title, subtitle, @@ -1383,8 +1348,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
    {children} @@ -1418,7 +1383,8 @@ function KV({ color: color ?? W.text2, fontSize: mono ? 11 : 12, fontFamily: mono ? wMono : undefined, - wordBreak: "break-all", + fontVariantNumeric: mono ? "tabular-nums" : undefined, + wordBreak: "break-word", }} > {value} @@ -1451,19 +1417,38 @@ function Row({ ); } -function Status({ children }: { children: React.ReactNode }) { +// Package-list skeleton, gated so a fast local fetch never flashes it. +function DARListLoading() { + const show = useLoadingDelay(true); return (
    - {children} +
    + Loading package list +
    + {show ? ( + + ) : ( +
    + )}
    ); } @@ -1495,7 +1480,7 @@ function EmptyPanel({ return (
    | "error", warning?: string, @@ -23,7 +13,6 @@ function mockListResponse( vi.stubGlobal( "fetch", vi.fn().mockImplementation((url: string) => { - // /api/instances/:name detail — for InstanceDetail card. if (url.match(/\/api\/instances\/[^/?]+(?:\?|$)/)) { return Promise.resolve( new Response( @@ -43,9 +32,7 @@ function mockListResponse( ), ); } - // /api/instances/{name}/containers — ContainerHealth's - // 3s poll. Return empty list so the panel renders the - // "no containers" placeholder rather than the error path. + // Empty list so ContainerHealth renders its placeholder, not the error path. if (url.match(/\/api\/instances\/[^/?]+\/containers/)) { return Promise.resolve( new Response( @@ -63,8 +50,7 @@ function mockListResponse( ), ); } - // /api/instances/{name}/transactions — the RecentActivity - // panel's ledger-event scan, fired only for a running instance. + // RecentActivity's ledger-event scan, fired only for a running instance. if (url.includes("/transactions")) { if (txOverride) { return Promise.resolve( @@ -99,7 +85,6 @@ function mockListResponse( ), ); } - // /api/instances list — primary fetch. if (url.includes("/api/instances")) { if (instances === "error") { return Promise.resolve( @@ -130,9 +115,7 @@ function mockListResponse( ), ); } - // JWT + app-config — DeveloperSetup fires these once the - // instance is selected. Return minimal payloads to keep - // the components happy. + // DeveloperSetup fires these once an instance is selected. if (url.includes("/jwt")) { return Promise.resolve( new Response( @@ -177,19 +160,15 @@ describe("Dashboard", () => { ]); renderDashboard(); - // "demo" appears in the table AND in the InstanceDetail - // header (auto-selected); "hubble" only in the table. - // Scope to so we're asserting the row, not the - // detail card's echo. + // Scope to
    so we assert the row, not the detail card's echo of "demo". await waitFor(() => { const table = screen.getByRole("table"); expect(within(table).getByText("demo")).toBeInTheDocument(); expect(within(table).getByText("hubble")).toBeInTheDocument(); }); - // STATE badges within the table. const table = screen.getByRole("table"); - expect(within(table).getByText("running")).toBeInTheDocument(); - expect(within(table).getByText("stopped")).toBeInTheDocument(); + expect(within(table).getByText("Running")).toBeInTheDocument(); + expect(within(table).getByText("Stopped")).toBeInTheDocument(); }); it("renders the EmptyState when no instances are registered", async () => { @@ -198,8 +177,6 @@ describe("Dashboard", () => { await waitFor(() => { expect(screen.getByText(/no localnet instances/i)).toBeInTheDocument(); }); - // The remediation hint must include the dpm command — this - // is the user's first interaction with an empty UI. expect(screen.getByText(/dpm localnet up/i)).toBeInTheDocument(); }); @@ -212,9 +189,6 @@ describe("Dashboard", () => { }); it("renders the warning strip when ListResponse.warning is set", async () => { - // Same warning the CLI's `dpm localnet list` surfaces (e.g. - // registry parse drift). Should show as an amber strip above - // the table. mockListResponse( [{ name: "demo", status: "running" }], "registry has 1 unreadable entry; ignoring", @@ -234,20 +208,12 @@ describe("Dashboard", () => { ]); renderDashboard(); - // The auto-pick rule picks demo (first running). Click on - // hubble's row to override. + // Auto-pick selects demo (first running); click hubble to override. const hubbleCell = await screen.findByText("hubble"); await userEvent.click(hubbleCell); - // After selection, the InstanceDetail card pops with the - // detail-fetched data. We fetch a static "demo" detail in - // the mock, but the card header echoes the URL-selected - // name (hubble), so look for that as the source-of-truth. + // InstanceDetail only renders once selection is non-null. await waitFor(() => { - // The hubble cell should now show in the brand colour - // class — but we can't easily check colour. Instead pin - // that the InstanceDetail section appeared, which only - // happens once selection is non-null. expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); }); @@ -259,9 +225,7 @@ describe("Dashboard", () => { ]); renderDashboard(); - // InstanceDetail appears because the auto-pick selected demo. - // Without the auto-pick rule there'd be no selected - // instance and the detail card wouldn't render. + // InstanceDetail renders only because auto-pick selected demo. await waitFor(() => { expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); @@ -270,8 +234,6 @@ describe("Dashboard", () => { it("shows the recent-activity panel with ledger events for a running instance", async () => { mockListResponse([{ name: "demo", status: "running" }]); renderDashboard(); - // The panel mounts for the auto-selected running instance and - // flattens transactions → one row per ledger event. await waitFor(() => expect(screen.getByText(/recent activity/i)).toBeInTheDocument(), ); @@ -292,8 +254,7 @@ describe("Dashboard", () => { }); it("recent-activity shows the restart-to-capture hint for the no-JWT-recorded 500", async () => { - // The real e2e-metrics-demo case: instances predating JWT capture - // return a generic 500, distinguished by message, not a code. + // Instances predating JWT capture return a generic 500 distinguished by message, not code. mockListResponse([{ name: "demo", status: "running" }], undefined, { status: 500, body: { code: "INTERNAL", error: "no JWT recorded for role app-provider" }, diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx index b8317603..ae21898e 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -6,9 +6,12 @@ import { type TransactionEvent, type TransactionRow, } from "../api"; -import { W, wMono, tableCaps } from "../tokens"; +import { W, wMono, tableCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; -import { Dot, IcPlus, IcRefresh } from "../components/icons"; +import { IcPlus, IcRefresh } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { MonoId } from "../components/MonoId"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { useInstanceSelection } from "../shell/useInstanceSelection"; import { ContainerHealth } from "./ContainerHealth"; import { CreateLocalNetModal } from "./CreateLocalNetModal"; @@ -16,16 +19,12 @@ import { CreatingPanel } from "./CreatingPanel"; import { DeveloperSetup } from "./DeveloperSetup"; import { InstanceDetail } from "./InstanceDetail"; -// Dashboard — the Overview screen. Renders the registered-instance -// table from GET /api/instances. -// -// Selection state lives in the URL (?instance=) via -// useInstanceSelection so the topbar switcher and Dashboard agree on a -// single source of truth — and so shared links preserve the user's -// pick. +// Selection state lives in the URL (?instance=) so the topbar +// switcher and Dashboard share one source of truth and links survive. export function Dashboard() { const sel = useInstanceSelection(); const [createOpen, setCreateOpen] = useState(false); + const showSkeleton = useLoadingDelay(sel.loading); return (
    @@ -41,8 +40,7 @@ export function Dashboard() { LocalNet instances
    - - - - + + + + - {instances.map((i) => ( - onSelect(i.name)} - style={{ - borderTop: `1px solid ${W.border}`, - background: i.name === selected ? W.surface2 : undefined, - cursor: "pointer", - }} - > - - - - - - ))} + {instances.map((i) => { + const isSel = i.name === selected; + return ( + onSelect(i.name)} + style={{ + borderTop: `1px solid ${W.border}`, + // Flat fill, no padding swap, so the row never shifts on select. + background: isSel ? W.selRow : undefined, + cursor: "pointer", + }} + > + + + + + + ); + })}
    NAMESTATESPLICEPORTSNameStateSplicePorts
    - - {i.name} - - - - {i.splice_version} - {i.ports} -
    + + {i.name} + + + + + {i.splice_version} + {i.ports}
    ); } +function InstanceTableLoading() { + return ( +
    + +
    + ); +} + const th: React.CSSProperties = { ...tableCaps, padding: "8px 12px", @@ -221,31 +231,11 @@ const td: React.CSSProperties = { verticalAlign: "middle", }; -function StatusBadge({ status }: { status: string }) { - const color = (() => { - switch (status) { - case "running": - return W.ok; - case "creating": - case "stopping": - case "partial": - return W.warn; - case "failed": - return W.err; - case "stopped": - default: - return W.dim; - } - })(); - return ( - - - {status} - - ); -} +const numCell: React.CSSProperties = { + textAlign: "right", + fontFamily: wMono, + fontVariantNumeric: "tabular-nums", +}; function EmptyState({ onCreate }: { onCreate: () => void }) { return ( @@ -253,10 +243,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - padding: 32, + borderRadius: R.card, + padding: 16, color: W.dim, - textAlign: "center", }} >

    @@ -273,7 +262,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {

    Or run{" "} - dpm localnet up --name demo{" "} + + dpm localnet up --name demo + {" "} in your terminal.

    @@ -283,10 +274,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { function ErrorPanel({ error }: { error: string }) { return (
    -

    Recent activity

    +

    Recent activity

    ledger events · as seen by the app-provider participant @@ -391,15 +378,15 @@ function RecentActivity({ name }: { name: string }) { )} {state.kind === "needs-jwt" && (
    - Ledger activity needs a party-rights JWT — Splice LocalNet signs user-id tokens by + Ledger activity needs a party-rights JWT. Splice LocalNet signs user-id tokens by default. Open the Explorer to project through a specific party.
    )} {state.kind === "err" && (
    {/no jwt recorded/i.test(state.error) - ? "Ledger activity needs recorded role JWTs — restart the instance to capture them (older instances predate JWT capture)." - : `Ledger activity unavailable — ${state.error}.`}{" "} + ? "Ledger activity needs recorded role JWTs. Restart the instance to capture them (older instances predate JWT capture)." + : `Ledger activity unavailable. ${state.error}.`}{" "} Open the Explorer for the full ledger view.
    )} @@ -421,13 +408,14 @@ function RecentActivity({ name }: { name: string }) { {events.map((e) => ( - {e.time} + {e.time} {e.event} - - {e.cid.slice(0, 10)}… + + ))} @@ -453,9 +441,7 @@ function RecentActivity({ name }: { name: string }) { ); } -// shortTemplate drops the package-id prefix from a fully-qualified -// template id (`:Module:Entity` → `Module:Entity`) for a compact, -// readable EVENT column. +// `:Module:Entity` → `Module:Entity` for a compact EVENT column. function shortTemplate(t?: string): string { if (!t) return "—"; const parts = t.split(":"); diff --git a/frontend/src/screens/DeveloperSetup.tsx b/frontend/src/screens/DeveloperSetup.tsx index 4c58290b..ca0e3aab 100644 --- a/frontend/src/screens/DeveloperSetup.tsx +++ b/frontend/src/screens/DeveloperSetup.tsx @@ -9,27 +9,13 @@ import { } from "../api"; import { W, wMono } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; -// DeveloperSetup — the "Developer setup" card. Two sub-panels: -// -// 1. JWT generator: role/audience picker + a usable token preview + -// copy button. LocalNet is loopback-only with dev-secret tokens -// (the dev-secret warning renders below), so the raw token is -// surfaced directly — no redaction toggle. -// -// 2. App config exporter: format tabs (env / json / yaml) + monospace -// preview + copy button, all backed by -// /api/instances/{name}/app-config?format=. -// -// The Dashboard owns instance selection; this component just receives -// `name` as a prop. +// Two panels: a JWT generator and an app-config exporter (env/json/yaml). const ROLES = ["app-provider", "app-user", "sv"] as const; type Role = (typeof ROLES)[number]; -// The backend redacts JWTs by default; this LocalNet-only UI opts -// into the raw token (?include_jwt=true) so the generated token is -// usable as-is. The dev-secret warning makes the trade-off explicit. export function DeveloperSetup({ name }: { name: string }) { return (
    (null); const [busy, setBusy] = useState(false); - // Issue a usable JWT on mount + whenever role/audience/name changes. - // include_jwt=true so the raw token is returned — LocalNet only. + // include_jwt=true returns the raw token, usable as-is (LocalNet only). useEffect(() => { let cancelled = false; setBusy(true); @@ -103,9 +88,11 @@ function JwtPanel({ name }: { name: string }) { /> - - {jwt?.party ?? "—"} - + {jwt?.party ? ( + + ) : ( + + )}
    @@ -220,11 +207,6 @@ function AppConfigPanel({ name }: { name: string }) { ); } -// ──────────────────────── shared primitives ───────────────────────── -// -// Kept inline while this screen is the only consumer; promote to a -// shared module when a second screen needs them. - interface CardProps { title: string; subtitle?: string; @@ -299,7 +281,7 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { onClick={() => onChange(opt)} style={{ background: opt === value ? W.brand : W.surface2, - color: opt === value ? "#0B0F1A" : W.text2, + color: opt === value ? W.onAccent : W.text2, border: `1px solid ${opt === value ? W.brand : W.border}`, borderRadius: 2, padding: "4px 10px", @@ -316,9 +298,8 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { } function TokenBox({ token, revealed }: { token: string; revealed: boolean }) { - // Split the JWT into header.payload.signature for the colored - // preview. Placeholders ("—", "…") aren't 3-part tokens, so they - // render as plain text. + // header.payload.signature for the colored preview; placeholders + // ("—", "…") aren't 3-part tokens and render as plain text. const parts = token.split("."); const isJwt = parts.length === 3 && revealed; return ( diff --git a/frontend/src/screens/DoctorScreen.tsx b/frontend/src/screens/DoctorScreen.tsx index 69470114..fc2a56aa 100644 --- a/frontend/src/screens/DoctorScreen.tsx +++ b/frontend/src/screens/DoctorScreen.tsx @@ -7,44 +7,30 @@ import { fetchDoctor, fetchSpliceVersions, } from "../api"; -import { W, wMono, wideCaps } from "../tokens"; +import { W, wMono, wideCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; -// DoctorScreen — the Web UI surface for `dpm localnet doctor`. -// -// GET /api/doctor runs the same shared localnet.CollectDoctor collector -// as the CLI verb: the resource/Docker gate /api/preflight exposes, -// plus two advisory checks (platform-support matrix + host-port -// availability). The report shape is types.PreflightReport — identical -// to the create-modal preflight panel — so the two surfaces can't -// drift. -// -// Not instance-scoped: doctor diagnoses the HOST, so it sits in the nav -// alongside Overview rather than under an instance selector. - +// Web UI surface for `dpm localnet doctor`: GET /api/doctor runs the +// same shared CollectDoctor collector as the CLI. Host-scoped, not per-instance. export function DoctorScreen() { const [report, setReport] = useState(null); const [versions, setVersions] = useState([]); - // "" → server's "latest" alias. The picker lets an operator grade - // the memory checks against a heavier Splice version's floor before - // they commit to creating an instance on that version. + // "" → server's "latest" alias; the picker grades memory checks + // against a chosen Splice version's floor before committing to it. const [version, setVersion] = useState(""); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); - // Load the curated version list once so the picker can offer the - // same tags the create modal does. A failure here is non-fatal: the - // doctor still runs against "latest", we just hide the picker. + // Non-fatal: on failure the picker hides and doctor runs against "latest". useEffect(() => { let cancelled = false; fetchSpliceVersions() .then((r) => { if (!cancelled) setVersions(r.versions); }) - .catch(() => { - /* picker stays hidden; doctor still works against latest */ - }); + .catch(() => {}); return () => { cancelled = true; }; @@ -70,8 +56,6 @@ export function DoctorScreen() { }; }, []); - // Re-run whenever the selected version changes (including the first - // mount with the default "latest"). useEffect(() => run(version), [run, version]); return ( @@ -86,31 +70,70 @@ export function DoctorScreen() { {report && } - {err && ( + {err && run(version)} />} + + {loading && !report && !err && } + + {report?.sections.map((sec) => ( +
    + ))} +
    + ); +} + +function DoctorError({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
    + Couldn't run host checks.{" "} + The doctor endpoint didn't respond. Confirm the devkit server is up, + then retry. +
    + +
    +
    + + Server message +
    - {err} -
    - )} - - {loading && !report && ( -
    - Running host checks… + {message}
    - )} +
    +
    + ); +} - {report?.sections.map((sec) => ( -
    - ))} +function DoctorLoading() { + const shown = useLoadingDelay(true); + if (!shown) return null; + return ( +
    +
    ); } @@ -169,10 +192,11 @@ function Header({ background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "5px 8px", fontSize: 12, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > @@ -197,9 +221,7 @@ function Header({ ); } -// SummaryBanner colors itself by the worst result: failing → red, -// warning → amber, all-pass → brand. Mirrors the CLI's colored summary -// Box so the two surfaces read the same. +// Colored by the worst result: fail → red, warn → amber, all-pass → brand. function SummaryBanner({ report }: { report: PreflightReport }) { const warned = report.sections.some((s) => s.checks.some((c) => c.result === "warn"), @@ -218,9 +240,9 @@ function SummaryBanner({ report }: { report: PreflightReport }) { style={{ marginTop: 16, padding: "12px 14px", - background: `${accent}14`, + background: tint(accent, 8), border: `1px solid ${accent}`, - borderRadius: 4, + borderRadius: R.control, color: accent, fontSize: 13, fontWeight: 600, @@ -263,7 +285,7 @@ function Section({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, overflow: "hidden", }} > @@ -318,6 +340,7 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { color: W.dim, fontSize: 11.5, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", marginTop: 2, }} > diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx index 294d7b26..e3e787f0 100644 --- a/frontend/src/screens/ExplorerScreen.tsx +++ b/frontend/src/screens/ExplorerScreen.tsx @@ -16,24 +16,16 @@ import { import { useInstanceSelection } from "../shell/useInstanceSelection"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; -import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps } from "../tokens"; +import { MonoId } from "../components/MonoId"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; +import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../tokens"; import { ContractDetailDrawer } from "./ContractDetailDrawer"; import { TxReplayDrawer } from "./TxReplayDrawer"; -// ExplorerScreen — live Active Contract Set, transaction history, and -// per-party visibility for the selected instance. -// -// The ACS table is a live snapshot + SSE delta stream: an initial -// snapshot fills it, an EventSource applies create/archive deltas, and -// a 30s timer reconciles drift. The Transactions view supports the -// same party/template/offset filters the CLI `tx ls` has, and each -// transaction row can be replayed as a per-party visibility projection -// (the Web UI counterpart of `tx replay`). - const ROLES: Role[] = ["app-user", "app-provider", "sv"]; -// Hash palette for template/party dots — the dataviz ramp ordered so -// neighbouring indices never share a hue family, and no danger red -// (red stays reserved for errors). +// Template/party dot palette, ordered so neighbouring indices differ in +// hue; no red (reserved for errors). const PALETTE = [ "#6480E6", "#7BD2C6", "#DDB25E", "#7CC89A", "#93A7F0", "#C8971F", "#189E8C", "#9BA3B5", @@ -41,6 +33,12 @@ const PALETTE = [ type View = "contracts" | "transactions" | "timeline"; +// Honour the OS reduced-motion setting for the timeline glyph fades. +const prefersReducedMotion = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + export function ExplorerScreen() { const sel = useInstanceSelection(); const name = sel.selected; @@ -57,19 +55,13 @@ export function ExplorerScreen() { const [activeParties, setActiveParties] = useState>(new Set()); const [search, setSearch] = useState(""); const [selectedCid, setSelectedCid] = useState(null); - // Live-stream status: "live" after the first frame, "reconnecting" - // while the browser retries a dropped connection, "truncated" when - // the backend hit its event cap. const [streamStatus, setStreamStatus] = useState< "idle" | "live" | "reconnecting" | "truncated" >("idle"); const searchRef = useRef(null); - // refreshSnapshot fills the table from the snapshot endpoint. - // Background callers (the 30s reconciliation timer, SSE recovery) - // pass quiet=true so the table repopulates in place without - // flashing the loading panel; the initial mount uses quiet=false - // so users see "Snapshotting ACS…" before the first paint. + // quiet=true (reconciliation timer, SSE recovery) repopulates in place; + // quiet=false (initial mount) shows the loading panel first. const refreshSnapshot = useCallback( async (instance: string, asRole: Role, quiet: boolean) => { if (!quiet) { @@ -114,8 +106,7 @@ export function ExplorerScreen() { error: e instanceof ApiError ? e.message : "failed to load ACS", }); } - // Quiet background failures are swallowed — the user keeps - // the last-known good state and the next tick retries. + // Quiet background failures are swallowed; next tick retries. } }, [], @@ -127,19 +118,14 @@ export function ExplorerScreen() { void refreshSnapshot(name, role, false); }, [name, role, refreshSnapshot]); - // Live SSE subscription, mounted once the snapshot has loaded; - // tears down when the instance/role changes or the screen unmounts. - // EventSource auto-reconnects on transient failures; the `error` - // listener triggers a snapshot refetch to recover missed events. - // - // Deltas are applied via a Map, which dedupes - // create-then-archive races: an archive arriving before its create - // removes nothing, so either ordering converges to the same state. + // Live SSE subscription, mounted once the snapshot has loaded. Deltas + // apply via a Map so create/archive races converge to + // the same state regardless of arrival order. useEffect(() => { if (!name) return; if (state.kind !== "ok") return; - // Resume from the snapshot's `ledger_end` so no events are - // skipped between the snapshot fetch and the stream open. + // Resume from ledger_end so no events are skipped between the + // snapshot fetch and the stream open. const es = openContractsStream(name, role, state.data.ledger_end); let opened = false; const onMessage = (raw: MessageEvent) => { @@ -153,8 +139,6 @@ export function ExplorerScreen() { } if (payload.event === "truncated") { setStreamStatus("truncated"); - // Backend stopped sending — reconcile and we'll re-open - // when the user picks a different instance. void refreshSnapshot(name, role, true); return; } @@ -190,9 +174,8 @@ export function ExplorerScreen() { }; es.addEventListener("contracts", onMessage as EventListener); es.onerror = () => { - // EventSource auto-reconnects unless closed. Show the - // reconnecting state and reconcile via snapshot — the browser - // may have been suspended (lid-close) for minutes. + // EventSource auto-reconnects; reconcile via snapshot since the + // browser may have been suspended for minutes. setStreamStatus("reconnecting"); if (opened) { void refreshSnapshot(name, role, true); @@ -203,13 +186,12 @@ export function ExplorerScreen() { es.close(); setStreamStatus("idle"); }; - // Depend on state.kind (not state) so the subscription is set up - // once per snapshot transition, not on every contract-list change. + // Depend on state.kind (not state) so the subscription resets once + // per snapshot transition, not on every contract-list change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, role, state.kind, refreshSnapshot]); - // Every 30s, quietly re-pull the snapshot to correct any drift the - // SSE deltas missed (network hiccups, browser suspend, restarts). + // Every 30s, re-pull the snapshot to correct drift the SSE deltas missed. useEffect(() => { if (!name) return; if (state.kind !== "ok") return; @@ -220,8 +202,7 @@ export function ExplorerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, role, state.kind, refreshSnapshot]); - // Keyboard: "/" focuses search (unless typing in an editable - // element); Esc clears the selection. + // "/" focuses search (unless already in an editable); Esc clears selection. useEffect(() => { const onKey = (e: KeyboardEvent) => { const active = document.activeElement as HTMLElement | null; @@ -241,7 +222,7 @@ export function ExplorerScreen() { return () => window.removeEventListener("keydown", onKey); }, [selectedCid]); - // Derive template + party facets from the (unfiltered) ACS. + // Template + party facets from the unfiltered ACS. const facets = useMemo(() => { if (state.kind !== "ok") return { templates: [], parties: [] }; const tpl = new Map(); @@ -257,7 +238,7 @@ export function ExplorerScreen() { return { templates: colored(tpl), parties: colored(pty) }; }, [state]); - // Filter the ACS in render. Search matches template, cid, payload JSON, party. + // Search matches template, cid, payload JSON, and party. const filtered = useMemo(() => { if (state.kind !== "ok") return []; const needle = search.trim().toLowerCase(); @@ -293,9 +274,7 @@ export function ExplorerScreen() { [state, selectedCid], ); - // j/k navigation over the *filtered* view so the user follows what - // they see, not the underlying ACS order. The drawer registers its - // own keydown listener (Esc + j/k) and invokes these callbacks. + // Navigate over the filtered view (what the user sees), not the ACS order. const goPrev = useCallback(() => { if (!selectedCid) return; const i = filtered.findIndex((c) => c.contract_id === selectedCid); @@ -339,8 +318,13 @@ export function ExplorerScreen() { streamStatus={streamStatus} /> - {state.kind === "loading" && Snapshotting ACS…} - {state.kind === "err" && } + {state.kind === "loading" && } + {state.kind === "err" && ( + void refreshSnapshot(name, role, false)} + /> + )} {state.kind === "port-missing" && ( - {/* LEFT — filter sidebar */}
    Stream - - {streamStatus} - +
    {state.data.ledger_end ?? "—"} @@ -447,7 +435,6 @@ export function ExplorerScreen() {
    - {/* CENTER — ACS table */}
    - {/* Column header row */}
    Template - Cid - Owner / signatory - Payload + Contract Id + Owner / Signatory + Payload Age Sig · Obs
    - {filtered.length === 0 && ( -
    - No contracts match the current filters. -
    - )} + {filtered.length === 0 && + (() => { + const hasAcsFilters = + activeTemplates.size > 0 || + activeParties.size > 0 || + search.trim() !== ""; + return ( +
    + {hasAcsFilters ? ( + <> + + No contracts match these filters.{" "} + {state.data.contracts.length.toLocaleString()} in the + snapshot. + + + + ) : ( + <> + + The active contract set is empty. Create a contract to + populate it. + + + dpm localnet tx submit + + + )} +
    + ); + })()}
    {filtered.map((c) => ( - + Showing {filtered.length} of {state.data.contracts.length} ·{" "} {streamStatus === "live" ? "live" : "snapshot"} @ offset{" "} {state.data.ledger_end ?? "—"} @@ -577,7 +614,6 @@ export function ExplorerScreen() {
    )} - {/* Detail drawer — fixed right-side overlay, outside the grid */} {state.kind === "ok" && view === "contracts" && selected && ( {v} ))}
    - {pillLabel} +
    ); } @@ -767,27 +786,28 @@ function FilterChip({ return ( + + ) : ( + <> + No updates in the current ledger window. + + dpm localnet tx ls + + + )}
    )} @@ -1125,8 +1179,6 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { active={!!hasFilters} /> {body} - {/* Replay drawer — fixed right-side overlay; the table keeps - its full width underneath. */} {replayId && ( s @@ -1272,7 +1321,7 @@ function TxRowComponent({ gap: 14, padding: "9px 14px", alignItems: "center", - background: open ? `${W.brand}10` : "transparent", + background: open ? `${tint(W.brand, 6)}` : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", }} @@ -1287,22 +1336,23 @@ function TxRowComponent({ > {tx.kind}
    - - {tx.offset.toLocaleString()} - - {tx.command_id ?? tx.update_id?.slice(0, 16) ?? "—"} + {tx.offset.toLocaleString()} + {tx.command_id ? ( + + ) : tx.update_id ? ( + + ) : ( + + )} {tx.event_count ?? "—"} @@ -1353,7 +1404,7 @@ function TxRowComponent({ {open && tx.events && tx.events.length > 0 && (
    - - {ev.contract_id.slice(0, 16)}… - +
    ); } -// TimelineView — time-axis strip showing every update as a coloured -// glyph. Clicking a glyph highlights it and shows quick metadata in -// a side card — useful for "what happened in the last minute". function TimelineView({ name, role }: { name: string; role: Role }) { const [state, setState] = useState< | { kind: "loading" } @@ -1436,10 +1481,12 @@ function TimelineView({ name, role }: { name: string; role: Role }) { | { kind: "port-missing"; remediation: string } | { kind: "err"; error: string } >({ kind: "loading" }); - // Click = persistent selection; hover = preview when nothing is - // selected. Click again or Esc clears. + // Click pins a selection; hover previews when nothing is pinned. const [selectedIdx, setSelectedIdx] = useState(null); const [hoverIdx, setHoverIdx] = useState(null); + // Bumped by the error-state Retry to re-run the fetch effect. + const [nonce, setNonce] = useState(0); + const reload = useCallback(() => setNonce((n) => n + 1), []); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1488,10 +1535,14 @@ function TimelineView({ name, role }: { name: string; role: Role }) { return () => { cancelled = true; }; - }, [name, role]); + }, [name, role, nonce]); - if (state.kind === "loading") return Loading timeline…; - if (state.kind === "err") return ; + if (state.kind === "loading") + return ( + + ); + if (state.kind === "err") + return ; if (state.kind === "port-missing") return (
    - {/* Activity strip */}
    {buckets.map((b, i) => { @@ -1563,11 +1610,8 @@ function TimelineView({ name, role }: { name: string; role: Role }) { style={{ flex: 1, height: h, - background: - b.count === 0 - ? W.border - : `linear-gradient(180deg, ${W.brand}66 0%, ${W.brand} 100%)`, - borderRadius: 2, + background: b.count === 0 ? W.border : W.brand, + borderRadius: R.control, }} /> ); @@ -1594,7 +1638,6 @@ function TimelineView({ name, role }: { name: string; role: Role }) { )}
    - {/* Event glyph row */}
    ); @@ -1672,14 +1711,12 @@ function TimelineView({ name, role }: { name: string; role: Role }) { {selectedIdx !== null - ? "Selected — click again or press Esc to clear." + ? "Pinned. Click again or press Esc to clear." : "Hover for preview · click to pin."}
    - {/* Detail overlay — hovered/pinned update, fixed to the right - edge so the timeline strip keeps its full width. */} {focused && (
    {focused.kind} - + offset {focused.offset.toLocaleString()}
    @@ -1777,7 +1816,8 @@ function Mono({ children }: { children: React.ReactNode }) { fontFamily: wMono, color: W.text2, fontSize: 11, - wordBreak: "break-all", + fontVariantNumeric: "tabular-nums", + wordBreak: "break-word", }} > {children} @@ -1817,11 +1857,9 @@ function hhmmss(iso: string): string { if (!Number.isFinite(d.getTime())) return iso; return d .toISOString() - .slice(11, 19); // "HH:MM:SS" + .slice(11, 19); } -// ─────── Tiny shared primitives ─────────────────────────────── - function Card({ title, subtitle, @@ -1836,7 +1874,7 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 10, }} > @@ -1883,11 +1921,11 @@ function Pill({ color, children }: { color: string; children: React.ReactNode }) return ( - {children} +
    ); } -function ErrorPanel({ msg }: { msg: string }) { +function TableLoading({ + columns, + rows, + rowHeight, +}: { + columns: (number | string)[]; + rows: number; + rowHeight: number; +}) { + const show = useLoadingDelay(true); + if (!show) return null; return (
    + +
    + ); +} + +function ErrorPanel({ msg, onRetry }: { msg: string; onRetry?: () => void }) { + return ( +
    - {msg} +
    + Could not load ledger data. +
    +
    + The participant did not answer. Check the instance is running, then + retry. +
    + {onRetry && ( + + )} +
    + Details + + {msg} + +
    ); } @@ -1944,17 +2037,21 @@ function EmptyPanel({ return (

    {title}

    -

    {body}

    -

    {remediation}

    +

    + {body} +

    +

    + {remediation} +

    ); } @@ -1967,8 +2064,6 @@ function Hint({ children }: { children: React.ReactNode }) { ); } -// ─────── Helpers ────────────────────────────────────────────── - function shortTemplate(tpl: string): string { const parts = tpl.split(":"); return parts.length >= 3 ? `${parts[1]}:${parts[2]}` : tpl; diff --git a/frontend/src/screens/InstanceDetail.test.tsx b/frontend/src/screens/InstanceDetail.test.tsx index db98bcc6..d81d9c2e 100644 --- a/frontend/src/screens/InstanceDetail.test.tsx +++ b/frontend/src/screens/InstanceDetail.test.tsx @@ -1,13 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { InstanceDetail } from "./InstanceDetail"; - -// InstanceDetail tests — surfaces every field the /api/instances/:name -// endpoint returns beyond the summary. Three states: -// -// 1. ok with full payload → grid populated -// 2. ok with live_probe_failed=true → warning pill in header -// 3. fetch error → red error line +import { ConfirmHost } from "../components/ConfirmDialog"; function mockInstanceFetch( body: object | { status: number; error: string }, @@ -44,14 +38,10 @@ describe("InstanceDetail", () => { render(); - // Wait for the loading state to clear. await waitFor(() => { expect(screen.getByText("0.4.12")).toBeInTheDocument(); }); - // Identity + runtime + paths — pin one from each block to - // catch a future refactor that drops a section. "cdk-demo" - // appears in both compose-project and container-prefix - // fields, so use getAllByText and assert the count. + // "cdk-demo" is both compose-project and container-prefix, hence count 2. expect(screen.getAllByText("cdk-demo")).toHaveLength(2); expect(screen.getByText("2h 14m")).toBeInTheDocument(); expect( @@ -152,8 +142,7 @@ describe("InstanceDetail", () => { }); it("shows em-dash for missing uptime", async () => { - // Uptime is optional in the type — a freshly-stopped instance - // may not carry it. The grid uses "—" as the muted fallback. + // Uptime is optional; the grid uses "—" as the muted fallback. mockInstanceFetch({ schema_version: 1, name: "demo", @@ -169,10 +158,8 @@ describe("InstanceDetail", () => { }); render(); - // Find the row labelled "uptime" and check its sibling. await waitFor(() => { const uptimeLabel = screen.getByText("uptime"); - // Sibling is the next div under the same grid-row. expect(uptimeLabel.nextElementSibling?.textContent).toBe("—"); }); }); @@ -230,10 +217,6 @@ describe("InstanceDetail", () => { }); it("posts to /recreate and fires onChanged when the Recreate button is clicked", async () => { - // The restart button is offered on running / paused / failed / - // partial. The click invokes recreateInstance which POSTs to the - // backend; on the 202 response the detail card refetches and - // bubbles onChanged so the dashboard's row updates. const fetchMock = vi.fn().mockImplementation((url: string) => { if (typeof url === "string" && url.endsWith("/recreate")) { return Promise.resolve( @@ -266,11 +249,13 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); const onChanged = vi.fn(); render( - , + <> + + + , ); // Wait for the Recreate button to appear (the action-button @@ -278,6 +263,10 @@ describe("InstanceDetail", () => { const restartBtn = await screen.findByRole("button", { name: /recreate/i }); fireEvent.click(restartBtn); + // Recreate routes through the confirm dialog; approve it. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /recreate/i })); + await waitFor(() => { const calls = fetchMock.mock.calls.map((c) => c[0]); expect( @@ -293,8 +282,6 @@ describe("InstanceDetail", () => { }); it("posts to /stop (not /down) when the Stop button is clicked on a running instance", async () => { - // Gentle Stop = docker compose stop, containers kept. Distinct - // from the Down button (docker compose down, removes containers). const fetchMock = vi.fn().mockImplementation((url: string) => { if (typeof url === "string" && url.endsWith("/stop")) { return Promise.resolve(new Response(null, { status: 204 })); @@ -335,7 +322,6 @@ describe("InstanceDetail", () => { typeof u === "string" && u.endsWith("/api/instances/demo/stop"), ), ).toBe(true); - // Must NOT have hit /down. expect( calls.some( (u: string) => typeof u === "string" && u.endsWith("/down"), @@ -348,7 +334,6 @@ describe("InstanceDetail", () => { it("posts to /start when the Start button is clicked on a stopped instance", async () => { const fetchMock = vi.fn().mockImplementation((url: string) => { if (typeof url === "string" && url.endsWith("/start")) { - // 204 fast-start path. return Promise.resolve(new Response(null, { status: 204 })); } return Promise.resolve( @@ -415,16 +400,22 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); const onChanged = vi.fn(); render( - , + <> + + + , ); const downBtn = await screen.findByRole("button", { name: /^Down$/ }); fireEvent.click(downBtn); + // Down routes through the confirm dialog; approve it. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /^Down$/ })); + await waitFor(() => { const calls = fetchMock.mock.calls.map((c) => c[0]); expect( @@ -438,9 +429,7 @@ describe("InstanceDetail", () => { }); it("re-fetches when the name prop changes", async () => { - // The Dashboard hands a new name when the user switches - // instances. Without the useEffect dep on `name`, the - // first-fetched detail would stick forever. + // Without the useEffect dep on `name`, the first detail would stick forever. let i = 0; vi.stubGlobal( "fetch", diff --git a/frontend/src/screens/InstanceDetail.tsx b/frontend/src/screens/InstanceDetail.tsx index fbbf6a4b..405d0647 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -12,7 +12,7 @@ import { stopInstance, unpauseInstance, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { IcEject, @@ -22,29 +22,22 @@ import { IcStop, IcX, } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonBar, useLoadingDelay } from "../components/Skeleton"; +import { confirmDialog } from "../components/ConfirmDialog"; import { BackupRestore } from "./BackupRestore"; -// UI endpoints the backend probed and found not serving HTTP. function unreachableUIs(inst: Instance): Endpoint[] { return (inst.endpoints ?? []).filter( (e) => e.reachability === "unreachable", ); } -// InstanceDetail — the per-instance detail card the dashboard shows -// when a row is selected. Surfaces the fields GET /api/instances/:name -// returns beyond the summary row (compose project, docker network, -// data dir, container prefix, uptime, live-probe state). interface Props { name: string; - // statusHint comes from the dashboard's always-fresh instance list - // and gates which action button renders. Falls back to this card's - // own fetched status if omitted — but the dashboard should pass it - // so the button reflects the latest list state immediately after - // onChanged, not the stale copy from this card's mount-time fetch. + // From the dashboard's fresh list; gates which action button renders. + // Falls back to this card's own fetched status when omitted. statusHint?: string; - // Refresh the dashboard's instance list after an action succeeds so - // the row's status updates. onChanged?: () => void; } @@ -54,18 +47,17 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { | { kind: "ok"; instance: Instance } | { kind: "err"; error: string } >({ kind: "loading" }); - // Bumped after an action so the cached instance.status doesn't lie - // about the post-action state. + // Bumped after an action so the cached instance.status is refetched. const [refetchTick, setRefetchTick] = useState(0); const [stopping, setStopping] = useState< | { kind: "idle" } | { kind: "running" } | { kind: "err"; message: string } >({ kind: "idle" }); + const showSkeleton = useLoadingDelay(state.kind === "loading"); async function onStop() { - // Gentle stop: `docker compose stop` keeps containers around for a - // fast Start. No destructive confirm needed — nothing is removed. + // docker compose stop keeps containers for a fast Start; no confirm needed. setStopping({ kind: "running" }); try { await stopInstance(name); @@ -81,15 +73,21 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { } async function onDown() { - if (!confirm(`Tear down instance ${name}? Containers will be removed via docker compose down. Data volumes are preserved.`)) { + if ( + !(await confirmDialog({ + title: "Tear down instance?", + body: `Removes ${name}'s containers and networks. Data volumes are preserved, so Start recreates it.`, + detail: `dpm localnet down ${name}`, + confirmLabel: "Down", + danger: true, + })) + ) { return; } setStopping({ kind: "running" }); try { await downInstance(name); setStopping({ kind: "idle" }); - // Refetch our own status, then notify the parent so the - // dashboard's row + ActionButton catch up too. setRefetchTick((n) => n + 1); onChanged?.(); } catch (e) { @@ -130,19 +128,19 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRecreate() { if ( - !confirm( - `Recreate ${name}? Containers will be brought down and back up via docker compose. ` + - `The recorded Splice version and profiles are preserved; data volumes are NOT touched.`, - ) + !(await confirmDialog({ + title: "Recreate instance?", + body: `Brings ${name} down then back up. The recorded Splice version and profiles are preserved. Data volumes are not touched.`, + detail: `dpm localnet down ${name} && dpm localnet up ${name}`, + confirmLabel: "Recreate", + })) ) { return; } setStopping({ kind: "running" }); try { await recreateInstance(name); - // 202 — recreate is async (down → up). Refresh both surfaces - // eagerly so the user sees the transitional status before the - // dashboard's next poll. + // 202 async (down → up); refresh eagerly to show the transitional status. setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -157,10 +155,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onStart() { setStopping({ kind: "running" }); try { - // 204 → fast `docker compose start` done; 202 → full bring-up in - // progress (containers had been removed). Either way, refresh - // both surfaces so the user sees the transitional status before - // the dashboard's next poll. + // 204 → fast start done; 202 → full bring-up (containers had been removed). await startInstance(name); setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); @@ -173,10 +168,13 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRemove() { if ( - !confirm( - `Remove ${name} from the registry?\n\nThis deletes the instance entry + state.json. ` + - `Docker volumes (if any) are NOT touched — for that, use \`dpm localnet remove --name ${name}\` from a terminal.`, - ) + !(await confirmDialog({ + title: "Remove from registry?", + body: `Deletes the ${name} entry and its state.json. Docker volumes (if any) are not touched. To drop those, run dpm localnet remove from a terminal.`, + detail: `dpm localnet remove --name ${name}`, + confirmLabel: "Remove", + danger: true, + })) ) { return; } @@ -185,8 +183,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { await scrubInstance(name); setStopping({ kind: "idle" }); onChanged?.(); - // No setRefetchTick — the entry is gone; the parent's refresh - // drops this whole card. + // No setRefetchTick — the entry is gone; the parent's refresh drops this card. } catch (e) { const msg = e instanceof ApiError ? e.message : "failed to remove"; setStopping({ kind: "err", message: msg }); @@ -196,9 +193,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { useEffect(() => { let cancelled = false; - // Show the loading placeholder only on a true name-change mount, - // not on a refetchTick bump — without this guard, every action - // would briefly blank the detail card. + // Only blank to loading on a name-change mount, not a refetchTick bump. if (refetchTick === 0) { setState({ kind: "loading" }); } @@ -224,7 +219,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -238,18 +233,16 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { style={{ color: W.warn, fontSize: 11, - border: `1px solid ${W.warn}`, - borderRadius: 2, + border: `1px solid ${tint(W.warn, 34)}`, + background: tint(W.warn, 13), + borderRadius: R.control, padding: "2px 8px", }} > - live probe failed + Live probe failed )} - {/* Prefer statusHint (parent's fresh list) over this card's - own fetch so the action button updates the instant the - dashboard refreshes. */} {(statusHint || state.kind === "ok") && ( e.label) .join(", ")}{" "} - not serving HTTP — usually a stale port overlay from an instance + not serving HTTP. Usually a stale port overlay from an instance created by an older DevKit. Use Recreate (or re-run{" "} dpm localnet up --name {name} @@ -307,32 +300,29 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
    )} - {state.kind === "loading" && ( -
    Loading…
    - )} + {state.kind === "loading" && showSkeleton && } {state.kind === "err" && ( -
    {state.error}
    +
    {state.error}
    )} {state.kind === "ok" && } - {/* Rendered even on loading/error so the user can still take a - snapshot of a mostly-broken instance for support tickets. */} + {/* Rendered even on loading/error so a broken instance can still be snapshotted. */} ); } function DetailGrid({ instance }: { instance: Instance }) { - // Identity first, then runtime, then on-disk locations. - const rows: Array<[string, React.ReactNode]> = [ - ["splice", instance.splice_version], - ["status", instance.status], - ["created", instance.created_at], - ["uptime", instance.uptime ?? "—"], - ["compose project", instance.compose_project], - ["docker network", instance.docker_network], - ["container prefix", instance.container_prefix], - ["project dir", instance.project_dir], - ["data dir", instance.data_dir], + // `mono` marks machine-string rows so prose values (status/uptime) stay proportional. + const rows: Array<[string, React.ReactNode, boolean]> = [ + ["splice", instance.splice_version, true], + ["status", , false], + ["created", instance.created_at, true], + ["uptime", instance.uptime ?? "—", false], + ["compose project", instance.compose_project, true], + ["docker network", instance.docker_network, true], + ["container prefix", instance.container_prefix, true], + ["project dir", instance.project_dir, true], + ["data dir", instance.data_dir, true], ]; return ( @@ -345,10 +335,17 @@ function DetailGrid({ instance }: { instance: Instance }) { fontSize: 12.5, }} > - {rows.map(([k, v]) => ( -
    + {rows.map(([k, v, mono]) => ( +
    {k}
    -
    +
    {v}
    @@ -357,20 +354,28 @@ function DetailGrid({ instance }: { instance: Instance }) { ); } -// ActionButton dispatches the right verb(s) per instance status. -// Registry status alone isn't enough — docker truth may diverge: -// -// - running/paused → Pause/Resume + Recreate + Stop + Down -// - failed/partial → Recreate + Down + Remove (containers MAY still -// be up even though the orchestrator gave up; -// compose down no-ops cleanly if not) -// - stopped → Start + Down + Remove -// - creating/other → no button (CreatingPanel owns that surface) -// -// Stop (docker compose stop) is the gentle halt — containers are kept -// so Start is fast. Down (docker compose down) removes containers; a -// following Start recreates them via up. On failed/partial, Down is -// labeled "Down containers" to signal a force-cleanup. +function DetailGridLoading() { + return ( +
    + {Array.from({ length: 6 }).map((_, r) => ( +
    + + +
    + ))} +
    + ); +} + +// Dispatches verbs per status; on failed/partial containers MAY still be +// up (compose down no-ops cleanly if not), so Down is offered there too. function ActionButton({ status, busy, @@ -447,9 +452,6 @@ function ActionButton({ ); } if (status === "failed" || status === "partial") { - // Recreate is offered because failed/partial often comes from a - // transient compose hiccup that a clean down + up resolves - // without losing the instance metadata. return (
    - Go from empty to a live, transferable token in one click — no party ids to paste. + Go from empty to a live, transferable token in one click. No party ids to paste.
    ) : (
    - {/* Left rail: instrument list (ACS-discovered) */}
    {list.map((t) => { const sym = t.symbol ?? t.instrument_id; @@ -460,8 +426,9 @@ export function TokensScreen() { onClick={() => setActiveSymbol(sym)} style={{ display: "block", width: "100%", textAlign: "left", padding: "10px 14px", - background: isActive ? W.surface2 : "transparent", border: "none", - borderLeft: `2px solid ${isActive ? W.brand : "transparent"}`, cursor: "pointer", + background: isActive ? tint(W.brand, 12) : "transparent", border: "none", + cursor: "pointer", + transition: `background-color ${FAST}`, }} >
    @@ -475,7 +442,6 @@ export function TokensScreen() { })}
    - {/* Right pane: detail + holdings + actions */}
    {active && (() => { const sym = active.symbol ?? active.instrument_id; @@ -509,11 +475,12 @@ export function TokensScreen() {
    -
    - admin {partyLabel(aliases, active.admin)} · id {active.instrument_id} +
    + admin {partyLabel(aliases, active.admin)} + · id +
    - {/* Overview / Activity tab switcher */}
    {(["overview", "activity"] as const).map((tab) => (