A session workflow lives in one directory, workflows/my-session/, and needs three
things:
| File | Runs on | Purpose |
|---|---|---|
app/controller.sh |
Controller (login) node | Install software, download dependencies |
app/start-template.sh |
Controller or compute node | Start the web service |
yamls/<variant>.yaml (e.g. yamls/general.yaml) |
Platform | Define the UI form, generate inputs.sh, orchestrate |
app/ holds everything the run needs (scripts + support files) and is the only
subtree the workflow sparse-checkouts; yamls/, thumbnails/, README, and build
tooling stay outside it.
The controller node always has internet access. The compute node may not.
The platform docs are the source of truth — consult them when anything here is
unclear or behaves differently:
building workflows
(YAML fields ·
inputs & expressions ·
actions) ·
endpoint sessions ·
pw endpoints CLI.
Working with an AI assistant? The repo ships a Claude Code skill —
.claude/skills/activate-workflows/ — that encodes this process plus the platform
reference. In Claude Code: "Using the activate-workflows skill, create a new
interactive session workflow for [deployment] that [does X]."
workflows/my-session/app/controller.sh runs before the service starts, on the login
node. Use it for anything needing internet. All inputs.sh variables are available.
#!/usr/bin/env bash
set -o pipefail
if [ -z ${service_parent_install_dir} ]; then
service_parent_install_dir=${HOME}/pw/software
fi
if ! [ -f "${service_parent_install_dir}/my-server" ]; then
echo "Installing my-server..."
mkdir -p ${service_parent_install_dir}
wget https://example.com/my-server.tar.gz -O /tmp/my-server.tar.gz
tar -xzf /tmp/my-server.tar.gz -C ${service_parent_install_dir}
fiKeep it idempotent — check whether software exists before installing.
workflows/my-session/app/start-template.sh starts the web service. The platform provides
service_port — your service must listen on it. All inputs.sh variables are
available.
#!/bin/bash
if [ -z ${service_parent_install_dir} ]; then
service_parent_install_dir=${HOME}/pw/software
fi
# cancel.sh lets the platform stop the service
echo '#!/bin/bash' > cancel.sh
chmod +x cancel.sh
${service_parent_install_dir}/my-server --port=${service_port} &
pid=$!
echo "kill ${pid}" >> cancel.sh
sleep infRequirements: listen on service_port, write a cancel.sh, end with
sleep inf (or run the service in the foreground).
workflows/my-session/yamls/general.yaml. Its jobs:
- preprocessing —
parallelworks/checkoutof this repo (sparse:workflows/my-session/app, plustools/...if the scripts use the shared tools), generateinputs.shfrom the form values +PW_*environment, runinputs.sh + controller.shinline, and assemble the start script (inputs.sh+ a cleanup trap +start-template.sh). - session_runner (the job name kept for history) — submit the start script via
workflows/script_submitter/v3.6/<variant>.yaml(uses: github/parallelworks/workflows@canary). - wait_for_endpoint — poll
pw endpoints listuntil the endpoint named<service.name>-${PW_RUN_SLUG}is online, then leave the service running (SKIP_CLEANUPmarker) and cancel the submitter's wait.
Copy a real one instead of writing from scratch —
workflows/webshell/yamls/general.yaml is the smallest complete example;
workflows/jupyterlab/yamls/general.yaml shows a conda install plus support files;
workflows/streamlit/yamls/general.yaml + its scripts show a Singularity/SIF
service (SIF pulled via oras, .def + build-container.sh alongside).
Key parts to adapt:
- the hidden
service.nameinput (endpoint name prefix), - the sparse-checkout paths (
workflows/my-session/app,tools/...), - the
cat workflows/my-session/app/controller.sh/start-template.shlines, - the
serviceinput group (your form fields →inputs.shvariables).
One YAML per deployment: yamls/general.yaml (standard SLURM/PBS clusters), plus
emed.yaml / hsp.yaml / noaa.yaml where the workflow is offered there. Variants
differ in scheduler directives, partitions, module loads, and defaults — copy the
matching variant of a similar workflow (they pass their variant's
workflows/script_submitter/v3.6/<variant>.yaml).
Every workflow is tested end-to-end at least once, and any end-to-end test is recorded
under workflows/my-session/tests/<variant>/. The test layout, the test file keys, the
pass criteria and the result columns are documented once, in
tools/tests/README.md. Start from an existing test, e.g.
workflows/webshell/tests/<variant>/<test-name>.json.
Push first — the YAML pulls this repo from GitHub at run time, so local edits to
app/ are invisible until they are on the referenced branch. Then:
python3 tools/tests/run-workflow-test.py workflows/my-session/tests/<variant>/<test-name>.jsonCommit the test and the rows the runner appends to its CSV with your change; never edit a CSV by hand.
Verify cleanup on cancel — part of testing, every time. Cancel a run mid-flight
(pw workflows runs cancel <slug> while the service is starting or serving) and
confirm the cleanup actually ran: no service processes left (ps -x), no scheduler
job (squeue/qstat when scheduler:true), no container instances
(singularity instance list, docker ps), no stray listeners. Apps that daemonize
and re-parent to PID 1 (e.g. RStudio's rsession) can survive the tree kill and need
handling in cancel.sh. Write cancel.sh at the very top of the start script so a
cancel at any moment finds it.
While iterating, point the YAML's checkout branch: at a development branch and
restore it to canary before the PR merges (canary only accepts pull requests).
Everything a run did is in its job dir on the execution node:
~/pw/jobs/<run-slug>/ for CLI file runs, or
~/pw/jobs/<workflow-name>/<run-number, 5 digits>/ for registered workflows.
run.<JOBID>.out— the service's stdout/stderrlogs/<job>/step_N/step.out,step.exit— per-step trace and exit codelogs/<job>/step_N/script-unstable.sh— the rendered step: every${{ input }}appears as the literal value the form sent. When a value seems ignored (a default not applied, an empty field), read this first.- From any machine:
pw workflows runs errors <slug>andpw workflows runs logs <slug>.
- Testing unpushed code — the checkout fetches GitHub, not your working tree.
- Relative YAML path in
pw workflows run— parsed as a git host; use absolute. - Composing checkout paths wrong — checked-out files materialize at
${PW_PARENT_JOB_DIR}/workflows/<name>/app/…(or…/<impl>/…), including paths built from variables ("${PW_PARENT_JOB_DIR}/${service_name}"-style bugs surface only at run time). - Globs copy everything in the directory they target. Some preprocessing steps
collect support files with a glob — e.g. jupyter runs
cp workflows/jupyter/app/*.yaml .to gather its conda-env files. That only works becauseapp/contains nothing but runtime files: the workflow's own variant YAMLs live inyamls/, outsideapp/. Keep it that way — a workflow YAML (or any unrelated file) placed insideapp/would be swept into every run. - Single-attempt ghcr pulls — ghcr intermittently rate-limits anonymous pulls;
use
tools/oras/libs.sh:oras_pull_file(it retries) and keep packages public. - A registered workflow ignoring your defaults — the registration pins one YAML
path (
pw workflows get <name>→remote.yaml); if it points at the wrong variant, the form (and its defaults) are the wrong variant's. - "Authentication has expired" —
pwtokens lapse; re-runpw auth.
Nothing in this repo uses the older session pattern (a sessions: block + the
session_runner subworkflow). To convert a legacy workflow to the endpoint pattern
and bring it here, follow
.claude/skills/activate-workflows/references/session-to-endpoint-upgrade.md;
which workflows are still legacy and where they live is in MIGRATION.md.