Introduction
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 combinedprocman.log. - Process output — a job can write
KEY=VALUEpairs to$PROCMAN_OUTPUT; downstream jobs and services reference them with@job.KEYsyntax. - Fan-out — use
forblocks 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 asargs.nameexpressions 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 tasks —
taskblocks define one-shot processes that don’t auto-start; trigger them with-t/--taskfor 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 —
.pmandescribes 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 .
Links
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 thewebservicelogs/procman/api.log— output from theapiservicelogs/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
jobthat 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
servicethat starts immediately and serves on port 3000, with thePORTenvironment variable set viaenv. - api is a
servicethat waits for two things before starting: themigratejob must have exited successfully (after @migrate), and the web server’s health endpoint must be returning HTTP 200. Only then doesapi-server startrun.
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 declarationenv { }/env KEY = expr— global environment variable bindingsjob name { }— a one-shot process that runs to completionjob name if expr { }— a conditionally evaluated one-shot jobservice name { }— a long-running daemon processservice name if expr { }— a conditionally evaluated servicetask name { }— an on-demand one-shot process (does not auto-start; triggered via-t/--task)task name if expr { }— a conditionally evaluated taskevent name { }— a dormant process, only started viaon_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 therunstring 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_COLORenvironment 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 themodule.dirbuilt-in.procman— used in theprocman.dirbuilt-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
}
| Field | Required | Default | Description |
|---|---|---|---|
type | no | string | string or bool |
short | no | — | Single character shorthand |
description | no | — | Help text for -- --help |
default | no | — | Fallback 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:
| Source | Priority |
|---|---|
| System env (inherited) | lowest |
CLI -e KEY=VALUE flags | |
Top-level env { } | |
Per-job env | |
Per-iteration for bindings | highest |
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
| Syntax | Description |
|---|---|
args.name | CLI arg value |
@job.KEY | Output from a job’s PROCMAN_OUTPUT |
local_var | Job-scoped variable (from for or var binding) |
Literals
| Type | Examples |
|---|---|
| String | "hello", "3000" |
| Number | 42, 3.14 |
| Bool | true, false |
| Duration | 5s, 500ms, 2m |
| None | none |
Operators
| Category | Operators |
|---|---|
| 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 @jobtargets — must reference ajob(not aservice)@job.KEYreferences — must point to ajob(not aservice) and requireafter @jobin the process’swaitblock (direct or transitive)- Circular dependencies — cycles in
afterreferences on_fail spawn @name— must reference anevent- Variable shadowing — reusing a name already bound by
for,var, or args - Empty
runcommands — 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
}
}
| Field | Required | Description |
|---|---|---|
format | yes | "json" or "yaml" |
key | yes | JSONPath expression (RFC 9535) |
var | no | If 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"
}
| Field | Required | Description |
|---|---|---|
timeout | no | Duration 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:
| Option | Default | Description |
|---|---|---|
timeout | none | Duration before giving up. none means wait indefinitely. |
poll | 1s (100ms for after) | Duration between checks |
retry | true | false = 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:
- Start with the first condition.
- Poll the current condition using its check function.
- If the check succeeds, log
dependency satisfied: <description>and advance to the next condition. - If the check fails for the first time and
retryisfalse, logdependency failed (retry disabled): <description>and trigger shutdown. - Otherwise, if the check fails for the first time, log
dependency not ready: <description>(logged only once per condition to avoid noise). - If the check fails, sleep for the condition’s
pollinterval and retry. - 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:
- The waiter logs
dependency timed out: <description>. - The global shutdown flag is set.
- 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:
migratestarts and runs migrations.migratewritesDATABASE_URL=postgres://...to its$PROCMAN_OUTPUTfile.migrateexits with code 0 — procman marks it as complete.api’safter @migratecondition is satisfied.- Procman resolves
@migrate.DATABASE_URLby readingmigrate’s output file. apistarts withDB_URLset in its environment.
Resolution
Output resolution happens at spawn time, after all wait conditions for the
job are satisfied. The resolver:
- Reads the referenced job’s output file (
logs/procman/<job>.output). - Parses it into a key-value map.
- Substitutes each
@job.KEYreference 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
| Syntax | Description |
|---|---|
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..3 | Exclusive range: 0, 1, 2 |
0..=3 | Inclusive 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
forblock - It shares the local variable namespace with
varbindings fromcontainsconditions args.xand@job.KEYhave 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
.pmanconfig 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
--checkon 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):
| Source | Priority |
|---|---|
| System environment | lowest |
CLI -e flags | |
Global config { env { } } | |
Per-job env | |
Per-iteration for bindings | highest |
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
joband 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
- SIGTERM is sent to each remaining child’s process group (
killpg). - Procman waits up to 2 seconds for processes to exit cleanly.
- Any process groups still alive after the grace period receive SIGKILL.
- 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 —
.pmandescribes 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 jobon_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.pmanfile. 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.pmanfile (the one invoked by the CLI). Always the same value in every module.ns::module.dir— directory of the imported module aliased asns.
# 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):
- CLI flags (
--alias::arg-name) — always wins - Import-site bindings (
import "..." as alias { name = expr }) - 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
importstatements. 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.pmanfileconfig { }— global settings (logs, log_time; root file only)arg name { }— CLI argument declarationenv { }/env KEY = expr— global environment variable bindingsjob name { }— one-shot process (runs to completion)job name if expr { }— conditionally evaluated one-shot jobservice name { }— long-running daemon processservice name if expr { }— conditionally evaluated servicetask name { }— on-demand one-shot process (does not auto-start)task name if expr { }— conditionally evaluated taskevent name { }— dormant process, only started viaon_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).
| Field | Required | Default | Description |
|---|---|---|---|
type | no | string | string or bool |
short | no | — | Single character shorthand |
description | no | — | Help text for -- --help |
default | no | — | Fallback 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:
- System env (inherited)
- CLI
-e KEY=VALUEflags - Top-level
env { } - Per-job
env - Per-iteration
forbindings
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
| Field | Required | Description |
|---|---|---|
run | yes | Shell command — inline "..." or fenced triple-quote block |
env | no | Single env KEY = expr or env { } block. Both styles can coexist. |
wait | no | Block of conditions, all must pass before run |
if | no | Expression on the job/service line: job name if expr { } |
watch | no | Named runtime health check blocks (services only) |
for | no | Iteration 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
| Syntax | Description |
|---|---|
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..3 | Exclusive range: 0, 1, 2 |
0..=3 | Inclusive range: 0, 1, 2, 3 |
Scoping
- The iteration variable is scoped to the
forblock - It shares the local variable namespace with
varbindings fromcontainsconditions args.xand@job.KEYhave 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
| Syntax | Description |
|---|---|
after @job | Wait 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:
| Option | Default | Description |
|---|---|---|
timeout | none | Duration before giving up. none means wait indefinitely. |
poll | 1s (100ms for after) | Duration between checks |
retry | true | false = fail immediately on first check |
The status option is specific to http conditions:
| Option | Default | Description |
|---|---|---|
status | 200 | Expected 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
}
}
| Field | Default | Description |
|---|---|---|
| check | (required) | One condition, same syntax as wait conditions |
initial_delay | 0s | Time before first check |
poll | 5s | Time between checks |
threshold | 3 | Consecutive failures before triggering action |
on_fail | shutdown | Action 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
| Syntax | Description |
|---|---|
args.name | CLI arg value |
@job.KEY | Output from a job’s PROCMAN_OUTPUT |
local_var | Job-scoped variable (from for or var binding) |
module.dir | Directory of the current .pman file |
ns::module.dir | Directory of the imported module’s .pman file |
procman.dir | Directory of the root .pman file |
Literals
| Type | Examples |
|---|---|
| String | "hello", "3000" |
| Number | 42, 3.14 |
| Bool | true, false |
| Duration | 5s, 500ms, 2m |
| None | none |
Operators
| Category | Operators |
|---|---|
| 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 @namemust target ajob(not aservice)@job.KEYreferences must point to ajob(not aservice)@job.KEYreferences requireafter @jobin the referencing process’swaitblock (direct or transitive)- Circular dependencies in
afterreferences on_fail spawn @namemust reference anevent- Variable shadowing (between
forloop variables andcontainsvarbindings) - Empty
runcommands
Runtime
All fatal — immediate shutdown:
- Type errors in expression evaluation
- Missing key in
@job.KEYresolution 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_failblock 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"
}