Reference

CLI reference

The hop CLI is the control surface for the registry, the local dev stack, and a running server’s operator RPCs. This page documents the commands that exist in the tree today, grouped by task; hopskip <command> --help is authoritative for flags. During the preview the surface still moves; see Compatibility & versioning.

Run hop with no arguments when you are not sure where you are: it prints what this project is pointed at, what is built and deployed, whether anything is listening, and the one command to run next.

Conventions every command follows

stdout is data, stderr is narration. A command’s result (a hash, a table, a JSON document) goes to stdout; everything a human reads about the work goes to stderr. So hop deploy $(hop build --manifest-path .) composes, and hop run … | jq sees the workflow’s result and nothing else.

--output json means only JSON. One document on stdout, no narration, no color, no --quiet needed. Supported by status, doctor, config, workflows, build, describe and run.

Color follows the environment. --color auto|always|never, plus NO_COLOR, CLICOLOR_FORCE and TERM=dumb. A non-UTF-8 locale gets ASCII glyphs instead of check marks.

Every failure ends in something to try. Errors name what went wrong, the cause chain, and pasteable commands that address it.

Exit codes distinguish the cases a script must tell apart:

CodeMeaning
0Success
1Failed, no more specific code
2Usage error: bad flags, unknown hash, unknown profile
3The workflow failed; the CLI worked
4Could not reach or authenticate to the server
5A safety gate refused (compatibility, breaker, policy)

Global flags: --registry <path>, --profile <name>, --output, --color, --verbose, --quiet.

Settings, and where they come from

Every setting resolves through the same layers, highest first: the flag, the environment (HOPSKIP_SERVER_ADDR, HOPSKIP_NAMESPACE, HOPSKIP_REGISTRY), .hop/config.toml (personal, gitignored), hopskip.toml (the project’s, committed, found by walking up from the working directory), $XDG_CONFIG_HOME/hop/config.toml (~/.config/hop/config.toml by default), $XDG_CONFIG_DIRS/hop/config.toml (/etc/xdg/hop/config.toml by default, for a machine-wide answer an administrator sets), then the built-in default.

# hopskip.toml: committed
default_profile = "dev"
namespace = "default"

[profile.dev]
server = "http://127.0.0.1:50051"

[profile.staging]
server = "https://hopskip.staging.internal:443"
namespace = "payments"
token_env = "HOPSKIP_STAGING_TOKEN"

hop --profile staging deploy … selects a profile; so does HOPSKIP_PROFILE, or a default_profile line.

A config file names a token’s environment variable (token_env) and cannot hold a token value at all, because config files get committed. HOPSKIP_TOKEN works for every command, including the operator surfaces with their own HOPSKIP_DLQ_TOKEN / HOPSKIP_SCHEDULE_TOKEN variables (those still win when set).

hop config prints what every setting resolves to and which layer decided it: the answer to “why is it talking to that server?”

The golden path

hop build

Compile a guest workflow to Wasm and register it: content hash, CFG fingerprint + full control-flow graph, source/lockfile/toolchain metadata, and (with --task-type) the dispatch task-type binding that makes deployments of the blob auto-servable by a worker. Language is auto-detected from --manifest-path’s extension (.toml Rust, .ts TypeScript, .py Python, .hs/.cabal Haskell, .go Go) or forced with --lang.

hop build --manifest-path sdk/rust/hello-workflow/Cargo.toml \
  --task-type hopskip:hello.greet@1

Notable flags: --wit <contract> (WIT-driven codegen + build-time handler-mismatch check, Rust guests), --long-running <export> / --long-running-all / --long-running-auto (heartbeat injection at loop back-edges via IR rewriting).

--plan <path> is the build’s only view of what is deployed. It is required of a component that dispatches an activity by name, and a call site naming something the plan does not have is refused here rather than accepted and discovered later as a workflow that waits forever. A component that calls nothing needs no plan. hop dev re-plans on every rebuild, so the dev loop is unchanged.

hop deploy <hash>

Point a namespace at a built blob. Diffs the new build’s CFG against the currently-deployed one first (compat_gate, consulting the live server for suspension points when one is given) and blocks replay-breaking changes; --force --principal <you> overrides with a durable, attributed OverrideEvent, never silently. With --addr (or HOPSKIP_SERVER_ADDR set) it also calls DeployAdmin.SetCurrentDeployment on the live server, so new workflow starts in that namespace route to the deployed blob immediately; the server journals the pointer durably and restores it at restart.

hop deploy 3f9ac2 --namespace default --addr http://127.0.0.1:50051

The live-suspension consult. With --addr, the gate compares two control-flow graphs and also asks the server how many workflows are currently parked at each await the new build removes (DeployAdmin.SuspendedCount). A removed await with nothing waiting at it is a warning, not a block:

==> compat gate: diffing 8c11de against currently-deployed 3f9ac2 for namespace 'default':
  [warning] run: the suspension point on host call `charge_card` (ordinal 1) was removed. ...
            Core reports zero currently-suspended workflows at this exact site right now, so this
            is demoted to a warning and does not block the deploy - but it remains a real
            structural change; re-check before it stops being true.

Only a positively established zero softens anything. If any of these hold, the finding stays breaking and says which one it was:

  • no --addr, or the RPC failed;
  • the server is not maintaining a suspension index (HOPSKIP_SUSPENSION_TRACKING is off (it is off by default, see configuration);
  • the server cannot account for every live instance in the namespace (a worker that has not reported since the server started, or one running an older build), reported as “cannot account for N other live instance(s)”;
  • something is parked there, reported with the count.

Turn the index on only once every worker serving those namespaces reports into it. hop dev turns it on, because there the whole fleet is one process.

Buffering starts across the deploy (--drain). A pointer flip is not instantaneous from a client’s point of view: for a few seconds the fleet is partway between two builds. --drain opens an upgrade drain around the deploy, so starts arriving in that window are accepted and held in Core rather than dispatched, and released onto the build the deploy installed:

hop deploy 8c11de --namespace default --addr http://127.0.0.1:50051 --drain
==> upgrade drain: namespace 'default' is buffering new workflow starts
==> upgrade drain: every live instance in 'default' has reported where it is parked; the gate can answer precisely
==> live server ...: namespace 'default' now routes new starts to 8c11de (was 3f9ac2)
==> upgrade drain: 4 start(s) that arrived during the deploy were released onto 8c11de

Clients see no error: they get their workflow id as usual, and the work starts a moment later. If the gate blocks the deploy, the drain is unwound and its buffered starts are released onto the unchanged deployment. In-flight workflows are never paused; a drain gates new starts only.

That second line is the reason to reach for --drain even when you do not care about the arriving starts. The live-suspension consult above only downgrades a removed await when the server can account for every live instance in the namespace, and under any real load there is almost always somebody between boundaries, so asking at an arbitrary instant answers “cannot account for N live instances” almost every time. A drain is what makes waiting for an answer terminate: with new starts held, the in-flight set can only shrink. --drain therefore waits (up to --drain-settle-ms, default 10s) for the fleet to settle before running the gate.

Expiring is not an error. A namespace with a workflow parked on a long timer or a human approval never fully settles, and the gate asks against whatever coverage exists; an incomplete answer still blocks. hop suspensions list shows who is holding it up.

hop suspensions list|signal

The companion to a blocked deploy. When the gate says it cannot account for N live instances, this says which N and why:

hop suspensions list --namespace orders
==> namespace "orders": 2 live instance(s) the deploy gate cannot account for.
    While any of these are listed, every removed-await finding stays BREAKING - the
    gate refuses to certify a zero it cannot establish.

  orders:order-4711  [suspended_at_unnameable_site]
      parked on a timer, an RPC or an approval gate - it will not resolve on its own. ...
  orders:order-4713  [never_reported]
      no report this server generation - it may have just started, ...

Three reasons, with different fixes:

reasonwhat it meanswhat to do
moved_since_last_reportits worker is between boundariestransient: retry the deploy in a moment
never_reportedjust started, an older worker, or a restart since it last reportedwait, or check the worker reports suspensions
suspended_at_unnameable_siteparked on a timer, a Hopskip.RPC call, or an approval gatewill not resolve on its own; see below

The third is why this command exists. A workflow waiting on a human approval can sit there for days, and until it moves it blocks every downgrade in its namespace. Deliver what it is waiting for:

hop suspensions signal orders:order-4711 --channel approvals --payload '{"approved":true}'

That is an ordinary client action, so it needs a token for the workflow’s namespace (HOPSKIP_TOKEN), not the deploy-admin one list uses.

Terminating such an instance is not offered. Abandoning a live workflow has to run its compensation stack, and nothing in hopskip-server drives that yet; a terminate here would leave effects in the world with their undo unrun. Until that lands, the ways past a stuck instance are: wait for it, signal it, or deploy to a different --namespace.

hop drain begin|end|status

The same mechanism, unbundled, for upgrades that are not a single hop deploy call: rolling a worker fleet, a schema change, or a multi-namespace release:

hop drain begin  --namespace orders --reason "rolling the worker fleet" --capacity 5000
# ... do the upgrade ...
hop drain status --namespace orders     # buffered starts: 137 / 5000
hop drain end    --namespace orders     # releases them onto whatever is deployed now

begin is idempotent (a retry refreshes the reason and capacity and keeps what is already buffered). Past --capacity, admission fails with the ordinary RESOURCE_EXHAUSTED backpressure rather than growing without limit, so a forgotten drain degrades visibly instead of consuming memory. end --discard throws the buffered starts away instead of running them and prints every discarded task id; it is only correct for an abandoned upgrade.

All three require an operator token with membership in the reserved hopskip-deploy-admin namespace, like the other deploy-admin commands.

hop dev

The one-command local stack, in one process: a single-node hopskip-server with a durable disk log store (--data-dir, default .hop), a worker serving every registry deployment with a task-type binding (--concurrency, default 4, plus a pool of the same size for activities that --activity-concurrency resizes), and the console API (--console-bind, default 127.0.0.1:8090). Seeds the server’s deployment pointers from the registry, mints and prints dev bearer tokens, and prints the environment for other terminals. Ctrl-C stops everything.

hop run <task-type>

Start a workflow on a live server and await its result over the public WorkflowClient RPCs. --input <bytes>, --input @file, or --input - to read stdin; --namespace (default default).

hop run hopskip:hello.greet@1 --input Ada

The result goes to stdout on its own line, so hop run … | jq works. A workflow that fails exits 3: the CLI worked, the workflow did not.

Waiting is unbounded by default, because a durable workflow parked on a human approval for three days has not failed. After 15 seconds of silence hop run explains what silence usually means (an activity nothing is registered to serve waits forever) and notes that Ctrl-C is safe. Pass --timeout <seconds> to stop watching; the workflow keeps running either way.

If the local registry has no build declaring the task type you asked for, hop run says so before starting (with a “did you mean” when something close exists) and starts it anyway, because the server, not this registry, is the authority on what it serves.

Orientation

hop status

Where am I, what is built, what is deployed, and what to run next. Resolves every setting (showing its provenance), counts the registry, lists deployed namespaces, TCP-probes the server, and reports what the bearer token you are holding says about itself, expiry included. It never fails because the server is down; an unreachable server is one of the things it reports. --no-scan skips the workflow-source scan, --output json for scripts.

hop doctor

Check this machine against everything hop needs, and print the command that fixes each thing that is wrong: the wasm32-* targets, the guest toolchains (absent ones are notes, not problems; not writing Haskell is not a defect), registry existence and writability, config files, server reachability, token expiry, and whether hop dev’s three ports are free. Reads only; safe against production. Exits nonzero when a check fails, so it can gate a CI job or a setup script.

hop config

Every setting’s resolved value and the layer that decided it. Never prints a token value.

hop update

Replace this binary with a newer release of itself.

hop update --check     # is there a newer one?
hop update             # install it

It downloads the release built for this platform, checks it against the release’s published checksums, runs --version on it, and only then replaces the running binary, atomically, in the same directory, so a failure at any point leaves the working one where it was. A --dry-run prints the plan; --version <v> installs one specific release, which is also how a downgrade happens.

Nothing checks for updates on its own. No background poll, no phone-home after other commands, nothing to opt out of. This CLI runs in CI and in containers, and a tool that quietly contacts a server to ask about itself is one that turns up in an egress audit nobody scheduled.

Two refusals are by design:

  • A binary inside a cargo target/ directory is not replaced, because the next cargo build would undo it and cargo run would meanwhile disagree with the source tree. --force overrides.
  • An unwritable install directory is reported before anything downloads, with sudo hop update and “install somewhere of your own” as the two ways on.

HOPSKIP_UPDATE_TOKEN or GITHUB_TOKEN raises the API rate limit and reaches a private repository; HOPSKIP_UPDATE_API_BASE points at a GitHub Enterprise instance.

What it verifies is integrity, not authenticity: the checksums come from the same place as the binary, so this catches a truncated or substituted-in-flight download but not a compromised release. Signature verification is the missing piece, and core/hopskip-cli/src/update.rs says where it goes.

What a release has to contain

hop update reads GitHub releases whose tag is v<version>, and accepts either packaging:

AssetWhat it is
hopskip-<version>-<target>.gzgzip of the bare hop executable (preferred)
hopskip-<version>-<target>.tar.gzthe full stack tarball; only hop is taken out of it

<target> is the Rust target triple. Checksums come from an aggregate SHA256SUMS asset, or from a per-asset <asset>.sha256 sidecar. A host built for -unknown-linux-gnu will accept the -musl asset, since a static binary runs on either.

Releases are found by listing releases and picking the newest version, not through GitHub’s /releases/latest, which excludes prereleases, and every 0.x tag here is flagged as one. Semver prereleases (1.0.0-rc.1) are skipped unless --version names one.

.buildkite/release.yml publishes all of this.

hop completions [shell]

Tab completion, generated from the same command tree the parser uses, so it cannot drift from the flags it completes.

hop completions --install

That works out your shell from $SHELL (or name it: hop completions zsh --install), writes the script where that shell looks for it, and adds the one startup line that makes the shell find it:

ShellScriptStartup file
bash~/.local/share/bash-completion/completions/hopskipone line in ~/.bashrc (or ~/.bash_profile, whichever you have)
zsh~/.zfunc/_hopskipone line in ~/.zshrc putting it on $fpath
fish~/.config/fish/completions/hopskip.fishnone; fish loads that directory itself

Every path it touches is printed. A startup file is only ever appended to, once, with a # hop completions marker; running it again says “already loads it” instead of adding a second line, and deleting that one line undoes it. --dry-run shows the plan and changes nothing; --dir <path> installs somewhere you choose instead.

Without --install the script goes to stdout and nothing else does, which is what packaging and your own placement want:

hop completions bash > /etc/bash_completion.d/hopskip
source <(hop completions zsh)

elvish and powershell generate, but --install will not pick a location for them; it asks for --dir rather than writing a guess into a profile.

Inspecting

hop describe <hash>

Print a registry entry’s metadata (source, toolchain, CFG fingerprint, task type, deployment cross-references). Hash prefixes resolve like git’s.

hop plan

Freeze what the registry deploys into a committed file, so a build can be hermetic:

hop plan                    # write hopskip.plan.json
hop plan --check            # has the registry moved since?
wrote hopskip.plan.json
  2 activities and 1 workflow in namespace *
  charge_card                  hopskip:activity.charge_card@2
  ship_order                   hopskip:activity.ship_order@1
  orders.process               hopskip:orders.process@2
  9c893c98d3776ed6479776ceee2a39ea43e283bea3de657e4ac6026aeeab9770

This is the only step between the deployment registry and a component that reads live state. hop typegen generates from the file it writes and hop build verifies against it, so both run with the network off and give the same answer on every machine, including inside a nix sandbox.

Commit the plan, like a lockfile. Re-running hop plan against a moved registry produces a diff somebody approves, which is where changing the version your calls resolve to becomes an act with a commit attached; --check in CI tells you the registry moved without writing anything.

The file carries no timestamp and does not record which blob currently serves each task type, so re-planning an unchanged registry produces an identical file and rolling an implementation forward or back does not touch it. A plan whose recorded digest disagrees with its own contents is refused rather than trusted: a hand-edited plan is the one thing a hermetic build must never accept.

hop typegen ts

Write the TypeScript declarations for the activities that are deployed right now, so a workflow imports them by name instead of spelling them as strings:

import { chargeCard, reserveInventory } from "hopskip:activities";

const held = await reserveInventory(payload);
const receipt = await chargeCard(held);
hop typegen ts --out sdk/ts/order-workflow/src/hopskip-activities.d.ts

The generated hopskip-activities.d.ts declares the hopskip:activities module the V8 host serves at runtime, with one function per deployed hopskip:activity.<name>@<version> task type (charge_card becomes chargeCard). Both are generated from one catalog read out of the deployment registry, so what your editor offers and what the host binds cannot drift. Commit the file, and configure your bundler to treat hopskip:activities and hopskip:runtime as external so the specifiers survive bundling.

What this buys is the failure that used to cost an afternoon. A misspelled activity string is accepted, durably recorded under a task type nothing serves, and never picked up - the run blocks forever with every component behaving exactly as designed. A misspelled import fails when the workflow is instantiated, before its first line runs and before anything is recorded, and the message lists what is deployed.

--namespace <ns> restricts to one namespace’s deployment; the default covers every namespace in the registry. --check compares the committed file against what is deployed and writes nothing, failing if it is stale - put that in CI.

--plan <path> generates from a committed plan instead of reading the registry, which is the hermetic path: with it the command touches no live state, so it runs with the network off and --check becomes a comparison between two committed files. Whether the registry has moved is hop plan --check’s question.

hop typegen catalog

The runtime half. A worker cannot read the registry - it is a separate process, usually on a different machine - so the catalog reaches it as a file, the same way its guest sources already do:

hop typegen catalog --out .hop/js-workflows/hopskip-activities.json

hopskip-v8-worker loads it from HOPSKIP_V8_ACTIVITY_CATALOG, or from hopskip-activities.json beside the guests under HOPSKIP_V8_WORKFLOW_DIR. Generate both files from the same run, so the names your editor promised are the names the host binds; they carry the same digest, so a mismatch is detectable rather than silent. Takes the same --namespace and --check options.

A worker with no catalog starts and warns. Its guests can still dispatch by string; a guest that imports an activity fails to instantiate, saying the namespace deploys none - which is the point, and better than a permissive catalog that would be the untyped behaviour wearing a type’s clothes.

Payloads are Uint8Array in both directions. The name is typed; the encoding is yours, as it is at every other layer of this system, and a generated signature claiming otherwise would typecheck against bytes nothing validated.

hop typegen hs

The same registry read, for Haskell, and both halves of it, activities and workflows:

hop typegen hs --out src/Hopskip/Deployed.hs
import Hopskip.Deployed (chargeCard, reserveInventory, ordersProcess)

workflow sku = do
  held <- invokeActivity reserveInventory sku
  invokeActivity chargeCard held

Every binding holds the whole task type: what invokeActivity and Control.Hopskip.Activity’s activity take for an activity, and what Hopskip.Client.startWorkflow or hop run take to start a workflow. Both are Text, and the module imports text and nothing else, so a client program and a workflow guest (two different packages) can both import it.

Why names rather than Activity handles: an Activity carries your encoder and decoder, and the SDK does not choose an encoding on your behalf, here or anywhere. The name is the part generation can honestly supply; the handle is one line of your own code, in your own types.

--module sets the module the file declares (default Hopskip.Deployed), and the default --out follows it: MyApp.Deployed writes to MyApp/Deployed.hs, which is where GHC looks. --namespace and --check work as they do for ts; the Haskell --check also fails on a workflow deploy, because a workflow deploy changes what the module says.

A name a Haskell keyword would swallow is suffixed rather than dropped (type becomes type_), and two things that want one binding are refused with both named instead of one of them silently vanishing.

Two deployed versions of one activity are two bindings, chargeCardV1 and chargeCardV2, and importing one is how you say which you mean. Under rolling deploys two live versions is the steady state, not an edge case, so nothing resolves a version on your behalf, and deploying @3 adds a binding beside the others rather than redirecting anything. Moving to it is an edit with a commit attached. The version suffix appears only when there is something to choose between, so an ordinary namespace reads as ordinary names. hop dependents says when a newer version exists.

hop typegen go / py / rust

The same thing again for the other three guest languages, as a file of constants:

hop typegen go   --out internal/hopskipdeployed/deployed.go
hop typegen py   --out workflows/hopskip_deployed.py
hop typegen rust --out src/hopskip_deployed.rs
const ChargeCardV2 = "hopskip:activity.charge_card@2"
const OrdersProcess = "hopskip:orders.process@2"

One command per language and one renderer behind all three: every one of these guests compiles to wasm and reaches invoke_activity through the same ABI, so what each needs from the registry is identical and only the punctuation differs. The naming follows each language’s own convention: Go exports with a capital because in Go that is the export rule, Python and Rust use SCREAMING_SNAKE module constants. --namespace, --check and --plan work as they do for hs, and like hs these cover both halves, so a workflow deploy fails their --check too.

hop dependents [task-type]

hop deps <hash> answers “what does this component call”. This answers the other direction, “who calls this”, which is the question an upgrade or a decommission actually turns on:

hop dependents                                            # the whole deployed surface
hop dependents hopskip:activity.charge_card@1                # who breaks if I change this
hop dependents --unused                                   # decommission candidates
hop dependents hopskip:activity.ship_order@1 --assert-unused # the gate, for CI
activities
  chargeCard          hopskip:activity.charge_card@1     2 dependents
      hopskip:orders.process@2 (orders) 1 site in `run`
      hopskip:orders.refund@1 (refund) 1 site in `run`
  shipOrder           hopskip:activity.ship_order@1      no dependents

The names in the left-hand column are the ones hop typegen generates, so an entry in this report and the binding in a guest’s source are the same thing. Dependents are always searched for across the whole registry (a caller in another namespace breaks just as hard), while --namespace scopes only what is reported on.

It also lists call sites naming something nothing deploys. That is the failure hop dev warns about one component at a time: the dispatch is accepted, durably recorded, and never picked up, so the run blocks forever with every component behaving exactly as designed.

Versions are pinned, not guessed. An unversioned activity name resolves to the latest deployed version, and hop build records that resolution in the calling component’s registry entry. So a dependent is attributed to exactly one version, and a pin that has fallen behind what is deployed is reported:

      hopskip:orders.process@2 (orders) 1 site in `run` - pinned here, and a newer
      version is deployed; rebuild to move it

which is the upgrade signal: nothing else would ever mention that a component is still calling a version two deploys old. A component built before pins existed has no recorded resolution, so its versionless call counts against every deployed version of the name rather than being attributed to a guess.

It consults what is running, and refuses when it cannot. An execution parked for three weeks on @1 keeps a claim on @1 that lives in its history, not in anybody’s call sites, so no registry scan can see it. Pass --addr (or set HOPSKIP_SERVER_ADDR) and the gate asks the server, per namespace, how many runs have not terminated (DeployAdmin.LiveExecutions):

hop dependents hopskip:activity.ship_order@1 --assert-unused --addr http://127.0.0.1:50051

Without --addr no verdict is provable and --assert-unused refuses. So does a server that is not maintaining a suspension index (HOPSKIP_SUSPENSION_TRACKING is off by default), and so does one that cannot be reached: an unreachable server reported as an empty fleet would turn a network hiccup into permission to delete a deployment. A certified zero is a reachable server, with the index on, reporting no live runs and none it cannot account for.

It says when it cannot prove an answer. A component that dispatches by a name computed at runtime could reach anything; a component whose dependencies were never recorded says nothing about what it calls (a JavaScript bundle is this today; hop build records no static dependencies for one). Either makes “nothing depends on this” an unproven claim, and the report says so rather than handing back a clean bill of health. --assert-unused exits nonzero on both, as well as on a live dependent: a gate that passes when it does not know is not a gate.

Evidence comes from each entry’s recorded static_dependencies: the compiled module’s own call sites, merged with whatever the language adapter declared at build time. This command adds no new analysis; it resolves those sites against the deployment catalog, which is the join neither side had.

hop logs <namespace:workflow>

Query a workflow’s projected log entries from the hopskip-visibility SQLite store (--since-ms/--until-ms; --cluster host:port,... scatter-merges across peer visibility endpoints).

This is the only visibility command. There is no hop query: the raw-SQL surface was removed because the namespace scoping lived in the caller’s SQL rather than in the server, and a shard given arbitrary SQL has no partition dimension to filter on. See the visibility guide for the typed surface that replaced it.

Operating

hop dlq list | replay

List and edit-and-replay dead-letter-queue entries on a running server (hopskip.dlq.v1.DlqTriage). Replay supports payload edits, a reason, and pinning the replay to a specific blob hash.

hop canary start | abort | status

Percentage canary routing of new starts between a baseline and canary blob, per namespace, with automated rollback thresholds (hopskip.deploy_admin.v1.DeployAdmin; requires an operator token in the hopskip-deploy-admin namespace).

hop deploy-breaker set

Configure the always-on deployment circuit breaker for a component hash (failure threshold, window, cooldown, half-open probes).

hop quarantine list | replay | bulk-replay

Triage the deployment breaker’s durable quarantine queue: list pending entries, replay one, or bulk-replay everything (optionally narrowed to one component hash, optionally retargeted to a fixed blob).

hop migration <verb>

User-initiated fleet migration over the versioning API (which the server serves when HOPSKIP_VERSIONING_API_BIND is set; versioning-api-dev serves the same routes over a data-directory copy). Verbs: versions, patches, adopt, register-version, register-patch, register-artifact, dry-run, apply, rollback. The API base comes from --api or HOPSKIP_VERSIONING_API (default http://127.0.0.1:8091). The CLI renders the fleet reports and relays refusals verbatim; it adds no judgment of its own, so nothing migrates that the server’s checks did not clear. The walkthrough is the patching guide.

hop gc

Delete registry blobs unreachable from any namespace’s deployment pointer or its build lineage (--dry-run, --retain-last N rollback roots, --live-blob-store extra roots).

Pass --live-fleet <addr> (or set HOPSKIP_SERVER_ADDR) so the running fleet keeps a claim on the blobs it can still reach. Two of them a registry scan cannot see:

  • A running execution. Dispatch is late-bound, so a run that began three weeks ago may since have executed under any deployment made since, and every one of them stays rooted until it finishes.
  • An active canary. hop deploy --canary routes traffic to a hash that is deliberately not the deployment pointer, so mid-rollout it looks exactly like an orphaned build. It stays rooted until the rollout ends.

Without the flag both are invisible and those blobs can be collected. With it, a server that cannot be reached makes the run collect nothing and say so: a hash the fleet can route to need not be in any deployment history, so there is no narrower answer that is still honest.

Provability and debugging

hop audit <namespace:workflow>

Fetch a workflow’s Merkle (MMR) audit history from a running server and write an inclusion proof file for a chosen record (--record-index, --out).

hop replay <hash>

Deterministically re-execute a registered guest module: fully live against a built-in activity stand-in table, or strictly against a recorded history file (--history), verifying command-stream equivalence.

hop debug attach

Open a hopskip-dap-server debugger attach against a previously recorded execution timeline for a deployment’s component and print its live state: the CLI half of “click through from a CFG failure node to a debugger.”

hop fork <namespace> <value> <run-hex> --principal <who>

Fork a previously recorded workflow from a retained snapshot into a target-prefixed namespace (--target, default scratch, always composed as <target>/<original namespace> so a fork can never collide with real workflows). --boundary-ordinal picks the retained point (default: the most recent). Both the fork and every injected value are durably recorded as principal-attributed override events on the fork’s own history partition; --principal is required because an override is never anonymous.

Overrides. --override <selector>=<value> (repeatable) answers a suspension synthetically instead of dispatching it. --override-file <path> reads the same grammar from a JSON array of {"select": …, "value": …} objects; inline rules are matched first, and first match wins is the only precedence rule.

selector := @<pending-id>              exact, matches once
          | <label>                    an activity name, or a reserved
          |                            $hopskip.* label (timer, yield, RPC)
          | <prefix>*                  prefix glob
          | <label>#<occurrences>      #* all · #2 one · #1..3 range · #0,2 set

value    := <literal>                  UTF-8 bytes (the default)
          | i64: · json: · hex: · b64: · str: · empty:
          | @<file> · file:<path>
          | ok:<value>                 activity-result envelope, success
          | fail:[<class>/]<message>   activity-result envelope, failure

Occurrences count from the fork point: #0 is the first matching suspension the fork observes, whether it was already parked on it or reaches it while driving forward. A rule with no # clause is a statement about the fork point and fails the fork if it matches nothing there; a rule with one (charge_card#*) may legitimately match nothing yet, and is reported afterwards if it never fired.

hop fork prod order-7b3a91 <run-hex> --principal ian@example.com \
  --override 'charge_card#*=fail:CardDeclined/insufficient funds' \
  --override '$hopskip.timer.sleep#*=empty:' \
  --override @3=i64:0 \
  --override-file ./scenario.json

Without --dispatch, the fork is recorded and left for hop debug attach. With --dispatch (plus --addr/--token, resolved exactly as hop run resolves them), the fork is then re-executed forward to completion: every activity it suspends on is dispatched through the live server’s real dispatcher and executed by whatever worker is registered for its task type, results are delivered back in, and the fork’s own partition accumulates recorded steps, snapshots, and timeline points as it goes, ending with the fork’s final result on stdout. The drive is the worker’s own fan-out loop, so a fork suspended mid-fan-out with several activities outstanding dispatches them all concurrently. When the guest module exports both run and run_concurrent (two workflows in one module), pass --workflow-export to say which one the snapshot recorded.

Command summary

CommandPurpose
hop buildCompile + register a guest workflow (with --task-type binding)
hop deployPoint a namespace at a blob, compat-gated; --addr updates the live server; --drain buffers starts across the flip and waits (--drain-settle-ms) for the gate to be answerable
hop drainBuffer a namespace’s new workflow starts across an upgrade, then release them
hop suspensionsList the live instances blocking the deploy gate, and signal them
hop devOne-command local stack: server + worker + console API
hop runStart a workflow and await its result
hop planFreeze the deployed surface into a committed plan a hermetic build reads
hop describePrint a registry entry
hop dependentsWhat still depends on a deployed activity or workflow; --assert-unused gates a decommission
hop typegenGenerate typed references to what is deployed (ts: declarations for an editor; hs: a Haskell module of activity names and workflow task types; catalog: the file a worker loads)
hop logsVisibility projection log queries (single-node or cluster)
hop dlqDead-letter triage: list, edit-and-replay
hop canaryPercentage rollout with auto-rollback
hop deploy-breakerDeployment circuit-breaker policy
hop quarantineBulk-replay quarantined executions
hop gcGarbage-collect unreachable registry blobs
hop migrationFleet migration: adopt, register, dry-run, apply, roll back
hop auditMerkle inclusion proof for a history record
hop replayDeterministic re-execution, optionally history-strict
hop debugTimeline debugger attach (DAP)
hop forkFork a recorded workflow from a retained snapshot, optionally injecting a synthetic activity result
hop statusWhere am I, what is deployed, what to run next
hop doctorCheck the machine; print the fix for what is wrong
hop configWhat every setting resolves to, and why
hop completionsShell completions for bash/zsh/fish/elvish/powershell
hop updateReplace this binary with a newer release; --check only reports