Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

See the changelog and crates.io for the current release.

procman is a Foreman-like process supervisor written in Rust. It reads a .pman configuration file, spawns all listed commands, and multiplexes their output to the terminal with right-aligned name prefixes. When any child exits or a signal arrives, procman tears everything down cleanly.

Key Features

  • Dependency-aware startup ordering — jobs can wait on HTTP health checks, TCP ports, file existence, file content, or the exit of another job before starting.
  • Multiplexed output — every line is prefixed with the originating job name, right-aligned for easy scanning.
  • Per-process log files — each job gets its own log in logs/procman, plus a combined procman.log.
  • Process output — a job can write KEY=VALUE pairs to $PROCMAN_OUTPUT; downstream jobs and services reference them with @job.KEY syntax.
  • Fan-out — use for blocks with globs, literal arrays, or ranges to spawn multiple instances of a job.
  • User-defined CLI arguments — define typed arguments in a config { } block and pass them after -- on the command line. Arg values are available as args.name expressions and flow into shell via env vars.
  • Conditional job execution — use job name if expr { } to evaluate an expression before spawning; falsy results skip the job entirely.
  • On-demand taskstask blocks define one-shot processes that don’t auto-start; trigger them with -t/--task for test suites, migrations, or other on-demand operations.
  • Clean shutdown — Ctrl-C sends SIGTERM to every child, waits 2 seconds, then sends SIGKILL to anything still running.

Design Principles

The .pman language is built on three core ideas:

  • Declarative.pman describes what to run and when, not how. Runtime semantics (polling, fan-out tracking, shutdown cascades) remain procman’s domain.
  • Two worlds, clearly separated — procman expressions use their own syntax. Shell blocks are opaque strings. Values flow into shell exclusively via environment variables. Procman never interpolates inside shell strings.
  • Strict typing — type errors in expressions cause immediate shutdown. No silent coercion.

Installation

cargo install procman

Or clone and build from source:

git clone https://github.com/wbbradley/procman.git
cd procman
cargo install --path .

Getting Started

A Minimal Configuration

Create a file called procman.pman in your project root:

service web {
  run "python3 -m http.server 8000"
}

service api {
  run "node server.js"
}

Each service block defines a long-running process with a name and a run command. That’s all you need.

Running

Start everything with:

procman procman.pman

The config file path is a required positional argument. procman spawns both processes and multiplexes their output:

   web | Serving HTTP on 0.0.0.0 port 8000
   api | Server listening on port 3000
   web | 127.0.0.1 - "GET / HTTP/1.1" 200 -

Process names are right-aligned and separated from output by a | character, making it easy to scan which process produced each line.

Log Files

procman automatically writes logs to a logs/procman/ directory:

  • logs/procman/web.log — output from the web service
  • logs/procman/api.log — output from the api service
  • logs/procman/procman.log — combined output from all processes

These files are created fresh on each run.

Stopping

Press Ctrl-C to shut down. procman sends SIGTERM to every child process, waits up to 2 seconds for them to exit, then sends SIGKILL to anything still running. procman exits with the exit code of the first process that terminated.

A More Advanced Example

Here’s a configuration that uses jobs, services, dependencies, and environment variables:

job migrate {
  run "db-migrate up"
}

service web {
  env PORT = "3000"
  run "serve --port $PORT"
}

service api {
  wait {
    after @migrate
    http "http://localhost:3000/health" {
      status = 200
      poll = 500ms
      timeout = 30s
    }
  }
  run "api-server start"
}

In this setup:

  • migrate is a job that runs the database migration and exits. Jobs run to completion — a successful exit (code 0) won’t trigger a shutdown of everything else.
  • web is a service that starts immediately and serves on port 3000, with the PORT environment variable set via env.
  • api is a service that waits for two things before starting: the migrate job must have exited successfully (after @migrate), and the web server’s health endpoint must be returning HTTP 200. Only then does api-server start run.

This is the core pattern for dependency-aware startup — later chapters cover the full set of dependency types and more advanced features like fan-out and process output.

Tasks: On-Demand Operations

Tasks are one-shot processes that don’t auto-start. They’re useful for operations you only want to run on demand — test suites, database migrations, cleanup scripts:

task test_suite {
  wait {
    after @migrate
  }
  run "pytest tests/"
}

task seed {
  run "db-seed"
}

Trigger them with the -t flag:

procman procman.pman -t test_suite
procman procman.pman -t test_suite -t seed

Configuration Reference

Procman reads a single .pman file (passed as a positional argument). The file contains top-level blocks in any order.

Top-Level Structure

config {
  logs = "./my-logs"
}

env {
  RUST_LOG = args.log_level
}

arg port {
  type = string
  default = "3000"
  short = "p"
  description = "Port to listen on"
}

job migrate {
  run "db-migrate up"
}

service web {
  env PORT = args.port
  run "serve --port $PORT"
}

service worker if args.enable_worker {
  run "worker-service start"
}

task test_suite {
  run "pytest tests/"
}

event recovery {
  run "./scripts/recover.sh"
}

A .pman file may contain:

  • config { } — global settings (logs, log_time)
  • arg name { } — CLI argument declaration
  • env { } / env KEY = expr — global environment variable bindings
  • job name { } — a one-shot process that runs to completion
  • job name if expr { } — a conditionally evaluated one-shot job
  • service name { } — a long-running daemon process
  • service name if expr { } — a conditionally evaluated service
  • task name { } — an on-demand one-shot process (does not auto-start; triggered via -t/--task)
  • task name if expr { } — a conditionally evaluated task
  • event name { } — a dormant process, only started via on_fail spawn

Process I/O defaults

  • stdin: redirected to /dev/null. Procman puts every child in its own process group, so an inherited TTY would be a background-pgid read and raise SIGTTIN. If you need to feed input to a process, plumb it through the run string itself (e.g. run "source | program").
  • stdout / stderr: captured (combined into one stream) and written to the per-process and combined log files; ANSI escapes are stripped from log files only.

Output formatting

  • Per-process prefix: every line on stdout is prefixed with the originating job/service name, right-aligned and padded for visual scan.
  • Color: on a TTY, the prefix is colored using a deterministic hash of the process name (so the same name always gets the same color across runs). Set the NO_COLOR environment variable (any non-empty value) to disable.
  • Log files stay plain: ANSI escape sequences from the child process and the prefix coloring are both stripped from the per-process and combined log files.
  • Startup messages: at startup, procman prints to stderr the resolved (canonicalized, absolute) log directory and each per-process log file path, so log locations are unambiguous.

Identifiers

Job names, event names, arg names, and variable names are identifiers. Valid identifiers match [a-zA-Z_][a-zA-Z0-9_-]* — they start with a letter or underscore, followed by letters, digits, underscores, or hyphens.

Reserved Keywords

The following identifiers are reserved by the language and cannot be used as job, service, task, event, arg, or for/var names:

  • module — used in the module.dir built-in.
  • procman — used in the procman.dir built-in.

Other reserved tokens (job, service, task, event, config, env, arg, import, as, wait, watch, for, if, in, on_fail, run, true, false, none) are also unavailable as identifiers, but module and procman are called out here because they look like ordinary names yet are reserved for built-in directory references (see Language Design).

String Literals

String literals are double-quoted. Supported escape sequences: \" (literal quote), \\ (literal backslash), \n (newline), \t (tab). No other backslash escapes are recognized.

Duration Literals

Duration literals are a number followed by a unit suffix: s (seconds), ms (milliseconds), m (minutes). Fractional values are allowed (e.g., 1.5s).

The none Literal

none represents the absence of a value. It is valid only in specific positions: timeout = none (infinite wait), default = none (no default). Using none in env value positions or boolean contexts is a parse-time error.

config { } Block

Global settings applied to all jobs.

config.logs

Optional log directory path. Defaults to logs/procman. Recreated each run.

config {
  logs = "./my-logs"
}

env { } Block

Global environment variable bindings applied to all jobs. Overridable per-job. Declared at the top level.

Block form:

env {
  RUST_LOG = args.log_level
}

Single-binding form:

env RUST_LOG = args.log_level

Both forms can appear multiple times and coexist in the same file.

arg name { } Block

CLI arguments parsed after --. Declared at the top level. Underscores become dashes on the CLI (log_level--log-level).

arg port {
  type = string
  default = "3000"
  short = "p"
  description = "Port to listen on"
}

arg enable_feature {
  type = bool
  default = false
}
FieldRequiredDefaultDescription
typenostringstring or bool
shortnoSingle character shorthand
descriptionnoHelp text for -- --help
defaultnoFallback value. Args without a default are required.

Arg values are referenced in expressions as args.name. There is no env field on args — use a top-level env { } block to explicitly bind args to environment variables.

Running procman myapp.pman -- --help prints generated usage based on the arg definitions.

Env Precedence

Lowest to highest:

SourcePriority
System env (inherited)lowest
CLI -e KEY=VALUE flags
Top-level env { }
Per-job env
Per-iteration for bindingshighest

Note: var bindings from contains conditions are procman expressions, not direct env injections. They enter the environment only when explicitly assigned via env KEY = var_name.

job and service Blocks

Each job block defines a one-shot process (runs to completion). Each service block defines a long-running daemon process. Both share the same fields.

run (required)

The command to execute. All commands are passed to bash -euo pipefail -c, so shell features like pipes, redirects, &&, variable expansion, and multi-line scripts all work naturally. bash must be on PATH.

Inline form:

run "echo hello"

Multi-line fenced form:

run """
  ./run-migrations
  echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
"""

Procman never interpolates inside shell strings. Values flow in exclusively via environment variables.

An empty or whitespace-only run value is rejected at parse time.

env (optional)

Environment variables merged into the job’s environment. Single-binding and block forms can coexist:

service api {
  env DB_URL = @migrate.DATABASE_URL
  env {
    API_KEY = "secret"
    LOG_DIR = args.log_dir
  }
  run "start-api --db $DB_URL"
}

job vs service

A job is a one-shot process that runs to completion. Exit code 0 is treated as success and does not trigger supervisor shutdown. A non-zero exit code still triggers shutdown. Jobs can write key-value output to $PROCMAN_OUTPUT, which other processes reference via @job.KEY expressions (see Process Output).

A service is a long-running daemon process. If a service exits, it triggers supervisor shutdown.

task

A task is a one-shot process that does not auto-start. Tasks must be explicitly triggered via the -t/--task CLI flag. Like jobs, tasks run to completion — exit code 0 is success, non-zero triggers shutdown.

Tasks are useful for operations that should only run on demand: test suites, database migrations, cleanup scripts, or any one-shot utility work.

task test_suite {
  wait {
    after @migrate
  }
  run "pytest tests/"
}

Trigger with: procman myapp.pman -t test_suite

Tasks share all the same fields as jobs and services (run, env, wait, for, if, watch).

wait (optional)

A block of conditions that must all be satisfied before run executes. See the Dependencies chapter for the full reference.

wait {
  after @migrate
  http "http://localhost:3000/health" {
    status = 200
    timeout = 30s
    poll = 500ms
  }
}

if (optional)

An expression on the job or service line. If falsy, the job/service is not evaluated at all — no dependency waiting, no env resolution. Skipped jobs still register as exited so after dependents can proceed.

service worker if args.enable_worker {
  run "worker-service start"
}

watch (optional)

Named runtime health check blocks that monitor a service after it starts. See the Dependencies chapter for condition syntax.

service web {
  run "web-server --port 8080"

  watch health {
    http "http://localhost:8080/health" {
      status = 200
    }
    initial_delay = 5s
    poll = 10s
    threshold = 3
    on_fail shutdown
  }
}

for (optional)

Iteration block that wraps env and run, spawning one instance per element. See the Fan-out chapter for full details.

job nodes {
  for config_path in glob("configs/node-*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG"
  }
}

event name { } Block

Event handlers are declared at the top level. They are never auto-started — they are spawned via on_fail spawn @name in a watch block.

event recovery {
  run "./scripts/recover.sh"
}

on_fail spawn @name must reference an event, not a job.

Shell Blocks

Procman never interpolates inside shell strings. Values flow into shell exclusively via environment variables set with env bindings.

Inline:

run "echo hello"

Multi-line fenced:

run """
  ./run-migrations
  echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
"""

Expression Language

Expressions appear in if conditions, env value positions, and var bindings. They are never evaluated inside shell strings.

Value References

SyntaxDescription
args.nameCLI arg value
@job.KEYOutput from a job’s PROCMAN_OUTPUT
local_varJob-scoped variable (from for or var binding)

Literals

TypeExamples
String"hello", "3000"
Number42, 3.14
Booltrue, false
Duration5s, 500ms, 2m
Nonenone

Operators

CategoryOperators
Comparison==, !=, >, <, >=, <=
Logical&&, ||, !
Grouping( )

No arithmetic in v1.

Type Errors

Type errors in expressions cause immediate procman runtime panic and shutdown. There is no silent coercion. A type error is a bug in the config.

Parse-Time Validation

Procman validates the configuration at parse time and exits with an error (with file:line:col location) if any of these checks fail:

  • Syntax errors — malformed blocks, missing fields, invalid tokens
  • Unknown identifiers — referencing an arg or job that doesn’t exist
  • after @job targets — must reference a job (not a service)
  • @job.KEY references — must point to a job (not a service) and require after @job in the process’s wait block (direct or transitive)
  • Circular dependencies — cycles in after references
  • on_fail spawn @name — must reference an event
  • Variable shadowing — reusing a name already bound by for, var, or args
  • Empty run commands — rejected at parse time

Dependencies

The wait block controls process startup order. It contains conditions evaluated sequentially — all must pass before run executes. A job without a wait block starts immediately.

Full Example

service api {
  env DB_URL = @migrate.DATABASE_URL

  wait {
    after @migrate
    http "http://localhost:3000/health" {
      status = 200
      timeout = 30s
      poll = 500ms
    }
    connect "127.0.0.1:5432"
    exists "/tmp/ready.flag"
  }

  run "api-server start --db $DB_URL"
}

Here api waits for migrate to exit, then checks an HTTP endpoint, then waits for a TCP port, then checks for a file — all in order — before starting.

Condition Types

after @job

Wait for a job to exit successfully (exit code 0).

wait {
  after @migrate
}

A non-zero exit triggers supervisor shutdown and the condition is never satisfied. Parse-time error if the target is not a job (services do not exit, so after cannot reference them).

For for jobs, after @nodes is satisfied only when all fan-out instances have exited successfully.

http "url" { status = N }

Wait for an HTTP endpoint to return an expected status code.

wait {
  http "http://localhost:8080/health" {
    status = 200
  }
}

The HTTP client uses a 5-second per-request timeout. Only the status code is checked — the response body is ignored.

connect "host:port"

Wait for a TCP port to accept connections.

wait {
  connect "127.0.0.1:5432"
}

Each poll attempt uses a 1-second connect timeout.

!connect "host:port"

Wait until a TCP port is not accepting connections.

wait {
  !connect "127.0.0.1:8080"
}

The condition is satisfied when the connection is refused (nobody is listening). Useful to ensure a stale process has released a port before starting a replacement.

exists "path"

Wait for a file to appear on disk.

wait {
  exists "/tmp/ready.flag"
}

!exists "path"

Wait until a file does not exist on disk.

wait {
  !exists "/tmp/api.lock"
}

Useful to wait for a lockfile or PID file to be cleaned up.

!running "pattern"

Wait until no process matching a pattern is running.

wait {
  !running "old-api.*"
}

Uses pgrep -f which matches against the full command line. Available on both macOS and Linux. There is no positive running form — “wait until a process is running” is inherently racy; use connect or http for readiness checks instead.

contains "path" { ... }

Wait for a file to contain a specific key, with optional value extraction into a job-scoped variable.

wait {
  contains "/tmp/config.yaml" {
    format = "yaml"
    key = "$.database.url"
    var = database_url
  }
}
FieldRequiredDescription
formatyes"json" or "yaml"
keyyesJSONPath expression (RFC 9535)
varnoIf set, the resolved value is bound to this job-scoped variable

The key field accepts a JSONPath expression. Use $ to refer to the document root, . to traverse nested maps, and bracket notation for array filtering. The first matching value is used. Scalar values (strings, numbers, booleans) are converted to strings. Null values are treated as missing. Mappings and sequences are serialized as JSON strings.

Array filtering example — extract rpc from the entry where alias == "local":

wait {
  contains "/tmp/sui_client.yaml" {
    format = "yaml"
    key = "$.envs[?(@.alias == 'local')].rpc"
    var = sui_rpc_url
  }
}

env SUI_RPC_URL = sui_rpc_url

output_matches @job "pattern"

Wait for an upstream job or service to emit a line containing pattern on its captured output stream. The match is a literal substring (not a regex), case-sensitive, evaluated per line. ANSI color escapes are stripped before matching, so patterns work against colorized output.

service api {
  wait {
    output_matches @migrate "Migrations complete."
  }
  run "api-server"
}
FieldRequiredDescription
timeoutnoDuration or none. Defaults to none (wait indefinitely).

Differences from other conditions:

  • The matcher is event-driven, not polled — poll = ... is rejected at parse time.
  • A stream match either happens or it doesn’t — retry = ... is rejected at parse time.
  • Negation (!output_matches ...) is not supported and rejected at parse time.

Pre-spawn registration prevents missed signals. Matchers are registered before any process is spawned, so if the upstream emits the pattern before the downstream waiter actually reaches the output_matches step (e.g. when output_matches follows another condition like after @setup), the match is latched and the waiter releases immediately when it gets there. There is no race window between upstream startup and the waiter reaching the condition.

Upstream exits without match. If the upstream’s output stream reaches EOF without the pattern ever being observed, the waiter logs:

dependency failed: output_matches @upstream "pattern" (upstream exited, pattern never observed)

and triggers shutdown — same shape as after @job failing.

Allowed targets. The target must be a job or service. task and event targets are rejected at validate time. Self-references and unknown targets are also rejected. Output_matches edges contribute to cycle detection alongside after edges.

Fan-out upstreams (for ... in ...): when the upstream is a fan-out template, the matcher is copied to each materialized instance. Any one instance emitting the pattern satisfies the condition (first-wins).

Interpolation. The pattern supports ${args.NAME}, ${module.dir}, ${procman.dir}, and ${alias::args.NAME} interpolation, consistent with other string-bearing conditions:

service api {
  wait {
    output_matches @worker "${args.phase_token}"
  }
  run "serve"
}

Not yet supported (planned): regex pattern syntax and capture-group extraction into a var. For v1, only literal substring matching is available.

Condition Options

Any condition can have a sub-block with options:

OptionDefaultDescription
timeoutnoneDuration before giving up. none means wait indefinitely.
poll1s (100ms for after)Duration between checks
retrytruefalse = fail immediately on first check
wait {
  connect "127.0.0.1:5432" {
    timeout = 10s
    retry = false
  }
  after @migrate {
    timeout = 30s
  }
}

Use an explicit timeout = 30s (or any duration) when you want a bounded wait; the default is unbounded.

No-retry mode

When retry = false, the condition is checked exactly once. If it is not satisfied on the first check, procman logs dependency failed (retry disabled): <description> and triggers shutdown immediately, without polling or waiting for a timeout.

This is useful to catch stale state that should have been cleaned up before procman started: leftover lock files, ports still bound by a previous run, or zombie processes.

wait {
  !exists "/tmp/api.lock" {
    retry = false
  }
}

String Interpolation

String arguments to wait conditions (connect, http, exists, contains, !running) support ${args.NAME} interpolation. The built-in ${module.dir} and ${procman.dir} variables are also available. For imported modules, use ${alias::args.NAME} and ${alias::module.dir}.

arg working_dir { type = string default = "/tmp" }
service api {
  wait { exists "${args.working_dir}/config.yaml" }
  run "start"
}

var Binding

The contains condition can extract a value into a job-scoped variable, referenced in env bindings. The two-step pattern:

service api {
  wait {
    contains "/tmp/config.yaml" {
      format = "yaml"
      key = "$.database.url"
      var = database_url
    }
  }

  env DB_URL = database_url
  run "start-api --db $DB_URL"
}

The variable is scoped to the enclosing job (not to the wait block), so it can be referenced in env bindings anywhere in the job body. It follows the same no-shadowing rules as for iteration variables — shadowing any existing name (args, other locals, other var bindings) is a parse-time error.

Note: var bindings are procman expressions, not direct env injections. They enter the environment only when explicitly assigned via env KEY = var_name.

Evaluation Order

Conditions within a wait block are evaluated sequentially in declaration order. Each condition is fully satisfied before the next one is checked:

  1. Start with the first condition.
  2. Poll the current condition using its check function.
  3. If the check succeeds, log dependency satisfied: <description> and advance to the next condition.
  4. If the check fails for the first time and retry is false, log dependency failed (retry disabled): <description> and trigger shutdown.
  5. Otherwise, if the check fails for the first time, log dependency not ready: <description> (logged only once per condition to avoid noise).
  6. If the check fails, sleep for the condition’s poll interval and retry.
  7. Once all conditions are satisfied, proceed to spawn the process.

This sequential evaluation prevents stale-data races — for example, a contains condition listed after an after condition will not be checked until the upstream job has actually exited, ensuring it reads freshly generated data rather than leftovers from a prior run.

Timeout Behavior

Each condition’s timeout clock starts when that condition begins being evaluated (i.e., when the previous condition is satisfied), not when the waiter thread starts. This means total wall-clock time for a job with multiple conditions is the sum of individual wait times rather than the maximum.

If any single condition exceeds its timeout:

  1. The waiter logs dependency timed out: <description>.
  2. The global shutdown flag is set.
  3. All processes are torn down (SIGTERM, then SIGKILL after a grace period).

A timed-out condition is fatal — procman does not continue with partial dependencies.

Circular Dependency Detection

At parse time, procman builds a directed graph from after references and runs a DFS cycle detection pass. If a cycle is found, parsing fails with an error showing the full cycle path:

Error: circular dependency: a -> b -> c -> a

Self-dependencies (a -> a) are also detected. References to job names not defined in the config file are rejected with:

Error: process 'a' depends on unknown process 'nonexistent'

Process Output

Every job and service receives a PROCMAN_OUTPUT environment variable pointing to a per-process output file at logs/procman/<name>.output. Jobs (one-shot processes) write data here; downstream jobs and services reference it with @job.KEY expressions.

Output File Format

The output file supports two formats:

Simple key-value lines — one per line, first = splits key from value:

DATABASE_URL=postgres://localhost:5432/mydb
API_KEY=secret123

Heredoc blocks for multi-line values:

CERT<<EOF
-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIJALP...
-----END CERTIFICATE-----
EOF

The heredoc delimiter is arbitrary — KEY<<DELIM starts a block and a line containing only DELIM ends it.

Referencing Output

Reference another job’s output with @job.KEY in env bindings. Values flow into shell via environment variables — procman never interpolates inside shell strings.

job migrate {
  run """
    ./run-migrations
    echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
  """
}

service api {
  env DB_URL = @migrate.DATABASE_URL

  wait {
    after @migrate
  }

  run "api-server --db $DB_URL"
}

The sequence:

  1. migrate starts and runs migrations.
  2. migrate writes DATABASE_URL=postgres://... to its $PROCMAN_OUTPUT file.
  3. migrate exits with code 0 — procman marks it as complete.
  4. api’s after @migrate condition is satisfied.
  5. Procman resolves @migrate.DATABASE_URL by reading migrate’s output file.
  6. api starts with DB_URL set in its environment.

Resolution

Output resolution happens at spawn time, after all wait conditions for the job are satisfied. The resolver:

  1. Reads the referenced job’s output file (logs/procman/<job>.output).
  2. Parses it into a key-value map.
  3. Substitutes each @job.KEY reference with the corresponding value.

If a referenced key is not found in the output file, resolution fails and the job is not started.

Validation Rules

Procman enforces three rules at parse time to catch output reference errors before any job starts:

Rule 1: Referenced process must exist

job app {
  env KEY = @nonexistent.KEY  # Error: process 'nonexistent' does not exist
  run "echo $KEY"
}

Rule 2: Referenced process must be a job

Only jobs (one-shot processes) produce output that is guaranteed to be available. Referencing a service is rejected:

service server {
  run "start-server"
}

job app {
  env PORT = @server.PORT  # Error: 'server' is not a job
  run "echo $PORT"
}

Rule 3: Referencing process must have after @job in its wait block

The referencing job or service must have an after condition (direct or transitive) on the referenced job. This guarantees the output file exists when references are resolved:

job setup {
  run "echo KEY=value > $PROCMAN_OUTPUT"
}

service app {
  env KEY = @setup.KEY  # Error: no 'after @setup' in wait block
  run "echo $KEY"
}

Transitive dependencies are followed — if app waits on middle and middle waits on setup, then app can reference setup’s output.

Fan-out

The for block lives inside a job or service and wraps env and run. It iterates over a typed iterable, binding a local variable per iteration — one process instance is spawned for each element.

Basic Example

job nodes {
  for config_path in glob("configs/node-*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG"
  }
}

This spawns one instance per matching file, each with its own NODE_CONFIG environment variable.

Iterable Types

SyntaxDescription
glob("pattern")File glob, evaluated at runtime (after wait conditions are satisfied), sorted lexicographically. Zero matches is a runtime error.
["a", "b", "c"]Literal array of strings
0..3Exclusive range: 0, 1, 2
0..=3Inclusive range: 0, 1, 2, 3

glob()

job nodes {
  for config_path in glob("configs/node-*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG"
  }
}

Literal array

job services {
  for svc in ["auth", "billing", "notifications"] {
    env SERVICE = svc
    run "deploy-service $SERVICE"
  }
}

Range

service workers {
  for i in 0..4 {
    env WORKER_ID = i
    run "worker --id $WORKER_ID"
  }
}

Instance Naming

Instances are named {job_name}-{index} where the index is 0-based. For the nodes example with three glob matches, the instances are nodes-0, nodes-1, and nodes-2.

Group Completion

An after @nodes condition in another job’s wait block is satisfied only when all instances have exited successfully (exit code 0). This lets you gate a downstream job on the entire fan-out group completing:

job nodes {
  for config_path in glob("configs/node-*.yaml") {
    env NODE_CONFIG = config_path
    run "provision --config $NODE_CONFIG"
  }
}

job deploy {
  wait {
    after @nodes
  }

  run "deploy-cluster"
}

Here deploy will not start until every nodes-* instance has completed successfully.

Env Inheritance

env bindings outside the for block apply to all instances. Bindings inside are per-iteration:

job nodes {
  env CLUSTER = "prod"

  for config_path in glob("configs/*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG --cluster $CLUSTER"
  }
}

All instances share CLUSTER=prod, but each gets its own NODE_CONFIG.

Scoping

  • The iteration variable is scoped to the for block
  • It shares the local variable namespace with var bindings from contains conditions
  • args.x and @job.KEY have distinct syntactic prefixes and cannot collide with bare local names
  • Shadowing any existing local variable name is a parse-time error

CLI Reference

procman <CONFIG> [OPTIONS] [-- ARGS]

Spawn all processes defined in the config file and wait for exit or signal.

  • CONFIG is a required positional argument — the path to the .pman config file.
  • Acquires an exclusive advisory lock on the config file to prevent concurrent instances.
  • On SIGINT or SIGTERM, initiates graceful shutdown.
procman myapp.pman
procman myapp.pman -e PORT=3000 -e RUST_LOG=debug
procman myapp.pman --debug
procman myapp.pman --check                     # validate config and exit
procman myapp.pman -- --rust-log debug --verbose

-e / --env — Extra environment variables

A repeatable -e KEY=VALUE flag to inject environment variables without modifying the config file.

procman myapp.pman -e PORT=3000 -e RUST_LOG=debug

-t / --task — Trigger tasks

A repeatable -t NAME flag that activates one or more tasks by name. Tasks are one-shot processes defined with task blocks that don’t auto-start — they must be explicitly triggered via this flag.

procman myapp.pman -t migrate
procman myapp.pman -t test_a -t test_b

If a named task does not exist in the config, procman exits with an error and lists available tasks. A non-zero exit code from any task triggers shutdown of all processes.

-- [ARGS] — User-defined arguments

Arguments after -- are parsed according to the config.args definitions in the config file. See the Configuration chapter for how to define args.

procman myapp.pman -- --rust-log debug --enable-feature

Running -- --help prints generated usage based on the config.args definitions:

procman myapp.pman -- --help

This shows each defined argument’s name, type, description, default value, and short form.

--check — Validate config and exit

The --check flag runs the full config parse and validation pipeline — arg definitions, template resolution, dependency graph cycle detection, output reference validation, watch uniqueness, and all other static checks — then exits without starting any processes.

procman myapp.pman --check

On success, exits silently with code 0. On failure, prints the error and exits non-zero. Warnings (e.g. parameterized imports skipped because args are unbound) are printed in the standard file:line:col: warning: description format. This is useful for:

  • Editor integration — run --check on save for instant feedback.
  • CI pipelines — catch config errors before deployment.
  • Quick validation — verify a config without spawning anything.

No signal handlers, loggers, or processes are created.

--debug — Pause before shutdown

The --debug flag pauses the shutdown sequence when a child process fails or a dependency times out, giving you time to inspect remaining processes before they are terminated.

procman myapp.pman --debug

When triggered, procman prints:

  • Which process caused the shutdown (name, PID, exit code or signal)
  • A list of processes still running (name and PID)
  • A prompt to press ENTER (or Ctrl+C) to continue with the normal shutdown sequence

The --debug flag requires an interactive terminal (stdin must be a TTY). If stdin is not a TTY, procman exits immediately with an error.

Environment variable precedence

Precedence (lowest → highest):

SourcePriority
System environmentlowest
CLI -e flags
Global config { env { } }
Per-job env
Per-iteration for bindingshighest

Per-iteration for bindings win over per-job env, which wins over global config { env { } }, which wins over CLI -e flags, which win over inherited system environment variables.

File locking

Procman acquires an exclusive advisory lock (flock) on the config file before starting. If another procman instance is already running with the same config, the second instance exits immediately with an error message.

Exit code

Procman’s exit code is the exit code of the first process that terminated (the one that triggered shutdown). If the first termination was caused by a signal rather than a normal exit, the exit code is 1.

Signals

On SIGINT (Ctrl-C) or SIGTERM, procman initiates graceful shutdown: SIGTERM is sent to each child’s process group, followed by a 2-second grace period, then SIGKILL for any stragglers.

Logging & Output

Procman captures all process output and writes it to both the terminal and log files.

Multiplexed stdout

All process output is interleaved on procman’s stdout with right-aligned name labels and a | separator:

 procman | started with 3 process(es), mode=run
     web | listening on :8080
  worker | processing jobs
     web | GET /health 200

The name column width adjusts to the longest process name so that all | separators align.

Log directory

At startup, procman creates (or recreates) a logs/procman/ directory in the current working directory. Any existing logs/procman/ directory is removed first to ensure a clean state.

Combined log

logs/procman/procman.log contains every line from every process, in the same right-aligned format as the terminal output. This is a complete record of the session.

Per-process logs

Each process gets its own log file at logs/procman/<name>.log. These files contain only that process’s output lines with no name prefix — just the raw output. This makes them easy to feed into other tools or search with grep.

The procman pseudo-process does not get its own per-process log file. Supervisor messages appear only in the combined log and on stdout.

Supervisor messages

Procman logs its own messages under the procman name. These include:

  • Startup information (process count, mode).
  • Dependency status (waiting, satisfied).
  • Process lifecycle events (started, completed, exited, killed).
  • Shutdown sequence progress.
  • Error messages.

stderr handling

Each child process has stderr redirected to stdout (dup2) before exec. This means both streams are captured through the same pipe and appear interleaved in the logs. There is no separate stderr log.

Shutdown & Signals

When procman shuts down, it ensures every child process — and all of its descendants — receives a termination signal and is cleaned up.

What triggers shutdown

Shutdown begins when either:

  • A child process exits (unless it is a job and exits with code 0).
  • The user sends Ctrl-C (SIGINT) or SIGTERM to procman.

Process groups

Each child process runs in its own process group (setpgid(0, 0) is called before exec). This means:

  • Signals sent to the group reach every descendant of the child, not just the top-level PID.
  • Unrelated processes managed by procman do not receive each other’s signals.

This is especially important for multi-line run commands, which are executed via bash -euo pipefail -c. The shell spawns child processes (pipes, subshells, backgrounded commands) that would otherwise be orphaned on shutdown. Because the entire process group is signaled, those descendants are cleaned up together with the shell.

Shutdown sequence

  1. SIGTERM is sent to each remaining child’s process group (killpg).
  2. Procman waits up to 2 seconds for processes to exit cleanly.
  3. Any process groups still alive after the grace period receive SIGKILL.
  4. Procman waits for all remaining processes to be reaped.

Exit code

Procman’s exit code is the exit code of the first process that terminated (the one that triggered shutdown). If the first process was killed by a signal rather than exiting normally, the exit code is 1.

The .pman Language — Design Spec

Design Principles

  • Declarative.pman describes what to run and when, not how. Runtime semantics (polling, fan-out tracking, shutdown cascades) remain procman’s domain.
  • Two worlds, clearly separated — procman expressions use their own syntax. Shell blocks are opaque strings. Values flow into shell exclusively via environment variables. Procman never interpolates inside shell strings.
  • Strict typing — type errors in expressions cause immediate shutdown. No silent coercion.
  • Fail early — as much validation as possible at parse time.

File Format

Extension: .pman

Comments: # to end of line.

Identifiers

Job names, event names, arg names, and variable names are identifiers. Valid identifiers match [a-zA-Z_][a-zA-Z0-9_-]* — they start with a letter or underscore, followed by letters, digits, underscores, or hyphens.

String Literals

String literals are double-quoted. Supported escape sequences: \" (literal quote), \\ (literal backslash), \n (newline), \t (tab). No other backslash escapes are recognized.

Duration Literals

Duration literals are a number followed by a unit suffix: s (seconds), ms (milliseconds), m (minutes). Fractional values are allowed (e.g., 1.5s). No other units in v1.

The none Literal

none represents the absence of a value. It is valid only in specific positions: timeout = none (infinite wait), default = none (no default). Using none in env value positions or boolean contexts is a parse-time error.

Imports

A root .pman file can import other .pman files to compose multi-module configurations:

import "db/migrations.pman" as db
import "monitoring.pman"

Syntax

import "path" as alias — loads the file at path and makes its entities available under alias. If as alias is omitted, the alias is derived from the filename stem (e.g., monitoring.pman becomes monitoring).

Namespaced References

Imported entities are referenced with the @alias::name syntax:

service api {
  wait { after @db::migrate }
  env DB_URL = @db::migrate.DATABASE_URL
  run "serve"
}

service web {
  watch health {
    http "http://localhost:8080/health"
    on_fail spawn @monitoring::recovery
  }
  run "web-server"
}

The @alias::name syntax works in:

  • after @alias::job — wait for an imported job to complete
  • @alias::job.KEY — reference output from an imported job
  • on_fail spawn @alias::event — spawn an imported event handler

Path Resolution

Import paths are resolved relative to the importing file’s directory.

Parameterized Imports

Imports can supply argument bindings to the imported module, allowing the same module to be configured differently per import site:

import "db.pman" as db { url = "postgres://localhost/mydb" }

Arg Declarations in Imported Modules

Imported modules declare their parameters with arg blocks, just like the root file:

# db.pman
arg url { type = string }
arg pool_size { type = string default = "5" }

job migrate {
  env DB_URL = args.url
  run "migrate --pool $DB_URL"
}

Import-Site Bindings

Bindings appear inside { } after the alias and provide values for the imported module’s args. Binding expressions are evaluated in the root file’s context, so they can reference the root file’s own args:

arg db_url { type = string default = "postgres://localhost/mydb" }
import "db.pman" as db { url = args.db_url pool_size = "10" }

Namespaced Args Refs

The root file can reference an imported module’s resolved arg values using alias::args.name syntax:

import "db.pman" as db { url = "postgres://localhost/mydb" }

service api {
  env DB_URL = db::args.url
  run "serve"
}

This works in any expression position: env values, if conditions, etc.

Built-in Directory References

Two built-in keyword namespaces provide access to file system paths:

  • module.dir — directory of the current .pman file. In the root file, this is the root file’s directory. In an imported module, it is the imported file’s directory.
  • procman.dir — directory of the root .pman file (the one invoked by the CLI). Always the same value in every module.
  • ns::module.dir — directory of the imported module aliased as ns.
# In an imported module (db/migrations.pman):
job migrate {
  env MIGRATION_DIR = module.dir     # directory of this file
  env ROOT_DIR = procman.dir         # root project directory
  run "$MIGRATION_DIR/run.sh"
}

In the root file, module.dir == procman.dir. Only module.dir supports the namespaced form (ns::module.dir); procman.dir always refers to the root.

The names module and procman are reserved keywords (see Configuration › Reserved Keywords) and cannot be reused as job, service, task, event, arg, or local-variable names.

CLI Overrides

Unbound imported args (no binding and no default) are exposed as required CLI flags in the form --alias::arg-name. Args with bindings or defaults can still be overridden from the CLI:

pman run my.pman -- --db::url "postgres://prod/mydb"

Resolution Priority

Imported module arg values resolve with three levels of priority (highest to lowest):

  1. CLI flags (--alias::arg-name) — always wins
  2. Import-site bindings (import "..." as alias { name = expr })
  3. Defaults (arg name { default = "..." } in the imported module)

If none of the three provides a value, the arg is required and surfaces as a CLI flag error.

Restrictions

  • Config block: only the root file may contain a config { } block. Imported files with config blocks produce an error.
  • Nested imports: imported files may contain import statements. Each module’s imports are private; transitive namespaces are not accessible from parent modules.
  • Alias uniqueness: each import alias must be unique within the root file.
  • Diamond imports: two imports that resolve to the same canonical file path produce an error. Use a single import with one alias.
  • Binding validation: import-site bindings must reference args that are declared in the imported module. Binding an undefined arg is an error.

Path Variable Substitution

Import paths can reference root-level CLI arguments using ${args.NAME} syntax:

arg lib_dir { type = string }
import "${args.lib_dir}/services.pman" as services

Root-level args are resolved before imports are loaded, so the substitution happens at parse time. During --check validation, if an argument reference is unresolved (no CLI value and no default), the import is skipped with a warning rather than failing.

Env Scoping

Each module’s top-level env { } bindings apply only to entities defined in that module. The root file’s env does not leak into imported modules, and vice versa. System env and CLI -e flags are shared across all modules.

Runtime Names

At runtime, imported entities have names prefixed with alias:: (e.g., db::migrate). This prefix appears in logs, process names, and dependency references.

Cycle Detection

Circular dependencies are detected across the combined graph of all modules.

Top-Level Blocks

A .pman file contains top-level blocks in any order:

  • import "path" as alias — import another .pman file
  • config { } — global settings (logs, log_time; root file only)
  • arg name { } — CLI argument declaration
  • env { } / env KEY = expr — global environment variable bindings
  • job name { } — one-shot process (runs to completion)
  • job name if expr { } — conditionally evaluated one-shot job
  • service name { } — long-running daemon process
  • service name if expr { } — conditionally evaluated service
  • task name { } — on-demand one-shot process (does not auto-start)
  • task name if expr { } — conditionally evaluated task
  • event name { } — dormant process, only started via on_fail spawn

Config Block

config {
  logs = "./my-logs"
  log_time = true
}

config.logs

Optional log directory path. Defaults to logs/procman. Recreated each run.

config.log_time

Optional boolean. When true, every log line is prefixed with elapsed time since procman started (e.g., api 1.2s | listening on :3000). Defaults to false.

Env Block

Global environment variable bindings applied to all jobs. Overridable per-job. Declared at the top level.

Block form:

env {
  RUST_LOG = args.log_level
  PORT = "3000"
}

Single-binding form:

env RUST_LOG = args.log_level

Both forms can appear multiple times and coexist in the same file.

Arg Declarations

CLI arguments parsed after --. Declared at the top level, outside config.

arg port {
  type = string
  default = "3000"
  short = "p"
  description = "Port to listen on"
}

arg log_level {
  type = string
  default = "info"
  short = "r"
  description = "RUST_LOG configuration"
}

arg enable_feature {
  type = bool
  default = false
}

Underscores become dashes on the CLI (log_level -> --log-level).

FieldRequiredDefaultDescription
typenostringstring or bool
shortnoSingle character shorthand
descriptionnoHelp text for -- --help
defaultnoFallback value. Args without a default are required.

Arg values are referenced in expressions as args.name. There is no env field on args — use a top-level env { } block to explicitly bind args to environment variables.

Arg defaults may reference procman.dir, module.dir, other args, and use + for string concatenation. Defaults referencing other args are evaluated in dependency order; cyclical references are a parse-time error.

Env Precedence

Lowest to highest:

  1. System env (inherited)
  2. CLI -e KEY=VALUE flags
  3. Top-level env { }
  4. Per-job env
  5. Per-iteration for bindings

Note: var bindings from contains conditions are procman expressions, not direct env injections. They enter the environment only when explicitly assigned via env KEY = var_name.

Job and Service Definitions

A job is a one-shot process that runs to completion. Exit code 0 is treated as success without triggering supervisor shutdown. Jobs can write key-value output to $PROCMAN_OUTPUT for downstream references via @job.KEY.

A service is a long-running daemon process that runs for the lifetime of the supervisor. If a service exits, it triggers shutdown.

job migrate {
  run """
    ./run-migrations
    echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
  """
}

service api {
  env DB_URL = @migrate.DATABASE_URL
  env {
    API_KEY = "secret"
    LOG_DIR = args.log_dir
  }

  wait {
    after @migrate
    http "http://localhost:3000/health" {
      status = 200
      timeout = 30s
      poll = 500ms
    }
  }

  run "start-api --db $DB_URL"
}

Fields

FieldRequiredDescription
runyesShell command — inline "..." or fenced triple-quote block
envnoSingle env KEY = expr or env { } block. Both styles can coexist.
waitnoBlock of conditions, all must pass before run
ifnoExpression on the job/service line: job name if expr { }
watchnoNamed runtime health check blocks (services only)
fornoIteration block wrapping env/run

Shell Blocks

Inline:

run "echo hello"

Multi-line fenced:

run """
  ./run-migrations
  echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
"""

Procman never interpolates inside shell strings. Values flow in exclusively via environment variables.

Conditional Jobs and Services

service worker if args.enable_worker {
  run "worker-service start"
}

If the expression is falsy, the job/service is not evaluated at all — no dependency waiting, no env resolution. Skipped jobs still register as exited so after @job dependents can proceed.

Task Definitions

A task is a one-shot process that does not auto-start. Tasks must be explicitly triggered via the -t/--task CLI flag. Like jobs, exit code 0 is success without triggering shutdown; non-zero triggers shutdown.

Tasks share all fields with jobs and services (run, env, wait, for, if, watch).

task test_suite {
  wait {
    after @migrate
  }
  run "pytest tests/"
}

task cleanup if args.enable_cleanup {
  run "./scripts/cleanup.sh"
}

Invoke with: procman config.pman -t test_suite -t cleanup

Fan-Out (for)

The for block lives inside a job or service and wraps env and run. It iterates over a typed iterable, binding a local variable per iteration:

job nodes {
  wait {
    after @setup
  }

  for config_path in glob("configs/node-*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG"
  }
}

Iterables

SyntaxDescription
glob("pattern")File glob, evaluated at runtime (after wait conditions are satisfied), sorted lexicographically. Zero matches is a runtime error.
["a", "b", "c"]Literal array of strings
0..3Exclusive range: 0, 1, 2
0..=3Inclusive range: 0, 1, 2, 3

Scoping

  • The iteration variable is scoped to the for block
  • It shares the local variable namespace with var bindings from contains conditions
  • args.x and @job.KEY have distinct syntactic prefixes and cannot collide with bare local names
  • Shadowing any existing local variable name is a parse-time error
  • Lowercase is convention, not enforced

Instance Naming

{job_name}-{index} (0-based). Three glob matches on nodes produce nodes-0, nodes-1, nodes-2.

Group Completion

after @nodes in another job’s wait block is satisfied only when all instances have exited successfully.

Env Inheritance

env bindings outside the for block apply to all instances. Bindings inside are per-iteration:

job nodes {
  env CLUSTER = "prod"

  for config_path in glob("configs/*.yaml") {
    env NODE_CONFIG = config_path
    run "start-node --config $NODE_CONFIG --cluster $CLUSTER"
  }
}

Wait Conditions

The wait block contains conditions evaluated sequentially. Each must be satisfied before the next is checked. All must pass before run executes.

wait {
  after @migrate
  connect "127.0.0.1:5432"
  http "http://localhost:8080/health" {
    status = 200
    timeout = 30s
    poll = 500ms
  }
  exists "/tmp/ready.flag"
  contains "/tmp/config.yaml" {
    format = "yaml"
    key = "$.database.url"
    var = database_url
  }
  !connect "127.0.0.1:8080"
  !exists "/tmp/api.lock"
  !running "old-api.*"
}

Condition Types

SyntaxDescription
after @jobWait for a job to exit successfully. Parse-time error if the target is not a job.
http "url" { status = N }HTTP GET returns expected status
connect "host:port"TCP port accepts connections
!connect "host:port"TCP port stops accepting connections
exists "path"File exists on disk
!exists "path"File does not exist
!running "pattern"No process matches pattern (pgrep -f). No positive running form — “wait until a process is running” is inherently racy; use connect or http for readiness checks instead.
contains "path" { ... }File contains a key (format = "json" or "yaml"); optionally binds to a local var

Condition Options

Any condition can have a sub-block with options:

OptionDefaultDescription
timeoutnoneDuration before giving up. none means wait indefinitely.
poll1s (100ms for after)Duration between checks
retrytruefalse = fail immediately on first check

The status option is specific to http conditions:

OptionDefaultDescription
status200Expected HTTP status code
wait {
  connect "127.0.0.1:5432" {
    timeout = 10s
    retry = false
  }
  after @migrate {
    timeout = 30s
  }
}

Use timeout = none to explicitly set an infinite wait.

String Interpolation in Wait Conditions

String arguments to connect, http, exists, contains, and !running support ${args.NAME} interpolation. The built-in variables ${module.dir} and ${procman.dir} are also available. For imported modules, use the ${alias::args.NAME} and ${alias::module.dir} forms.

arg working_dir { type = string default = "/tmp" }
service api {
  wait {
    exists "${args.working_dir}/config.yaml"
    connect "${args.host}:${args.port}"
    http "http://localhost:${args.port}/health"
  }
  run "start"
}

var Binding

The contains condition can extract a value into a job-scoped variable:

wait {
  contains "/tmp/config.yaml" {
    format = "yaml"
    key = "$.database.url"
    var = database_url
  }
}

env DB_URL = database_url
run "start-api --db $DB_URL"

The variable is scoped to the enclosing job (not to the wait block), so it can be referenced in env bindings and other expressions anywhere in the job body. It follows the same no-shadowing rules as for iteration variables — shadowing any existing name (args, other locals, other var bindings) is a parse-time error.

Watches and Events

Watch Blocks

Named runtime health checks that monitor a service after it starts:

service web {
  run "web-server --port 8080"

  watch health {
    http "http://localhost:8080/health" {
      status = 200
    }
    initial_delay = 5s
    poll = 10s
    threshold = 3
    on_fail shutdown
  }

  watch disk {
    exists "/var/run/healthy"
    on_fail spawn @recovery
  }
}
FieldDefaultDescription
check(required)One condition, same syntax as wait conditions
initial_delay0sTime before first check
poll5sTime between checks
threshold3Consecutive failures before triggering action
on_failshutdownAction instruction

on_fail Actions (v1)

on_fail shutdown
on_fail debug
on_fail log
on_fail spawn @recovery

on_fail is a prefix to an action instruction, not an assignment. This leaves room for block-based multi-action handlers in the future.

Event Handlers

Declared at the top level with event. Never auto-started:

event recovery {
  run "./scripts/recover.sh"
}

on_fail spawn @name must reference an event, not a job or service. The @ sigil is a general “named entity” prefix used for jobs, services, and events throughout the language; the parser validates the target type based on context (after requires a job, spawn requires an event). When spawned, the event handler receives PROCMAN_WATCH_* environment variables with failure context.

Expression Language

Expressions appear in if conditions, env value positions, and var bindings. Never evaluated inside shell strings.

Value References

SyntaxDescription
args.nameCLI arg value
@job.KEYOutput from a job’s PROCMAN_OUTPUT
local_varJob-scoped variable (from for or var binding)
module.dirDirectory of the current .pman file
ns::module.dirDirectory of the imported module’s .pman file
procman.dirDirectory of the root .pman file

Literals

TypeExamples
String"hello", "3000"
Number42, 3.14
Booltrue, false
Duration5s, 500ms, 2m
Nonenone

Operators

CategoryOperators
String+ (concatenation)
Comparison==, !=, >, <, >=, <=
Logical&&, ||, !
Grouping( )

No arithmetic in v1. + is string concatenation only.

PROCMAN_OUTPUT Format

Every job and service receives a PROCMAN_OUTPUT environment variable pointing to a per-process output file. Jobs write key-value data to this file, which other jobs and services reference via @job.KEY expressions.

Simple key-value lines: KEY=VALUE (one per line, first = splits key from value).

Heredoc blocks for multi-line values:

CERT<<EOF
-----BEGIN CERTIFICATE-----
MIIBxTCCAWugAwIBAgIJALP...
-----END CERTIFICATE-----
EOF

The heredoc delimiter is arbitrary — KEY<<DELIM starts a block and a line containing only DELIM ends it.

Type Errors

Type errors in expressions cause immediate procman runtime panic and shutdown. There is no silent coercion. A type error is a bug in the config.

Validation

Error Reporting

All parse-time and runtime errors include the source file path, line number, and column number (1-based) where the error was detected. Format: {path}:{line}:{col}: {message}.

Parse-Time

  • Syntax errors
  • Duplicate job, service, task, or event names
  • Jobs, services, and tasks share a namespace — a service cannot have the same name as a job or task
  • Duplicate watch names within a single job/service/event
  • Unknown identifiers (referencing an arg or job that doesn’t exist)
  • after @name must target a job (not a service)
  • @job.KEY references must point to a job (not a service)
  • @job.KEY references require after @job in the referencing process’s wait block (direct or transitive)
  • Circular dependencies in after references
  • on_fail spawn @name must reference an event
  • Variable shadowing (between for loop variables and contains var bindings)
  • Empty run commands

Runtime

All fatal — immediate shutdown:

  • Type errors in expression evaluation
  • Missing key in @job.KEY resolution
  • glob() pattern matching zero files
  • Dependency timeout exceeded
  • Non-zero exit from a job

General principle: All expressions in .pman files are evaluated at runtime, not parse time. The parser validates syntax, identifiers, and structural rules. Value resolution (including glob(), @job.KEY, and args.* references) happens at the point of use — after upstream dependencies are satisfied.

Future Work (Out of Scope for v1)

  • on_fail block syntax for multi-action handlers
  • Arithmetic in expressions

Full Example

config {
  logs = "./my-logs"
  log_time = true
}

env {
  RUST_LOG = args.log_level
}

arg port {
  type = string
  default = "3000"
  short = "p"
  description = "Port to listen on"
}

arg log_level {
  type = string
  default = "info"
  short = "r"
  description = "RUST_LOG configuration"
}

arg enable_worker {
  type = bool
  default = false
}

job migrate {
  run """
    ./run-migrations
    echo "DATABASE_URL=postgres://localhost:5432/mydb" > $PROCMAN_OUTPUT
  """
}

service web {
  env PORT = args.port
  run "serve --port $PORT"
}

service api {
  env DB_URL = @migrate.DATABASE_URL

  wait {
    after @migrate
    http "http://localhost:3000/health" {
      status = 200
      timeout = 30s
      poll = 500ms
    }
  }

  run "api-server start --db $DB_URL"
}

service db {
  wait {
    connect "127.0.0.1:5432"
  }
  run "db-client start"
}

job extract-config {
  wait {
    contains "/tmp/config.json" {
      format = "json"
      key = "$.database.host"
      var = db_host
    }
  }
  env DB_HOST = db_host
  run "echo connected to $DB_HOST"
}

service healthcheck {
  wait {
    !connect "127.0.0.1:8080"
    !exists "/tmp/api.lock"
    !running "old-api.*"
  }
  run "api-server --port 8080"
}

service worker if args.enable_worker {
  run "worker-service start"
}

job nodes {
  for config_path in glob("/etc/nodes/*.yaml") {
    env NODE_CONFIG = config_path
    run "node-agent --config $NODE_CONFIG"
  }
}

service web-watched {
  run "web-server --port 8080"

  watch health {
    http "http://localhost:8080/health" {
      status = 200
    }
    initial_delay = 5s
    poll = 10s
    threshold = 3
    on_fail shutdown
  }

  watch disk {
    exists "/var/run/healthy"
    on_fail spawn @recovery
  }
}

task test_suite {
  wait {
    after @migrate
  }
  run "pytest tests/"
}

event recovery {
  run "./scripts/recover.sh"
}