diff --git a/playbooks/deploy-task-board.yaml b/playbooks/deploy-task-board.yaml new file mode 100644 index 0000000..ea9c624 --- /dev/null +++ b/playbooks/deploy-task-board.yaml @@ -0,0 +1,124 @@ +# Deploy task-board (https://gitea.phasefinal.com/vh/task-board) to a +# Docker host following the PFI /opt/docker/ convention (ana-docker by +# default, but the playbook works against any host with Docker + +# traefik-net in place). +# +# Idempotent: rerunning is safe. Creates-gates + conditional when: +# checks skip work that's already done; `docker compose up -d` is itself +# idempotent (no restart unless compose content or env changed). +# +# Usage: +# scripts/elway ana-docker --playbook playbooks/deploy-task-board.yaml +# scripts/elway ana-docker --playbook playbooks/deploy-task-board.yaml --var ref=v0.1.0 +# +# Prereqs on the target host: +# - Docker + docker compose plugin +# - `traefik-net` docker network (external) +# - Target user (lkraven) has git SSH access to gitea.phasefinal.com +# — either SSH key authorized in gitea, or the repo is HTTPS-reachable +# if you swap `repo_url` below. +# - Target user is in the `docker` group. + +vars: + repo_url: git@gitea.phasefinal.com:vh/task-board.git + ref: main + build_dir: /opt/docker/build/task-board + image_tag: task-board:local + compose_dir: /opt/docker/compose/task-board + data_dir: /opt/docker/conf/task-board/data + host_port: "7878" + +steps: + # ── host-side directory prep ───────────────────────────────────────── + - name: Ensure /opt/docker/build parent exists + shell: mkdir -p /opt/docker/build + sudo: true + creates: /opt/docker/build + + - name: Chown /opt/docker/build to lkraven (only if mkdir'd by root above) + shell: chown lkraven:lkraven /opt/docker/build + sudo: true + when: '[ "$(stat -c %U /opt/docker/build)" != lkraven ]' + + # ── fetch / sync source ───────────────────────────────────────────── + - name: Clone task-board repo if absent + # Auto-accept the first-run host key so the playbook doesn't hang + # prompting for yes/no. + shell: GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new" git clone {{ repo_url }} {{ build_dir }} + creates: "{{ build_dir }}/.git" + + - name: Fetch from origin + shell: cd {{ build_dir }} && git fetch --quiet origin + + - name: Reset working tree to origin/{{ ref }} + shell: cd {{ build_dir }} && git reset --hard origin/{{ ref }} + # Report ok (no-change) when the tree was already at the requested + # ref — saves a noisy CHANGED status line on no-op reruns. + changed_when: '[ "$(cd {{ build_dir }} && git rev-parse HEAD 2>/dev/null)" != "$(cd {{ build_dir }} && git rev-parse origin/{{ ref }} 2>/dev/null)" ]' + + # ── image build ───────────────────────────────────────────────────── + - name: Build image {{ image_tag }} + shell: cd {{ build_dir }} && docker build -t {{ image_tag }} . + # Docker build reuses layer cache and is fast on reruns, but it + # always runs — we can't cheaply know up-front whether anything + # downstream has changed. Leave it in the always-run lane; Docker + # itself handles the no-op efficiently. + + # ── compose + data dirs ───────────────────────────────────────────── + - name: Ensure compose dir exists + shell: mkdir -p {{ compose_dir }} + creates: "{{ compose_dir }}" + + - name: Ensure data dir exists + # Created as lkraven (uid 1000 on these hosts), matching the + # container's `app` user — no chown dance needed. + shell: mkdir -p {{ data_dir }} + creates: "{{ data_dir }}" + + # ── deploy compose files ──────────────────────────────────────────── + - name: Upload compose.yaml + upload: + src: stacks/task-board/compose.yaml + dest: "{{ compose_dir }}/compose.yaml" + mode: "0644" + + - name: Seed .env from template (only if absent) + upload: + src: stacks/task-board/.env.example + dest: "{{ compose_dir }}/.env" + mode: "0644" + when: "[ ! -f {{ compose_dir }}/.env ]" + + # ── bring up + wait for ready ─────────────────────────────────────── + - name: docker compose up -d + shell: cd {{ compose_dir }} && docker compose up -d + + - name: Wait for /api/health to respond + # Short retry loop — docker compose up returns before healthcheck + # stabilizes; we want verify: to run against a live server. + shell: | + for i in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:{{ host_port }}/api/health && exit 0 + sleep 1 + done + exit 1 + changed_when: "false" + +verify: + - name: /api/health returns 200 + shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/api/health + changed_when: "false" + + - name: /api/tasks returns a JSON tasks list + shell: curl -sf http://localhost:{{ host_port }}/api/tasks | grep -q '"tasks"' + changed_when: "false" + + - name: MCP endpoint reachable (expect 3xx redirect for GET /mcp → /mcp/) + shell: '[ "$(curl -s -o /dev/null -w %{http_code} http://localhost:{{ host_port }}/mcp)" -ge 300 ]' + changed_when: "false" + + - name: Container is in the traefik-net network + # `traefik-net` has a dash, so it's not accessible via Go template dot + # syntax — JSON-encode the networks map and grep for the key instead. + shell: docker inspect task-board --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net + changed_when: "false" diff --git a/stacks/task-board/.env.example b/stacks/task-board/.env.example new file mode 100644 index 0000000..85c0a02 --- /dev/null +++ b/stacks/task-board/.env.example @@ -0,0 +1,25 @@ +# task-board stack tunables. Copy to `.env` on ana-docker before deploying. +# +# The deploy playbook seeds `.env` from this template on first run only — +# it won't clobber an existing `.env`. + +# Image tag. Built locally from the task-board git repo by the playbook. +TASK_BOARD_IMAGE=task-board:local + +# Host port exposing MCP + web UI (container listens on 7878 internally). +TASK_BOARD_PORT=7878 + +# Bind address. 0.0.0.0 exposes to LAN; 127.0.0.1 restricts to localhost. +# Claude Code running on another machine needs LAN (or SSH-forward + 127.0.0.1). +TASK_BOARD_BIND=0.0.0.0 + +# Host path for the SQLite store. Container runs as uid 1000 — path must be +# writable by that uid (mkdir'd by the playbook without sudo, so lkraven-owned +# when lkraven is uid 1000 on the host). +TASK_BOARD_DATA_DIR=/opt/docker/conf/task-board/data + +# Seconds of inactivity before a card turns grey (dormant). +TASK_BOARD_DORMANT_AFTER=3600 + +# Seconds archived (completed) cards are kept before hard-delete. +TASK_BOARD_ARCHIVE_TTL=86400 diff --git a/stacks/task-board/README.md b/stacks/task-board/README.md new file mode 100644 index 0000000..a2944cc --- /dev/null +++ b/stacks/task-board/README.md @@ -0,0 +1,51 @@ +# task-board + +MCP + web dashboard that shows what the assistant is working on. Green +(active) / Red (waiting) / Grey (dormant) cards with comment streams. + +**Server:** ana-docker +**Port:** 7878 (configurable via `.env`) +**Upstream repo:** [vh/task-board](https://gitea.phasefinal.com/vh/task-board) +**Image:** `task-board:local` — built on the host from the git repo by +the deploy playbook. Not pulled from a registry. + +## Deploy + +Via elway — see `playbooks/deploy-task-board.yaml` in the eshpfi-management +root. The playbook owns the full flow: clone/update the source repo, +`docker build`, install compose + seed .env, bring up, verify health. + +```bash +# First deploy (or update to latest main) +scripts/elway ana-docker --playbook playbooks/deploy-task-board.yaml + +# Pin to a specific ref (tag, branch, or commit SHA) +scripts/elway ana-docker --playbook playbooks/deploy-task-board.yaml --var ref=v0.1.0 +``` + +## Wiring into Claude Code + +After deploy, add to `~/.claude/settings.json` (user-level) or `.mcp.json` +(project-level): + +```json +{ + "mcpServers": { + "task-board": { + "type": "http", + "url": "http://10.250.50.70:7878/mcp" + } + } +} +``` + +Assistant gains four tools (`task_start`, `task_update`, `task_wait`, +`task_complete`). Web UI at `http://10.250.50.70:7878/`. + +## Path layout (on ana-docker) + +| Host path | Container path | Purpose | Restic? | +|---|---|---|---| +| `/opt/docker/build/task-board/` | — | git checkout used as docker build context | excluded | +| `/opt/docker/compose/task-board/` | — | compose.yaml + .env | included (via `/opt/docker`) | +| `/opt/docker/conf/task-board/data/` | `/app/data` | SQLite (`tasks.db`) | **included** | diff --git a/stacks/task-board/compose.yaml b/stacks/task-board/compose.yaml new file mode 100644 index 0000000..b1023ab --- /dev/null +++ b/stacks/task-board/compose.yaml @@ -0,0 +1,46 @@ +# task-board — MCP + web dashboard for assistant task state. +# +# Image is built on the host from the task-board git repo by the deploy +# playbook (`playbooks/deploy-task-board.yaml`), which clones into +# /opt/docker/build/task-board and runs `docker build -t task-board:local .` +# before installing this compose and bringing it up. +# +# SQLite state persists under ${TASK_BOARD_DATA_DIR} (bind-mounted) so +# container rebuilds don't lose task history. +# +# All tunables live in .env — edit that, not this file. + +services: + task-board: + image: ${TASK_BOARD_IMAGE} + container_name: task-board + restart: unless-stopped + ports: + - "${TASK_BOARD_BIND:-0.0.0.0}:${TASK_BOARD_PORT}:7878" + volumes: + - ${TASK_BOARD_DATA_DIR}:/app/data + environment: + # Container always listens on 7878 internally; host port is the only knob. + - TASK_BOARD_PORT=7878 + - TASK_BOARD_BIND=0.0.0.0 + - TASK_BOARD_DORMANT_AFTER=${TASK_BOARD_DORMANT_AFTER:-3600} + - TASK_BOARD_ARCHIVE_TTL=${TASK_BOARD_ARCHIVE_TTL:-86400} + healthcheck: + test: ["CMD-SHELL", "python -c 'import urllib.request,sys; r=urllib.request.urlopen(\"http://127.0.0.1:7878/api/health\",timeout=3); sys.exit(0 if r.status==200 else 1)' || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + networks: + - tnet + labels: + - homepage.group=Toolchain + - homepage.name=task-board + - homepage.icon=mdi-view-dashboard-variant + - homepage.description=Assistant task state dashboard (MCP + web UI) + - homepage.href=http://10.250.50.70:${TASK_BOARD_PORT} + +networks: + tnet: + name: traefik-net + external: true