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

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.

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.

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.

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)

Literals

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

Operators

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

No arithmetic in v1.

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"
}