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:
@@ -149,10 +149,13 @@ n8n の work-tracker(`work_log`)に送れます。手で作業開始・終
|
|||||||
|
|
||||||
- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、
|
- **作業区間**: 同じセッションの中で、応答が終わってから次の発言までの空きが 30 分未満ならつなぎ、
|
||||||
30 分以上で区切ります。5 分未満の区間は出しません(どちらも接続設定で変更可)。
|
30 分以上で区切ります。5 分未満の区間は出しません(どちらも接続設定で変更可)。
|
||||||
まだ応答中・ログ更新から 30 分以内の最後の区間は「進行中」で、送れません。
|
最後の区間は、ログ更新から 30 分以内か、応答やツールの結果を待っている間は「進行中」で、送れません
|
||||||
|
(`/exit` などのローカルのコマンドは待ちに数えず、24 時間更新の無いログは進行中にしません)。
|
||||||
一晩中動かし続けたセッションは 1 つの長い区間になるので、送る前に時間を確かめてください。
|
一晩中動かし続けたセッションは 1 つの長い区間になるので、送る前に時間を確かめてください。
|
||||||
- **続きのセッション**: 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写されます。
|
- **続きのセッション**: 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写されます。
|
||||||
開始時刻が同じ区間は写しとみなし、いちばん後まで続いているものだけを出します。
|
開始時刻が同じ区間は写しとみなし、いちばん後まで続いているものだけを出します。写し元で送った区間も送信済みになります。
|
||||||
|
- **送信後に変化**: 送った時点で区間が終わっていなかった場合(長いツール実行の途中など)に付きます。
|
||||||
|
n8n 側の終了時刻は自動では直さないので、work-tracker の編集画面で直してください。
|
||||||
- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと
|
- **タスク名**: 初期値は `[作業フォルダ名] セッションのタイトル`。「選ぶ」で Redmine のチケットを検索して選ぶと
|
||||||
`[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。
|
`[Redmine のプロジェクト名] #番号 題名` になり、work-tracker の Redmine 工数同期とガントの実績に載ります。
|
||||||
決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります(切り替え可)。
|
決めた名前はセッションごとにブラウザが覚え、同じセッションの他の区間にも入ります(切り替え可)。
|
||||||
@@ -161,6 +164,9 @@ n8n の work-tracker(`work_log`)に送れます。手で作業開始・終
|
|||||||
n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。
|
n8n 側で同じ `source` は追加しません。送信済みの区間は選べません。
|
||||||
ただし 2 つの画面から同時に送ると二重に入ることがあります(確認と追加が一度の処理ではないため)。
|
ただし 2 つの画面から同時に送ると二重に入ることがあります(確認と追加が一度の処理ではないため)。
|
||||||
- **時刻**: 送るときはブラウザの値を使わず、サーバーがログから区間を計算し直します。
|
- **時刻**: 送るときはブラウザの値を使わず、サーバーがログから区間を計算し直します。
|
||||||
|
- **他のサイトからの要求**: API は 127.0.0.1 / localhost 宛てで、同じ画面から来た要求だけを受け付けます
|
||||||
|
(POST は `Content-Type: application/json` 必須)。URL を別のホストに変えると、保存済みの合言葉は消えます。
|
||||||
|
合言葉は空白を含まない半角英数字・記号にしてください。
|
||||||
- **設定**: `n8n.json`(git 管理外。`--n8n-config` で場所を変更可)。タブの「接続設定」からも変えられます。
|
- **設定**: `n8n.json`(git 管理外。`--n8n-config` で場所を変更可)。タブの「接続設定」からも変えられます。
|
||||||
`{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}`
|
`{"url": "https://n8n.yfox.jp", "token": "<合言葉>", "verifySsl": false, "gapMinutes": 30, "minMinutes": 5}`
|
||||||
合言葉は n8n の Header Auth 認証情報(ヘッダー `X-WT-Token`)と同じ値。画面とログには出しません。
|
合言葉は n8n の Header Auth 認証情報(ヘッダー `X-WT-Token`)と同じ値。画面とログには出しません。
|
||||||
|
|||||||
+63
-14
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import errno
|
import errno
|
||||||
|
import http.client
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -144,6 +145,7 @@ def new_turn(index, kind, text, ts):
|
|||||||
"sidechain": 0,
|
"sidechain": 0,
|
||||||
"interrupted": False,
|
"interrupted": False,
|
||||||
"models": [],
|
"models": [],
|
||||||
|
"openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +229,9 @@ class SessionParser:
|
|||||||
match = TS_RE.search(raw)
|
match = TS_RE.search(raw)
|
||||||
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
ts = parse_ts(match.group(1).decode("utf-8")) if match else None
|
||||||
self.counts["toolResult"] += 1
|
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:
|
if ts is not None:
|
||||||
self.bump(ts)
|
self.bump(ts)
|
||||||
self.extend_last(ts)
|
self.extend_last(ts)
|
||||||
@@ -302,6 +307,7 @@ class SessionParser:
|
|||||||
name = block.get("name") or "?"
|
name = block.get("name") or "?"
|
||||||
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
turn["tools"][name] = turn["tools"].get(name, 0) + 1
|
||||||
turn["toolCalls"] += 1
|
turn["toolCalls"] += 1
|
||||||
|
turn["openTools"] += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
# その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。
|
||||||
@@ -1214,6 +1220,7 @@ N8N_CONFIG = APP_DIR / "n8n.json"
|
|||||||
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
|
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
|
||||||
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
|
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
|
||||||
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
|
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
|
||||||
|
N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない
|
||||||
|
|
||||||
|
|
||||||
class N8nError(Exception):
|
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})
|
groups.append({"start": ts, "end": end, "turns": 1, "last": turn})
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
|
quiet = now - (session.get("mtime") or (groups[-1]["end"] if groups else now))
|
||||||
for index, group in enumerate(groups):
|
for index, group in enumerate(groups):
|
||||||
last = group["last"]
|
last = group["last"]
|
||||||
waiting = last["firstReplyTs"] is None and not last["interrupted"]
|
# 応答を待っているのは、人の発言 (prompt / resume) に返事が無いときと、ツールの結果を待っているとき。
|
||||||
recent = now - (session.get("mtime") or group["end"]) < gap
|
# ローカルのコマンド (/exit など) には返事が来ないので待ちにしない。
|
||||||
in_progress = index == len(groups) - 1 and (waiting or recent)
|
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
|
minutes = (group["end"] - group["start"]) / 60.0
|
||||||
if minutes < min_min and not in_progress:
|
if minutes < min_min and not in_progress:
|
||||||
continue
|
continue
|
||||||
@@ -1309,17 +1319,26 @@ def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N
|
|||||||
"projectPath": folder,
|
"projectPath": folder,
|
||||||
"title": session["title"],
|
"title": session["title"],
|
||||||
"taskName": default_task_name(folder, session["title"]),
|
"taskName": default_task_name(folder, session["title"]),
|
||||||
|
"_mtime": session.get("mtime") or 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
out.append(block)
|
out.append(block)
|
||||||
# 会話を続けると新しいセッションのファイルに前の履歴が同じ時刻のまま写されるので、
|
# 会話を続けると新しいセッションのファイルに前の履歴が同じ時刻のまま写されるので、
|
||||||
# 開始時刻 (ミリ秒) が同じ区間は写しとみなし、いちばん後まで続いているものだけを残す。
|
# 開始時刻 (ミリ秒) が同じ区間は写しとみなして 1 件にする。いちばん後まで続いているものを残し、
|
||||||
unique = {}
|
# 同着なら古いファイルのものを残す。外した写しの source は aliases に持ち、送信済みの判定に使う。
|
||||||
|
groups = {}
|
||||||
for block in out:
|
for block in out:
|
||||||
key = int(round(block["startEpoch"] * 1000))
|
groups.setdefault(int(round(block["startEpoch"] * 1000)), []).append(block)
|
||||||
if key not in unique or block["endEpoch"] > unique[key]["endEpoch"]:
|
result = []
|
||||||
unique[key] = block
|
for copies in groups.values():
|
||||||
return sorted(unique.values(), key=lambda b: b["startEpoch"])
|
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):
|
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 は除く。
|
相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。
|
||||||
"""
|
"""
|
||||||
now = time.time() if now is None else now
|
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 ())
|
deleted = set(deleted_sources or ())
|
||||||
spans = []
|
spans = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -1344,11 +1363,16 @@ def mark_blocks(blocks, rows, deleted_sources, now=None):
|
|||||||
"task": block.get("taskName") or "",
|
"task": block.get("taskName") or "",
|
||||||
"start": block["start"], "end": block["end"]}))
|
"start": block["start"], "end": block["end"]}))
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
block["sent"] = block["source"] in sent
|
own = {block["source"], *block.get("aliases", ())}
|
||||||
block["deleted"] = not block["sent"] and block["source"] in deleted
|
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"] = [
|
block["overlaps"] = [
|
||||||
info for start, end, info in spans
|
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
|
return blocks
|
||||||
|
|
||||||
@@ -1393,6 +1417,9 @@ class N8n:
|
|||||||
raise N8nError(f"接続できません: {exc.reason}") from None
|
raise N8nError(f"接続できません: {exc.reason}") from None
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise N8nError(f"接続できません: {exc}") from None
|
raise N8nError(f"接続できません: {exc}") from None
|
||||||
|
except (ValueError, http.client.HTTPException) as exc:
|
||||||
|
# 送れない文字の合言葉 (UnicodeEncodeError) など。文言に合言葉が入りうるので型名だけ出す
|
||||||
|
raise N8nError(f"通信に失敗しました ({type(exc).__name__})") from None
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw or b"{}")
|
data = json.loads(raw or b"{}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -1443,7 +1470,10 @@ class N8nHolder:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
cfg = dict(self.cfg)
|
cfg = dict(self.cfg)
|
||||||
if "url" in patch:
|
if "url" in patch:
|
||||||
|
old_host = urlparse(cfg.get("url") or "").hostname
|
||||||
cfg["url"] = str(patch["url"] or "").strip()
|
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:
|
if "verifySsl" in patch:
|
||||||
cfg["verifySsl"] = bool(patch["verifySsl"])
|
cfg["verifySsl"] = bool(patch["verifySsl"])
|
||||||
for key, (low, high) in self.LIMITS.items():
|
for key, (low, high) in self.LIMITS.items():
|
||||||
@@ -1458,7 +1488,10 @@ class N8nHolder:
|
|||||||
if patch.get("clearToken"):
|
if patch.get("clearToken"):
|
||||||
cfg.pop("token", None)
|
cfg.pop("token", None)
|
||||||
elif patch.get("token"):
|
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.cfg = cfg
|
||||||
self.client = self._build()
|
self.client = self._build()
|
||||||
try:
|
try:
|
||||||
@@ -1660,6 +1693,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
query = parse_qs(parsed.query)
|
query = parse_qs(parsed.query)
|
||||||
try:
|
try:
|
||||||
if route.startswith("/api/"):
|
if route.startswith("/api/"):
|
||||||
|
if self.foreign_request():
|
||||||
|
return self.send_json({"error": "この画面以外からの要求は受け付けません"}, 403)
|
||||||
return self.api(route, query)
|
return self.api(route, query)
|
||||||
return self.static(route)
|
return self.static(route)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
@@ -1669,8 +1704,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
|
||||||
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
|
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):
|
def do_POST(self):
|
||||||
route = urlparse(self.path).path
|
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:
|
try:
|
||||||
if route.startswith("/api/"):
|
if route.startswith("/api/"):
|
||||||
return self.api_post(route, self.read_json())
|
return self.api_post(route, self.read_json())
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ Claude Code での作業を、手で「作業開始・終了」を記録しな
|
|||||||
- ターンの起点は既存の規則どおり (人間の発言だけ。サブエージェント・再開時のメタ行は起点にしない)。
|
- ターンの起点は既存の規則どおり (人間の発言だけ。サブエージェント・再開時のメタ行は起点にしない)。
|
||||||
- `date` は区間の開始時刻のローカル日付 (日本時間)。日をまたぐ区間も開始日の記録。
|
- `date` は区間の開始時刻のローカル日付 (日本時間)。日をまたぐ区間も開始日の記録。
|
||||||
- 30 分と 5 分は設定 (`n8n.json` の `gapMinutes` / `minMinutes`) で変えられる。画面から変更できる。
|
- 30 分と 5 分は設定 (`n8n.json` の `gapMinutes` / `minMinutes`) で変えられる。画面から変更できる。
|
||||||
- セッションの最後の区間で、最後のターンがまだ応答中 (ログの最終更新から `gapMinutes` 経っていない、
|
- セッションの最後の区間は、次のどちらかなら「進行中」とし、送れない (最終レビューで改訂):
|
||||||
または最後のターンに応答完了が無い) のものは「進行中」とし、送れない。
|
- ログの最終更新から `gapMinutes` 経っていない
|
||||||
|
- 最後のターンが応答を待っている: 人の発言 (prompt / resume) に返事が無い、またはツール呼び出しの結果がまだ返っていない
|
||||||
|
(ローカルのコマンド `/exit` などは返事が来ないので待ちにしない。中断されたターンも待ちにしない)
|
||||||
|
ただしログが 24 時間更新されていなければ、待ちのままでも進行中にしない。
|
||||||
|
- 会話を続けると前の履歴が新しいセッションのファイルに同じ時刻のまま写される。開始時刻 (ミリ秒) が同じ区間は写しとみなして
|
||||||
|
1 件にし (長く続いている方、同着なら古いファイル)、外した写しの `source` を送信済みの判定に使う。
|
||||||
|
- 送信済みの区間が、送った後に伸び縮みしていたら「送信後に変化」と知らせる (n8n 側の行は自動では直さない)。
|
||||||
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。同じログからは毎回同じ値になる。
|
- `source` = `chktime:<セッションID>:<区間開始の epoch ミリ秒>`。同じログからは毎回同じ値になる。
|
||||||
|
|
||||||
区間の計算は、HTTP やファイル読み込みから切り離した純粋な関数 (ターンの列と設定値 → 区間の列) にする。
|
区間の計算は、HTTP やファイル読み込みから切り離した純粋な関数 (ターンの列と設定値 → 区間の列) にする。
|
||||||
|
|||||||
@@ -407,5 +407,172 @@ class RedmineSearchTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.client.search("404"), [])
|
self.assertEqual(self.client.search("404"), [])
|
||||||
|
|
||||||
|
|
||||||
|
def write_raw(root, project_dir, sid, lines, mtime):
|
||||||
|
"""jsonl の行 (dict) をそのまま書く。mtime を指定する。"""
|
||||||
|
import os
|
||||||
|
folder = Path(root) / project_dir
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = folder / f"{sid}.jsonl"
|
||||||
|
with path.open("w", encoding="utf-8") as fh:
|
||||||
|
for obj in lines:
|
||||||
|
# 本物のログと同じく区切りの空白なしで書く (tool_result 行の高速判定がこの形を前提にしている)
|
||||||
|
fh.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
os.utime(path, (mtime, mtime))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def ts(minute):
|
||||||
|
return chktime.iso(BASE + minute * MIN)
|
||||||
|
|
||||||
|
|
||||||
|
def user_line(minute, text, sid="p1", sidechain=False):
|
||||||
|
return {"type": "user", "timestamp": ts(minute), "sessionId": sid, "cwd": r"C:\debug\demo",
|
||||||
|
"isSidechain": sidechain, "message": {"role": "user", "content": text}}
|
||||||
|
|
||||||
|
|
||||||
|
def reply_line(minute, sid="p1", tool=None):
|
||||||
|
content = [{"type": "tool_use", "id": "t1", "name": tool, "input": {}}] if tool else [{"type": "text", "text": "ok"}]
|
||||||
|
return {"type": "assistant", "timestamp": ts(minute), "sessionId": sid, "message": {"model": "m", "content": content}}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_result_line(minute, sid="p1"):
|
||||||
|
return {"type": "user", "timestamp": ts(minute), "sessionId": sid,
|
||||||
|
"message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "done"}]}}
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReviewFixTest(unittest.TestCase):
|
||||||
|
"""最終レビューで見つかった問題の再現テスト。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.tmp.name) / "projects"
|
||||||
|
self.store = chktime.Store(self.root)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def blocks(self, now):
|
||||||
|
return chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=now)
|
||||||
|
|
||||||
|
# Important 1: 最後がローカルのコマンド (/exit など) だと永遠に進行中になっていた
|
||||||
|
def test_trailing_local_command_is_not_in_progress(self):
|
||||||
|
write_raw(self.root, "C--debug-demo", "p1",
|
||||||
|
[user_line(0, "作業して"), reply_line(20), user_line(21, "<command-name>/exit</command-name>")],
|
||||||
|
BASE + 21 * MIN)
|
||||||
|
self.assertEqual([b["inProgress"] for b in self.blocks(LATER)], [False])
|
||||||
|
|
||||||
|
def test_prompt_without_reply_long_ago_is_not_in_progress(self):
|
||||||
|
write_raw(self.root, "C--debug-demo", "p1", [user_line(0, "作業して"), reply_line(20), user_line(21, "次")],
|
||||||
|
BASE + 21 * MIN)
|
||||||
|
self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 21 * MIN + 25 * 3600)], [False])
|
||||||
|
self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 21 * MIN + 2 * 3600)], [True])
|
||||||
|
|
||||||
|
# Important 2: ツールの結果を待っている間に 30 分静かだと、途中で切れた区間が送れていた
|
||||||
|
def test_open_tool_call_keeps_block_in_progress(self):
|
||||||
|
write_raw(self.root, "C--debug-demo", "p1", [user_line(0, "調べて"), reply_line(5, tool="Agent")],
|
||||||
|
BASE + 5 * MIN)
|
||||||
|
self.assertEqual([b["inProgress"] for b in self.blocks(BASE + 40 * MIN)], [True])
|
||||||
|
write_raw(self.root, "C--debug-demo", "p1",
|
||||||
|
[user_line(0, "調べて"), reply_line(5, tool="Agent"), tool_result_line(45), reply_line(46)],
|
||||||
|
BASE + 46 * MIN)
|
||||||
|
self.store.clear()
|
||||||
|
self.assertEqual([(b["inProgress"], b["minutes"]) for b in self.blocks(BASE + 100 * MIN)], [(False, 46)])
|
||||||
|
|
||||||
|
def test_sent_block_that_grew_is_flagged(self):
|
||||||
|
block = {"source": "chktime:a:1", "startEpoch": BASE, "endEpoch": BASE + 46 * MIN, "start": ts(0), "end": ts(46),
|
||||||
|
"taskName": "A"}
|
||||||
|
rows = [{"id": 1, "source": "chktime:a:1", "start_time": ts(0), "end_time": ts(5), "task": "A"}]
|
||||||
|
chktime.mark_blocks([block], rows, [], now=LATER)
|
||||||
|
self.assertTrue(block["sent"])
|
||||||
|
self.assertTrue(block["changedSinceSent"])
|
||||||
|
|
||||||
|
# Important 3: 写しの同着で、送信済みの区間が未送信に見えていた
|
||||||
|
def test_copy_with_same_end_keeps_sent_state(self):
|
||||||
|
lines_old = [user_line(0, "設計して", sid="aaa"), reply_line(10, sid="aaa")]
|
||||||
|
lines_new = [user_line(0, "設計して", sid="zzz"), reply_line(10, sid="zzz"), user_line(60, "続き", sid="zzz"),
|
||||||
|
reply_line(70, sid="zzz")]
|
||||||
|
write_raw(self.root, "C--debug-demo", "zzz", lines_new, BASE + 70 * MIN)
|
||||||
|
write_raw(self.root, "C--debug-demo", "aaa", lines_old, BASE + 10 * MIN)
|
||||||
|
blocks = self.blocks(LATER)
|
||||||
|
first = blocks[0]
|
||||||
|
self.assertEqual(len(blocks), 2)
|
||||||
|
sent_source = f"chktime:aaa:{int(round(BASE * 1000))}"
|
||||||
|
self.assertIn(sent_source, [first["source"]] + first["aliases"])
|
||||||
|
rows = [{"id": 1, "source": sent_source, "start_time": ts(0), "end_time": ts(10), "task": "A"}]
|
||||||
|
chktime.mark_blocks(blocks, rows, [], now=LATER)
|
||||||
|
self.assertTrue(first["sent"])
|
||||||
|
self.assertEqual(first["overlaps"], [])
|
||||||
|
|
||||||
|
# Important 5: 送れない文字の合言葉で一覧ごと 500 になっていた
|
||||||
|
def test_non_ascii_token_is_n8n_error_without_token_text(self):
|
||||||
|
client = chktime.N8n("http://127.0.0.1:9", "合言葉です")
|
||||||
|
with self.assertRaises(chktime.N8nError) as ctx:
|
||||||
|
client.rows(date(2026, 1, 1), date(2026, 1, 1))
|
||||||
|
self.assertNotIn("合言葉です", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_holder_rejects_non_ascii_token(self):
|
||||||
|
holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json")
|
||||||
|
with self.assertRaises(chktime.N8nError):
|
||||||
|
holder.update({"token": "合言葉"})
|
||||||
|
|
||||||
|
# Important 4: URL だけ変えると、保存済みの合言葉が別のサーバーへ送られていた
|
||||||
|
def test_changing_url_host_clears_token(self):
|
||||||
|
holder = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json")
|
||||||
|
holder.update({"url": "https://n8n.example", "token": "secret"})
|
||||||
|
self.assertFalse(holder.update({"url": "https://attacker.example"})["hasToken"])
|
||||||
|
holder.update({"url": "https://n8n.example", "token": "secret"})
|
||||||
|
self.assertTrue(holder.update({"url": "https://n8n.example/"})["hasToken"])
|
||||||
|
self.assertTrue(holder.update({"url": "https://other.example", "token": "secret2"})["hasToken"])
|
||||||
|
|
||||||
|
|
||||||
|
class PostGuardTest(unittest.TestCase):
|
||||||
|
"""Important 4: 他のサイトから POST で設定を書き換えられないこと (Content-Type と Origin)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
server = chktime.Server(("127.0.0.1", 0), chktime.Handler)
|
||||||
|
server.store = chktime.Store(Path(self.tmp.name))
|
||||||
|
server.redmine = chktime.RedmineHolder(Path(self.tmp.name) / "redmine.json")
|
||||||
|
server.n8n = chktime.N8nHolder(Path(self.tmp.name) / "n8n.json")
|
||||||
|
server.jobs = chktime.Jobs()
|
||||||
|
self.server = server
|
||||||
|
self.port = server.server_address[1]
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def post(self, body, ctype="application/json", origin=None, host=None):
|
||||||
|
import http.client
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
||||||
|
headers = {"Content-Type": ctype}
|
||||||
|
if origin:
|
||||||
|
headers["Origin"] = origin
|
||||||
|
if host:
|
||||||
|
headers["Host"] = host
|
||||||
|
conn.request("POST", "/api/n8n/config", body=json.dumps(body).encode(), headers=headers)
|
||||||
|
res = conn.getresponse()
|
||||||
|
status = res.status
|
||||||
|
res.read()
|
||||||
|
conn.close()
|
||||||
|
return status
|
||||||
|
|
||||||
|
def test_rejects_simple_cross_site_post(self):
|
||||||
|
self.assertEqual(self.post({"url": "https://attacker.example"}, ctype="text/plain"), 415)
|
||||||
|
self.assertEqual(self.server.n8n.public()["url"], "")
|
||||||
|
|
||||||
|
def test_rejects_foreign_origin_and_host(self):
|
||||||
|
self.assertEqual(self.post({"url": "https://a.example"}, origin="https://evil.example"), 403)
|
||||||
|
self.assertEqual(self.post({"url": "https://a.example"}, host="evil.example"), 403)
|
||||||
|
self.assertEqual(self.server.n8n.public()["url"], "")
|
||||||
|
|
||||||
|
def test_accepts_same_origin_json(self):
|
||||||
|
origin = f"http://127.0.0.1:{self.port}"
|
||||||
|
self.assertEqual(self.post({"url": "https://n8n.example"}, origin=origin), 200)
|
||||||
|
self.assertEqual(self.server.n8n.public()["url"], "https://n8n.example")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+3
-1
@@ -62,7 +62,8 @@ async function loadN8n({ silent = false } = {}) {
|
|||||||
n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source));
|
n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source));
|
||||||
for (const b of n8.data.blocks) {
|
for (const b of n8.data.blocks) {
|
||||||
if (n8.edits.has(b.source)) continue;
|
if (n8.edits.has(b.source)) continue;
|
||||||
const saved = names[b.sessionId] || {};
|
// 続きのセッションに写された区間は、写し元のセッションで覚えた名前も使う
|
||||||
|
const saved = [b.sessionId, ...(b.aliasSessions || [])].map((id) => names[id]).find(Boolean) || {};
|
||||||
n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' });
|
n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' });
|
||||||
}
|
}
|
||||||
renderN8n();
|
renderN8n();
|
||||||
@@ -74,6 +75,7 @@ async function loadN8n({ silent = false } = {}) {
|
|||||||
function n8Status(b) {
|
function n8Status(b) {
|
||||||
const marks = [];
|
const marks = [];
|
||||||
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
|
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
|
||||||
|
if (b.changedSinceSent) marks.push('<span class="n8-mark warn" title="送った時点では区間が終わっていなかった。work_log の終了時刻は work-tracker の編集画面で直す">送信後に変化</span>');
|
||||||
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
||||||
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
||||||
if ((b.overlaps || []).length) {
|
if ((b.overlaps || []).length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user