windhover 0.9.0__tar.gz → 0.10.0__tar.gz
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.
- {windhover-0.9.0 → windhover-0.10.0}/PKG-INFO +6 -3
- {windhover-0.9.0 → windhover-0.10.0}/README.md +5 -2
- {windhover-0.9.0 → windhover-0.10.0}/pyproject.toml +1 -1
- {windhover-0.9.0 → windhover-0.10.0}/tests/test_smoke.py +65 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/demo_graph.py +9 -1
- {windhover-0.9.0 → windhover-0.10.0}/windhover/server.py +99 -19
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/index.html +140 -2
- {windhover-0.9.0 → windhover-0.10.0}/windhover/tracer.py +32 -2
- {windhover-0.9.0 → windhover-0.10.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/.gitignore +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/LICENSE +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/SPEC.md +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/graph.png +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/logo.svg +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/runs.png +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/social-preview.png +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/stats.png +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/docs/trace.png +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/tests/__init__.py +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/__init__.py +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/config.py +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/extract.py +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/pricing.json +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/icon.svg +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/manifest.json +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
- {windhover-0.9.0 → windhover-0.10.0}/windhover/store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
5
|
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
6
|
Author: justfeltlikerunning
|
|
@@ -81,8 +81,11 @@ graph.invoke(input, config={
|
|
|
81
81
|
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
82
82
|
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
83
83
|
graph, and the node's source renders with the **throwing line highlighted**.
|
|
84
|
-
- **Human-in-the-loop
|
|
85
|
-
|
|
84
|
+
- **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
|
|
85
|
+
question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
|
|
86
|
+
static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
|
|
87
|
+
(`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
|
|
88
|
+
all pure LangGraph primitives.
|
|
86
89
|
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
87
90
|
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
88
91
|
(`get_graph(xray=True)`).
|
|
@@ -62,8 +62,11 @@ graph.invoke(input, config={
|
|
|
62
62
|
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
63
63
|
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
64
64
|
graph, and the node's source renders with the **throwing line highlighted**.
|
|
65
|
-
- **Human-in-the-loop
|
|
66
|
-
|
|
65
|
+
- **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
|
|
66
|
+
question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
|
|
67
|
+
static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
|
|
68
|
+
(`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
|
|
69
|
+
all pure LangGraph primitives.
|
|
67
70
|
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
68
71
|
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
69
72
|
(`get_graph(xray=True)`).
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -360,6 +360,69 @@ def test_demo_memory_and_events_end_to_end():
|
|
|
360
360
|
print("demo memory + custom event end-to-end OK")
|
|
361
361
|
|
|
362
362
|
|
|
363
|
+
def test_hitl_interrupt_resume():
|
|
364
|
+
"""Dynamic interrupt pauses (status interrupted); Command(resume) finishes."""
|
|
365
|
+
from typing import TypedDict
|
|
366
|
+
from langgraph.graph import StateGraph, START, END
|
|
367
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
368
|
+
from langgraph.types import Command, interrupt
|
|
369
|
+
p = tempfile.mktemp(suffix=".db")
|
|
370
|
+
s = Store(p)
|
|
371
|
+
|
|
372
|
+
class St(TypedDict):
|
|
373
|
+
x: int
|
|
374
|
+
def gate(st):
|
|
375
|
+
ok = interrupt({"question": f"allow x={st['x']}?"})
|
|
376
|
+
return {"x": st["x"] if ok else 0}
|
|
377
|
+
g = StateGraph(St)
|
|
378
|
+
g.add_node("gate", gate)
|
|
379
|
+
g.add_edge(START, "gate"); g.add_edge("gate", END)
|
|
380
|
+
app = g.compile(checkpointer=MemorySaver())
|
|
381
|
+
cfgc = {"configurable": {"thread_id": "hitl-1"}}
|
|
382
|
+
|
|
383
|
+
tr1 = SpanBuilder(db_sink(s), run_name="hitl")
|
|
384
|
+
out = app.invoke({"x": 5}, config={"callbacks": [tr1], **cfgc})
|
|
385
|
+
assert "__interrupt__" in out
|
|
386
|
+
time.sleep(.05)
|
|
387
|
+
assert s.run_detail(tr1.run_id)["status"] == "interrupted"
|
|
388
|
+
|
|
389
|
+
tr2 = SpanBuilder(db_sink(s), run_name="hitl")
|
|
390
|
+
out2 = app.invoke(Command(resume=True), config={"callbacks": [tr2], **cfgc})
|
|
391
|
+
assert out2["x"] == 5
|
|
392
|
+
time.sleep(.05)
|
|
393
|
+
assert s.run_detail(tr2.run_id)["status"] == "done"
|
|
394
|
+
print("HITL interrupt/resume OK")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_static_breakpoint_and_state_edit():
|
|
398
|
+
"""interrupt_before pauses with pending next; update_state edit sticks."""
|
|
399
|
+
from typing import TypedDict
|
|
400
|
+
from langgraph.graph import StateGraph, START, END
|
|
401
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
402
|
+
p = tempfile.mktemp(suffix=".db")
|
|
403
|
+
s = Store(p)
|
|
404
|
+
|
|
405
|
+
class St(TypedDict):
|
|
406
|
+
x: int
|
|
407
|
+
g = StateGraph(St)
|
|
408
|
+
g.add_node("inc", lambda st: {"x": st["x"] + 1})
|
|
409
|
+
g.add_node("mul", lambda st: {"x": st["x"] * 10})
|
|
410
|
+
g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
|
|
411
|
+
app = g.compile(checkpointer=MemorySaver())
|
|
412
|
+
cfgc = {"configurable": {"thread_id": "bp-1"}}
|
|
413
|
+
|
|
414
|
+
tr = SpanBuilder(db_sink(s), run_name="bp")
|
|
415
|
+
for _ in app.stream({"x": 1}, config={"callbacks": [tr], **cfgc},
|
|
416
|
+
stream_mode="updates", interrupt_before=["mul"]):
|
|
417
|
+
pass
|
|
418
|
+
st = app.get_state(cfgc)
|
|
419
|
+
assert st.next == ("mul",), st.next # paused before mul
|
|
420
|
+
app.update_state(cfgc, {"x": 100}) # human edits the state
|
|
421
|
+
out = app.invoke(None, config=cfgc) # continue
|
|
422
|
+
assert out["x"] == 1000, out # 100 * 10 — edit took effect
|
|
423
|
+
print("static breakpoint + state edit OK")
|
|
424
|
+
|
|
425
|
+
|
|
363
426
|
if __name__ == "__main__":
|
|
364
427
|
test_cost(); print("cost OK")
|
|
365
428
|
test_store_roundtrip()
|
|
@@ -377,4 +440,6 @@ if __name__ == "__main__":
|
|
|
377
440
|
test_datasets_and_scoring()
|
|
378
441
|
test_ttft_retry_custom_event_usage_detail()
|
|
379
442
|
test_demo_memory_and_events_end_to_end()
|
|
443
|
+
test_hitl_interrupt_resume()
|
|
444
|
+
test_static_breakpoint_and_state_edit()
|
|
380
445
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -24,7 +24,15 @@ def seed(s):
|
|
|
24
24
|
if n < 0:
|
|
25
25
|
raise ValueError(f"n must be non-negative, got {n} — the demo guard tripped")
|
|
26
26
|
return {"n": n}
|
|
27
|
-
def grow(s):
|
|
27
|
+
def grow(s):
|
|
28
|
+
time.sleep(.3)
|
|
29
|
+
grown = s["n"] * 3
|
|
30
|
+
if grown > 500: # human-in-the-loop: big growth needs approval
|
|
31
|
+
from langgraph.types import interrupt
|
|
32
|
+
approved = interrupt({"question": f"grow {s['n']} -> {grown}? (true/false)"})
|
|
33
|
+
if not approved:
|
|
34
|
+
return {"n": s["n"]}
|
|
35
|
+
return {"n": grown}
|
|
28
36
|
def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
|
|
29
37
|
def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
|
|
30
38
|
def magnitude(s): time.sleep(.35); return {"notes": ["big" if abs(s["n"]) > 100 else "small"]}
|
|
@@ -163,31 +163,19 @@ def _sse(ev: str, data: dict) -> str:
|
|
|
163
163
|
return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
|
|
164
164
|
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
try:
|
|
171
|
-
payload = await request.json()
|
|
172
|
-
except Exception:
|
|
173
|
-
payload = {}
|
|
174
|
-
session = payload.pop("_session", None)
|
|
175
|
-
tags = payload.pop("_tags", None)
|
|
176
|
-
thread = payload.pop("_thread", None)
|
|
177
|
-
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
166
|
+
def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
167
|
+
"""Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
|
|
168
|
+
dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
|
|
169
|
+
ends with pending next-nodes) and corrects the run status accordingly."""
|
|
178
170
|
run_id = tracer.run_id
|
|
179
|
-
config = {"callbacks": [tracer]}
|
|
180
|
-
if thread or getattr(graph, "checkpointer", None) is not None:
|
|
181
|
-
# a checkpointed graph needs a thread; default to the run id so
|
|
182
|
-
# time-travel works out of the box
|
|
183
|
-
config["configurable"] = {"thread_id": thread or run_id}
|
|
184
171
|
q: "queue.Queue" = queue.Queue()
|
|
185
172
|
|
|
186
173
|
def worker():
|
|
187
174
|
try:
|
|
188
175
|
interrupted = False
|
|
189
|
-
for mode, chunk in graph.stream(
|
|
190
|
-
stream_mode=["updates", "custom"]
|
|
176
|
+
for mode, chunk in graph.stream(graph_input, config=config,
|
|
177
|
+
stream_mode=["updates", "custom"],
|
|
178
|
+
**(stream_kwargs or {})):
|
|
191
179
|
if mode == "custom":
|
|
192
180
|
# get_stream_writer() output from inside a node -> live progress
|
|
193
181
|
q.put(("progress", {"data": _trunc(chunk, 600)}))
|
|
@@ -198,6 +186,18 @@ async def api_run(request: Request):
|
|
|
198
186
|
q.put(("interrupt", {"run_id": run_id}))
|
|
199
187
|
else:
|
|
200
188
|
q.put(("node", {"node": node}))
|
|
189
|
+
if not interrupted and getattr(graph, "checkpointer", None) is not None:
|
|
190
|
+
try: # static breakpoint: stream ended but nodes are pending
|
|
191
|
+
st = graph.get_state(config)
|
|
192
|
+
if st.next:
|
|
193
|
+
interrupted = True
|
|
194
|
+
q.put(("interrupt", {"run_id": run_id,
|
|
195
|
+
"next": list(st.next)}))
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
if interrupted:
|
|
199
|
+
# tracer closed the run as done on root end; correct it
|
|
200
|
+
store.close_run(run_id, "interrupted", int(time.time() * 1000))
|
|
201
201
|
q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
|
|
202
202
|
except Exception as e:
|
|
203
203
|
# tracer already recorded the error span/close; surface to the client too
|
|
@@ -216,6 +216,86 @@ async def api_run(request: Request):
|
|
|
216
216
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
@app.post("/api/run")
|
|
220
|
+
async def api_run(request: Request):
|
|
221
|
+
if graph is None:
|
|
222
|
+
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
|
|
223
|
+
try:
|
|
224
|
+
payload = await request.json()
|
|
225
|
+
except Exception:
|
|
226
|
+
payload = {}
|
|
227
|
+
session = payload.pop("_session", None)
|
|
228
|
+
tags = payload.pop("_tags", None)
|
|
229
|
+
thread = payload.pop("_thread", None)
|
|
230
|
+
pause_before = payload.pop("_interrupt_before", None)
|
|
231
|
+
pause_after = payload.pop("_interrupt_after", None)
|
|
232
|
+
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
233
|
+
config = {"callbacks": [tracer]}
|
|
234
|
+
if thread or getattr(graph, "checkpointer", None) is not None:
|
|
235
|
+
# a checkpointed graph needs a thread; default to the run id so
|
|
236
|
+
# time-travel works out of the box
|
|
237
|
+
config["configurable"] = {"thread_id": thread or tracer.run_id}
|
|
238
|
+
sk = {}
|
|
239
|
+
if pause_before:
|
|
240
|
+
sk["interrupt_before"] = [str(n) for n in pause_before]
|
|
241
|
+
if pause_after:
|
|
242
|
+
sk["interrupt_after"] = [str(n) for n in pause_after]
|
|
243
|
+
return _stream_execution(payload, config, tracer, sk)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.post("/api/threads/{thread_id}/resume")
|
|
247
|
+
async def api_thread_resume(request: Request, thread_id: str):
|
|
248
|
+
"""Human-in-the-loop: answer an interrupt (Command(resume=…)), redirect
|
|
249
|
+
(Command(goto=…)), continue past a static breakpoint (no body), or fork
|
|
250
|
+
from an earlier checkpoint (checkpoint_id)."""
|
|
251
|
+
if graph is None or getattr(graph, "checkpointer", None) is None:
|
|
252
|
+
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
253
|
+
try:
|
|
254
|
+
body = await request.json()
|
|
255
|
+
except Exception:
|
|
256
|
+
body = {}
|
|
257
|
+
try:
|
|
258
|
+
from langgraph.types import Command
|
|
259
|
+
except Exception:
|
|
260
|
+
return JSONResponse({"error": "langgraph.types.Command unavailable"}, 500)
|
|
261
|
+
if "value" in body:
|
|
262
|
+
graph_input = Command(resume=body["value"])
|
|
263
|
+
elif body.get("goto"):
|
|
264
|
+
graph_input = Command(goto=str(body["goto"]), update=body.get("update"))
|
|
265
|
+
else:
|
|
266
|
+
graph_input = None # plain continue (static breakpoint / after state edit)
|
|
267
|
+
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
|
|
268
|
+
session=body.get("_session"),
|
|
269
|
+
tags=(body.get("_tags") or []) + ["resume"])
|
|
270
|
+
configurable = {"thread_id": thread_id}
|
|
271
|
+
if body.get("checkpoint_id"):
|
|
272
|
+
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
273
|
+
config = {"callbacks": [tracer], "configurable": configurable}
|
|
274
|
+
return _stream_execution(graph_input, config, tracer)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.post("/api/threads/{thread_id}/state")
|
|
278
|
+
async def api_thread_update_state(request: Request, thread_id: str):
|
|
279
|
+
"""Human-in-the-loop: edit state at the current (or a given) checkpoint —
|
|
280
|
+
LangGraph's update_state. Follow with …/resume to continue on the edit."""
|
|
281
|
+
if graph is None or getattr(graph, "checkpointer", None) is None:
|
|
282
|
+
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
283
|
+
body = await request.json()
|
|
284
|
+
values = body.get("values")
|
|
285
|
+
if not isinstance(values, dict):
|
|
286
|
+
return JSONResponse({"error": "requires values (object of state keys)"}, 400)
|
|
287
|
+
configurable = {"thread_id": thread_id}
|
|
288
|
+
if body.get("checkpoint_id"):
|
|
289
|
+
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
290
|
+
try:
|
|
291
|
+
new_cfg = graph.update_state({"configurable": configurable}, values,
|
|
292
|
+
as_node=body.get("as_node"))
|
|
293
|
+
return JSONResponse({"ok": True, "checkpoint_id":
|
|
294
|
+
(new_cfg.get("configurable") or {}).get("checkpoint_id")})
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return JSONResponse({"error": str(e)}, 400)
|
|
297
|
+
|
|
298
|
+
|
|
219
299
|
@app.post("/api/ingest")
|
|
220
300
|
async def api_ingest(request: Request):
|
|
221
301
|
ev = await request.json()
|
|
@@ -130,6 +130,58 @@ td.r,th.r{text-align:right}
|
|
|
130
130
|
.empty{padding:56px 20px;text-align:center;color:var(--muted)}
|
|
131
131
|
.empty .t{font-weight:600;color:var(--text2);margin-bottom:4px}
|
|
132
132
|
|
|
133
|
+
/* ---------- human-in-the-loop ---------- */
|
|
134
|
+
async function consumeRunStream(res){
|
|
135
|
+
const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
|
|
136
|
+
while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
|
|
137
|
+
let ps=buf.split('\n\n'); buf=ps.pop();
|
|
138
|
+
for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
|
|
139
|
+
if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
|
|
140
|
+
else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
|
|
141
|
+
else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
|
|
142
|
+
else if(ev==='done'){ toast('Resumed run complete'); }
|
|
143
|
+
else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
|
|
144
|
+
if(VIEW==='runs') loadRuns();
|
|
145
|
+
}
|
|
146
|
+
function parseMaybeJSON(v){ if(v==='')return undefined;
|
|
147
|
+
try{ return JSON.parse(v); }catch(e){ return v; } }
|
|
148
|
+
window.resumeThread=async(tid)=>{
|
|
149
|
+
const raw=document.getElementById('hitl-val').value.trim();
|
|
150
|
+
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
151
|
+
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
152
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
153
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
154
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
155
|
+
await consumeRunStream(res);
|
|
156
|
+
};
|
|
157
|
+
window.resumeGoto=async(tid)=>{
|
|
158
|
+
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
159
|
+
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
160
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
161
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
162
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
163
|
+
await consumeRunStream(res);
|
|
164
|
+
};
|
|
165
|
+
window.rerunFrom=async(tid,cid)=>{
|
|
166
|
+
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
167
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
168
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
169
|
+
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
170
|
+
await consumeRunStream(res);
|
|
171
|
+
};
|
|
172
|
+
window.editState=async(tid,cid,valuesJson)=>{
|
|
173
|
+
const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
|
|
174
|
+
if(cur==null) return;
|
|
175
|
+
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
176
|
+
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
177
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
178
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
179
|
+
const d=await res.json();
|
|
180
|
+
if(!res.ok) return toast(d.error||'update_state failed');
|
|
181
|
+
toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
|
|
182
|
+
showHistory(tid);
|
|
183
|
+
};
|
|
184
|
+
|
|
133
185
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
134
186
|
window.showHistory=async(tid)=>{
|
|
135
187
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
@@ -153,6 +205,10 @@ window.showHistory=async(tid)=>{
|
|
|
153
205
|
<div class="tr-detail" id="${did}">
|
|
154
206
|
${s.writes!=null?kv('writes',s.writes):''}
|
|
155
207
|
${kv('state at this checkpoint',s.values)}
|
|
208
|
+
<div style="display:flex;gap:8px;margin:4px 0 10px">
|
|
209
|
+
<button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
|
|
210
|
+
<button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
|
|
211
|
+
</div>
|
|
156
212
|
</div></div>`;
|
|
157
213
|
}).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
|
|
158
214
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
@@ -297,6 +353,8 @@ textarea:focus{border-color:var(--accent)}
|
|
|
297
353
|
.star.on{color:var(--warn)}
|
|
298
354
|
.tagchip{display:inline-block;background:var(--accent-weak);color:var(--accent-text);font-size:10.5px;font-weight:600;
|
|
299
355
|
padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0;cursor:pointer}
|
|
356
|
+
.pause-chip{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
|
|
357
|
+
.pause-chip.on{background:var(--warn-weak);color:var(--warn);border-style:solid;border-color:var(--warn)}
|
|
300
358
|
.scorechip{display:inline-block;background:var(--ok-weak);color:var(--ok);font-size:10.5px;font-weight:600;
|
|
301
359
|
padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0}
|
|
302
360
|
.pager{display:flex;align-items:center;gap:12px;justify-content:flex-end;margin-top:10px;color:var(--muted);font-size:12.5px}
|
|
@@ -396,7 +454,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
396
454
|
<button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
|
|
397
455
|
<button data-v="stats">${ICON.chart} Stats</button>
|
|
398
456
|
</nav>
|
|
399
|
-
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.
|
|
457
|
+
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.10</span></div>
|
|
400
458
|
</aside>
|
|
401
459
|
|
|
402
460
|
<main>
|
|
@@ -496,6 +554,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
496
554
|
<input id="run-session" placeholder="session (optional)" style="flex:1;min-width:130px">
|
|
497
555
|
<input id="run-tags" placeholder="tags, comma-separated (optional)" style="flex:1;min-width:160px">
|
|
498
556
|
</div>
|
|
557
|
+
<div class="hint" style="margin:8px 0 4px">Pause before (static breakpoints — resume from the run drawer):</div>
|
|
558
|
+
<div id="pause-chips" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
|
499
559
|
<div class="actions">
|
|
500
560
|
<button class="btn ghost" onclick="closeAll()">Cancel</button>
|
|
501
561
|
<button class="btn primary" id="do-run">${ICON.play} Run</button>
|
|
@@ -699,11 +759,17 @@ $$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.datas
|
|
|
699
759
|
|
|
700
760
|
/* ---------- new run ---------- */
|
|
701
761
|
$('#newrun').onclick=async()=>{ let tpl={}; try{tpl=(await(await fetch('/api/schema')).json()).template||{};}catch(e){}
|
|
702
|
-
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
762
|
+
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
763
|
+
const pc=document.getElementById('pause-chips');
|
|
764
|
+
if(pc&&cy) pc.innerHTML=cy.nodes().filter(n=>!n.data('terminal')).map(n=>
|
|
765
|
+
`<span class="tagchip pause-chip" data-n="${esc(n.id())}" onclick="this.classList.toggle('on')">${esc(n.id())}</span>`).join('');
|
|
766
|
+
$('#scrim').classList.add('on'); $('#modal').classList.add('on'); };
|
|
703
767
|
$('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json').value||'{}');}catch(e){return toast('Invalid JSON input');}
|
|
704
768
|
const sess=$('#run-session').value.trim();
|
|
705
769
|
const tags=$('#run-tags').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
706
770
|
if(sess)input._session=sess; if(tags.length)input._tags=tags;
|
|
771
|
+
const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
|
|
772
|
+
if(paused.length) input._interrupt_before=paused;
|
|
707
773
|
closeAll(); switchView('graph'); await runNow(input); };
|
|
708
774
|
|
|
709
775
|
async function runNow(input){
|
|
@@ -822,6 +888,19 @@ async function openRun(id){
|
|
|
822
888
|
<span class="chip node">${esc(s.name)}</span>
|
|
823
889
|
${Object.keys(s.output).map(k=>`<span class="tagchip" style="cursor:default">${esc(k)}</span>`).join('')}
|
|
824
890
|
<span style="color:var(--muted);font-size:11px;margin-left:auto" class="mono">${s.dur_ms!=null?fmtms(s.dur_ms):''}</span></div>`).join('');
|
|
891
|
+
if(r.status==='interrupted'&&r.thread_id){
|
|
892
|
+
const ivs=r.spans.filter(s=>s.type==='interrupt'); const iv=ivs.find(s=>s.output!=null)||ivs.pop();
|
|
893
|
+
html += `<div class="kv" style="margin-top:14px"><div class="k" style="color:var(--warn)">interrupt — awaiting human</div>
|
|
894
|
+
${iv&&iv.output!=null?`<pre>${esc(JSON.stringify(iv.output,null,2))}</pre>`:'<pre>paused at a breakpoint</pre>'}
|
|
895
|
+
<div class="scoreadd" style="margin-top:8px">
|
|
896
|
+
<input id="hitl-val" placeholder='resume value — JSON or text (blank = just continue)' style="flex:1;min-width:200px">
|
|
897
|
+
<button class="btn primary" onclick="resumeThread('${esc(r.thread_id)}')">Resume</button>
|
|
898
|
+
</div>
|
|
899
|
+
<div class="scoreadd">
|
|
900
|
+
<select id="hitl-goto"><option value="">goto node (optional)…</option></select>
|
|
901
|
+
<button class="btn ghost" onclick="resumeGoto('${esc(r.thread_id)}')">Send to node</button>
|
|
902
|
+
</div></div>`;
|
|
903
|
+
}
|
|
825
904
|
html += `<div class="kv" style="margin-top:14px"><div class="k">scores</div></div>
|
|
826
905
|
${(r.scores||[]).map(sc=>`<div class="scorerow"><span class="scorechip">${esc(sc.name)} ${sc.value}</span>
|
|
827
906
|
${sc.comment?`<span style="color:var(--muted);font-size:12px">${esc(sc.comment)}</span>`:''}
|
|
@@ -838,6 +917,9 @@ async function openRun(id){
|
|
|
838
917
|
$('#d-body').innerHTML=html;
|
|
839
918
|
openIds.forEach(i=>{const e=document.getElementById(i); if(e){e.style.display='block';
|
|
840
919
|
const row=e.previousElementSibling; row&&row.querySelector('.caret')?.classList.add('open');}});
|
|
920
|
+
const gsel=document.getElementById('hitl-goto');
|
|
921
|
+
if(gsel&&cy) gsel.innerHTML='<option value="">goto node (optional)…</option>'+
|
|
922
|
+
cy.nodes().filter(n=>!n.data('terminal')).map(n=>`<option>${esc(n.id())}</option>`).join('');
|
|
841
923
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
842
924
|
clearTimeout(window.__liveT);
|
|
843
925
|
if(r.status==='running')
|
|
@@ -906,6 +988,58 @@ window.showSpanSource=async(sid)=>{
|
|
|
906
988
|
:'<pre>no source available for this node (external run, or inspect could not trace it)</pre>';
|
|
907
989
|
};
|
|
908
990
|
|
|
991
|
+
/* ---------- human-in-the-loop ---------- */
|
|
992
|
+
async function consumeRunStream(res){
|
|
993
|
+
const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
|
|
994
|
+
while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
|
|
995
|
+
let ps=buf.split('\n\n'); buf=ps.pop();
|
|
996
|
+
for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
|
|
997
|
+
if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
|
|
998
|
+
else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
|
|
999
|
+
else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
|
|
1000
|
+
else if(ev==='done'){ toast('Resumed run complete'); }
|
|
1001
|
+
else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
|
|
1002
|
+
if(VIEW==='runs') loadRuns();
|
|
1003
|
+
}
|
|
1004
|
+
function parseMaybeJSON(v){ if(v==='')return undefined;
|
|
1005
|
+
try{ return JSON.parse(v); }catch(e){ return v; } }
|
|
1006
|
+
window.resumeThread=async(tid)=>{
|
|
1007
|
+
const raw=document.getElementById('hitl-val').value.trim();
|
|
1008
|
+
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
1009
|
+
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
1010
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1011
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1012
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1013
|
+
await consumeRunStream(res);
|
|
1014
|
+
};
|
|
1015
|
+
window.resumeGoto=async(tid)=>{
|
|
1016
|
+
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
1017
|
+
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
1018
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1019
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
1020
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1021
|
+
await consumeRunStream(res);
|
|
1022
|
+
};
|
|
1023
|
+
window.rerunFrom=async(tid,cid)=>{
|
|
1024
|
+
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
1025
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1026
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
1027
|
+
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
1028
|
+
await consumeRunStream(res);
|
|
1029
|
+
};
|
|
1030
|
+
window.editState=async(tid,cid,valuesJson)=>{
|
|
1031
|
+
const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
|
|
1032
|
+
if(cur==null) return;
|
|
1033
|
+
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
1034
|
+
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
1035
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
1036
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1037
|
+
const d=await res.json();
|
|
1038
|
+
if(!res.ok) return toast(d.error||'update_state failed');
|
|
1039
|
+
toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
|
|
1040
|
+
showHistory(tid);
|
|
1041
|
+
};
|
|
1042
|
+
|
|
909
1043
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
910
1044
|
window.showHistory=async(tid)=>{
|
|
911
1045
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
@@ -929,6 +1063,10 @@ window.showHistory=async(tid)=>{
|
|
|
929
1063
|
<div class="tr-detail" id="${did}">
|
|
930
1064
|
${s.writes!=null?kv('writes',s.writes):''}
|
|
931
1065
|
${kv('state at this checkpoint',s.values)}
|
|
1066
|
+
<div style="display:flex;gap:8px;margin:4px 0 10px">
|
|
1067
|
+
<button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
|
|
1068
|
+
<button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
|
|
1069
|
+
</div>
|
|
932
1070
|
</div></div>`;
|
|
933
1071
|
}).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
|
|
934
1072
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
@@ -137,6 +137,7 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
137
137
|
self._open: dict[str, dict] = {} # langchain run_id -> pending span
|
|
138
138
|
self._span_of: dict[str, str] = {} # langchain run_id -> our span id (for parent links)
|
|
139
139
|
self._seq = 0
|
|
140
|
+
self._interrupted = False
|
|
140
141
|
|
|
141
142
|
# -- infra --
|
|
142
143
|
def _emit(self, ev: dict) -> None:
|
|
@@ -206,8 +207,11 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
206
207
|
# LangGraph human-in-the-loop: a paused graph surfaces __interrupt__
|
|
207
208
|
# in its final output — that's a pause awaiting Command(resume=...),
|
|
208
209
|
# not a completion.
|
|
209
|
-
interrupted =
|
|
210
|
-
|
|
210
|
+
interrupted = self._interrupted or (
|
|
211
|
+
isinstance(outputs, dict) and "__interrupt__" in outputs)
|
|
212
|
+
if interrupted and not self._interrupted:
|
|
213
|
+
# marker not yet emitted by the GraphInterrupt path
|
|
214
|
+
self._interrupted = True
|
|
211
215
|
self._finish_span(
|
|
212
216
|
{"span_id": uuid.uuid4().hex[:12], "type": "interrupt",
|
|
213
217
|
"name": "interrupt", "t": time.time(), "parent": None,
|
|
@@ -219,6 +223,32 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
219
223
|
|
|
220
224
|
def on_chain_error(self, error, *, run_id=None, **kw):
|
|
221
225
|
info = self._open.pop(run_id, None)
|
|
226
|
+
# LangGraph signals a human-in-the-loop pause by raising GraphInterrupt
|
|
227
|
+
# through the node — that's a pause, not a failure.
|
|
228
|
+
if type(error).__name__ in ("GraphInterrupt", "NodeInterrupt"):
|
|
229
|
+
payload = []
|
|
230
|
+
try:
|
|
231
|
+
for i in (error.args[0] if error.args else []):
|
|
232
|
+
payload.append(getattr(i, "value", str(i)))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
if info:
|
|
236
|
+
self._finish_span(info, status="interrupted",
|
|
237
|
+
output=_trunc(payload) if payload else None)
|
|
238
|
+
if not self._interrupted: # one marker span per pause
|
|
239
|
+
self._interrupted = True
|
|
240
|
+
now = time.time()
|
|
241
|
+
self._emit({"kind": "span", "id": uuid.uuid4().hex[:12],
|
|
242
|
+
"run_id": self.run_id, "parent_id": None,
|
|
243
|
+
"seq": self._seq, "type": "interrupt", "name": "interrupt",
|
|
244
|
+
"status": "ok", "started_ms": int(now * 1000),
|
|
245
|
+
"ended_ms": int(now * 1000), "offset_ms": self._rel(now),
|
|
246
|
+
"dur_ms": 0, "input": None,
|
|
247
|
+
"output": _trunc(payload) if payload else None,
|
|
248
|
+
"model": None, "prompt_tokens": None,
|
|
249
|
+
"completion_tokens": None, "cost_usd": None, "error": None})
|
|
250
|
+
self._seq += 1
|
|
251
|
+
return
|
|
222
252
|
err = _err_text(error)
|
|
223
253
|
if info:
|
|
224
254
|
self._finish_span(info, status="error", error=err)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|