Files
ai-agent/backend/conversations/deploycmd.py
Gabriel Vidal 8e6cef1cd4 feat(backend): compose and rsync deploys, read off the archive
Mined what ran just before a `notify-done --type deploy` without any of the
classic deploy commands (159 of 725): mostly `docker compose … up --build`.
Now also a deploy: compose `up --build` / `--force-recreate` (and via
docker-compose-lab.sh), a plain `compose up` after a `compose build` in the
same command, and rsync into zipgo's domains/ tree. A bare `up -d`, a
lone build and docker build/run test containers stay out. Deploy notifies
with no detected deploy: 159 → 99.

The widget judges failure on the output's last lines — build logs quote
"error" in passing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:47:16 +02:00

348 lines
13 KiB
Python

"""Is this shell command a deploy? — by *what it runs*, not what it mentions.
The deploy widget, the conversation's "deploying…" state and its ``deployed``
lifecycle stamp all hang off one question about a Bash tool call. A plain regex
over the command text answered it for anything that *named* a deploy script —
``cat deploy.sh``, ``grep -n … deploy.sh``, ``bash -n deploy.sh``,
``git add services/ai-agent/deploy.sh``, a commit message, a heredoc'd Python
patch of the script, a ``pgrep -f deploy.sh`` wait loop — which in the archive
was about four calls in ten. So the command is split into simple commands
(quotes, heredoc bodies and comments dropped), wrapper prefixes are peeled
(``cd … &&``, ``VAR=x``, ``timeout 600``, ``setsid nohup``, ``rtk``, ``bash
<script>``…), and only the program actually being *run* is matched.
What counts was read off the archive — the commands run just before a
``notify-done --type deploy``:
* a ``deploy*.sh`` script (the ai-agent blue-green, project ``scripts/deploy.sh``,
``deploy-test-site.sh`` previews…), ``npm|pnpm|yarn run deploy[:x]``,
``zipgo deploy``, ``make deploy*``, the ``deploy-html`` skill;
* ``docker compose … up --build`` / ``up --force-recreate`` — how a lab service
ships (also through ``scripts/docker-compose-lab.sh``) — and a plain
``compose up`` that follows a ``compose build`` in the same command. A bare
``up -d`` alone is mostly a first bring-up or a throwaway test stack, and
``docker build`` / ``docker run`` are test containers: neither counts;
* ``rsync`` into zipgo's ``…/domains/…`` tree on raspy2 (a hand-rolled static
deploy).
The frontend doesn't re-match: the answer rides on the tool card as ``deploy``.
"""
from __future__ import annotations
import re
# Prefixes that run the command that follows them → the flags of theirs that
# take a value as the next word.
_WRAPPERS: dict[str, set[str]] = {
"sudo": {"-u", "-g", "-C", "-D", "-h", "-p", "-U"},
"env": {"-u", "-C", "-S"},
"time": set(), "nohup": set(), "setsid": set(), "exec": {"-a"},
"command": set(), "caffeinate": {"-t", "-w"}, "!": set(), "rtk": set(),
"nice": {"-n"},
"stdbuf": {"-i", "-o", "-e"},
"timeout": {"-s", "-k", "--signal", "--kill-after"},
"flock": {"-w", "-E", "--timeout", "--wait", "--conflict-exit-code"},
}
# How many leading positional args a wrapper consumes (timeout's DURATION,
# flock's lock file).
_WRAPPER_ARGS = {"timeout": 1, "flock": 1}
# Shell keywords that introduce a command in the same segment.
_KEYWORDS = {"then", "do", "else", "elif", "if", "while", "until", "{", "time"}
# Shells that run a script given as their first non-flag argument.
_SHELLS = {"bash", "sh", "zsh", "dash", "source", "."}
_ASSIGN_RE = re.compile(r"^[A-Za-z_]\w*=")
_HEREDOC_RE = re.compile(r"(?<!<)<<-?(?!<)\s*(['\"]?)([A-Za-z_][\w.-]*)\1")
_SCRIPT_RE = re.compile(r"deploy[\w.-]*\.sh$")
_MAKE_TARGET_RE = re.compile(r"^deploy[\w.-]*$")
_NPM_SCRIPT_RE = re.compile(r"^deploy(?::[\w.-]+)?$")
# Compose's global options that take a value (before the subcommand), and
# `up`'s own (between `up` and the service names).
_COMPOSE_GLOBAL_VALUE = {"-f", "--file", "-p", "--project-name", "--profile",
"--project-directory", "--env-file", "--ansi",
"--progress", "--parallel", "--context", "-c", "-H",
"--host"}
_COMPOSE_UP_VALUE = {"--scale", "-t", "--timeout", "--wait-timeout", "--pull",
"--exit-code-from", "--attach", "--no-attach"}
# rsync into zipgo's static-site tree on a remote host (`host:…/domains/…`).
_RSYNC_DEST_RE = re.compile(r"^[\w.@-]+:.*/domains/")
# npm/pnpm/yarn/make flags that take a value as the next word.
_PM_VALUE_FLAGS = {"--prefix", "-C", "--dir", "--cwd", "-w", "--workspace",
"--filter", "-F", "-f", "--file", "--makefile", "-j"}
def _strip_heredocs(cmd: str) -> str:
"""Drop heredoc bodies (``<<'EOF' … EOF``) — they're data, not commands."""
lines = cmd.split("\n")
out: list[str] = []
pending: list[str] = []
for line in lines:
if pending:
if line.strip() == pending[0] or line.lstrip("\t") == pending[0]:
pending.pop(0)
continue
out.append(line)
pending = [m.group(2) for m in _HEREDOC_RE.finditer(line)]
return "\n".join(out)
def _split(cmd: str) -> list[list[tuple[str, bool]]]:
"""Split a command line into simple commands, each a list of words.
A word is ``(text, quoted)``: quoting is remembered so a quoted string is
never mistaken for a program (``echo "./deploy.sh"``) yet can still be
re-parsed where it *is* a command (``bash -c '…'``, ``ssh host '…'``).
Separators: ``&& || ; | & |&``, newlines, parentheses, backticks and
``$(``. ``2>&1``/``&>`` stay inside their redirect word. ``#`` at a word
start comments out the rest of the line.
"""
cmd = _strip_heredocs(cmd)
segs: list[list[tuple[str, bool]]] = []
words: list[tuple[str, bool]] = []
buf: list[str] = []
quoted = False
i, n = 0, len(cmd)
def end_word():
nonlocal buf, quoted
if buf or quoted:
words.append(("".join(buf), quoted))
buf, quoted = [], False
def end_seg():
nonlocal words
end_word()
if words:
segs.append(words)
words = []
while i < n:
c = cmd[i]
if c in "'\"":
j = cmd.find(c, i + 1)
if c == '"': # skip escaped quotes inside "…"
while j != -1 and cmd[j - 1] == "\\":
j = cmd.find(c, j + 1)
j = n if j == -1 else j
buf.append(cmd[i + 1:j])
quoted = True
i = j + 1
continue
if c == "\\" and i + 1 < n:
if cmd[i + 1] != "\n":
buf.append(cmd[i + 1])
i += 2
continue
if c == "#" and not buf and not quoted:
j = cmd.find("\n", i)
i = n if j == -1 else j
continue
if c in " \t":
end_word()
i += 1
continue
if c == "&":
# a redirect (`2>&1`, `>&2`, `&>log`, `&>>log`) stays in its word
if (buf and buf[-1] in "<>") or cmd[i + 1:i + 2] == ">":
buf.append(c)
i += 1
continue
end_seg()
i += 2 if cmd[i + 1:i + 2] == "&" else 1
continue
if c == "$" and cmd[i + 1:i + 2] == "(":
end_seg()
i += 2
continue
if c in "\n;|()`":
end_seg()
i += 1
continue
buf.append(c)
i += 1
end_seg()
return segs
def _is_redirect(w: str) -> bool:
return bool(re.match(r"^\d*(?:>>?|<<?|&>>?|>&|<&)", w))
def _peel(words: list[tuple[str, bool]]) -> list[tuple[str, bool]]:
"""Drop env assignments, wrappers (and their flags/args), shell keywords."""
i = 0
while i < len(words):
w, q = words[i]
if q:
break
if _ASSIGN_RE.match(w) or w in _KEYWORDS:
i += 1
continue
if w in _WRAPPERS:
name = w
i += 1
if name == "rtk" and i < len(words) and words[i][0] == "proxy":
i += 1
while i < len(words) and words[i][0].startswith("-") and not words[i][1]:
flag = words[i][0]
i += 2 if flag in _WRAPPERS[name] else 1
i += _WRAPPER_ARGS.get(name, 0)
continue
break
return words[i:]
def _plain(words: list[tuple[str, bool]]) -> list[str]:
"""The non-flag, non-redirect argument words."""
out, skip = [], False
for w, q in words:
if skip:
skip = False
continue
if not q and _is_redirect(w):
# `> file` (bare operator) consumes the next word as its target
skip = bool(re.fullmatch(r"\d*(?:>>?|<<?|&>>?)", w))
continue
out.append(w)
return out
def _pm_args(args: list[tuple[str, bool]]) -> list[str]:
"""npm/make positional args, skipping flags (and their values)."""
out: list[str] = []
i = 0
while i < len(args):
w, q = args[i]
if not q and _is_redirect(w):
break
if not q and w.startswith("-"):
i += 2 if (w in _PM_VALUE_FLAGS and "=" not in w) else 1
continue
out.append(w)
i += 1
return out
def _compose(args: list[tuple[str, bool]], state: dict) -> tuple[str, str] | None:
"""`docker compose …` args → a deploy if it ships new containers."""
i = 0
while i < len(args) and not args[i][1] and args[i][0].startswith("-"):
flag = args[i][0]
i += 2 if (flag in _COMPOSE_GLOBAL_VALUE and "=" not in flag) else 1
if i >= len(args):
return None
sub, rest = args[i][0], args[i + 1:]
if sub == "build":
state["built"] = True # a later `compose up` in this command ships it
return None
if sub != "up":
return None
flags, services = set(), []
j = 0
while j < len(rest):
w, q = rest[j]
if not q and _is_redirect(w):
break
if not q and w.startswith("-"):
flags.add(w.split("=", 1)[0])
j += 2 if (w in _COMPOSE_UP_VALUE and "=" not in w) else 1
continue
services.append(w)
j += 1
if "--build" in flags or "--force-recreate" in flags or state.get("built"):
return ("docker compose", " ".join(services))
return None
def _match_simple(words: list[tuple[str, bool]], depth: int,
state: dict) -> tuple[str, str] | None:
words = _peel(words)
if not words or words[0][1]:
return None
prog, args = words[0][0], words[1:]
base = prog.rsplit("/", 1)[-1]
if base in _SHELLS:
# the shell's own options come before the script / `-c` string
i = 0
while i < len(args) and not args[i][1] and args[i][0].startswith("-"):
opt = args[i][0]
if re.fullmatch(r"-[a-zA-Z]*n[a-zA-Z]*", opt):
return None # `bash -n deploy.sh`: a syntax check
if re.fullmatch(r"-[a-zA-Z]*c[a-zA-Z]*", opt): # `bash -lc '…'`
if i + 1 < len(args) and depth < 3:
return deploy_invocation(args[i + 1][0], depth + 1)
return None
i += 1
return _match_simple(args[i:], depth, state) if i < len(args) else None
if base == "ssh":
# the remote command is everything after the host
i = 0
while i < len(args) and args[i][0].startswith("-") and not args[i][1]:
i += 2 if args[i][0] in ("-p", "-i", "-o", "-l", "-F", "-J") else 1
remote = " ".join(w for w, _ in args[i + 1:])
return deploy_invocation(remote, depth + 1) if remote and depth < 3 else None
if base == "docker" and args and args[0][0] == "compose" and not args[0][1]:
return _compose(args[1:], state)
if base in ("docker-compose", "docker-compose-lab.sh"):
return _compose(args, state)
if base == "rsync":
pos = [w for w in _plain(args) if not w.startswith("-")]
if pos and _RSYNC_DEST_RE.match(pos[-1]):
return ("rsync", pos[-1])
return None
if base in ("deploy-html", "deploy-html.sh"):
pos = [w for w in _plain(args) if not w.startswith("-")]
return ("deploy-html", pos[0] if pos else "")
if base == "zipgo":
pos = [w for w in _plain(args) if not w.startswith("-")]
if pos and pos[0] == "deploy":
return ("zipgo deploy", pos[1] if len(pos) > 1 else "")
return None
if base in ("npm", "pnpm", "yarn", "bun"):
pos = _pm_args(args)
if pos and pos[0] in ("run", "run-script"):
pos = pos[1:]
if pos and _NPM_SCRIPT_RE.match(pos[0]):
return ("npm run deploy", "")
return None
if base == "make":
for t in _pm_args(args):
if _MAKE_TARGET_RE.match(t):
return ("make deploy", t)
return None
if _SCRIPT_RE.search(base):
return ("deploy.sh", prog)
return None
# Cheap pre-filter: every deploy shape names one of these somewhere.
_HINT_RE = re.compile(r"deploy|compose|rsync")
def deploy_invocation(command: str, depth: int = 0) -> tuple[str, str] | None:
"""``(kind, target)`` for the first deploy this command *runs*, else ``None``.
kind is one of ``deploy-html``, ``zipgo deploy``, ``npm run deploy``,
``make deploy``, ``deploy.sh``, ``docker compose``, ``rsync``; target is
what was deployed, best-effort (the script path, the zipgo/deploy-html
argument, the make target, the compose services, the rsync destination).
"""
if not isinstance(command, str) or not _HINT_RE.search(command):
return None
state: dict = {}
for words in _split(command):
hit = _match_simple(words, depth, state)
if hit:
return hit
return None