Ship

Old Docs · CLI Workflows
On this page · 11 sections

CLI Workflows

Decisions

  • ship dev, test, build, and run compile project declarations into one executable typed workflow graph.

  • ship plan <kind> renders the exact graph that execution consumes. It is not a separately maintained summary.

  • Planning is capability-enforced read-only. It may observe declared inputs, persisted state, and explicitly permitted live metadata, but cannot mutate infrastructure, bootstrap state, spawn work, or write resource state.

  • Apply consumes the same immutable plan value it displayed. A separately saved plan carries graph/input hashes and a state revision; stale plans are rejected rather than silently recomputed into different work.

  • Preflight validates the complete graph before work starts: node inputs, produced outputs, dependencies, tool requirements, target compatibility, ownership, and cycles.

  • Executors follow graph edges and typed outputs. They never rediscover targets, databases, artifacts, commands, ports, or teardown policy.

  • External tools remain first-class runner engines behind typed adapters. Ship owns semantic planning and lifecycle, not reimplementations of Vite, Playwright, Docker, Node, Gradle, or ADB.

Ownership

ComponentOwns
ship-cliargument parsing, project loading, workflow selection, typed graph execution, rendering events/results, exit status
ship-workflow-planpure declarations/facts → typed graph + diagnostics
ship-runner-*typed adapter configuration → command specification / structured engine integration
ship-supervisorchild lifecycle, readiness, cancellation, bounded output, structured process events
compiler/runtime cratesparsing, checking, lowering, artifact semantics, Ship execution

ship-cli owns the reusable application workflow but does not absorb compiler, provider, runner, or process-supervision implementations.

HTTP readiness is a typed host/port/path contract: the supervisor sends GET to that exact endpoint and requires a complete HTTP response with a 2xx status. A process merely accepting TCP connections is not HTTP-ready. Vite uses its separate typed TCP-only readiness operation because it does not expose the application /health contract.

The executor starts as focused internal ship-cli modules, not a separate crate. Compilation, preflight, execution, event production, rendering, and exit status stay close while the graph model is still becoming real. This does not mean adding more branches to the crate root: command dispatch and workflow execution have explicit module boundaries, and runner/process semantics remain in their owning crates. Extract an executor crate only when a second real consumer needs the same boundary.

The internal dependency shape is layered:

lib.rs                 public entrypoints + dispatch only
args/                  CLI text -> typed command requests
commands/              request -> project/workflow selection
workflow/
  compile.rs            project facts -> typed builder -> graph IR
  preflight.rs          complete graph validation
  execute.rs            scheduler + adapter dispatch
  event.rs              WorkflowEvent / WorkflowResult
output/
  lines.rs
  json.rs
  tui.rs

Commands do not spawn processes, emit artifacts, or rediscover targets outside workflow execution or a deliberately domain-specific owning crate. Output modules consume only structured workflow messages. Moving code by command name without removing parallel orchestration is not the refactor.

Project loading is a frozen checked-session boundary. Loading records parsed modules and target bodies, runs generation and project typechecking, and stores the resulting elaborations, lowerable modules, control-flow facts, and diagnostics. Workflow-fact extraction and planning consume that same checked value. The graph's project:check node prepares every declared target fact into a typed, lowered session handle before artifact nodes can run; emitters consume those handles instead of reloading, regenerating, rechecking, or relowering. The compiled CLI value therefore pairs the serializable graph with its checked project session. Workflow.CheckedProject is a runtime-only typed edge, not a serialized { root, workflow } recipe for downstream handlers to reload. Planning renders only the graph, while immediate execution retains the paired session. A DEV source generation deliberately loads one replacement session, shared by every rebuild in that generation.

Graph Contract

Graph construction has two layers:

typed builder:
  Node(Input, Output, Phase, Capability)
  typed output handles connect compatible nodes only

normalized serializable IR:
  stable node / edge ids
  operation-provider id + version
  input/output schema ids + canonical descriptors
  explicit value/artifact references
  phase + capability + lifecycle
  preconditions + sensitivity
  structured diff + explicit unknowns

The builder rejects invalid edges before heterogeneous nodes are erased into one normalized IR. The executor consumes only that IR. Plans contain no closures, live provider clients, trait objects with hidden behavior, or target names that ship-cli matches to recover semantics.

A small closed algebra owns universal graph facts: phase, observe/apply capability, lifecycle, change kind, sensitivity, node state, and edge/value transport. Target operations are versioned typed registrations owned by runner or provider adapters. Adding a custom operation does not edit a central enum or executor match.

The graph represents compiler-owned actions as well as long-running external runners and covers these semantic families without command-specific side paths:

check
  -> emit artifacts
  -> provision / migrate owned resources
  -> install target dependencies
  -> start runners
  -> wait for readiness
  -> run tests / application / package
  -> teardown

Every node has:

  • stable typed identity;

  • a versioned operation/provider identity and payload schema;

  • typed inputs and outputs;

  • explicit dependencies;

  • preflight requirements;

  • lifecycle/cancellation behavior;

  • structured diagnostics and events.

    Local graph nodes serialize tool locators, target-family compatibility, and TCP port claims as preflight requirements. Execution resolves every required tool to an absolute executable and reserves every fixed or ephemeral port before the first node runs. Runner handlers consume those resolved values; they do not search PATH, inspect SDK roots, or choose ports after work has started.

    An output consumed by another node is a typed edge, not a path, port, environment variable, or runner name reconstructed by the consumer. Target adapters may materialize those boundary values only when producing their command specification.

    Canonical serialization includes schema/provider versions so plan hashing, review, saved-plan application, and compatibility diagnostics remain honest. Unknown values and provider-supplied before/after diffs are structured values, not rendered strings. Sensitive fields retain their classification after typed builder erasure.

Canonical schemas

Every persistent workflow input and output type derives WorkflowSchema from its Rust definition. The derive emits one SchemaDescriptor; there is no parallel schema catalog. A descriptor records:

  • the nominal path, schema version, and generic type arguments;

  • product fields and enum variants in declaration order;

  • primitive, list, and option structure;

  • Output<T>, Sensitive<T>, and Unknown<T> wrappers recursively.

    An operation descriptor combines those input/output descriptors with the output port's value, artifact, or runtime-only transport and sensitivity policy. SchemaId hashes the full descriptor with a domain-separated canonical encoding. Changing a field name, field type, declaration order, variant, wrapper, nested nominal identity, or generic argument therefore changes the fingerprint even when the root name and version are unchanged. Declaration order is intentionally semantic; map insertion order is not.

    The operation registry is keyed by operation path and nonzero version. It rejects duplicate registrations and explains contract differences across input schema, output schema/port/transport/sensitivity, phase, capabilities, requirements, lifecycle, and availability. Multiple versions may coexist; a saved node must match the exact registered version and contract.

    Saved-plan loading is a validation boundary, not plain deserialization. It rejects malformed JSON, duplicate or unknown fields, trailing content, unsupported graph formats, non-canonical schema fingerprints, malformed descriptors, graph invariant failures, digest mismatches, missing operation versions, and registry contract mismatches. Applying a loaded plan executes the same CompiledWorkflow and digest that were reviewed; it never recompiles a replacement graph.

    Built-in platform and resource operations do not use alias-only nominal schemas or a hand-maintained descriptor catalog. Their inputs and outputs derive the complete structural contract, including target-profile generic arguments. Transparent alias descriptors are reserved for deliberate opaque boundary wrappers such as environment-secret observations; adding one requires the wrapper and its opacity to be explicit in the Rust type. When a structural input or output contract changes, the producing operation version advances so an older saved node cannot be accepted under a new fingerprint.

Rust Representation And Prior Art

No Rust crate provides Ship's complete boundary. Use the strongest pieces without adopting their incompatible runtime/serialization models:

  • taski: graph-branded Handle<Output> / typed dependency construction.

  • Hydro: typed handles lowered into an explicit IR.

  • Bevy schedules: separate graph compilation from dense dependency-count execution.

  • petgraph: private topology/SCC/cycle algorithms and invariant boundaries.

  • Cargo unit graph: platform/profile-aware units and artifact requirements on edges.

  • Buck2 architecture: typed action/process specifications, content-addressed artifacts, span events.

  • cargo-nextest runner: process-tree ownership, retries/attempts, cancellation causes, structured terminal results.

  • Salsa: revision invalidation and dependency provenance; not runtime graph discovery.

    The concrete construction boundary is:

pub struct Output<'graph, T> {
    graph: GraphToken,
    node: NodeId,
    port: PortId,
    schema: SchemaId,
    marker: PhantomData<fn() -> T>,
}

pub fn add<I, O>(
    graph: &mut GraphBuilder<'_>,
    operation: &'static Operation<I, O>,
    input: I,
) -> Result<Output<'_, O>, BuildDiagnostic>;

Handles are branded to one builder. Adapter-defined input encoders walk typed input structs, turn Output<T> fields into explicit edges, and encode ordinary values. GraphBuilder::finish validates every invariant, erases generics, sorts canonically, and returns:

pub struct WorkflowGraph {
    format_version: GraphFormatVersion,
    kind: WorkflowKind,
    inputs_digest: Digest,
    state_revision: Option<StateRevision>,
    nodes: Vec<NodeRecord>,
    edges: Vec<EdgeRecord>,
    plan_digest: Digest,
}

NodeId is a stable semantic path, never an insertion index or content hash. Plan digest, execution ID, node attempt ID, generation ID, and artifact content digest are separate identities.

Operation behavior lives outside the graph in a deterministic registry of versioned descriptors and plain function pointers. Start with an explicit sorted static table; linker registration (inventory / linkme) earns a dependency only when separately linked third-party adapters exist.

The graph's EncodedValue is a small closed algebra with explicit schema, edge, artifact, unknown, and sensitive-reference variants. It is not serde_json::Value, Any, or a trait object. TypeId, type_name, Rust Hash/DefaultHasher, map iteration order, and raw memory layout never enter persistent identity.

Canonical bytes use an explicit graph format version, fixed tags/endianness, lengths and ordering, strict duplicate/unknown/trailing-byte rejection, then a domain-separated SHA-256 graph hash. JSON is a review/debug encoding of the same records, not the canonical hash format.

petgraph may be used privately during finish/load validation, but its node indices, storage order, holes, and Serde representation are never the plan IR. Every decoded plan is revalidated independently.

After canonical validation, lower to a Bevy-style dense execution cache: topological rank, remaining dependency counts, reverse dependents, resource semaphores, ready queue, and cleanup order. This cache is disposable and never hashed or reviewed.

Reject these prior-art traps:

  • daggy/petgraph serialization as the canonical plan;

  • Any downcasts (taski/dagx) as the value protocol;

  • boxed workflow nodes, typetag, erased-serde, or live closures in plans;

  • inventory iteration order as operation identity;

  • Bevy TypeId / systems as persistent schema;

  • Salsa-style dependency discovery during execution;

  • layer-at-a-time scheduling instead of releasing each ready dependent;

  • DAG cycles for dev watching—watch events create new generation IDs and rerun affected closures of the static graph.

Command Invariant

Commands are thin policy selections over the same graph compiler:

ship plan test  -> compile Test graph -> render
ship test       -> compile Test graph -> preflight -> execute -> render

The two commands may choose different terminal actions, but they cannot compile different workflows. The same invariant applies to dev, build, and run.

Plan, Diff, And Apply

Planning normalizes desired declarations, typed output references, observations, and prior state into a serializable immutable plan:

desired graph + read-only observations + state revision
  -> create / update / replace / delete / action / no-op
  -> typed dependencies and outputs
  -> preconditions + explicit unknowns
  -> graph hash

The renderer, JSON output, approval UI, and executor consume that same value. Apply may resolve values marked unknown and perform provider operations already authorized by plan nodes; it may not invent new nodes or silently change a node's operation. If an observation or state revision invalidates a precondition, apply stops and requires a new plan.

Read and mutation capabilities are different types. Plan-time provider methods receive observation/state-read capabilities only. Reconcile/delete, process spawn, filesystem writes, state bootstrap, and arbitrary effectful callbacks exist only in the apply evaluator. --dry-run is not a Boolean convention over a write-capable executor.

Sensitive values remain typed opaque references through graph edges, plan serialization, rendering, state, and boundary materialization. Redaction is not encryption, and local plan/state files never contain plaintext merely wrapped in a redaction marker.

Alchemy Prior Art

Alchemy is a primary inspiration:

  • Resources register inert desired state into one graph.

  • Lazy typed Outputs create dependency edges.

  • Providers classify noop / update / replace and converge through idempotent reconcile/delete operations.

  • Resources and deploy-time Actions share one dependency scheduler.

  • The same in-memory Plan is displayed and then applied after approval.

  • Intermediate lifecycle state and persisted downstream edges support interruption recovery and safe deletion ordering.

    Ship should strengthen several Alchemy boundaries rather than copy them:

  • Alchemy plan-time Inputs, provider reads/diffs, and stack evaluation may run arbitrary Effects; Ship planning has no mutation capabilities.

  • Alchemy can bootstrap its state backend during plan; Ship fails with an explicit bootstrap command instead.

  • A standalone Alchemy plan is recomputed by a later deploy; Ship can serialize and bind review to graph/input hashes plus a state revision.

  • Alchemy state has no stack lease/CAS revision; Ship apply must acquire one.

  • Alchemy provider diffs are action-level rather than necessarily field-level; Ship plans keep structured redacted before/after facts when providers can supply them and mark unavailable detail explicitly.

  • Convergence requires a finite bound or monotonic proof with oscillation diagnostics.

    Useful sources:

  • Resources

  • Inputs and Outputs

  • Providers

  • Resource lifecycle

  • Plan CLI

  • State stores

Events And Results

The executor emits one versioned structured message protocol. Terminal lines, JSON/NDJSON, TUI state, snapshots, and later remote observation are renderings or encodings of these messages, not separate event models.

The universal lifecycle algebra stays small:

WorkflowStarted
NodeQueued
NodeStarted
NodeProgress
NodeDiagnostic
NodeOutput
NodeFinished
NodeBlocked
WorkflowFinished

Every envelope carries schema version, workflow/graph identity, monotonic sequence, stable node identity when applicable, phase/lifecycle state, and sensitivity metadata. Typed payloads include structured compiler diagnostics, progress, output/value references, artifact references, state/precondition facts, and bounded log attachments.

Adapters may define versioned typed attachments registered with their operation payload schema. They may not make raw strings or arbitrary JSON objects the semantic result. JSON is a valid encoding for a registered payload; ordinary stdout/stderr remains a bounded Log attachment.

Events are append-only observations. The authoritative terminal WorkflowResult projects:

  • terminal state for every node;

  • typed produced outputs/artifacts;

  • failures and causes;

  • blocked dependents;

  • cancellation and cleanup outcomes;

  • stale-plan/state-revision or other precondition failures.

    Renderers consume only WorkflowEvent / WorkflowResult; they do not inspect operation implementations or reconstruct executor state. Sensitive values stay opaque in every encoding.

Local command execution

The complete local command matrix executes through the typed workflow graph:

ship plan build  <-> ship build
ship plan run    <-> ship run
ship plan test   <-> ship test
ship plan dev    <-> ship dev

Every pair compiles the same graph; plan renders it and the command executes it. DEV covers native entries and servers, bound browser clients, standalone Android clients, SQLite and managed Postgres, readiness, retained services, source watches, rebuild generations, restart/HMR, cancellation, and graph-ordered teardown. Watch events publish one monotonic generation ID and execute only the dependent watch closure. The outer ship-dev compiler watcher remains separate and restarts the complete application graph after compiler, runtime, or stdlib changes.

Playwright TEST has one typed stage/install/run operation family. CLI resolution discovers tests/**/*.spec.js once and records exact facts: ordinary *.spec.js is Browser, *.fullstack.spec.js plus sync.spec.js is Fullstack, and *.server.spec.js is Server. ship test --server [--spec <file>] [server-target|path] runs the Server lane directly against one ready non-Static server origin without building a browser artifact or starting Vite. Browser and Fullstack use the same operations with a browser-artifact stage source and typed Vite run target; Server uses a standalone-package stage source and typed ready-server run target. Executors copy only the exact planned specs and support files and never classify suffixes.

Project TEST skips a clientless server with no Server specs and skips Static servers entirely. A bound Web server selects only Browser plus Fullstack facts and is never duplicated as a standalone Server lane. Targeted lanes with no matching facts and explicit specs from the wrong lane are planning diagnostics, before builds, databases, dependency installation, or runners start.

Other unsettled providers may remain typed blocked nodes. A blocked node names its missing capability and question/issue, appears in plan output, and makes apply fail before mutation. It is never skipped or treated as success.

Keep custom runner and provider end-to-end fixtures so the graph remains a system rather than a fixed enum of first-party target names.

Implementation Status

Implemented:

  • graph-branded typed output handles and adapter-typed operation inputs;

  • normalized serializable node/edge records and closed encoded values;

  • decoded-plan validation, cycle diagnostics, canonical bytes, and SHA-256 graph digests;

  • the complete local build/run/test/dev plan compiler, including watch/reload generations and typed remote blocked nodes;

  • ship plan <kind> rendering/JSON from the normalized graph;

  • one shared BUILD request compiler for ship plan build and ship build, including bare project builds and explicitly selected native entries;

  • an internal executor core with registry preflight, dependency scheduling, serialized tool/target/port requirements, whole-graph tool resolution and port reservation, typed events/results, failure blocking, and deferred cleanup;

  • declaration-digest preconditions that reject a compiled local graph before artifact execution when target declarations have changed;

  • real local BUILD handlers for native entries/servers, browser, Android project/APK packaging;

  • ship build graph execution with terminal output and exit status rendered from WorkflowEvent / WorkflowResult;

  • real local RUN handlers and graph execution for native entries/servers, browser/Vite, bound clients, Android, SQLite, managed Postgres, readiness, cancellation, and deferred teardown;

  • real local TEST handlers and graph execution for language and repository suites, browser, full-stack, and server-only Playwright, native servers, isolated SQLite, managed ephemeral Postgres, readiness, typed reports, cancellation, and deferred teardown. The graph owns Vite and Playwright reuses its ready endpoint. Android TEST uses generated AndroidX instrumentation source, explicit application/test APK facts, deterministic ADB device selection, structured results, rollback, and deferred uninstall cleanup;

  • real local DEV handlers and reactive graph execution for native entries, native servers, browser/Vite HMR, fullstack bound clients, Android, SQLite, managed Postgres, source-root watches, monotonic generations, restart/reload closures, readiness, cancellation, and deferred teardown;

  • managed resource check/plan/apply/destroy/output through normalized typed resource workflow graphs and events, with exact serializable saved plans, environment-scoped revisioned state, lease/CAS atomic checkpoints, provider-neutral lifecycle adapters, reverse dependency deletion, replacement cutover, partial-failure aggregation, and opaque sensitive references; see 26-managed-resources.md;

  • ship deploy / ship plan deploy: the BUILD graph for every target a resource consumes as an artifact input, then the exact resource plan with those digests. ship plan deploy never builds and shows the digests as unknown.

    All public build/run/test/dev paths now compile and execute the same typed graph as ship plan. Android instrumentation TEST is a typed executable adapter rather than a blocker. The checked project value is shared from loading through planning and artifact execution; handlers no longer reload paths or reparse planner facts. Checked HTTP route semantics include method, path fields, query/body types, and response representation once in IR. ship-js, ship-android, and ship-native-server own their artifact emission and focused tests; ship-cli keeps only thin orchestration over frozen typed handles. ship check stops after project and declared-target typechecking; the path locates the project and does not select a target. Runnable-target selection and graph validity belong to ship plan <kind> and preflight. Generated structural workflow schemas bind operation compatibility and plan decoding to the actual input/output types. Remaining semantic node-ID grammar refinements are implementation details of this model, not alternate graph designs.