garc-gws-agent-runtime/lib/daemon.sh
林 駿甫 (Shunsuke Hayashi) a69b9d9160 feat: initial release — GARC v0.1.0
Permission-first AI agent runtime for Google Workspace.
Ports the LARC/OpenClaw governance model (disclosure chain,
execution gates, queue/ingress) to Gmail, Calendar, Drive,
Sheets, Tasks, and People APIs with Claude Code as the
execution engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 08:59:12 +09:00

433 lines
14 KiB
Bash

#!/usr/bin/env bash
# GARC daemon.sh — Background polling daemon
#
# Polls Gmail inbox for new messages and auto-enqueues them.
# This is the GWS equivalent of LARC's IM poller.
#
# Usage:
# garc daemon start — start Gmail poller + worker in background
# garc daemon stop — stop all daemon processes
# garc daemon status — show running daemon info
# garc daemon poll-once — single poll cycle (for testing)
DAEMON_PID_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/daemon"
DAEMON_LOG_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/logs"
DAEMON_SEEN_DIR="${GARC_CACHE_DIR:-${HOME}/.garc/cache}/seen"
GMAIL_POLLER_PID="${DAEMON_PID_DIR}/gmail-poller.pid"
GMAIL_POLLER_LOG="${DAEMON_LOG_DIR}/gmail-poller.log"
WORKER_PID="${DAEMON_PID_DIR}/worker.pid"
WORKER_LOG="${DAEMON_LOG_DIR}/worker.log"
garc_daemon() {
local subcommand="${1:-help}"
shift || true
case "${subcommand}" in
start) _daemon_start "$@" ;;
stop) _daemon_stop "$@" ;;
status) _daemon_status "$@" ;;
restart) _daemon_stop "$@"; sleep 1; _daemon_start "$@" ;;
poll-once) _daemon_poll_once "$@" ;;
logs) _daemon_logs "$@" ;;
install) _daemon_install_launchd "$@" ;;
*)
cat <<EOF
Usage: garc daemon <subcommand>
Subcommands:
start Start Gmail poller and worker in background
stop Stop all daemon processes
status Show daemon status
restart Restart daemon
poll-once Run one Gmail poll cycle (foreground, for testing)
logs Tail daemon logs
install Install as macOS launchd service (auto-start on login)
Options:
--agent <id> Agent ID to use (default: GARC_DEFAULT_AGENT)
--interval <sec> Poll interval in seconds (default: 60)
--label <filter> Gmail label to watch (default: INBOX)
--unread-only Only enqueue unread messages (default: true)
--max <N> Max messages per poll cycle (default: 10)
Examples:
garc daemon start
garc daemon start --interval 30 --agent main
garc daemon poll-once
garc daemon status
garc daemon stop
garc daemon logs --follow
EOF
return 1
;;
esac
}
# ─────────────────────────────────────────────────────────────────
# start
# ─────────────────────────────────────────────────────────────────
_daemon_start() {
local agent="${GARC_DEFAULT_AGENT:-main}"
local interval=60
local label="INBOX"
local max_msgs=10
while [[ $# -gt 0 ]]; do
case "$1" in
--agent|-a) agent="$2"; shift 2 ;;
--interval|-i) interval="$2"; shift 2 ;;
--label) label="$2"; shift 2 ;;
--max) max_msgs="$2"; shift 2 ;;
*) shift ;;
esac
done
_daemon_ensure_dirs
# Check if already running
if _daemon_is_running "${GMAIL_POLLER_PID}"; then
echo "⚠️ Gmail poller already running (PID $(cat "${GMAIL_POLLER_PID}"))"
else
_start_gmail_poller "${agent}" "${interval}" "${label}" "${max_msgs}"
fi
echo ""
_daemon_status
}
_daemon_ensure_dirs() {
mkdir -p "${DAEMON_PID_DIR}" "${DAEMON_LOG_DIR}" "${DAEMON_SEEN_DIR}"
}
_daemon_is_running() {
local pid_file="$1"
[[ -f "${pid_file}" ]] || return 1
local pid
pid=$(cat "${pid_file}")
kill -0 "${pid}" 2>/dev/null
}
_start_gmail_poller() {
local agent_id="$1"
local interval="$2"
local label="$3"
local max_msgs="$4"
# Export needed env vars for subprocess
export GARC_DIR GARC_LIB GARC_CONFIG GARC_CACHE_DIR GARC_DEFAULT_AGENT
[[ -f "${GARC_CONFIG}/config.env" ]] && export $(grep -v '^#' "${GARC_CONFIG}/config.env" | xargs) 2>/dev/null || true
( _gmail_poller_loop "${agent_id}" "${interval}" "${label}" "${max_msgs}" \
>> "${GMAIL_POLLER_LOG}" 2>&1 ) &
local poller_pid=$!
echo "${poller_pid}" > "${GMAIL_POLLER_PID}"
echo "✅ Gmail poller started (PID ${poller_pid}, interval ${interval}s)"
echo " Log: ${GMAIL_POLLER_LOG}"
}
# ─────────────────────────────────────────────────────────────────
# Gmail polling loop — the core ingress driver
# ─────────────────────────────────────────────────────────────────
_gmail_poller_loop() {
local agent_id="${1:-main}"
local interval="${2:-60}"
local label="${3:-INBOX}"
local max_msgs="${4:-10}"
local seen_file="${DAEMON_SEEN_DIR}/seen-${agent_id}.txt"
touch "${seen_file}"
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] Starting (agent=${agent_id}, interval=${interval}s, label=${label})"
# Reload config
[[ -f "${GARC_CONFIG:-${HOME}/.garc}/config.env" ]] && \
source "${GARC_CONFIG:-${HOME}/.garc}/config.env" 2>/dev/null || true
while true; do
# ── Fetch recent unread emails ───────────────────────────────
local raw_msgs fetch_ok
raw_msgs=$(python3 "${GARC_DIR}/scripts/garc-gmail-helper.py" inbox \
--max "${max_msgs}" --unread 2>/dev/null) && fetch_ok=1 || fetch_ok=0
if [[ "${fetch_ok}" -eq 0 ]] || [[ -z "${raw_msgs}" ]]; then
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] [gmail-poller] fetch failed, retrying in ${interval}s"
sleep "${interval}"
continue
fi
# ── Parse and enqueue new messages ───────────────────────────
python3 - "${seen_file}" "${agent_id}" <<'PY'
import json, sys, subprocess, os, re
seen_file = sys.argv[1]
agent_id = sys.argv[2]
garc_dir = os.environ.get("GARC_DIR", "")
garc_lib = os.environ.get("GARC_LIB", "")
# Read seen message IDs
try:
with open(seen_file) as f:
seen = set(line.strip() for line in f if line.strip())
except Exception:
seen = set()
# Parse inbox output (table format from gmail helper)
# Format: ID | FROM | SUBJECT | DATE | SNIPPET
raw = sys.stdin.read() if not sys.stdin.isatty() else ""
# Actually re-fetch as JSON for reliable parsing
result = subprocess.run(
["python3", os.path.join(garc_dir, "scripts", "garc-gmail-helper.py"),
"inbox", "--max", "10", "--unread", "--format", "json"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"[gmail-poller] inbox fetch error: {result.stderr.strip()}", flush=True)
sys.exit(0)
try:
messages = json.loads(result.stdout)
except Exception as e:
print(f"[gmail-poller] JSON parse error: {e}", flush=True)
sys.exit(0)
if not isinstance(messages, list):
messages = []
new_seen = []
for msg in messages:
msg_id = msg.get("id", "")
sender = msg.get("from", "")
subject = msg.get("subject", "(no subject)")
snippet = msg.get("snippet", "")[:120]
if not msg_id or msg_id in seen:
new_seen.append(msg_id)
continue
# Build a human-readable task description
text = f"Email from {sender}: {subject}"
if snippet:
text += f" — {snippet}"
cmd = [
"garc", "ingress", "enqueue",
"--text", text,
"--source", "gmail",
"--sender", sender,
"--agent", agent_id,
]
# Use larc path
garc_bin = os.path.join(garc_dir, "bin", "garc")
cmd[0] = garc_bin
env = os.environ.copy()
env["GARC_DIR"] = garc_dir
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
if r.returncode == 0:
print(f"[gmail-poller] Enqueued: {msg_id[:16]} from {sender[:30]}", flush=True)
else:
print(f"[gmail-poller] Enqueue failed: {r.stderr.strip()}", flush=True)
new_seen.append(msg_id)
if new_seen:
with open(seen_file, "a") as f:
f.write("\n".join(new_seen) + "\n")
PY
sleep "${interval}"
done
}
# ─────────────────────────────────────────────────────────────────
# stop
# ─────────────────────────────────────────────────────────────────
_daemon_stop() {
local stopped=0
for pid_file in "${GMAIL_POLLER_PID}" "${WORKER_PID}"; do
if _daemon_is_running "${pid_file}"; then
local pid
pid=$(cat "${pid_file}")
kill "${pid}" 2>/dev/null && {
echo "✅ Stopped PID ${pid} ($(basename "${pid_file}" .pid))"
((stopped++)) || true
}
fi
rm -f "${pid_file}"
done
if [[ ${stopped} -eq 0 ]]; then
echo "No daemon processes running."
fi
}
# ─────────────────────────────────────────────────────────────────
# status
# ─────────────────────────────────────────────────────────────────
_daemon_status() {
echo "GARC Daemon Status"
echo "──────────────────"
local name pid_file
for entry in "gmail-poller:${GMAIL_POLLER_PID}" "worker:${WORKER_PID}"; do
name="${entry%%:*}"
pid_file="${entry#*:}"
if _daemon_is_running "${pid_file}"; then
local pid
pid=$(cat "${pid_file}")
echo "${name} — running (PID ${pid})"
else
echo "${name} — stopped"
fi
done
echo ""
# Queue stats
local q_dir="${GARC_QUEUE_DIR:-${HOME}/.garc/cache/queue}"
if [[ -d "${q_dir}" ]]; then
local pending
pending=$(find "${q_dir}" -name "*.jsonl" -exec python3 -c "
import json, sys
try:
q = json.loads(open(sys.argv[1]).readline())
print(q.get('status',''))
except Exception:
pass
" {} \; 2>/dev/null | grep -c "^pending$" || echo 0)
echo " Queue: ${pending} pending item(s)"
fi
echo ""
echo "Logs:"
echo " ${GMAIL_POLLER_LOG}"
echo " ${WORKER_LOG}"
}
# ─────────────────────────────────────────────────────────────────
# poll-once — single cycle, foreground (for testing / manual trigger)
# ─────────────────────────────────────────────────────────────────
_daemon_poll_once() {
local agent="${GARC_DEFAULT_AGENT:-main}"
local max_msgs=10
while [[ $# -gt 0 ]]; do
case "$1" in
--agent|-a) agent="$2"; shift 2 ;;
--max) max_msgs="$2"; shift 2 ;;
*) shift ;;
esac
done
_daemon_ensure_dirs
echo "🔍 Polling Gmail inbox (agent=${agent}, max=${max_msgs})..."
_gmail_poller_loop "${agent}" "0" "INBOX" "${max_msgs}" &
local pid=$!
# Wait a moment for one cycle to complete then stop
sleep 5
kill "${pid}" 2>/dev/null || true
echo ""
echo "Poll cycle complete. Check: garc ingress list"
}
# ─────────────────────────────────────────────────────────────────
# logs
# ─────────────────────────────────────────────────────────────────
_daemon_logs() {
local follow=false
while [[ $# -gt 0 ]]; do
case "$1" in
--follow|-f) follow=true; shift ;;
*) shift ;;
esac
done
if [[ "${follow}" == "true" ]]; then
tail -f "${GMAIL_POLLER_LOG}" "${WORKER_LOG}" 2>/dev/null
else
echo "=== Gmail Poller (last 30 lines) ==="
tail -30 "${GMAIL_POLLER_LOG}" 2>/dev/null || echo "(no log yet)"
echo ""
echo "=== Worker (last 30 lines) ==="
tail -30 "${WORKER_LOG}" 2>/dev/null || echo "(no log yet)"
fi
}
# ─────────────────────────────────────────────────────────────────
# install — macOS launchd plist for auto-start on login
# ─────────────────────────────────────────────────────────────────
_daemon_install_launchd() {
local agent="${GARC_DEFAULT_AGENT:-main}"
local interval=60
local label="com.garc.gmail-poller"
local plist_path="${HOME}/Library/LaunchAgents/${label}.plist"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent|-a) agent="$2"; shift 2 ;;
--interval|-i) interval="$2"; shift 2 ;;
*) shift ;;
esac
done
_daemon_ensure_dirs
local garc_bin="${GARC_DIR}/bin/garc"
cat > "${plist_path}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${garc_bin}</string>
<string>daemon</string>
<string>poll-once</string>
<string>--agent</string>
<string>${agent}</string>
</array>
<key>StartInterval</key>
<integer>${interval}</integer>
<key>EnvironmentVariables</key>
<dict>
<key>GARC_DIR</key>
<string>${GARC_DIR}</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
<key>StandardOutPath</key>
<string>${GMAIL_POLLER_LOG}</string>
<key>StandardErrorPath</key>
<string>${GMAIL_POLLER_LOG}</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
EOF
echo "✅ Installed launchd plist: ${plist_path}"
echo ""
echo "To activate:"
echo " launchctl load ${plist_path}"
echo ""
echo "To unload:"
echo " launchctl unload ${plist_path}"
}