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.
Files changed (32) hide show
  1. {windhover-0.9.0 → windhover-0.10.0}/PKG-INFO +6 -3
  2. {windhover-0.9.0 → windhover-0.10.0}/README.md +5 -2
  3. {windhover-0.9.0 → windhover-0.10.0}/pyproject.toml +1 -1
  4. {windhover-0.9.0 → windhover-0.10.0}/tests/test_smoke.py +65 -0
  5. {windhover-0.9.0 → windhover-0.10.0}/windhover/demo_graph.py +9 -1
  6. {windhover-0.9.0 → windhover-0.10.0}/windhover/server.py +99 -19
  7. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/index.html +140 -2
  8. {windhover-0.9.0 → windhover-0.10.0}/windhover/tracer.py +32 -2
  9. {windhover-0.9.0 → windhover-0.10.0}/.github/workflows/ci.yml +0 -0
  10. {windhover-0.9.0 → windhover-0.10.0}/.gitignore +0 -0
  11. {windhover-0.9.0 → windhover-0.10.0}/LICENSE +0 -0
  12. {windhover-0.9.0 → windhover-0.10.0}/SPEC.md +0 -0
  13. {windhover-0.9.0 → windhover-0.10.0}/docs/graph.png +0 -0
  14. {windhover-0.9.0 → windhover-0.10.0}/docs/logo.svg +0 -0
  15. {windhover-0.9.0 → windhover-0.10.0}/docs/runs.png +0 -0
  16. {windhover-0.9.0 → windhover-0.10.0}/docs/social-preview.png +0 -0
  17. {windhover-0.9.0 → windhover-0.10.0}/docs/stats.png +0 -0
  18. {windhover-0.9.0 → windhover-0.10.0}/docs/trace.png +0 -0
  19. {windhover-0.9.0 → windhover-0.10.0}/tests/__init__.py +0 -0
  20. {windhover-0.9.0 → windhover-0.10.0}/windhover/__init__.py +0 -0
  21. {windhover-0.9.0 → windhover-0.10.0}/windhover/config.py +0 -0
  22. {windhover-0.9.0 → windhover-0.10.0}/windhover/extract.py +0 -0
  23. {windhover-0.9.0 → windhover-0.10.0}/windhover/pricing.json +0 -0
  24. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/icon.svg +0 -0
  25. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/manifest.json +0 -0
  26. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
  27. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/cytoscape.min.js +0 -0
  28. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/dagre.min.js +0 -0
  29. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
  30. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
  31. {windhover-0.9.0 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
  32. {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.9.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 aware** — a graph paused on `interrupt()` shows an amber **interrupted**
85
- status plus the payload it's asking a human about.
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 aware** — a graph paused on `interrupt()` shows an amber **interrupted**
66
- status plus the payload it's asking a human about.
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.9.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): time.sleep(.3); return {"n": s["n"] * 3}
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
- @app.post("/api/run")
167
- async def api_run(request: Request):
168
- if graph is None:
169
- return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
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(payload, config=config,
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.9</span></div>
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); $('#scrim').classList.add('on'); $('#modal').classList.add('on'); };
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 = isinstance(outputs, dict) and "__interrupt__" in outputs
210
- if interrupted:
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