n8n 連携: 最終レビューの指摘を修正
- 進行中の判定: ローカルのコマンドを待ちにしない、ツールの結果待ちを進行中にする、 24 時間更新の無いログは進行中にしない - 写しの区間: 同着は古いファイルを残し、写し元の source でも送信済みにする。 送信後に区間が変わったら知らせる - 他のサイトからの API 要求を断る (Host / Origin / Content-Type)。 URL のホストを変えたら合言葉を消す。合言葉は半角の表示文字に限る - n8n との通信の想定外の例外 (送れない文字など) を N8nError にする Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+63
-14
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import csv
|
||||
import errno
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -144,6 +145,7 @@ def new_turn(index, kind, text, ts):
|
||||
"sidechain": 0,
|
||||
"interrupted": False,
|
||||
"models": [],
|
||||
"openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う)
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +229,9 @@ class SessionParser:
|
||||
match = TS_RE.search(raw)
|
||||
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
||||
self.counts["toolResult"] += 1
|
||||
if self.turns and b'"isSidechain":true' not in raw:
|
||||
last = self.turns[-1]
|
||||
last["openTools"] = max(0, last["openTools"] - raw.count(b'"type":"tool_result"'))
|
||||
if ts is not None:
|
||||
self.bump(ts)
|
||||
self.extend_last(ts)
|
||||
@@ -302,6 +307,7 @@ class SessionParser:
|
||||
name = block.get("name") or "?"
|
||||
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
||||
turn["toolCalls"] += 1
|
||||
turn["openTools"] += 1
|
||||
return
|
||||
|
||||
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
||||
@@ -1214,6 +1220,7 @@ N8N_CONFIG = APP_DIR / "n8n.json"
|
||||
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
|
||||
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
|
||||
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
|
||||
N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない
|
||||
|
||||
|
||||
class N8nError(Exception):
|
||||
@@ -1250,11 +1257,14 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
|
||||
groups.append({"start": ts, "end": end, "turns": 1, "last": turn})
|
||||
|
||||
out = []
|
||||
quiet = now - (session.get("mtime") or (groups[-1]["end"] if groups else now))
|
||||
for index, group in enumerate(groups):
|
||||
last = group["last"]
|
||||
waiting = last["firstReplyTs"] is None and not last["interrupted"]
|
||||
recent = now - (session.get("mtime") or group["end"]) < gap
|
||||
in_progress = index == len(groups) - 1 and (waiting or recent)
|
||||
# 応答を待っているのは、人の発言 (prompt / resume) に返事が無いときと、ツールの結果を待っているとき。
|
||||
# ローカルのコマンド (/exit など) には返事が来ないので待ちにしない。
|
||||
no_reply = last["firstReplyTs"] is None and last["kind"] in ("prompt", "resume")
|
||||
waiting = not last["interrupted"] and (no_reply or last.get("openTools", 0) > 0)
|
||||
in_progress = index == len(groups) - 1 and quiet < N8N_STALE_SEC and (waiting or quiet < gap)
|
||||
minutes = (group["end"] - group["start"]) / 60.0
|
||||
if minutes < min_min and not in_progress:
|
||||
continue
|
||||
@@ -1309,17 +1319,26 @@ def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N
|
||||
"projectPath": folder,
|
||||
"title": session["title"],
|
||||
"taskName": default_task_name(folder, session["title"]),
|
||||
"_mtime": session.get("mtime") or 0,
|
||||
}
|
||||
)
|
||||
out.append(block)
|
||||
# 会話を続けると新しいセッションのファイルに前の履歴が同じ時刻のまま写されるので、
|
||||
# 開始時刻 (ミリ秒) が同じ区間は写しとみなし、いちばん後まで続いているものだけを残す。
|
||||
unique = {}
|
||||
# 開始時刻 (ミリ秒) が同じ区間は写しとみなして 1 件にする。いちばん後まで続いているものを残し、
|
||||
# 同着なら古いファイルのものを残す。外した写しの source は aliases に持ち、送信済みの判定に使う。
|
||||
groups = {}
|
||||
for block in out:
|
||||
key = int(round(block["startEpoch"] * 1000))
|
||||
if key not in unique or block["endEpoch"] > unique[key]["endEpoch"]:
|
||||
unique[key] = block
|
||||
return sorted(unique.values(), key=lambda b: b["startEpoch"])
|
||||
groups.setdefault(int(round(block["startEpoch"] * 1000)), []).append(block)
|
||||
result = []
|
||||
for copies in groups.values():
|
||||
copies.sort(key=lambda b: (-b["endEpoch"], b["_mtime"]))
|
||||
keep = copies[0]
|
||||
keep["aliases"] = [b["source"] for b in copies[1:]]
|
||||
keep["aliasSessions"] = [b["sessionId"] for b in copies[1:]]
|
||||
result.append(keep)
|
||||
for block in out:
|
||||
block.pop("_mtime", None)
|
||||
return sorted(result, key=lambda b: b["startEpoch"])
|
||||
|
||||
|
||||
def mark_blocks(blocks, rows, deleted_sources, now=None):
|
||||
@@ -1329,7 +1348,7 @@ def mark_blocks(blocks, rows, deleted_sources, now=None):
|
||||
相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
sent = {r.get("source") for r in rows if r.get("source")}
|
||||
sent = {r.get("source"): r for r in rows if r.get("source")}
|
||||
deleted = set(deleted_sources or ())
|
||||
spans = []
|
||||
for row in rows:
|
||||
@@ -1344,11 +1363,16 @@ def mark_blocks(blocks, rows, deleted_sources, now=None):
|
||||
"task": block.get("taskName") or "",
|
||||
"start": block["start"], "end": block["end"]}))
|
||||
for block in blocks:
|
||||
block["sent"] = block["source"] in sent
|
||||
block["deleted"] = not block["sent"] and block["source"] in deleted
|
||||
own = {block["source"], *block.get("aliases", ())}
|
||||
row = next((sent[s] for s in own if s in sent), None)
|
||||
block["sent"] = row is not None
|
||||
block["deleted"] = not block["sent"] and bool(own & deleted)
|
||||
# 送信後に区間が伸び縮みした (送った時点では終わっていなかった) ことを知らせる
|
||||
row_end = parse_ts(row.get("end_time")) if row else None
|
||||
block["changedSinceSent"] = row_end is not None and abs(row_end - block["endEpoch"]) > 60
|
||||
block["overlaps"] = [
|
||||
info for start, end, info in spans
|
||||
if info["source"] != block["source"] and start < block["endEpoch"] and block["startEpoch"] < end
|
||||
if info["source"] not in own and start < block["endEpoch"] and block["startEpoch"] < end
|
||||
]
|
||||
return blocks
|
||||
|
||||
@@ -1393,6 +1417,9 @@ class N8n:
|
||||
raise N8nError(f"接続できません: {exc.reason}") from None
|
||||
except OSError as exc:
|
||||
raise N8nError(f"接続できません: {exc}") from None
|
||||
except (ValueError, http.client.HTTPException) as exc:
|
||||
# 送れない文字の合言葉 (UnicodeEncodeError) など。文言に合言葉が入りうるので型名だけ出す
|
||||
raise N8nError(f"通信に失敗しました ({type(exc).__name__})") from None
|
||||
try:
|
||||
data = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
@@ -1443,7 +1470,10 @@ class N8nHolder:
|
||||
with self.lock:
|
||||
cfg = dict(self.cfg)
|
||||
if "url" in patch:
|
||||
old_host = urlparse(cfg.get("url") or "").hostname
|
||||
cfg["url"] = str(patch["url"] or "").strip()
|
||||
if urlparse(cfg["url"]).hostname != old_host and not patch.get("token"):
|
||||
cfg.pop("token", None) # 送り先が変わったら、前の合言葉は送らない
|
||||
if "verifySsl" in patch:
|
||||
cfg["verifySsl"] = bool(patch["verifySsl"])
|
||||
for key, (low, high) in self.LIMITS.items():
|
||||
@@ -1458,7 +1488,10 @@ class N8nHolder:
|
||||
if patch.get("clearToken"):
|
||||
cfg.pop("token", None)
|
||||
elif patch.get("token"):
|
||||
cfg["token"] = str(patch["token"]).strip()
|
||||
token = str(patch["token"]).strip()
|
||||
if not all(33 <= ord(c) < 127 for c in token):
|
||||
raise N8nError("合言葉は空白を含まない半角英数字・記号にしてください")
|
||||
cfg["token"] = token
|
||||
self.cfg = cfg
|
||||
self.client = self._build()
|
||||
try:
|
||||
@@ -1660,6 +1693,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
if route.startswith("/api/"):
|
||||
if self.foreign_request():
|
||||
return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403)
|
||||
return self.api(route, query)
|
||||
return self.static(route)
|
||||
except FileNotFoundError as exc:
|
||||
@@ -1669,8 +1704,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
||||
|
||||
LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]")
|
||||
|
||||
def foreign_request(self):
|
||||
"""他のサイトや別名のホスト経由の要求を断る (設定の書き換えと合言葉の持ち出しを防ぐ)。"""
|
||||
host_header = (self.headers.get("Host") or "").lower()
|
||||
if host_header.rsplit(":", 1)[0] not in self.LOCAL_HOSTS:
|
||||
return True
|
||||
origin = self.headers.get("Origin")
|
||||
return bool(origin) and urlparse(origin).netloc.lower() != host_header
|
||||
|
||||
def do_POST(self):
|
||||
route = urlparse(self.path).path
|
||||
if self.foreign_request():
|
||||
return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403)
|
||||
if not (self.headers.get("Content-Type") or "").lower().startswith("application/json"):
|
||||
return self.send_json({"error": "Content-Type は application/json にしてください"}, 415)
|
||||
try:
|
||||
if route.startswith("/api/"):
|
||||
return self.api_post(route, self.read_json())
|
||||
|
||||
Reference in New Issue
Block a user