Files
chktime/chktime.py
T
yoshikazuandClaude Opus 5.5 bcdd469d7a n8n 連携: 後回しにした小さな問題 4 件を修正
- タスク名・チェックの変更で画面全体を作り直さない (直後のクリックが失われていた)
- チケット選択の番号もエスケープする
- 長さ 0 分・24 時間超の区間は送れない理由を付け、画面でも送信でも外す
- items に dict でない項目があっても 500 にせず、その項目のエラーにする

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:20:21 +09:00

1961 lines
81 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""chktime - Claude Code のタスク(セッション)ごとのやりとりの時刻を一覧表示するローカルWebアプリ.
~/.claude/projects/<project>/<sessionId>.jsonl を解析し、
* プロジェクト一覧
* セッション(タスク)一覧 … 開始/終了時刻・所要時間・ターン数
* ターン(ユーザー発言→Claude応答)ごとの時刻・初回応答までの待ち・処理時間・空き時間
を JSON API + ブラウザUI で提供する。標準ライブラリのみで動作する。
python chktime.py # http://127.0.0.1:8787 を開く
python chktime.py --port 9000 --no-browser
"""
from __future__ import annotations
import argparse
import csv
import errno
import http.client
import io
import json
import os
import re
import select
import socket
import ssl
import subprocess
import threading
import time
import urllib.error
import urllib.request
import webbrowser
from datetime import date, datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, quote, urlparse
APP_DIR = Path(__file__).resolve().parent
WEB_DIR = APP_DIR / "web"
TS_RE = re.compile(rb'"timestamp":"([^"]+)"')
SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.S)
TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", re.S)
PROMPT_LIMIT = 4000
SUMMARY_LIMIT = 200
WATCH_POLL = 5.0 # SSE: 切断を見に行く間隔(秒)
PING_INTERVAL = 20.0 # SSE: 接続維持のコメントを送る間隔(秒)
# ---------------------------------------------------------------- 解析ロジック
def parse_ts(value):
"""ISO8601(UTC) 文字列 -> epoch 秒(float)。失敗したら None。"""
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
def iso(epoch):
if epoch is None:
return None
return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def clean_text(text):
"""system-reminder などノイズを落とした本文を返す。"""
text = SYSTEM_REMINDER_RE.sub("", text or "")
return text.strip()
def classify_user(obj):
"""user レコードを人間の発言として解釈する。対象外なら None。
戻り値: (kind, text)
kind: prompt / command / interrupt / resume
"""
if obj.get("isMeta") or obj.get("isSidechain"):
return None
origin = obj.get("origin")
if isinstance(origin, dict) and origin.get("kind") not in (None, "human"):
return None
content = (obj.get("message") or {}).get("content")
extras = []
if isinstance(content, str):
text = content
elif isinstance(content, list):
parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "tool_result":
return None # ツール実行結果 = 人間の発言ではない
if btype == "text":
parts.append(block.get("text") or "")
elif btype in ("image", "document"):
extras.append(btype)
text = "\n".join(parts)
else:
return None
text = clean_text(text)
if text.startswith("[Request interrupted"):
return ("interrupt", text)
if "<local-command-stdout>" in text:
return None
if text.startswith("Caveat: The messages below were generated by the user while running"):
return None
if "<command-name>" in text:
tags = dict(TAG_RE.findall(text))
name = (tags.get("command-name") or "").strip()
args = (tags.get("command-args") or "").strip()
if not name:
return None
return ("command", (name + " " + args).strip())
if text.startswith("This session is being continued from a previous conversation"):
return ("resume", "(前の会話からの継続 / コンパクト後の再開)")
if extras:
text = (text + " " if text else "") + "[" + "+".join(extras) + "]"
if not text:
return None
return ("prompt", text)
def new_turn(index, kind, text, ts):
return {
"i": index,
"kind": kind,
"prompt": text[:PROMPT_LIMIT],
"truncated": len(text) > PROMPT_LIMIT,
"ts": ts,
"firstReplyTs": None,
"endTs": ts,
"assistant": 0,
"toolCalls": 0,
"tools": {},
"sidechain": 0,
"interrupted": False,
"models": [],
"openTools": 0, # 結果がまだ返っていないツール呼び出しの数 (進行中の判定に使う)
}
TAIL_BYTES = 1024 # 追記の続きかを確かめるために覚えておく末尾のバイト数
class SessionParser:
"""1セッションの jsonl を解析する。追記されたぶんだけ読み足せる。
read() は「改行で終わっている行」までしか消費しないので、書きかけの行が
あっても次回の read() で読み直される。ログは追記されるだけという前提だが、
書き換えられた場合は resumable() が False を返すので作り直せばよい。
"""
def __init__(self, path: Path):
self.path = path
self.turns = []
self.titles = {}
self.meta = {}
self.counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0}
self.start = None
self.end = None
self.pos = 0 # 解析済みのバイト位置
self.head = b"" # 先頭 TAIL_BYTES バイト
self.tail = b"" # pos の直前 TAIL_BYTES バイト
# -- 追記の読み取り ------------------------------------------------
def read(self, size=None):
"""未読部分を読んで解析する。前回の続きでなければ False を返す。
追記されるだけのログを前提に、先頭と解析済み末尾のバイト列だけを見て
「続き」かを判定する。ファイル open は Windows では数 ms かかるので、
確認と読み取りは 1 回の open で済ませる。
"""
if size is not None and size < self.pos:
return False # 縮んだ = 書き換えられた
with self.path.open("rb") as fh:
if self.pos:
if fh.read(len(self.head)) != self.head:
return False
fh.seek(self.pos - len(self.tail))
if fh.read(len(self.tail)) != self.tail:
return False
else:
fh.seek(0)
chunk = fh.read()
if not chunk:
return True
lines = chunk.split(b"\n")
partial = lines.pop() # 改行で終わっていなければ書きかけの行
used = len(chunk) - len(partial)
for raw in lines:
self.feed(raw)
if used:
if len(self.head) < TAIL_BYTES:
self.head = (self.head + chunk[:used])[:TAIL_BYTES]
self.tail = (self.tail + chunk[:used])[-TAIL_BYTES:]
self.pos += used
return True
# -- 1行の解析 ----------------------------------------------------
def bump(self, ts):
self.start = ts if self.start is None else min(self.start, ts)
self.end = ts if self.end is None else max(self.end, ts)
def extend_last(self, ts):
"""直前のターンの終端を伸ばす (サブエージェント・ツール結果・中断)。"""
if self.turns and ts is not None:
last = self.turns[-1]
last["endTs"] = max(last["endTs"] or ts, ts)
def feed(self, raw):
raw = raw.strip()
if not raw:
return
# tool_result 行は巨大なので JSON パースせず時刻だけ拾う
if b'"tool_result"' in raw and b'"type":"user"' in raw:
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)
return
try:
obj = json.loads(raw)
except (ValueError, UnicodeDecodeError):
return
rtype = obj.get("type")
if rtype == "custom-title":
self.titles["custom"] = obj.get("customTitle")
return
if rtype == "ai-title":
self.titles["ai"] = obj.get("aiTitle")
return
if rtype == "last-prompt":
self.titles["last"] = obj.get("lastPrompt")
return
ts = parse_ts(obj.get("timestamp"))
if ts is not None:
self.bump(ts)
for key in ("cwd", "gitBranch", "version", "slug", "sessionId"):
if not self.meta.get(key) and obj.get(key):
self.meta[key] = obj[key]
if rtype == "user":
self.counts["user"] += 1
parsed = classify_user(obj)
if not parsed:
if obj.get("isSidechain"):
self.counts["sidechain"] += 1
if self.turns and ts is not None:
self.turns[-1]["sidechain"] += 1
self.extend_last(ts)
return
kind, text = parsed
if kind == "interrupt":
if self.turns:
self.turns[-1]["interrupted"] = True
self.extend_last(ts)
return
self.turns.append(new_turn(len(self.turns) + 1, kind, text, ts))
return
if rtype == "assistant":
if obj.get("isSidechain"):
self.counts["sidechain"] += 1
if self.turns and ts is not None:
self.turns[-1]["sidechain"] += 1
self.extend_last(ts)
return
self.counts["assistant"] += 1
if not self.turns:
return
turn = self.turns[-1]
turn["assistant"] += 1
if ts is not None:
if turn["firstReplyTs"] is None:
turn["firstReplyTs"] = ts
turn["endTs"] = max(turn["endTs"] or ts, ts)
model = (obj.get("message") or {}).get("model")
if model and model not in turn["models"]:
turn["models"].append(model)
content = (obj.get("message") or {}).get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
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) はセッション範囲にのみ反映。
# 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。
# -- 結果 ----------------------------------------------------------
def result(self, stat=None):
"""現在までの解析結果を dict にする。派生値はここで計算し直す。"""
stat = stat or self.path.stat()
turns = self.turns
prev_end = None
active = 0.0
tool_total = 0
for turn in turns:
ts = turn["ts"]
turn["waitMs"] = ms(turn["firstReplyTs"], ts)
turn["durMs"] = ms(turn["endTs"], ts)
turn["idleMs"] = ms(ts, prev_end)
turn["tsIso"] = iso(ts)
turn["endIso"] = iso(turn["endTs"])
turn["summary"] = one_line(turn["prompt"], SUMMARY_LIMIT)
turn["toolList"] = sorted(turn["tools"].items(), key=lambda kv: (-kv[1], kv[0]))
tool_total += turn["toolCalls"]
if turn["durMs"]:
active += turn["durMs"]
prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end
titles = self.titles
first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "")
title = (
titles.get("custom")
or titles.get("ai")
or one_line(first_prompt, 120)
or titles.get("last")
or self.meta.get("slug")
or self.path.stem
)
return {
"id": self.path.stem,
"file": str(self.path),
"title": title,
"titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"),
"cwd": self.meta.get("cwd"),
"gitBranch": self.meta.get("gitBranch"),
"version": self.meta.get("version"),
"slug": self.meta.get("slug"),
"start": iso(self.start),
"end": iso(self.end),
"startEpoch": self.start,
"endEpoch": self.end,
"wallMs": ms(self.end, self.start),
"activeMs": int(active) if active else 0,
"turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")),
"userMsgs": self.counts["user"],
"assistantMsgs": self.counts["assistant"],
"toolResults": self.counts["toolResult"],
"sidechainMsgs": self.counts["sidechain"],
"toolCalls": tool_total,
"bytes": stat.st_size,
"mtime": stat.st_mtime,
"turns": turns,
}
def parse_file(path: Path):
"""1セッションの jsonl を頭から解析して dict を返す。"""
parser = SessionParser(path)
parser.read()
return parser.result()
def ms(later, earlier):
if later is None or earlier is None:
return None
return int(round((later - earlier) * 1000))
def one_line(text, limit):
text = " ".join((text or "").split())
return text[:limit] + ("…" if len(text) > limit else "")
def summary_of(session):
out = {k: v for k, v in session.items() if k != "turns"}
turns = session["turns"]
out["firstPrompt"] = one_line(next((t["prompt"] for t in turns if t["kind"] == "prompt"), ""), SUMMARY_LIMIT)
out["lastPrompt"] = one_line(turns[-1]["prompt"] if turns else "", SUMMARY_LIMIT)
models = []
for turn in turns:
for model in turn["models"]:
if model not in models:
models.append(model)
out["models"] = models
return out
# ---------------------------------------------------------------- ストア
class Entry:
"""1ファイルぶんのキャッシュ。パーサを持ち回して追記分だけ読み足す。"""
__slots__ = ("lock", "parser", "token", "data")
def __init__(self, path: Path):
self.lock = threading.Lock() # 同じファイルを同時に解析しない
self.parser = SessionParser(path)
self.token = None # (mtime, size)
self.data = None
class Store:
"""jsonl の解析結果を (mtime, size) でキャッシュする。
変化したファイルは、末尾に追記されただけなら追加分だけを解析する。
書き換えられていた場合 (縮んだ / 途中が変わった) は頭から読み直す。
"""
def __init__(self, root: Path):
self.root = root
self._cache = {}
self._lock = threading.Lock()
def projects(self):
items = []
if not self.root.is_dir():
return items
for entry in sorted(self.root.iterdir()):
if not entry.is_dir():
continue
files = list(entry.glob("*.jsonl"))
if not files:
continue
stats = [f.stat() for f in files]
newest = max(files, key=lambda f: f.stat().st_mtime)
items.append(
{
"id": entry.name,
"path": sniff_cwd(newest) or decode_project_dir(entry.name),
"sessions": len(files),
"bytes": sum(s.st_size for s in stats),
"lastActivity": iso(max(s.st_mtime for s in stats)),
"lastEpoch": max(s.st_mtime for s in stats),
}
)
items.sort(key=lambda p: p["lastEpoch"], reverse=True)
return items
def session_files(self, project_id):
directory = self.root / project_id
if not directory.is_dir() or os.path.sep in project_id or project_id in ("..", "."):
raise FileNotFoundError(project_id)
return sorted(directory.glob("*.jsonl"))
def session(self, path: Path):
key = str(path)
with self._lock:
entry = self._cache.get(key)
if entry is None:
entry = self._cache[key] = Entry(path)
with entry.lock:
stat = path.stat()
token = (stat.st_mtime, stat.st_size)
if entry.token == token and entry.data is not None:
return entry.data
if not entry.parser.read(stat.st_size):
entry.parser = SessionParser(path) # 書き換えられていた: 頭から
entry.parser.read()
entry.data = entry.parser.result(stat)
entry.token = token
return entry.data
def sessions(self, project_id):
out = [summary_of(self.session(p)) for p in self.session_files(project_id)]
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
return out
def one(self, project_id, session_id):
for path in self.session_files(project_id):
if path.stem == session_id:
return self.session(path)
raise FileNotFoundError(session_id)
def all_sessions(self):
"""全プロジェクトのタスクを1つのリストにまとめる (開始が新しい順)。"""
out = []
for project in self.projects():
for path in self.session_files(project["id"]):
summary = summary_of(self.session(path))
summary["project"] = project["id"]
summary["projectPath"] = summary.get("cwd") or project["path"]
out.append(summary)
out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True)
return out
def daily_stats(self, project_id=None, days=30, start=None, end=None, exclude=()):
"""ターンをローカル日付で集計する。project_id=None なら全プロジェクト。
期間は start/end (date) を渡せばその範囲、無ければ days>0 で「今日から
遡って days 日」、days<=0 なら全期間。exclude に入れたプロジェクトは
集計に含めない (横断モード用)。
活動のない日も 0 の行として埋めるので、そのまま横軸に使える。
"""
paths = {p["id"]: p["path"] for p in self.projects()}
if project_id:
if project_id not in paths:
raise FileNotFoundError(project_id)
targets = [project_id]
else:
targets = [pid for pid in paths if pid not in exclude]
today = datetime.now().date()
if start or end:
lo, hi = start, end or today
if lo and hi < lo:
lo, hi = hi, lo
else:
lo = today - timedelta(days=days - 1) if days and days > 0 else None
hi = None # 上限なし (今日 かデータの末尾まで)
per_day = {}
per_project = {}
seen = set()
for pid in targets:
for path in self.session_files(pid):
session = self.session(path)
for turn in session["turns"]:
if turn["ts"] is None:
continue
day = datetime.fromtimestamp(turn["ts"]).date()
if (lo and day < lo) or (hi and day > hi):
continue
dur = turn["durMs"] or 0
counted = 1 if turn["kind"] in ("prompt", "command") else 0
bucket = per_day.setdefault(day, {"activeMs": 0, "turns": 0, "sessions": set(), "byProject": {}})
bucket["activeMs"] += dur
bucket["turns"] += counted
bucket["sessions"].add(session["id"])
bucket["byProject"][pid] = bucket["byProject"].get(pid, 0) + dur
total = per_project.setdefault(pid, {"activeMs": 0, "turns": 0, "sessions": set()})
total["activeMs"] += dur
total["turns"] += counted
total["sessions"].add(session["id"])
seen.add(session["id"])
if per_day:
first = lo or min(per_day)
last = hi or max(max(per_day), today)
elif lo or hi:
first, last = (lo or hi), (hi or today) # データなしでも指定範囲は 0 で埋める
else:
first = last = None
rows = []
cur = first
while cur and cur <= last:
bucket = per_day.get(cur)
rows.append(
{
"date": cur.isoformat(),
"dow": cur.weekday(),
"activeMs": bucket["activeMs"] if bucket else 0,
"turns": bucket["turns"] if bucket else 0,
"sessions": len(bucket["sessions"]) if bucket else 0,
"byProject": bucket["byProject"] if bucket else {},
}
)
cur += timedelta(days=1)
projects = sorted(
(
{
"id": pid,
"path": paths.get(pid, pid),
"activeMs": v["activeMs"],
"turns": v["turns"],
"sessions": len(v["sessions"]),
}
for pid, v in per_project.items()
),
key=lambda p: -p["activeMs"],
)
busiest = max(rows, key=lambda r: r["activeMs"], default=None)
return {
"scope": project_id or "",
"rangeDays": 0 if (start or end) else (days if days and days > 0 else 0),
"custom": bool(start or end),
"from": first.isoformat() if first else None,
"to": last.isoformat() if last else None,
"excluded": sorted(exclude),
"days": rows,
"projects": projects,
"totals": {
"activeMs": sum(r["activeMs"] for r in rows),
"turns": sum(r["turns"] for r in rows),
"sessions": len(seen),
"activeDays": sum(1 for r in rows if r["activeMs"] or r["turns"]),
"busiestDate": busiest["date"] if busiest and busiest["activeMs"] else None,
"busiestMs": busiest["activeMs"] if busiest else 0,
},
}
def timeline(self, project_id=None, days=7):
"""ガント表示用。セッションを1本のバーとして、内側に応答中の区間を返す。
spans は各ターンの [発言時刻, 応答完了] を、隙間 1 秒以内なら繋げたもの。
表示範囲より前から続いているセッションは、全体時間・往復数・ツール呼び出しも
範囲内に切り詰めて返す(作業時間だけ切り詰めると数字が食い違うため)。
clipped が真なら切り詰めたもの。
"""
paths = {p["id"]: p["path"] for p in self.projects()}
if project_id:
if project_id not in paths:
raise FileNotFoundError(project_id)
targets = [project_id]
else:
targets = list(paths)
now = datetime.now().timestamp()
t0 = None
if days and days > 0:
midnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
t0 = (midnight - timedelta(days=days - 1)).timestamp()
rows = []
per_project = {}
for pid in targets:
for path in self.session_files(pid):
session = self.session(path)
start, end = session["startEpoch"], session["endEpoch"]
if start is None:
continue
if t0 is not None and (end if end is not None else start) < t0:
continue
spans = []
active = 0
turn_count = 0
tool_calls = 0
for turn in session["turns"]:
a = turn["ts"]
if a is None:
continue
b = turn["endTs"] if turn["endTs"] is not None else a
if t0 is not None and b < t0:
continue
active += turn["durMs"] or 0
turn_count += 1
tool_calls += turn["toolCalls"]
if spans and a - spans[-1][1] <= 1:
spans[-1][1] = max(spans[-1][1], b)
else:
spans.append([a, b])
clipped = t0 is not None and start < t0
if clipped:
# バーが左端で切れているぶん、全体時間も見えている範囲だけにする
wall = ms(end if end is not None else start, t0)
else:
wall = session["wallMs"]
turn_count = session["turnCount"]
tool_calls = session["toolCalls"]
rows.append(
{
"id": session["id"],
"project": pid,
"projectPath": session.get("cwd") or paths.get(pid, pid),
"title": session["title"],
"start": session["start"],
"end": session["end"],
"startEpoch": round(start, 1),
"endEpoch": round(end if end is not None else start, 1),
"wallMs": wall,
"activeMs": active,
"turnCount": turn_count,
"toolCalls": tool_calls,
"clipped": clipped,
"spans": [[round(a, 1), round(b, 1)] for a, b in spans],
}
)
total = per_project.setdefault(pid, {"activeMs": 0, "sessions": 0})
total["activeMs"] += active
total["sessions"] += 1
rows.sort(key=lambda s: s["startEpoch"])
if t0 is None:
t0 = min((s["startEpoch"] for s in rows), default=now)
t1 = max([now] + [s["endEpoch"] for s in rows])
projects = sorted(
(
{"id": pid, "path": paths.get(pid, pid), "activeMs": v["activeMs"], "sessions": v["sessions"]}
for pid, v in per_project.items()
),
key=lambda p: -p["activeMs"],
)
return {
"scope": project_id or "",
"rangeDays": days if days and days > 0 else 0,
"from": round(t0, 1),
"to": round(t1, 1),
"now": round(now, 1),
"sessions": rows,
"projects": projects,
}
def clear(self):
with self._lock:
self._cache.clear()
class Watcher(threading.Thread):
"""projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。
ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて
待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で
キャッシュしているので、ここでは通知だけを担う。
"""
def __init__(self, root: Path, interval=2.0):
super().__init__(daemon=True)
self.root = root
self.interval = max(0.5, interval)
self.rev = 0
self.newest = None # (project_id, session_id)
self._cv = threading.Condition()
self._token, self.newest = self._scan()
def _scan(self):
files = 0
total = 0
newest_mtime = 0.0
newest = None
if self.root.is_dir():
for entry in self.root.iterdir():
if not entry.is_dir():
continue
for path in entry.glob("*.jsonl"):
try:
stat = path.stat()
except OSError:
continue
files += 1
total += stat.st_size
if stat.st_mtime > newest_mtime:
newest_mtime = stat.st_mtime
newest = (entry.name, path.stem)
return (files, total, round(newest_mtime, 3)), newest
def run(self):
while True:
time.sleep(self.interval)
try:
token, newest = self._scan()
except OSError:
continue
with self._cv:
if token != self._token:
self._token = token
self.newest = newest
self.rev += 1
self._cv.notify_all()
def wait_change(self, rev, timeout):
"""rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。"""
with self._cv:
if self.rev != rev:
return self.rev
self._cv.wait(timeout)
return self.rev
def info(self):
with self._cv:
project, session = self.newest or (None, None)
return {"rev": self.rev, "project": project, "session": session, "interval": self.interval}
CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
def sniff_cwd(path: Path, max_lines=200):
"""jsonl の先頭から cwd を拾う (プロジェクトの実パス表示用)。"""
try:
with path.open("rb") as fh:
for _, raw in zip(range(max_lines), fh):
match = CWD_RE.search(raw)
if match:
return json.loads(b'"' + match.group(1) + b'"')
except OSError:
pass
return None
def decode_project_dir(name):
"""'C--debug-ImgChkSystem' -> 'C:\\debug\\ImgChkSystem' (推定)。"""
if len(name) > 3 and name[1:3] == "--" and name[0].isalpha():
return name[0].upper() + ":\\" + name[3:].replace("-", "\\")
return name.replace("-", "/")
# ---------------------------------------------------------------- Redmine 連携
REDMINE_CONFIG = APP_DIR / "redmine.json"
REDMINE_INTERVAL = 7.0 # 連続で叩くと 403 を返すサーバがあるので、呼び出しの間隔をあける
TURN_LIMIT = 60 # チケット本文に載せるやりとりの上限
COMMIT_LIMIT = 80 # チケット本文に載せるコミットの上限
GIT_SEP = "\x1f"
GIT_FMT = GIT_SEP.join(["%H", "%h", "%ad", "%an", "%s"])
COMMIT_TYPE_RE = re.compile(r"^(\w+)(?:\([^)]*\))?!?:\s")
TYPE_LABEL = {
"feat": "機能追加", "fix": "不具合修正", "docs": "書類", "test": "テスト",
"perf": "高速化", "refactor": "整理", "build": "ビルド", "chore": "雑務",
"release": "リリース", "style": "整形", "ci": "CI", "legal": "法務", "wip": "作業中",
}
class RedmineError(Exception):
"""Redmine 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def fmt_local(epoch, time_only=False):
if epoch is None:
return "–"
stamp = datetime.fromtimestamp(epoch)
return stamp.strftime("%H:%M:%S") if time_only else stamp.strftime("%Y-%m-%d %H:%M")
def local_date(epoch):
return datetime.fromtimestamp(epoch).date().isoformat() if epoch else None
class Redmine:
"""Redmine の REST API を叩く最小のクライアント (標準ライブラリのみ)。
社内 Redmine は自己署名証明書のことが多いので、verify=False なら検証を外す。
また連続アクセスに 403 を返す設定のサーバがあるため、呼び出しの間隔をあける。
"""
def __init__(self, url=None, key=None, verify=False, interval=REDMINE_INTERVAL):
self.url = (url or "").rstrip("/")
self.key = key or ""
self.interval = max(0.0, float(interval or 0))
self.ctx = None
if not verify:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
self._last = 0.0
self._lock = threading.Lock()
def throttle(self):
"""前回の呼び出しから interval 秒あくまで待つ。"""
with self._lock:
gap = self.interval - (time.monotonic() - self._last)
if gap > 0:
time.sleep(gap)
self._last = time.monotonic()
def call(self, path, method="GET", payload=None):
if not self.url:
raise RedmineError("Redmine の URL が未設定です")
if not self.key:
raise RedmineError("API アクセスキーが未設定です")
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req = urllib.request.Request(self.url + path, data=body, method=method)
req.add_header("X-Redmine-API-Key", self.key)
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
self.throttle()
try:
with urllib.request.urlopen(req, timeout=30, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
raise RedmineError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc:
raise RedmineError(f"接続できません: {exc.reason}") from None
except OSError as exc:
raise RedmineError(f"接続できません: {exc}") from None
if not raw:
return {}
try:
return json.loads(raw)
except ValueError:
raise RedmineError("JSON として読めない応答が返りました") from None
def projects(self):
out = []
offset = 0
while True:
data = self.call(f"/projects.json?limit=100&offset={offset}")
items = data.get("projects") or []
out += [{"id": p.get("id"), "identifier": p.get("identifier"), "name": p.get("name")} for p in items]
offset += len(items)
if not items or offset >= (data.get("total_count") or 0):
return out
def enums(self):
"""トラッカー / ステータス / 自分の情報。起票フォームの選択肢に使う。"""
trackers = self.call("/trackers.json").get("trackers") or []
statuses = self.call("/issue_statuses.json").get("issue_statuses") or []
me = (self.call("/users/current.json").get("user")) or {}
return {
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers],
"statuses": [{"id": s.get("id"), "name": s.get("name"), "closed": bool(s.get("is_closed"))} for s in statuses],
"user": {"id": me.get("id"), "login": me.get("login"),
"name": " ".join(filter(None, [me.get("lastname"), me.get("firstname")])).strip()},
}
def create(self, fields):
"""チケットを作る。
Redmine は作成時の status_id を既定値で上書きするので、指定があれば
作成後に PUT で付け直す。
"""
fields = dict(fields)
status = fields.pop("status_id", None)
data = self.call("/issues.json", "POST", {"issue": fields})
issue_id = (data.get("issue") or {}).get("id")
if issue_id and status:
self.call(f"/issues/{issue_id}.json", "PUT", {"issue": {"status_id": status}})
return issue_id
def search(self, text, limit=30):
"""未完了チケットを件名で探す (n8n へ送るときのチケット選択用)。数字だけなら番号で引く。"""
text = (text or "").strip()
if text.isdigit():
try:
issue = self.call(f"/issues/{text}.json").get("issue")
except RedmineError:
issue = None
items = [issue] if issue else []
else:
path = f"/issues.json?status_id=open&sort=updated_on:desc&limit={int(limit)}"
if text:
path += "&f[]=subject&op[subject]=~&v[subject][]=" + quote(text)
items = self.call(path).get("issues") or []
return [{"id": i.get("id"), "subject": i.get("subject") or "",
"project": (i.get("project") or {}).get("name") or ""} for i in items]
def load_redmine_config(path):
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
class RedmineHolder:
"""設定ファイルと、そこから作ったクライアントを保持する。
呼び出し間隔の記憶をなくさないよう、設定が変わったときだけ作り直す。
API キーは UI へ返さない (有無だけ返す)。
"""
def __init__(self, path):
self.path = Path(path)
self.lock = threading.Lock()
self.cfg = load_redmine_config(self.path)
self.client = self._build()
def _build(self):
return Redmine(self.cfg.get("url"), self.cfg.get("key"),
self.cfg.get("verifySsl", False), self.cfg.get("interval", REDMINE_INTERVAL))
def public(self):
with self.lock:
cfg = self.cfg
return {
"url": cfg.get("url", ""),
"hasKey": bool(cfg.get("key")),
"verifySsl": bool(cfg.get("verifySsl", False)),
"interval": cfg.get("interval", REDMINE_INTERVAL),
"projectId": cfg.get("projectId"),
"trackerId": cfg.get("trackerId"),
"statusId": cfg.get("statusId"),
"assignedToId": cfg.get("assignedToId"),
"configPath": str(self.path),
}
def update(self, patch):
"""URL・キー・既定値を保存する。key が空文字なら既存のキーを残す。"""
with self.lock:
cfg = dict(self.cfg)
for key in ("url", "verifySsl", "interval", "projectId", "trackerId", "statusId", "assignedToId"):
if key in patch:
cfg[key] = patch[key]
if patch.get("clearKey"):
cfg.pop("key", None)
elif patch.get("key"):
cfg["key"] = str(patch["key"]).strip()
self.cfg = cfg
self.client = self._build()
try:
self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as exc:
raise RedmineError(f"設定を保存できません: {exc}") from None
return self.public()
class Jobs:
"""起票をバックグラウンドで流し、UI からは進捗だけ見えるようにする。
1件ごとに間隔をあけるので数十件で数分かかる。HTTP を掴んだままにしないため、
開始だけ返して /api/redmine/job で進捗を取りに来てもらう。
"""
def __init__(self):
self._jobs = {}
self._lock = threading.Lock()
self._seq = 0
def start(self, client, drafts):
with self._lock:
self._seq += 1
job = {"id": f"j{self._seq}", "total": len(drafts), "done": 0, "state": "running", "results": []}
self._jobs[job["id"]] = job
threading.Thread(target=self._run, args=(job, client, drafts), daemon=True).start()
return job
def _run(self, job, client, drafts):
for draft in drafts:
subject = draft.get("subject") or "(件名なし)"
try:
issue_id = client.create(draft)
job["results"].append({"subject": subject, "issueId": issue_id, "ok": True})
except RedmineError as exc:
job["results"].append({"subject": subject, "error": str(exc), "ok": False})
job["done"] += 1
job["state"] = "done"
def get(self, job_id):
with self._lock:
job = self._jobs.get(job_id)
if job is None:
raise FileNotFoundError(job_id)
return job
# -- 下書きの組み立て -------------------------------------------------
def issue_from_session(session, project_path=None):
"""chktime のタスク 1 件をチケットの下書きにする。"""
start, end = session.get("startEpoch"), session.get("endEpoch")
head = [
f"* 期間: {fmt_local(start)} → {fmt_local(end)}",
f"* 全体: {fmt_dur(session['wallMs'])} / 作業時間: {fmt_dur(session['activeMs'])}",
f"* 往復: {session['turnCount']} · 応答メッセージ: {session['assistantMsgs']} · ツール呼び出し: {session['toolCalls']}",
]
if project_path:
head.append(f"* 作業ディレクトリ: {project_path}")
if session.get("gitBranch"):
head.append(f"* ブランチ: {session['gitBranch']}")
turns = [t for t in session.get("turns", []) if t.get("kind") in ("prompt", "command")]
rows = ["h3. やりとり", ""]
for turn in turns[:TURN_LIMIT]:
rows.append(f"* {fmt_local(turn['ts'], True)} #{turn['i']} "
f"{one_line(turn['prompt'], 120)} — {fmt_dur(turn['durMs'])}")
if len(turns) > TURN_LIMIT:
rows.append(f"* … 他 {len(turns) - TURN_LIMIT} 件")
if not turns:
rows.append("* (記録なし)")
return {
"subject": one_line(session.get("title") or session["id"], 240),
"description": "\n".join(head + [""] + rows + ["", "---", f"chktime から起票(タスク {session['id']})"]),
"start_date": local_date(start),
"due_date": local_date(end),
"source": {"kind": "task", "id": session["id"]},
}
def git_log(repo, since=None, until=None, limit=2000):
"""git 履歴を読む。リポジトリでなければ RedmineError。"""
path = Path(repo).expanduser()
if not path.is_dir():
raise RedmineError(f"フォルダが見つかりません: {repo}")
cmd = ["git", "-C", str(path), "log", "--no-merges", "--date=short", f"--pretty=format:{GIT_FMT}", f"-n{limit}"]
if since:
cmd.append(f"--since={since} 00:00:00")
if until:
cmd.append(f"--until={until} 23:59:59")
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except FileNotFoundError:
raise RedmineError("git が見つかりません (PATH を確認してください)") from None
except (OSError, subprocess.SubprocessError) as exc:
raise RedmineError(f"git を実行できません: {exc}") from None
if proc.returncode:
detail = one_line(proc.stderr.decode("utf-8", "replace"), 200)
raise RedmineError(detail or f"git log に失敗しました: {path}")
out = []
for line in proc.stdout.decode("utf-8", "replace").splitlines():
parts = line.split(GIT_SEP)
if len(parts) == 5:
out.append({"hash": parts[0], "short": parts[1], "date": parts[2], "author": parts[3], "subject": parts[4]})
out.reverse() # 古い順
return out
def commit_type(subject):
match = COMMIT_TYPE_RE.match(subject or "")
return match.group(1).lower() if match else ""
def group_commits(commits, mode):
"""コミットを起票の単位にまとめる。mode: day=日ごと / type=日×種別 / all=一括。
分類は機械的に行う (件名の prefix と日付だけを見る)。
"""
groups = {}
order = []
for commit in commits:
if mode == "all":
key = ("all",)
elif mode == "type":
key = (commit["date"], commit_type(commit["subject"]))
else:
key = (commit["date"],)
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(commit)
out = []
for key in order:
items = groups[key]
dates = [c["date"] for c in items]
start, due = min(dates), max(dates)
if mode == "all":
span = start if start == due else f"{start} 〜 {due}"
subject = f"{span} の作業({len(items)} コミット)"
elif mode == "type":
kind = key[1]
label = TYPE_LABEL.get(kind, kind or "その他")
subject = f"{key[0]} {label}({len(items)} コミット)"
else:
subject = f"{key[0]} の作業({len(items)} コミット)"
out.append({"subject": subject, "start": start, "due": due, "commits": items})
return out
def issue_from_commits(group, repo):
"""コミットのまとまり 1 つをチケットの下書きにする。"""
items = group["commits"]
rows = ["h3. 対応したコミット", ""]
for commit in items[:COMMIT_LIMIT]:
rows.append(f"* {commit['date']} {commit['short']} {commit['subject']}")
if len(items) > COMMIT_LIMIT:
rows.append(f"* … 他 {len(items) - COMMIT_LIMIT} 件")
return {
"subject": one_line(group["subject"], 240),
"description": "\n".join(rows + ["", "---", f"git 履歴から起票(リポジトリ: {repo})"]),
"start_date": group["start"],
"due_date": group["due"],
"source": {"kind": "git", "id": items[0]["short"], "count": len(items)},
}
def build_drafts(store, payload):
"""UI から来た指定で下書きの一覧を作る。source: tasks / git。"""
source = payload.get("source")
if source == "tasks":
project = payload.get("project") or ""
ids = payload.get("ids") or []
if not ids:
raise RedmineError("タスクが選ばれていません")
paths = {p["id"]: p["path"] for p in store.projects()}
drafts = []
for item in ids:
pid, sid = (item.get("project") or project, item.get("id")) if isinstance(item, dict) else (project, item)
session = store.one(pid, sid)
drafts.append(issue_from_session(session, session.get("cwd") or paths.get(pid)))
return drafts
if source == "git":
repo = payload.get("repo") or ""
commits = git_log(repo, payload.get("from"), payload.get("to"))
if not commits:
raise RedmineError("その期間のコミットがありません")
return [issue_from_commits(g, repo) for g in group_commits(commits, payload.get("mode") or "day")]
raise RedmineError(f"不明な取得元です: {source}")
def to_fields(draft, defaults):
"""下書き + 既定値を Redmine の issue フィールドにする。"""
fields = {
"project_id": defaults.get("projectId"),
"subject": draft.get("subject") or "(件名なし)",
"description": draft.get("description") or "",
}
if not fields["project_id"]:
raise RedmineError("起票先のプロジェクトが選ばれていません")
for key, name in (("trackerId", "tracker_id"), ("statusId", "status_id"), ("assignedToId", "assigned_to_id")):
if defaults.get(key):
fields[name] = defaults[key]
for key in ("start_date", "due_date"):
if draft.get(key):
fields[key] = draft[key]
if defaults.get("doneRatio") is not None:
fields["done_ratio"] = defaults["doneRatio"]
return fields
# ---------------------------------------------------------------- n8n 連携 (work_log への取り込み)
N8N_CONFIG = APP_DIR / "n8n.json"
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
N8N_MAX_BLOCK_SEC = 24 * 3600 # n8n の wt-import が受け付ける区間の長さの上限
N8N_STALE_SEC = 24 * 3600 # ログがこれだけ更新されていなければ、待ちのままでも進行中とみなさない
class N8nError(Exception):
"""n8n 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def block_source(session_id, start_epoch):
"""作業区間の識別子。同じログからは毎回同じ値になる (二重登録を防ぐ鍵)。"""
return f"chktime:{session_id}:{int(round(start_epoch * 1000))}"
def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"""1 セッションのターンを作業区間に分ける。
前のターンの応答完了から次の発言までの空きが gap_min 分未満ならつなぎ、
gap_min 分以上なら区間を切る。min_min 分未満の区間は捨てる (進行中は残す)。
最後の区間は、応答が返っていない / ログの更新から gap_min 分経っていない
なら進行中 (送れない) とする。
"""
now = time.time() if now is None else now
gap = gap_min * 60.0
groups = []
for turn in session["turns"]:
ts = turn["ts"]
if ts is None:
continue
end = turn["endTs"] if turn["endTs"] is not None else ts
if groups and ts - groups[-1]["end"] < gap:
group = groups[-1]
group["end"] = max(group["end"], end)
group["turns"] += 1
group["last"] = turn
else:
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"]
# 応答を待っているのは、人の発言 (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
# n8n の受け口が断る長さは、ここで送れない理由として付けておく
problem = None
if group["end"] <= group["start"]:
problem = "長さが 0 分です"
elif group["end"] - group["start"] > N8N_MAX_BLOCK_SEC:
problem = "24 時間を超えています"
out.append(
{
"source": block_source(session["id"], group["start"]),
"sessionId": session["id"],
"date": local_date(group["start"]),
"start": iso(group["start"]),
"end": iso(group["end"]),
"startEpoch": group["start"],
"endEpoch": group["end"],
"minutes": int(round(minutes)),
"turns": group["turns"],
"inProgress": in_progress,
"problem": problem,
}
)
return out
def default_task_name(folder, title):
"""タスク名の初期値: [作業フォルダ名] セッションのタイトル。"""
name = Path(str(folder).replace("\\", "/")).name if folder else ""
title = one_line(title, 200)
return f"[{name}] {title}" if name else title
def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"""期間 [start, end] (date, ローカル) に始まる作業区間を集める。project_id が空なら全プロジェクト。"""
lo = datetime.combine(start, datetime.min.time()).timestamp()
hi = datetime.combine(end + timedelta(days=1), datetime.min.time()).timestamp()
projects = store.projects()
if project_id:
projects = [p for p in projects if p["id"] == project_id]
if not projects:
raise FileNotFoundError(project_id)
out = []
for project in projects:
for path in store.session_files(project["id"]):
if path.stat().st_mtime < lo:
continue # 期間より前に書き終わったログは開かない
session = store.session(path)
if session["startEpoch"] is None or session["startEpoch"] >= hi:
continue
folder = session.get("cwd") or project["path"]
for block in make_blocks(session, gap_min, min_min, now):
if not lo <= block["startEpoch"] < hi:
continue
block.update(
{
"project": project["id"],
"projectPath": folder,
"title": session["title"],
"taskName": default_task_name(folder, session["title"]),
"_mtime": session.get("mtime") or 0,
}
)
out.append(block)
# 会話を続けると新しいセッションのファイルに前の履歴が同じ時刻のまま写されるので、
# 開始時刻 (ミリ秒) が同じ区間は写しとみなして 1 件にする。いちばん後まで続いているものを残し、
# 同着なら古いファイルのものを残す。外した写しの source は aliases に持ち、送信済みの判定に使う。
groups = {}
for block in out:
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):
"""n8n の work_log の行と突き合わせて、送信済み / 削除済み / 重なりを付ける。
重なりは [開始, 終了) が少しでも重なること (接しているだけは重ならない)。
相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。
"""
now = time.time() if now is None else now
sent = {r.get("source"): r for r in rows if r.get("source")}
deleted = set(deleted_sources or ())
spans = []
for row in rows:
start = parse_ts(row.get("start_time"))
if start is None:
continue
end = parse_ts(row.get("end_time")) or now
spans.append((start, end, {"kind": "row", "id": row.get("id"), "source": row.get("source") or "",
"task": row.get("task") or "", "start": iso(start), "end": iso(end)}))
for block in blocks:
spans.append((block["startEpoch"], block["endEpoch"], {"kind": "block", "source": block["source"],
"task": block.get("taskName") or "",
"start": block["start"], "end": block["end"]}))
for block in blocks:
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"] not in own and start < block["endEpoch"] and block["startEpoch"] < end
]
return blocks
class N8n:
"""n8n の work-tracker--import を呼ぶ最小のクライアント (標準ライブラリのみ)。
LAN からは n8n.yfox.jp の証明書名が一致しないので、verify=False なら検証を外す。
合言葉は X-WT-Token ヘッダーで送り、ログにも例外の文言にも出さない。
"""
def __init__(self, url=None, token=None, verify=False):
self.url = (url or "").rstrip("/")
self.token = token or ""
self.ctx = None
if not verify:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
def call(self, path, method="GET", payload=None):
if not self.url:
raise N8nError("n8n の URL が未設定です")
if not self.token:
raise N8nError("合言葉が未設定です")
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req = urllib.request.Request(self.url + path, data=body, method=method)
req.add_header("X-WT-Token", self.token)
req.add_header("Accept", "application/json")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=60, context=self.ctx) as res:
raw = res.read()
except urllib.error.HTTPError as exc:
with exc:
if exc.code == 403:
raise N8nError("合言葉が違います (HTTP 403)") from None
detail = one_line(exc.read().decode("utf-8", "replace"), 200)
raise N8nError(f"HTTP {exc.code}: {detail or exc.reason}") from None
except urllib.error.URLError as exc:
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:
raise N8nError("JSON として読めない応答が返りました") from None
if isinstance(data, dict) and data.get("ok") is False and data.get("error"):
raise N8nError(str(data["error"]))
return data if isinstance(data, dict) else {}
def rows(self, start, end):
return self.call(f"/webhook/wt-import-rows?from={start.isoformat()}&to={end.isoformat()}")
def import_rows(self, rows):
return self.call("/webhook/wt-import", "POST", {"rows": rows})
class N8nHolder:
"""n8n 連携の設定ファイルと、そこから作ったクライアントを保持する。合言葉は UI へ返さない。"""
LIMITS = {"gapMinutes": (1, 240), "minMinutes": (0, 240)}
def __init__(self, path):
self.path = Path(path)
self.lock = threading.Lock()
self.cfg = load_redmine_config(self.path) # 中身は汎用の JSON 読み込み
self.client = self._build()
def _build(self):
return N8n(self.cfg.get("url"), self.cfg.get("token"), self.cfg.get("verifySsl", False))
def settings(self):
with self.lock:
return int(self.cfg.get("gapMinutes", N8N_GAP_MIN)), int(self.cfg.get("minMinutes", N8N_MIN_MIN))
def public(self):
with self.lock:
cfg = self.cfg
return {
"url": cfg.get("url", ""),
"hasToken": bool(cfg.get("token")),
"verifySsl": bool(cfg.get("verifySsl", False)),
"gapMinutes": int(cfg.get("gapMinutes", N8N_GAP_MIN)),
"minMinutes": int(cfg.get("minMinutes", N8N_MIN_MIN)),
"configPath": str(self.path),
}
def update(self, patch):
"""URL・合言葉・区間の設定を保存する。token が空文字なら既存の合言葉を残す。"""
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():
if key in patch:
try:
value = int(patch[key])
except (TypeError, ValueError):
raise N8nError(f"{key} は整数で指定してください") from None
if not low <= value <= high:
raise N8nError(f"{key} は {low}〜{high} で指定してください")
cfg[key] = value
if patch.get("clearToken"):
cfg.pop("token", None)
elif patch.get("token"):
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:
self.path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as exc:
raise N8nError(f"設定を保存できません: {exc}") from None
return self.public()
SOURCE_RE = re.compile(r"^chktime:([^:]+):(\d+)$")
def n8n_range(query):
"""?from= / ?to= の解釈。既定は今日までの 7 日。逆順と N8N_MAX_DAYS 日超はエラー。"""
end = parse_date(query.get("to", [""])[0]) or date.today()
start = parse_date(query.get("from", [""])[0]) or end - timedelta(days=6)
if start > end:
raise N8nError("開始日が終了日より後です")
if (end - start).days + 1 > N8N_MAX_DAYS:
raise N8nError(f"期間は {N8N_MAX_DAYS} 日以内にしてください")
return start, end
def n8n_blocks(store, holder, query, now=None):
"""期間の作業区間に、n8n 側の送信済み・削除済み・重なりを付けて返す。
n8n に届かなくても区間は返し、n8nError に理由を入れる (送信済みは分からないので UI は送らせない)。
"""
start, end = n8n_range(query)
gap, minimum = holder.settings()
blocks = collect_blocks(store, query.get("project", [""])[0], start, end, gap, minimum, now)
rows, deleted, error = [], [], None
try:
data = holder.client.rows(start, end)
rows = data.get("rows") or []
deleted = data.get("deletedSources") or []
except N8nError as exc:
error = str(exc)
mark_blocks(blocks, rows, deleted, now)
return {"from": start.isoformat(), "to": end.isoformat(), "gapMinutes": gap, "minMinutes": minimum,
"blocks": blocks, "n8nError": error}
def n8n_send(store, holder, payload, now=None):
"""選ばれた区間を n8n の work_log に送る。時刻はブラウザの値を使わずログから計算し直す。"""
items = payload.get("items") or []
if not items:
raise N8nError("送る区間がありません")
if len(items) > 200:
raise N8nError("一度に送れるのは 200 件までです")
gap, minimum = holder.settings()
rows, errors, cache = [], [], {}
for item in items:
if not isinstance(item, dict):
errors.append({"source": "", "error": "項目の形が不正です"})
continue
source = str(item.get("source") or "")
task = " ".join(str(item.get("task") or "").split())
match = SOURCE_RE.match(source)
if not match:
errors.append({"source": source, "error": "source が不正です"})
continue
if not task:
errors.append({"source": source, "error": "タスク名が空です"})
continue
key = (str(item.get("project") or ""), match.group(1))
if key not in cache:
try:
cache[key] = make_blocks(store.one(*key), gap, minimum, now)
except FileNotFoundError:
cache[key] = None
if cache[key] is None:
errors.append({"source": source, "error": "セッションが見つかりません"})
continue
block = next((b for b in cache[key] if b["source"] == source), None)
if block is None:
errors.append({"source": source, "error": "区間が見つかりません (ログか設定が変わりました)"})
continue
if block["inProgress"]:
errors.append({"source": source, "error": "進行中の区間は送れません"})
continue
if block["problem"]:
errors.append({"source": source, "error": block["problem"]})
continue
rows.append({"source": source, "date": block["date"], "start_time": block["start"],
"end_time": block["end"], "task": task, "memo": str(item.get("memo") or "")})
result = {"ok": False, "inserted": [], "skipped": [], "errors": errors}
if rows:
data = holder.client.import_rows(rows)
result["inserted"] = data.get("inserted") or []
result["skipped"] = data.get("skipped") or []
result["errors"] = errors + (data.get("errors") or [])
result["ok"] = not result["errors"]
return result
# ---------------------------------------------------------------- HTTP
def fmt_dur(msec):
if msec is None:
return ""
seconds = msec / 1000.0
if seconds < 60:
return f"{seconds:.1f}s"
minutes, seconds = divmod(int(round(seconds)), 60)
if minutes < 60:
return f"{minutes}m{seconds:02d}s"
hours, minutes = divmod(minutes, 60)
return f"{hours}h{minutes:02d}m"
def parse_days(value):
"""?days= の解釈。'all' / 0 / 不正値は全期間 (0) とみなす。"""
try:
days = int(value)
except (TypeError, ValueError):
return 0
return max(0, min(days, 3650))
def parse_date(value):
"""?from= / ?to= の解釈。'YYYY-MM-DD' 以外は None (指定なし扱い)。"""
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
return None
def parse_ids(value):
"""?exclude= の解釈。カンマ区切りのプロジェクト ID。"""
return {part for part in (value or "").split(",") if part}
def sessions_csv(sessions, with_project=False):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
head = ["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"]
writer.writerow((["project"] if with_project else []) + head)
for s in sessions:
writer.writerow(
([s.get("projectPath") or s.get("project") or ""] if with_project else [])
+ [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]),
fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""]
)
return buf.getvalue()
def daily_csv(data):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
projects = data["projects"]
writer.writerow(["date", "dow", "active", "active_ms", "turns", "sessions"] + [p["path"] for p in projects])
labels = "月火水木金土日"
for row in data["days"]:
writer.writerow(
[row["date"], labels[row["dow"]], fmt_dur(row["activeMs"]), row["activeMs"], row["turns"], row["sessions"]]
+ [fmt_dur(row["byProject"].get(p["id"])) for p in projects]
)
return buf.getvalue()
def turns_csv(session):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
writer.writerow(["#", "kind", "time", "end", "first_reply", "duration", "idle_before", "assistant_msgs", "tool_calls", "prompt"])
for t in session["turns"]:
writer.writerow(
[t["i"], t["kind"], t["tsIso"] or "", t["endIso"] or "", fmt_dur(t["waitMs"]), fmt_dur(t["durMs"]),
fmt_dur(t["idleMs"]), t["assistant"], t["toolCalls"], one_line(t["prompt"], 500)]
)
return buf.getvalue()
def timeline_csv(data):
buf = io.StringIO(newline="")
writer = csv.writer(buf)
writer.writerow(["project", "session_id", "title", "start", "end", "wall", "active", "turns", "tool_calls", "clipped"])
for s in data["sessions"]:
writer.writerow(
[s["projectPath"], s["id"], s["title"], s["start"] or "", s["end"] or "",
fmt_dur(s["wallMs"]), fmt_dur(s["activeMs"]), s["turnCount"], s["toolCalls"],
"1" if s["clipped"] else ""]
)
return buf.getvalue()
class Server(ThreadingHTTPServer):
# Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、
# 二重起動が検出できなくなるため無効にする。
allow_reuse_address = False
daemon_threads = True
class Handler(BaseHTTPRequestHandler):
server_version = "chktime"
store: Store = None # set on the server class
def log_message(self, fmt, *args): # 静かに
pass
def do_GET(self):
parsed = urlparse(self.path)
route = parsed.path
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:
self.send_json({"error": f"not found: {exc}"}, 404)
except (RedmineError, N8nError) as exc:
self.send_json({"error": str(exc)}, 400)
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())
raise FileNotFoundError(route)
except FileNotFoundError as exc:
self.send_json({"error": f"not found: {exc}"}, 404)
except (RedmineError, N8nError, ValueError) as exc:
self.send_json({"error": str(exc)}, 400)
except Exception as exc: # noqa: BLE001 - UI にエラーを返す
self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500)
def read_json(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
data = json.loads(self.rfile.read(length))
except ValueError:
raise ValueError("JSON として読めません") from None
return data if isinstance(data, dict) else {}
def api_post(self, route, payload):
redmine = self.server.redmine
if route == "/api/n8n/config":
return self.send_json(self.server.n8n.update(payload))
if route == "/api/n8n/send":
return self.send_json(n8n_send(self.server.store, self.server.n8n, payload))
if route == "/api/redmine/config":
return self.send_json(redmine.update(payload))
if route == "/api/redmine/draft":
return self.send_json({"drafts": build_drafts(self.server.store, payload)})
if route == "/api/redmine/issues":
drafts = payload.get("drafts") or []
if not drafts:
raise RedmineError("起票するものがありません")
defaults = payload.get("defaults") or {}
fields = [to_fields(d, defaults) for d in drafts]
return self.send_json(self.server.jobs.start(redmine.client, fields))
raise FileNotFoundError(route)
def api(self, route, query):
store = self.server.store
if route == "/api/projects":
return self.send_json({"root": str(store.root), "projects": store.projects()})
if route == "/api/sessions":
project = query.get("project", [""])[0]
sessions = store.sessions(project)
if query.get("format", [""])[0] == "csv":
return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv")
return self.send_json({"project": project, "sessions": sessions})
if route == "/api/all":
sessions = store.all_sessions()
if query.get("format", [""])[0] == "csv":
return self.send_text(sessions_csv(sessions, True), "text/csv", "all-sessions.csv")
return self.send_json({"project": "", "sessions": sessions})
if route == "/api/daily":
project = query.get("project", [""])[0]
data = store.daily_stats(
project or None,
parse_days(query.get("days", ["30"])[0]),
parse_date(query.get("from", [""])[0]),
parse_date(query.get("to", [""])[0]),
parse_ids(query.get("exclude", [""])[0]),
)
if query.get("format", [""])[0] == "csv":
return self.send_text(daily_csv(data), "text/csv", f"{project or 'all'}-daily.csv")
return self.send_json(data)
if route == "/api/timeline":
project = query.get("project", [""])[0]
data = store.timeline(project or None, parse_days(query.get("days", ["7"])[0]))
if query.get("format", [""])[0] == "csv":
return self.send_text(timeline_csv(data), "text/csv", f"{project or 'all'}-timeline.csv")
return self.send_json(data)
if route == "/api/session":
project = query.get("project", [""])[0]
session_id = query.get("id", [""])[0]
session = store.one(project, session_id)
if query.get("format", [""])[0] == "csv":
return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv")
return self.send_json(session)
if route == "/api/n8n/config":
return self.send_json(self.server.n8n.public())
if route == "/api/n8n/blocks":
return self.send_json(n8n_blocks(store, self.server.n8n, query))
if route == "/api/redmine/search":
return self.send_json({"issues": self.server.redmine.client.search(query.get("q", [""])[0])})
if route == "/api/redmine/config":
return self.send_json(self.server.redmine.public())
if route == "/api/redmine/meta":
client = self.server.redmine.client
data = client.enums()
data["projects"] = client.projects()
return self.send_json(data)
if route == "/api/redmine/job":
return self.send_json(self.server.jobs.get(query.get("id", [""])[0]))
if route == "/api/reload":
store.clear()
return self.send_json({"ok": True})
if route == "/api/rev":
return self.send_json(self.server.watcher.info())
if route == "/api/watch":
return self.watch()
raise FileNotFoundError(route)
def watch(self):
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。
タブを閉じた接続はソケットの EOF で気付いて抜ける。書き込みが失敗する
まで (ping 最大20秒ぶん) スレッドを抱えたままにしないため。
"""
watcher = self.server.watcher
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
rev = watcher.rev
last_ping = time.monotonic()
try:
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
while not self.disconnected():
current = watcher.wait_change(rev, WATCH_POLL)
if current != rev:
rev = current
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
last_ping = time.monotonic()
elif time.monotonic() - last_ping >= PING_INTERVAL:
self.stream(": ping\n\n") # 接続維持
last_ping = time.monotonic()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
return # タブを閉じた等
def disconnected(self):
"""クライアントが切断済みかを見る (受信側の EOF で判定)。"""
try:
ready, _, _ = select.select([self.connection], [], [], 0)
return bool(ready) and not self.connection.recv(1, socket.MSG_PEEK)
except (OSError, ValueError):
return True
def stream(self, text):
self.wfile.write(text.encode("utf-8"))
self.wfile.flush()
def static(self, route):
name = "index.html" if route in ("/", "") else route.lstrip("/")
target = (WEB_DIR / name).resolve()
if not str(target).startswith(str(WEB_DIR)) or not target.is_file():
raise FileNotFoundError(name)
ctype = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
}.get(target.suffix, "application/octet-stream")
body = target.read_bytes()
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def send_json(self, payload, status=200):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_text(self, text, ctype, filename):
body = ("\ufeff" + text).encode("utf-8") # Excel 対応の BOM
self.send_response(200)
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
parser = argparse.ArgumentParser(description="Claude Code のタスク別やりとり時刻ビューア")
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
parser.add_argument("--redmine-config", default=str(REDMINE_CONFIG), help="Redmine 連携の設定ファイル")
parser.add_argument("--n8n-config", default=str(N8N_CONFIG), help="n8n 連携の設定ファイル")
parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args()
root = Path(args.dir).expanduser()
if not root.is_dir():
raise SystemExit(f"見つかりません: {root}")
url = f"http://{args.host}:{args.port}/"
try:
server = Server((args.host, args.port), Handler)
except OSError as exc:
if exc.errno not in (errno.EADDRINUSE, getattr(errno, "WSAEADDRINUSE", 10048)):
raise
# 既に起動済み: ブラウザだけ開いて終了する (ショートカットの二重起動対策)
print(f"すでに起動しています: {url}")
if not args.no_browser:
webbrowser.open(url)
return
server.store = Store(root)
server.redmine = RedmineHolder(args.redmine_config)
server.n8n = N8nHolder(args.n8n_config)
server.jobs = Jobs()
server.watcher = Watcher(root, args.interval)
server.watcher.start()
print(f"chktime: {root}")
print(f" -> {url} (Ctrl+C で終了)")
if not args.no_browser:
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nbye")
if __name__ == "__main__":
main()