Manage Service Lifecycle from One Interface
You run several services, such as a gRPC server, a vector store, and
a trace collector. To manage them, you must remember which command
starts each one. You must watch for crashes by hand. You must wade
through unstructured console output when something goes wrong. Three
libraries remove that overhead.
@forwardimpact/librc gives you a single CLI that
starts, stops, and checks every service.
@forwardimpact/libsupervise runs a supervision daemon
that restarts services automatically when they crash.
@forwardimpact/libtelemetry adds structured logging and
trace spans, so problems surface in context. They do not stay buried
in stdout.
This guide walks the full arc. Define services in a configuration file. Manage them through one interface. Observe their behavior through structured logs and spans. Each step produces a result that works. Two bounded tasks cover the details:
- Start, stop, or check a service — manage a service without the need to remember its specific incantation.
- Add observability — add a log line or trace span without the need to configure a logging framework.
Prerequisites
- Node.js 22+
- Install the three libraries:
npm install @forwardimpact/librc @forwardimpact/libsupervise @forwardimpact/libtelemetry
Or invoke fit-rc ephemerally with npx:
npx fit-rc --help
How the libraries fit together
Each library owns one concern. Together they form a supervision and observability stack:
config/config.json (service definitions)
|
v
librc / fit-rc (lifecycle commands: start, stop, status)
|
v
libsupervise / svscan (supervision daemon: restart on crash)
|
v
libtelemetry (structured logs and trace spans)
fit-rc reads the service configuration and sends
commands to the svscan supervision daemon (from
libsupervise). The daemon manages each process. It
restarts the process on failure with exponential backoff. It pipes
the output through a log writer that handles rotation.
libtelemetry provides the structured logging that both
the daemon and your services use to produce machine-readable output.
Define services
You define services in config/config.json under the
init key. Each service is either a
longrun (a process that should stay running) or a
oneshot (a command that runs once during startup or
shutdown):
{
"init": {
"log_dir": "data/logs",
"services": [
{
"name": "codegen",
"type": "oneshot",
"up": "npx fit-codegen generate --all",
"down": "echo codegen teardown"
},
{
"name": "trace",
"command": "npx gemba-trace serve"
},
{
"name": "vector",
"command": "npx fit-vector serve"
},
{
"name": "graph",
"command": "npx fit-graph serve",
"optional": true
}
]
}
}
| Field | Required | Notes |
|---|---|---|
name |
yes | Unique identifier for the service. |
type |
no |
"oneshot" for run-once commands. Omit
for longrun (the default).
|
command |
longrun | Shell command to run. The daemon restarts it on crash. |
up |
oneshot | Command to run on start. |
down |
oneshot | Command to run on stop. Optional. |
optional |
no |
When true, failure is a warning rather than an
error. Default false.
|
Services start in array order. When they stop, the order reverses. This matters when services depend on each other. Place dependencies earlier in the array.
Start all services
npx fit-rc start
This command:
-
Spawns the
svscansupervision daemon (or restarts it if it already runs). - Walks through each service in order.
-
For oneshot services, runs the
upcommand and waits for completion. - For longrun services, adds them to the supervision tree. The daemon keeps each one running.
Expected output (timestamps and process IDs will differ):
INFO 2026-05-04T10:00:01.123Z rc codegen 42001 MSG001 - Running oneshot direction="up" cmd="npx fit-codegen generate --all"
INFO 2026-05-04T10:00:03.456Z rc codegen 42001 MSG002 - Oneshot completed direction="up"
INFO 2026-05-04T10:00:03.789Z rc trace 42001 MSG003 - Service started
INFO 2026-05-04T10:00:04.012Z rc vector 42001 MSG004 - Service started
INFO 2026-05-04T10:00:04.234Z rc graph 42001 MSG005 - Service started
To start only up to a specific service (useful when you need only part of the stack):
npx fit-rc start trace
This starts every service from the beginning of the array through
trace. It skips later entries.
Check service status
npx fit-rc status
Expected output when services are running:
INFO 2026-05-04T10:05:00.123Z rc svscan 42001 MSG001 - Running
INFO 2026-05-04T10:05:00.234Z rc trace 42001 MSG002 - up pid="42010"
INFO 2026-05-04T10:05:00.345Z rc vector 42001 MSG003 - up pid="42011"
INFO 2026-05-04T10:05:00.456Z rc graph 42001 MSG004 - up pid="42012"
Expected output when nothing is running:
INFO 2026-05-04T10:05:00.123Z rc svscan 42001 MSG001 - Not running
Check a single service by name:
npx fit-rc status trace
Stop services
npx fit-rc stop
Services stop in reverse order. Longrun services receive
SIGTERM. If a process does not exit within the shutdown
timeout (default 3 seconds), the daemon sends
SIGKILL to the entire process group. Oneshot services
run their down command if they define one. When all
services stop, the daemon itself shuts down.
To stop from a specific service onward and leave earlier services running:
npx fit-rc stop vector
This stops vector and everything after it in the array
(here, graph), in reverse order.
Restart a service
npx fit-rc restart trace
This stops the named service and everything after it in the array. It then starts that same slice again. Dependents that it tore down come back up. It leaves the services before the target untouched. Without a name, it restarts all services.
Read service logs
Each longrun service writes output to a rotated log directory under
the path you configure in log_dir. View a service's
current log:
npx fit-rc logs trace
The log writer (from libsupervise) rotates files
automatically at 1 MB and keeps the 10 most recent archives. It
names archived files with ISO 8601 timestamps, so a sort by filename
gives chronological order.
Supervision behavior
The svscan daemon restarts crashed services
automatically. When a longrun service exits unexpectedly, the daemon
waits before it restarts the service. The wait uses exponential
backoff:
| Parameter | Default | Effect |
|---|---|---|
| Initial delay | 100 ms | Wait time after the first crash. |
| Backoff multiplier | 2x | Each subsequent crash doubles the wait. |
| Maximum delay | 5000 ms | The wait never exceeds this value. |
| Shutdown timeout | 3000 ms |
Time to wait for SIGTERM before the daemon
escalates to SIGKILL.
|
A successful restart resets the backoff counter. The daemon does not limit the total number of restart attempts. It keeps the service running as long as the supervision tree is active.
Each supervised process runs in its own process group (detached: true). When the daemon sends a signal, it targets the entire group
(shell and child processes). This prevents orphaned subprocesses.
Fail fast at startup
A service that starts on the wrong Node.js version, or with a
required secret left blank, should refuse to run. It should not fail
halfway through a request.
@forwardimpact/libpreflight triggers that refusal at
the very top of a service's entry script, before any heavy
import resolves.
Import the runtime-floor check as the first import in the entry file. It has no dependencies, so it runs before any sibling import body executes:
#!/usr/bin/env node
import "@forwardimpact/libpreflight/node22";
// the rest of the service's imports follow
Under a supported Node.js version the import returns silently. Under
an unsupported version the process writes a clear instruction to
stderr and exits with code 1:
Error: This command requires Node.js 22 or later (running 20.11.0).
Install Node.js 22 (LTS) from https://nodejs.org/ and re-run.
For required configuration, call assertNonEmpty right
after you load the config. A missing secret then stops the process
at startup. It does not surface as a confusing runtime error later:
import { assertNonEmpty } from "@forwardimpact/libpreflight/assert-non-empty.js";
const config = createServiceConfig("my-service", loadEnv());
assertNonEmpty(config.session_secret, "session_secret");
An empty string, empty array, empty Set, or
undefined/null all count as empty. On
failure the process writes
Error: required configuration "session_secret" is
empty.
to stderr and exits 1.
Add structured logging
Services that use @forwardimpact/libtelemetry produce
RFC 5424-formatted log lines. This structured format lets both
humans and agents grep and parse the logs.
import { createLogger } from "@forwardimpact/libtelemetry";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const logger = createLogger("my-service", createDefaultRuntime());
logger.info("startup", "Server listening", { port: "3000" });
// INFO 2026-05-04T10:00:00.000Z my-service startup 42001 MSG001 [port="3000"] Server listening
logger.error("handler", "Request failed", { status: "500" });
// ERROR 2026-05-04T10:00:01.000Z my-service handler 42001 MSG002 [status="500"] Request failed
The log format is:
LEVEL TIMESTAMP DOMAIN APP_ID PROC_ID MSG_ID [ATTRIBUTES] MESSAGE
Control verbosity with the LOG_LEVEL environment
variable:
LOG_LEVEL |
What prints |
|---|---|
error |
Errors only. |
info |
Errors and info (default). |
debug |
Everything including debug. |
For domain-specific debug output without a change to the global
level, set the DEBUG environment variable:
DEBUG=my-service npx fit-rc start
Use DEBUG=* to enable debug output for all domains.
For details on logging and trace spans, see Add Observability.
Programmatic usage
The ServiceManager class exposes the same operations as
the CLI. Use it when you need lifecycle control from within a
Node.js process:
import { spawn, execSync } from "node:child_process";
import { ServiceManager } from "@forwardimpact/librc";
import { createLogger } from "@forwardimpact/libtelemetry";
import { sendCommand, waitForSocket } from "@forwardimpact/librc";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";
const runtime = createDefaultRuntime();
const config = {
rootDir: process.cwd(),
init: {
log_dir: "data/logs",
services: [
{ name: "trace", command: "npx gemba-trace serve" },
{ name: "vector", command: "npx fit-vector serve" },
],
},
};
const logger = createLogger("rc", runtime);
// spawn/execSync are injected by the caller — there is no runtime-level
// equivalent for detached, stdio-redirected daemon spawning.
const manager = new ServiceManager(config, logger, {
runtime,
spawn,
execSync,
sendCommand,
waitForSocket,
});
await manager.start(); // Start all services
await manager.status(); // Print status of all services
await manager.status("trace"); // Print status of one service
await manager.logs("trace"); // Print logs to stdout
await manager.stop("vector"); // Stop one service
await manager.stop(); // Stop all services and daemon
What each library provides
| Library | Package | Concern |
|---|---|---|
| libpreflight | @forwardimpact/libpreflight |
Fail-fast runtime-floor and required-config checks at startup. |
| librc | @forwardimpact/librc |
Lifecycle CLI (fit-rc) and
ServiceManager class.
|
| libsupervise | @forwardimpact/libsupervise |
Supervision daemon (fit-svscan), log writer
(fit-logger), log rotation, process state.
|
| libtelemetry | @forwardimpact/libtelemetry |
Structured logging (Logger), trace spans
(Tracer), unified observer
(Observer), trace query and rendering
(fit-visualize).
|
The fifth concern in this job is to keep instruction files and
architecture honest. That check is jidoka. The
Jidoka standard documents it,
and this page does not, because jidoka runs at
authoring time against the repository. It does not run at service
runtime against a process. See
Distribute Skill Packs
for the publishing side. That page shows how you keep shared
instructions current.
What's next
Start, Stop, or Check a Service
Start, stop, restart, check status, and read logs through one interface. You do not need to remember each service's specific incantation.
Add Observability
Structured, machine-readable logs and spans without configuring a logging framework — drop in a log line or a span and it works.