HTTP service that accepts a job (a list of shell tasks with optional requires dependencies), topologically sorts them into a valid execution order, and returns either sorted JSON or an executable bash script.
Built with Elixir 1.20, Plug, and Bandit — no Phoenix, no database.
- Native: asdf with Erlang 27 and Elixir 1.20 (see
.tool-versions), or any Elixir~> 1.20install - Docker: Docker Engine + Docker Compose v2
mix deps.get
mix run --no-haltThe API listens on http://localhost:4000.
Run the test suite:
mix test
mix format --check-formatted
mix credo --strictBuild and start the app plus Swagger UI:
docker compose up --build| Service | URL | Purpose |
|---|---|---|
| app | http://localhost:4000 | TaskResolver HTTP API |
| swagger-ui | http://localhost:8080 | Interactive OpenAPI docs |
Swagger UI mounts ./docs/openapi.yaml from the host, so spec changes are reflected on container restart.
Stop everything:
docker compose downLiveness check — returns 200 ok.
curl http://localhost:4000/healthSort a job's tasks. Requires:
Content-Type: application/jsonAccept: application/jsonortext/plain/text/x-shellscript
curl -s -X POST http://localhost:4000/resolve \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"tasks": [
{"name": "task-1", "command": "touch /tmp/file1"},
{"name": "task-2", "command": "cat /tmp/file1", "requires": ["task-3"]},
{"name": "task-3", "command": "echo '\''Hello World!'\'' > /tmp/file1", "requires": ["task-1"]},
{"name": "task-4", "command": "rm /tmp/file1", "requires": ["task-2", "task-3"]}
]
}'Expected order: task-1 → task-3 → task-2 → task-4. The requires field is omitted from the output.
curl -s -X POST http://localhost:4000/resolve \
-H "Content-Type: application/json" \
-H "Accept: text/plain" \
-d '{
"tasks": [
{"name": "task-1", "command": "touch /tmp/file1"},
{"name": "task-2", "command": "cat /tmp/file1", "requires": ["task-3"]},
{"name": "task-3", "command": "echo '\''Hello World!'\'' > /tmp/file1", "requires": ["task-1"]},
{"name": "task-4", "command": "rm /tmp/file1", "requires": ["task-2", "task-3"]}
]
}'Expected output:
#!/usr/bin/env bash
touch /tmp/file1
echo 'Hello World!' > /tmp/file1
cat /tmp/file1
rm /tmp/file1Missing Content-Type → 415:
curl -s -X POST http://localhost:4000/resolve \
-H "Accept: application/json" \
-d '{"tasks": []}'Malformed JSON → 400:
curl -s -X POST http://localhost:4000/resolve \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{not json'Unhandled Accept → 406:
curl -s -X POST http://localhost:4000/resolve \
-H "Content-Type: application/json" \
-H "Accept: application/xml" \
-d '{"tasks": [{"name": "a", "command": "echo a"}]}'Cyclic dependency → 422:
curl -s -X POST http://localhost:4000/resolve \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"tasks": [{"name": "a", "command": "echo a", "requires": ["b"]}, {"name": "b", "command": "echo b", "requires": ["a"]}]}'The full API specification lives at docs/openapi.yaml.
Browse it interactively via Swagger UI at http://localhost:8080 when running docker compose up.
lib/task_resolver/
├── core/ # Phase 2 — validation & topological sort
│ ├── job_validator/ # Ecto embedded schemas
│ └── sorter.ex # Kahn's algorithm
└── web/ # Phase 3 — HTTP layer
├── router.ex # POST /resolve, content negotiation
├── bash_formatter.ex
└── error_mapper.ex
Design rationale and trade-offs are documented in DECISIONS.md. The implementation roadmap is in PLAN.md.