@giamme/forge 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""forge ui open|status|stop: one shared, detached forge-ui server for every run.
|
|
3
|
+
|
|
4
|
+
`open` starts the server when none is running (or replaces one that runs old code) and
|
|
5
|
+
prints a link to the run; every later `open` reuses it. State lives in
|
|
6
|
+
`${XDG_STATE_HOME:-~/.local/state}/forge/ui`: `server.json` (what we started), `server.log`
|
|
7
|
+
and `lock` (serialises launchers). The server exits by itself after FORGE_UI_IDLE_S idle seconds.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import errno
|
|
13
|
+
import fcntl
|
|
14
|
+
import http.client
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import signal
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
import webbrowser
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
29
|
+
# `-I` keeps the script's own directory off sys.path, so both are added by hand.
|
|
30
|
+
sys.path.insert(0, str(SKILL / 'scripts'))
|
|
31
|
+
sys.path.insert(0, str(SKILL / 'ui'))
|
|
32
|
+
|
|
33
|
+
HOST = '127.0.0.1'
|
|
34
|
+
LISTENING = re.compile(r'^forge-ui on http://127\.0\.0\.1:(\d+)/', re.M)
|
|
35
|
+
DEFAULT_PORT = 8765
|
|
36
|
+
DEFAULT_IDLE_S = 14400
|
|
37
|
+
LOCK_WAIT_S = 10
|
|
38
|
+
START_WAIT_S = 5
|
|
39
|
+
STOP_WAIT_S = 2
|
|
40
|
+
BROWSER_WAIT_S = 2 # a browser command that hangs must not hold `open` past its budget
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def state_dir() -> Path:
|
|
44
|
+
base = os.environ.get('XDG_STATE_HOME') or str(Path.home() / '.local/state')
|
|
45
|
+
return Path(base).expanduser() / 'forge/ui'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _say(text):
|
|
49
|
+
print(f'forge-ui: {text}', file=sys.stderr)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _env_int(name, default):
|
|
53
|
+
raw = os.environ.get(name, '')
|
|
54
|
+
if not raw.strip():
|
|
55
|
+
return default
|
|
56
|
+
try:
|
|
57
|
+
value = int(raw)
|
|
58
|
+
except ValueError:
|
|
59
|
+
value = -1
|
|
60
|
+
if value < 0:
|
|
61
|
+
raise ValueError(f'{name}={raw!r} is not a non-negative integer')
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pid_alive(pid) -> bool:
|
|
66
|
+
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
os.kill(pid, 0)
|
|
70
|
+
except (ProcessLookupError, OverflowError): # OverflowError: no process can have that pid
|
|
71
|
+
return False
|
|
72
|
+
except PermissionError:
|
|
73
|
+
return True
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def wait_dead(pid, seconds) -> bool:
|
|
78
|
+
deadline = time.monotonic() + seconds
|
|
79
|
+
while pid_alive(pid):
|
|
80
|
+
if time.monotonic() >= deadline:
|
|
81
|
+
return False
|
|
82
|
+
time.sleep(0.05)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def health(port):
|
|
87
|
+
"""The /api/health answer of whatever listens on 127.0.0.1:port, or None."""
|
|
88
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 0 < port < 65536:
|
|
89
|
+
return None
|
|
90
|
+
conn = http.client.HTTPConnection(HOST, port, timeout=1) # direct: no proxy handling
|
|
91
|
+
try:
|
|
92
|
+
conn.request('GET', '/api/health')
|
|
93
|
+
response = conn.getresponse()
|
|
94
|
+
if response.status != 200:
|
|
95
|
+
return None
|
|
96
|
+
data = json.loads(response.read(65536))
|
|
97
|
+
except (OSError, ValueError, RecursionError, http.client.HTTPException):
|
|
98
|
+
return None
|
|
99
|
+
finally:
|
|
100
|
+
conn.close()
|
|
101
|
+
return data if isinstance(data, dict) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def open_regular(path, flags):
|
|
105
|
+
"""An fd on path, which must be a regular file; OSError otherwise.
|
|
106
|
+
|
|
107
|
+
O_NONBLOCK: a FIFO or device planted at one of our paths must fail, never block.
|
|
108
|
+
"""
|
|
109
|
+
fd = os.open(path, flags | os.O_NONBLOCK | os.O_CLOEXEC, 0o644)
|
|
110
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
111
|
+
os.close(fd)
|
|
112
|
+
raise OSError(errno.EINVAL, 'not a regular file', str(path))
|
|
113
|
+
return fd
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_state(state):
|
|
117
|
+
"""server.json as a dict, or None when it is missing, unreadable or not a JSON object."""
|
|
118
|
+
try:
|
|
119
|
+
with os.fdopen(open_regular(state / 'server.json', os.O_RDONLY), 'rb') as handle:
|
|
120
|
+
data = json.loads(handle.read())
|
|
121
|
+
except (OSError, ValueError, RecursionError): # ValueError covers bad UTF-8 and bad JSON
|
|
122
|
+
return None
|
|
123
|
+
return data if isinstance(data, dict) else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write_state(state, data):
|
|
127
|
+
handle, temp = tempfile.mkstemp(prefix='server.json.', dir=str(state))
|
|
128
|
+
try:
|
|
129
|
+
with os.fdopen(handle, 'w') as out:
|
|
130
|
+
json.dump(data, out, indent=1, sort_keys=True)
|
|
131
|
+
out.write('\n')
|
|
132
|
+
os.replace(temp, state / 'server.json')
|
|
133
|
+
except BaseException:
|
|
134
|
+
try:
|
|
135
|
+
os.unlink(temp)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def confirmed(recorded):
|
|
142
|
+
"""The health answer if the recorded server is alive and is a forge-ui with that pid."""
|
|
143
|
+
if not recorded or not pid_alive(recorded.get('pid')):
|
|
144
|
+
return None
|
|
145
|
+
answer = health(recorded.get('port'))
|
|
146
|
+
if answer and answer.get('app') == 'forge-ui' and answer.get('pid') == recorded['pid']:
|
|
147
|
+
return answer
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Lock:
|
|
152
|
+
"""An flock on <state>/lock, so concurrent launchers see one server."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, state):
|
|
155
|
+
self.path = state / 'lock'
|
|
156
|
+
self.handle = None
|
|
157
|
+
|
|
158
|
+
def __enter__(self):
|
|
159
|
+
self.handle = open_regular(self.path, os.O_WRONLY | os.O_CREAT)
|
|
160
|
+
deadline = time.monotonic() + LOCK_WAIT_S
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
164
|
+
return self
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
busy = exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN)
|
|
167
|
+
if busy and time.monotonic() < deadline:
|
|
168
|
+
time.sleep(0.05)
|
|
169
|
+
continue
|
|
170
|
+
os.close(self.handle)
|
|
171
|
+
if busy:
|
|
172
|
+
raise TimeoutError(errno.ETIMEDOUT, 'another forge ui is holding it', str(self.path)) from None
|
|
173
|
+
raise OSError(exc.errno, exc.strerror, str(self.path)) from None # flock names no file
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *exc):
|
|
176
|
+
os.close(self.handle) # closing releases the lock
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def terminate(pid) -> bool:
|
|
180
|
+
"""SIGTERM a pid that health has confirmed; True once it is gone."""
|
|
181
|
+
try:
|
|
182
|
+
os.kill(pid, signal.SIGTERM)
|
|
183
|
+
except ProcessLookupError:
|
|
184
|
+
return True
|
|
185
|
+
except PermissionError:
|
|
186
|
+
return False
|
|
187
|
+
return wait_dead(pid, STOP_WAIT_S)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _spawn(state, port, registry, idle):
|
|
191
|
+
"""Start the server; returns (process, port) or (None, error text)."""
|
|
192
|
+
log = state / 'server.log'
|
|
193
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith('FORGE_')}
|
|
194
|
+
command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--port', str(port),
|
|
195
|
+
'--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
|
|
196
|
+
with os.fdopen(open_regular(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC), 'w') as out:
|
|
197
|
+
proc = subprocess.Popen(command, cwd=str(state), stdin=subprocess.DEVNULL, stdout=out, stderr=out,
|
|
198
|
+
start_new_session=True, close_fds=True, env=env)
|
|
199
|
+
deadline = time.monotonic() + START_WAIT_S
|
|
200
|
+
while time.monotonic() < deadline:
|
|
201
|
+
try:
|
|
202
|
+
match = LISTENING.search(log.read_text(errors='replace'))
|
|
203
|
+
except OSError:
|
|
204
|
+
match = None
|
|
205
|
+
if match:
|
|
206
|
+
return proc, int(match.group(1))
|
|
207
|
+
if proc.poll() is not None:
|
|
208
|
+
return None, proc.returncode
|
|
209
|
+
time.sleep(0.05)
|
|
210
|
+
proc.terminate()
|
|
211
|
+
return None, None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def start_server(state, port, registry, idle):
|
|
215
|
+
"""Spawn a server, falling back once to a free port when the preferred one is taken."""
|
|
216
|
+
proc, result = _spawn(state, port, registry, idle)
|
|
217
|
+
if proc is None and result == 2 and port != 0:
|
|
218
|
+
proc, result = _spawn(state, 0, registry, idle)
|
|
219
|
+
if proc is None:
|
|
220
|
+
return None
|
|
221
|
+
return {'pid': proc.pid, 'port': result}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def gui_available() -> bool:
|
|
225
|
+
if sys.platform == 'darwin':
|
|
226
|
+
return True
|
|
227
|
+
return any(os.environ.get(name) for name in ('DISPLAY', 'WAYLAND_DISPLAY', 'BROWSER'))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def browser_wanted(args) -> bool:
|
|
231
|
+
if args.no_browser or os.environ.get('FORGE_UI_BROWSER', '').strip().lower() in ('off', '0', 'false', 'no'):
|
|
232
|
+
return False
|
|
233
|
+
return gui_available()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def launch_browser(url):
|
|
237
|
+
"""Open url from a detached helper, waiting at most BROWSER_WAIT_S for it.
|
|
238
|
+
|
|
239
|
+
The helper's output goes to /dev/null: a browser command that blocks must not keep the
|
|
240
|
+
pipes of whoever captures our output open after we return.
|
|
241
|
+
"""
|
|
242
|
+
command = [sys.executable, '-I', str(Path(__file__).resolve()), '_browse', url]
|
|
243
|
+
try:
|
|
244
|
+
proc = subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
245
|
+
stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
|
|
246
|
+
proc.wait(BROWSER_WAIT_S)
|
|
247
|
+
except subprocess.TimeoutExpired:
|
|
248
|
+
pass # still starting its browser: leave it
|
|
249
|
+
except OSError: # a missing browser must not fail the run
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def browse(url):
|
|
254
|
+
try:
|
|
255
|
+
webbrowser.open(url)
|
|
256
|
+
except Exception:
|
|
257
|
+
return 1
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def run_info(directory):
|
|
262
|
+
"""(repo, None) for a run dir, or (None, why not). Never raises and never blocks."""
|
|
263
|
+
marker = directory / 'repo'
|
|
264
|
+
try:
|
|
265
|
+
plan = (directory / 'tasks.tsv').is_file()
|
|
266
|
+
solo = (directory / 'prompt.md').is_file() and marker.is_file()
|
|
267
|
+
if not (plan or solo):
|
|
268
|
+
return None, 'not a forge run (no tasks.tsv, and no prompt.md with repo)'
|
|
269
|
+
# a FIFO or a device named `repo` would block the read: only a regular file is read
|
|
270
|
+
if not stat.S_ISREG(os.stat(marker).st_mode):
|
|
271
|
+
return None, f'{marker} is not a regular file'
|
|
272
|
+
repo = marker.read_text().strip()
|
|
273
|
+
except (OSError, ValueError) as exc: # ValueError: not decodable text
|
|
274
|
+
return None, f'cannot read {marker}: {getattr(exc, "strerror", None) or exc}'
|
|
275
|
+
if not repo:
|
|
276
|
+
return None, f'{marker} is empty'
|
|
277
|
+
if '\x00' in repo: # readable text, but no path can hold it
|
|
278
|
+
return None, f'{marker} does not hold a path (it contains a NUL byte)'
|
|
279
|
+
return repo, None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_open(args):
|
|
283
|
+
import forge_registry
|
|
284
|
+
from forge_ui import __version__, build_id
|
|
285
|
+
from forge_ui.reader import run_key
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
port = args.port if args.port is not None else _env_int('FORGE_UI_PORT', DEFAULT_PORT)
|
|
289
|
+
idle = _env_int('FORGE_UI_IDLE_S', DEFAULT_IDLE_S)
|
|
290
|
+
except ValueError as exc:
|
|
291
|
+
_say(f'error: {exc}')
|
|
292
|
+
return 2
|
|
293
|
+
if not 0 <= port <= 65535:
|
|
294
|
+
_say(f'error: port {port} is out of range (0-65535)')
|
|
295
|
+
return 2
|
|
296
|
+
|
|
297
|
+
key = None
|
|
298
|
+
if args.dir:
|
|
299
|
+
directory = Path(os.path.realpath(args.dir))
|
|
300
|
+
repo, why = run_info(directory)
|
|
301
|
+
if why:
|
|
302
|
+
_say(f'error: {directory}: {why}')
|
|
303
|
+
return 3
|
|
304
|
+
forge_registry.register_run_dir(Path(repo), directory)
|
|
305
|
+
key = run_key(directory)
|
|
306
|
+
|
|
307
|
+
state = state_dir()
|
|
308
|
+
log = state / 'server.log'
|
|
309
|
+
registry = str(forge_registry.registry_dir())
|
|
310
|
+
spawned = None
|
|
311
|
+
# Any OSError below is state we cannot use (state dir, lock, server.log, server.json) or a
|
|
312
|
+
# spawn that failed: exit 4. An unreadable or invalid server.json is not one: it means no server.
|
|
313
|
+
try:
|
|
314
|
+
build = build_id()
|
|
315
|
+
state.mkdir(parents=True, exist_ok=True)
|
|
316
|
+
with Lock(state):
|
|
317
|
+
server = read_state(state)
|
|
318
|
+
answer = confirmed(server) if server else None
|
|
319
|
+
if answer:
|
|
320
|
+
# the record and the live answer must both name this code
|
|
321
|
+
if server.get('build') == build and answer.get('build') == build and server.get('registry') == registry:
|
|
322
|
+
pass # reuse
|
|
323
|
+
else:
|
|
324
|
+
terminate(server['pid'])
|
|
325
|
+
server = None
|
|
326
|
+
else:
|
|
327
|
+
server = None # dead, or a pid that is not our forge-ui: never signal it
|
|
328
|
+
if server is None:
|
|
329
|
+
spawned = start_server(state, port, registry, idle)
|
|
330
|
+
if spawned is None:
|
|
331
|
+
_say(f'error: the server did not start; see {log}')
|
|
332
|
+
return 4
|
|
333
|
+
server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log),
|
|
334
|
+
started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
|
|
335
|
+
opened = server.get('opened')
|
|
336
|
+
opened = opened if isinstance(opened, list) else []
|
|
337
|
+
mark = key or ''
|
|
338
|
+
# a key counts as opened only when a browser is really launched for it
|
|
339
|
+
first = mark not in opened and browser_wanted(args)
|
|
340
|
+
if first:
|
|
341
|
+
opened.append(mark)
|
|
342
|
+
server['opened'] = opened
|
|
343
|
+
write_state(state, server)
|
|
344
|
+
except OSError as exc: # TimeoutError (the lock) included
|
|
345
|
+
if spawned:
|
|
346
|
+
terminate(spawned['pid']) # our own child, but unrecorded: no later command could find it
|
|
347
|
+
hint = f'; see {log}' if os.path.isfile(log) else ''
|
|
348
|
+
_say(f'error: cannot use {exc.filename or state}: {exc.strerror or exc}{hint}')
|
|
349
|
+
return 4
|
|
350
|
+
|
|
351
|
+
url = f'http://{HOST}:{server["port"]}/'
|
|
352
|
+
if key:
|
|
353
|
+
url += f'#/runs/{key}'
|
|
354
|
+
print(f'forge-ui: {url}')
|
|
355
|
+
if first:
|
|
356
|
+
launch_browser(url)
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def cmd_status(args):
|
|
361
|
+
server = read_state(state_dir())
|
|
362
|
+
answer = confirmed(server)
|
|
363
|
+
if not answer:
|
|
364
|
+
if args.json:
|
|
365
|
+
print(json.dumps({'running': False}))
|
|
366
|
+
else:
|
|
367
|
+
print('forge-ui: not running')
|
|
368
|
+
return 1
|
|
369
|
+
info = {'running': True, 'pid': server['pid'], 'port': server['port'],
|
|
370
|
+
'url': f'http://{HOST}:{server["port"]}/', 'version': answer.get('version'),
|
|
371
|
+
'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log')}
|
|
372
|
+
if args.json:
|
|
373
|
+
print(json.dumps(info, sort_keys=True))
|
|
374
|
+
else:
|
|
375
|
+
print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]})')
|
|
376
|
+
return 0
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def cmd_stop(args):
|
|
380
|
+
state = state_dir()
|
|
381
|
+
if not os.path.isdir(state): # unlike Path.is_dir, never raises
|
|
382
|
+
print('forge-ui: not running')
|
|
383
|
+
return 0
|
|
384
|
+
try:
|
|
385
|
+
with Lock(state):
|
|
386
|
+
server = read_state(state)
|
|
387
|
+
if server and confirmed(server):
|
|
388
|
+
if terminate(server['pid']):
|
|
389
|
+
print('forge-ui: stopped')
|
|
390
|
+
else:
|
|
391
|
+
_say(f'warning: pid {server["pid"]} did not exit within {STOP_WAIT_S}s')
|
|
392
|
+
return 0
|
|
393
|
+
else:
|
|
394
|
+
print('forge-ui: not running')
|
|
395
|
+
if server and pid_alive(server.get('pid')):
|
|
396
|
+
return 0 # a live pid health could not vouch for: leave it and its record alone
|
|
397
|
+
try:
|
|
398
|
+
(state / 'server.json').unlink()
|
|
399
|
+
except OSError:
|
|
400
|
+
pass
|
|
401
|
+
except OSError as exc: # the lock, TimeoutError included: exit 0 either way
|
|
402
|
+
_say(f'warning: cannot use {exc.filename or state}: {exc.strerror or exc}')
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def serve(argv):
|
|
407
|
+
from forge_ui.__main__ import main
|
|
408
|
+
return main(argv)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def build_parser():
|
|
412
|
+
parser = argparse.ArgumentParser(prog='forge ui', description='Open runs in the shared forge-ui server.')
|
|
413
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
414
|
+
opened = sub.add_parser('open', help='start or reuse the server and open a run')
|
|
415
|
+
opened.add_argument('dir', nargs='?', metavar='DIR', help='a run dir (plan or solo); omit to open the run list')
|
|
416
|
+
opened.add_argument('--no-browser', action='store_true', help='print the link without opening it')
|
|
417
|
+
opened.add_argument('--port', type=int, metavar='N', help='preferred port (default FORGE_UI_PORT or 8765)')
|
|
418
|
+
status = sub.add_parser('status', help='exit 0 when the server is running, 1 when not')
|
|
419
|
+
status.add_argument('--json', action='store_true')
|
|
420
|
+
sub.add_parser('stop', help='stop the server')
|
|
421
|
+
return parser
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def main(argv=None):
|
|
425
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
426
|
+
if argv and argv[0] == '_serve':
|
|
427
|
+
return serve(argv[1:])
|
|
428
|
+
if len(argv) == 2 and argv[0] == '_browse':
|
|
429
|
+
return browse(argv[1])
|
|
430
|
+
args = build_parser().parse_args(argv) # a usage error exits 2
|
|
431
|
+
return {'open': cmd_open, 'status': cmd_status, 'stop': cmd_stop}[args.command](args)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == '__main__':
|
|
435
|
+
sys.exit(main())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Update the canonical checkout, then refresh each harness through the installer.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
main() {
|
|
6
|
+
local source_dir dry=0 local_only=0 upstream remote branch before after changes
|
|
7
|
+
source_dir="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || return 2
|
|
8
|
+
while [ $# -gt 0 ]; do
|
|
9
|
+
case "$1" in
|
|
10
|
+
--source) source_dir="${2:?--source needs a Forge checkout}"; shift 2 ;;
|
|
11
|
+
--dry-run) dry=1; shift ;;
|
|
12
|
+
--local) local_only=1; shift ;;
|
|
13
|
+
--help|-h)
|
|
14
|
+
echo 'usage: forge update [--source <checkout>] [--local] [--dry-run]'
|
|
15
|
+
echo 'Default: fast-forward the tracked upstream, then refresh harness installations.'
|
|
16
|
+
echo '--local: refresh installations from the current files without fetching.'
|
|
17
|
+
return 0 ;;
|
|
18
|
+
*) echo "forge: unknown update option: $1" >&2; return 2 ;;
|
|
19
|
+
esac
|
|
20
|
+
done
|
|
21
|
+
source_dir="$(cd -P "$source_dir" && pwd)" || return 2
|
|
22
|
+
if [ ! -f "$source_dir/SKILL.md" ] || [ ! -f "$source_dir/scripts/forge-install.sh" ]; then
|
|
23
|
+
echo "forge: not a Forge source directory: $source_dir" >&2; return 2
|
|
24
|
+
fi
|
|
25
|
+
echo "forge: update source: $source_dir"
|
|
26
|
+
if [ "$local_only" = 0 ]; then
|
|
27
|
+
if [ "$(git -C "$source_dir" rev-parse --show-toplevel 2>/dev/null)" != "$source_dir" ]; then
|
|
28
|
+
case "$source_dir" in
|
|
29
|
+
*/node_modules/*)
|
|
30
|
+
# @latest, because older npm updated a global package within ^version: never 0.7 -> 0.8.
|
|
31
|
+
echo 'forge: installed with npm; update with: npm install -g @giamme/forge@latest && forge install' >&2; return 2 ;;
|
|
32
|
+
esac
|
|
33
|
+
echo 'forge: update needs the original Git checkout; use --source <checkout> or --local' >&2; return 2
|
|
34
|
+
fi
|
|
35
|
+
changes="$(git -C "$source_dir" status --porcelain --untracked-files=all)" || return $?
|
|
36
|
+
if [ -n "$changes" ]; then
|
|
37
|
+
echo 'forge: checkout has local changes; commit or stash them, or use --local to refresh current files' >&2; return 2
|
|
38
|
+
fi
|
|
39
|
+
branch="$(git -C "$source_dir" symbolic-ref --quiet --short HEAD)" || {
|
|
40
|
+
echo 'forge: detached HEAD; check out a tracking branch or use --local' >&2; return 2;
|
|
41
|
+
}
|
|
42
|
+
upstream="$(git -C "$source_dir" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || {
|
|
43
|
+
echo 'forge: branch has no upstream; configure tracking or use --local' >&2; return 2;
|
|
44
|
+
}
|
|
45
|
+
remote="$(git -C "$source_dir" config --get "branch.$branch.remote")" || return 2
|
|
46
|
+
if [ "$dry" = 1 ]; then
|
|
47
|
+
echo "forge: would fetch $remote and fast-forward $branch to $upstream (no network in dry run)"
|
|
48
|
+
else
|
|
49
|
+
before="$(git -C "$source_dir" rev-parse HEAD)" || return 2
|
|
50
|
+
git -C "$source_dir" fetch -- "$remote" || return $?
|
|
51
|
+
git -C "$source_dir" merge --ff-only -- "$upstream" || {
|
|
52
|
+
echo 'forge: could not fast-forward; resolve branch divergence before updating installations' >&2; return 1;
|
|
53
|
+
}
|
|
54
|
+
after="$(git -C "$source_dir" rev-parse HEAD)" || return 2
|
|
55
|
+
echo "forge: source $before -> $after"
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
# Updating Forge does not offer installation of optional dependencies.
|
|
59
|
+
if [ "$dry" = 1 ]; then
|
|
60
|
+
FORGE_RIPWIRE=off /bin/bash "$source_dir/scripts/forge-install.sh" --dry-run || return $?
|
|
61
|
+
echo 'forge: preview only; remote changes and live harness availability are not verified'
|
|
62
|
+
else
|
|
63
|
+
FORGE_RIPWIRE=off /bin/bash "$source_dir/scripts/forge-install.sh" || {
|
|
64
|
+
echo 'forge: source retained; some installations failed. Fix the reported cause and retry with --local' >&2; return 1;
|
|
65
|
+
}
|
|
66
|
+
echo 'forge: installations refreshed; restart active harness sessions to load the updated skill'
|
|
67
|
+
fi
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Parse the function before fetching: the update may replace this script on disk.
|
|
71
|
+
main "$@"; exit $?
|