File size: 8,170 Bytes
a74a718
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
from __future__ import annotations

import argparse
import json
import os
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Iterable

OFFICIAL_UPSTREAM_REMOTE = "https://github.com/NousResearch/hermes-agent.git"
PROJECT_ROOT = Path(__file__).resolve().parent.parent
UPSTREAM_REF_FILE = PROJECT_ROOT / "docker" / "HERMES_UPSTREAM_REF"
DEFAULT_WAIT_TIMEOUT = 1800
POLL_INTERVAL_SECONDS = 5


def run(cmd, **kwargs):
    kwargs.setdefault("check", True)
    return subprocess.run(cmd, **kwargs)


def is_hf_space_runtime(env: dict[str, str] | None = None) -> bool:
    env = env or os.environ
    return bool(env.get("SPACE_ID") or env.get("SPACE_HOST"))


def get_hf_token(env: dict[str, str] | None = None) -> str | None:
    env = env or os.environ
    for key in ("HF_TOKEN", "HUGGINGFACE_TOKEN", "HF_API_TOKEN"):
        value = env.get(key)
        if value:
            return value
    return None


def list_remote_tags(remote: str = OFFICIAL_UPSTREAM_REMOTE) -> list[str]:
    result = run(
        ["git", "ls-remote", "--tags", "--refs", "--sort=-v:refname", remote],
        capture_output=True,
        text=True,
    )
    tags: list[str] = []
    for line in result.stdout.splitlines():
        parts = line.split()
        if len(parts) != 2:
            continue
        ref = parts[1]
        prefix = "refs/tags/"
        if ref.startswith(prefix):
            tags.append(ref[len(prefix) :])
    return tags


def pick_target_ref(requested_ref: str | None) -> str:
    if requested_ref:
        return requested_ref
    tags = list_remote_tags()
    if not tags:
        raise RuntimeError("Could not determine the latest upstream Hermes tag.")
    return tags[0]


def build_space_clone_url(space_id: str, token: str) -> str:
    return f"https://oauth2:{token}@huggingface.co/spaces/{space_id}"


def clone_space_repo(space_id: str, token: str, workdir: Path | None = None) -> Path:
    root = Path(workdir) if workdir else Path(tempfile.mkdtemp(prefix="hermes-space-update-"))
    repo_dir = root / "repo"
    run(["git", "clone", "--depth", "1", build_space_clone_url(space_id, token), str(repo_dir)])
    return repo_dir


def read_current_ref(path: Path = UPSTREAM_REF_FILE) -> str | None:
    if not path.exists():
        return None
    value = path.read_text(encoding="utf-8").strip()
    return value or None


def apply_space_repo_update(
    space_id: str,
    token: str,
    target_ref: str,
    workdir: Path | None = None,
) -> str | None:
    repo_dir = clone_space_repo(space_id, token, workdir=workdir)
    ref_file = repo_dir / "docker" / "HERMES_UPSTREAM_REF"
    current_ref = read_current_ref(ref_file)
    if current_ref == target_ref:
        return None

    ref_file.parent.mkdir(parents=True, exist_ok=True)
    ref_file.write_text(f"{target_ref}\n", encoding="utf-8")

    run(["git", "config", "user.email", "hermes-space-updater@local"], cwd=repo_dir)
    run(["git", "config", "user.name", "Hermes Space Updater"], cwd=repo_dir)
    run(["git", "add", "docker/HERMES_UPSTREAM_REF"], cwd=repo_dir)
    run(["git", "commit", "-m", f"Update Hermes upstream ref to {target_ref}"], cwd=repo_dir)
    run(["git", "push", "origin", "HEAD:main"], cwd=repo_dir)
    result = run(["git", "rev-parse", "HEAD"], cwd=repo_dir, capture_output=True, text=True)
    return result.stdout.strip()


def _runtime_request(space_id: str, token: str | None = None):
    request = urllib.request.Request(f"https://huggingface.co/api/spaces/{space_id}/runtime")
    if token:
        request.add_header("Authorization", f"Bearer {token}")
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.loads(response.read().decode("utf-8"))


def wait_for_space_runtime(
    space_id: str,
    expected_sha: str,
    token: str | None = None,
    timeout: int = DEFAULT_WAIT_TIMEOUT,
    poll_interval: int = POLL_INTERVAL_SECONDS,
) -> dict:
    deadline = time.time() + timeout
    last_stage = None
    while time.time() < deadline:
        payload = _runtime_request(space_id, token)
        stage = payload.get("stage")
        sha = payload.get("sha")
        if stage != last_stage:
            print(f"• Space stage: {stage} (sha={sha})")
            last_stage = stage
        if sha == expected_sha and stage == "RUNNING":
            return payload
        if stage in {"BUILD_ERROR", "RUNTIME_ERROR", "CONFIG_ERROR"}:
            raise RuntimeError(f"Space rebuild failed with stage {stage} (sha={sha})")
        time.sleep(poll_interval)
    raise TimeoutError(f"Timed out waiting for Space runtime to reach {expected_sha}")


def _gateway_state_paths(env: dict[str, str] | None = None) -> tuple[Path, Path]:
    env = env or os.environ
    hermes_home = Path(env.get("HERMES_HOME", str(Path.home() / ".hermes")))
    return hermes_home / ".update_output.txt", hermes_home / ".update_exit_code"


def write_gateway_exit_code(exit_code: int, env: dict[str, str] | None = None) -> None:
    _output_path, exit_code_path = _gateway_state_paths(env)
    exit_code_path.write_text(str(exit_code), encoding="utf-8")


def run_update_cli(argv: Iterable[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        prog="hermes update",
        description="Update a Hugging Face Space by bumping its upstream Hermes ref and triggering a rebuild.",
    )
    parser.add_argument("--gateway", action="store_true", default=False)
    parser.add_argument("--ref", help="Update to a specific upstream tag or commit instead of the latest tag.")
    parser.add_argument("--no-wait", action="store_true", default=False, help="Push the Space update and exit without waiting for rebuild completion.")
    parser.add_argument("--timeout", type=int, default=DEFAULT_WAIT_TIMEOUT)
    args = parser.parse_args(list(argv) if argv is not None else None)

    try:
        if not is_hf_space_runtime():
            print("✗ HF Space update flow is only available inside a Hugging Face Space runtime.")
            if args.gateway:
                write_gateway_exit_code(1)
            return 1

        space_id = os.environ.get("SPACE_ID")
        if not space_id:
            print("✗ SPACE_ID is not set; cannot locate the current Hugging Face Space.")
            if args.gateway:
                write_gateway_exit_code(1)
            return 1

        token = get_hf_token()
        if not token:
            print("✗ HF_TOKEN (or HUGGINGFACE_TOKEN) is required to update the Space repository.")
            if args.gateway:
                write_gateway_exit_code(1)
            return 1

        target_ref = pick_target_ref(args.ref)
        current_ref = read_current_ref()
        if current_ref == target_ref:
            print(f"Already up to date: {target_ref}")
            if args.gateway:
                write_gateway_exit_code(0)
            return 0

        print(f"⚕ Updating Hugging Face Space {space_id} to upstream ref {target_ref}...")
        commit_sha = apply_space_repo_update(space_id=space_id, token=token, target_ref=target_ref)
        if commit_sha is None:
            print(f"Already up to date: {target_ref}")
            if args.gateway:
                write_gateway_exit_code(0)
            return 0

        print(f"✓ Pushed Space commit {commit_sha}")
        if args.no_wait:
            print("✓ Space rebuild submitted. Current runtime will keep serving until the new build is ready.")
            if args.gateway:
                write_gateway_exit_code(0)
            return 0

        print("⏳ Waiting for Hugging Face Space rebuild to finish...")
        payload = wait_for_space_runtime(space_id, commit_sha, token=token, timeout=args.timeout)
        if args.gateway:
            write_gateway_exit_code(0)
        print(f"✓ Space is RUNNING at {payload.get('sha')}")
        return 0
    except (subprocess.CalledProcessError, RuntimeError, TimeoutError, urllib.error.URLError) as exc:
        print(f"✗ {exc}")
        if args.gateway:
            write_gateway_exit_code(1)
        return 1


if __name__ == "__main__":
    raise SystemExit(run_update_cli())