oh-aicoding-tool 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -80
- package/bin/cli.js +257 -384
- package/package.json +28 -56
- package/CODEX_LANGFUSE_PLAN.md +0 -62
- package/bin/langfuse-cli.js +0 -718
- package/codex_langfuse_notify.py +0 -591
- package/langfuse_hook.py +0 -603
- package/opencode-ohai-report/.claude/commands/report-ai-issue.md +0 -60
- package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +0 -30
- package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +0 -569
- package/opencode-ohai-report/README.md +0 -45
- package/opencode-ohai-report/bin/cli.js +0 -421
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +0 -313
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +0 -476
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +0 -405
- package/opencode-ohai-report/examples/issue_output.json +0 -4
- package/opencode-ohai-report/package.json +0 -40
- package/opencode-ohai-report/scripts/claude_report_hook.py +0 -257
- package/opencode-ohai-report/scripts/create_issue.py +0 -34
- package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +0 -254
- package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +0 -264
- package/opencode-ohai-report/scripts/install-opencode-plugin.sh +0 -218
- package/opencode-ohai-report/scripts/merge-claude-settings.py +0 -99
- package/opencode-ohai-report/tools/ohai-report/README.md +0 -151
- package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +0 -26
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +0 -5
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +0 -9
- package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +0 -319
- package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +0 -32
- package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +0 -14
- package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +0 -313
- package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +0 -360
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +0 -1
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +0 -38
- package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +0 -64
- package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +0 -80
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +0 -1
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +0 -15
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +0 -405
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +0 -21
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +0 -354
- package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +0 -9
- package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +0 -61
- package/opencode-ohai-report/tools/ohai-report/ohai_report.py +0 -10
- package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +0 -166
- package/scripts/codex-langfuse-check.mjs +0 -101
- package/scripts/codex-langfuse-setup.mjs +0 -181
- package/scripts/langfuse-check.mjs +0 -90
- package/scripts/langfuse-setup.mjs +0 -278
- package/scripts/opencode-langfuse-check.mjs +0 -94
- package/scripts/opencode-langfuse-run.mjs +0 -96
- package/scripts/opencode-langfuse-setup.mjs +0 -478
- package/scripts/resolve-opencode-cli.mjs +0 -58
- package/setup-langfuse.bat +0 -163
- package/setup-langfuse.sh +0 -130
|
@@ -1,360 +0,0 @@
|
|
|
1
|
-
"""Metadata resolution for agent/session/Langfuse indexes."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import datetime as dt
|
|
6
|
-
import os
|
|
7
|
-
import pathlib
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
from .git_context import git_context
|
|
11
|
-
from .observability.langfuse import trace_url_from_env
|
|
12
|
-
from .schema import MetadataUpdateInput
|
|
13
|
-
from .workspace import data_dir, read_json, write_json
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
DEFAULT_STORED_SESSION_MAX_AGE_SECONDS = 30 * 60
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _home_dir() -> pathlib.Path:
|
|
20
|
-
raw = (os.environ.get("USERPROFILE") or os.environ.get("HOME") or "").strip()
|
|
21
|
-
if raw:
|
|
22
|
-
return pathlib.Path(raw)
|
|
23
|
-
try:
|
|
24
|
-
return pathlib.Path.home()
|
|
25
|
-
except RuntimeError:
|
|
26
|
-
return pathlib.Path.cwd()
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def opencode_global_config_dir() -> pathlib.Path:
|
|
30
|
-
"""与 OpenCode 安装脚本一致:优先 ``XDG_CONFIG_HOME/opencode``,否则 Windows ``%USERPROFILE%\\.config\\opencode``,再否则 ``~/.config/opencode``。"""
|
|
31
|
-
xdg = (os.environ.get("XDG_CONFIG_HOME") or "").strip()
|
|
32
|
-
if xdg:
|
|
33
|
-
return pathlib.Path(xdg) / "opencode"
|
|
34
|
-
return _home_dir() / ".config" / "opencode"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _user_email_from_json_dict(data: dict[str, Any]) -> str:
|
|
38
|
-
raw = data.get("user_email", data.get("userEmail"))
|
|
39
|
-
if isinstance(raw, str):
|
|
40
|
-
t = raw.strip()
|
|
41
|
-
if t and "@" in t:
|
|
42
|
-
return t
|
|
43
|
-
return ""
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def read_user_email_from_sidecar_json_files(workspace_root: pathlib.Path) -> str:
|
|
47
|
-
"""独立 JSON 中的公司邮箱(便于用户直接编辑)。
|
|
48
|
-
|
|
49
|
-
读取顺序(后者不覆盖前者已读到的值):
|
|
50
|
-
|
|
51
|
-
1. ``<workspace>/.ohai-report/user_email.json`` — 按仓库覆盖
|
|
52
|
-
2. ``~/.config/opencode/ohai-report/email.json`` — 安装脚本写入的全局默认
|
|
53
|
-
3. ``~/.config/opencode/ohai-report-user.json`` — 旧版安装脚本,仍兼容
|
|
54
|
-
"""
|
|
55
|
-
candidates = [
|
|
56
|
-
workspace_root / ".ohai-report" / "user_email.json",
|
|
57
|
-
opencode_global_config_dir() / "ohai-report" / "email.json",
|
|
58
|
-
opencode_global_config_dir() / "ohai-report-user.json",
|
|
59
|
-
]
|
|
60
|
-
for path in candidates:
|
|
61
|
-
em = _user_email_from_json_dict(read_json(path))
|
|
62
|
-
if em:
|
|
63
|
-
return em
|
|
64
|
-
return ""
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def is_placeholder_tracing_user_id(value: str) -> bool:
|
|
68
|
-
"""排除明显非业务用户的占位(如环境里的 USER_ID=000000)。"""
|
|
69
|
-
t = (value or "").strip()
|
|
70
|
-
if not t:
|
|
71
|
-
return True
|
|
72
|
-
low = t.lower()
|
|
73
|
-
if low in ("unknown", "null", "undefined", "none", "n/a", "na"):
|
|
74
|
-
return True
|
|
75
|
-
if len(t) <= 12 and all(c == "0" for c in t):
|
|
76
|
-
return True
|
|
77
|
-
return False
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def tracing_user_id_from_env(env: dict[str, str]) -> str:
|
|
81
|
-
"""仅使用显式追踪相关变量,不把 OS 登录名当作 user_id(避免误显示)。"""
|
|
82
|
-
for key in ("OHAI_USER_ID", "OHAI_EMPLOYEE_ID", "OPENCODE_EMPLOYEE_ID", "LANGFUSE_USER_ID", "USER_ID"):
|
|
83
|
-
raw = (env.get(key) or "").strip()
|
|
84
|
-
if raw and not is_placeholder_tracing_user_id(raw):
|
|
85
|
-
return raw
|
|
86
|
-
return ""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def tracing_user_email_from_env(env: dict[str, str]) -> str:
|
|
90
|
-
"""公司/上报邮箱:显式邮箱变量优先;否则若追踪类 user id 形如邮箱则复用。"""
|
|
91
|
-
for key in ("OHAI_USER_EMAIL", "USER_EMAIL", "GIT_AUTHOR_EMAIL"):
|
|
92
|
-
raw = (env.get(key) or "").strip()
|
|
93
|
-
if raw and "@" in raw:
|
|
94
|
-
return raw
|
|
95
|
-
for key in ("OHAI_USER_ID", "LANGFUSE_USER_ID"):
|
|
96
|
-
raw = (env.get(key) or "").strip()
|
|
97
|
-
if raw and "@" in raw and "." in raw.split("@", 1)[-1]:
|
|
98
|
-
return raw
|
|
99
|
-
return ""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _traceparent_from_env(env: dict[str, str]) -> tuple[str, str]:
|
|
103
|
-
"""解析 W3C traceparent(00-<trace32>-<parent16>-<flags>),补全 trace / span 关联。"""
|
|
104
|
-
tp = (env.get("TRACEPARENT") or env.get("traceparent") or "").strip()
|
|
105
|
-
if not tp:
|
|
106
|
-
return "", ""
|
|
107
|
-
parts = tp.split("-")
|
|
108
|
-
if len(parts) < 4:
|
|
109
|
-
return "", ""
|
|
110
|
-
ver, trace, span = parts[0], parts[1], parts[2]
|
|
111
|
-
if len(ver) != 2 or any(c not in "0123456789abcdefABCDEF" for c in ver):
|
|
112
|
-
return "", ""
|
|
113
|
-
if len(trace) != 32 or any(c not in "0123456789abcdefABCDEF" for c in trace):
|
|
114
|
-
return "", ""
|
|
115
|
-
if len(span) != 16 or any(c not in "0123456789abcdefABCDEF" for c in span):
|
|
116
|
-
return trace.lower(), ""
|
|
117
|
-
return trace.lower(), span.lower()
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def env_metadata() -> dict[str, Any]:
|
|
121
|
-
"""Read correlation IDs from env (Langfuse / OpenCode / OHAI 桥接).
|
|
122
|
-
|
|
123
|
-
Langfuse 或宿主进程可将当前 trace 上下文写入下列变量,子进程执行
|
|
124
|
-
``ohai_report.py create --metadata auto`` 时即可带入 Issue「日志信息」。
|
|
125
|
-
"""
|
|
126
|
-
env = os.environ
|
|
127
|
-
session_id = (
|
|
128
|
-
env.get("OHAI_SESSION_ID")
|
|
129
|
-
or env.get("OPENCODE_SESSION_ID")
|
|
130
|
-
or env.get("LANGFUSE_SESSION_ID")
|
|
131
|
-
or env.get("SESSION_ID")
|
|
132
|
-
or ""
|
|
133
|
-
)
|
|
134
|
-
trace_id = env.get("LANGFUSE_TRACE_ID") or env.get("TRACE_ID") or env.get("OTEL_TRACE_ID") or ""
|
|
135
|
-
observation_id = (
|
|
136
|
-
env.get("LANGFUSE_OBSERVATION_ID")
|
|
137
|
-
or env.get("OHAI_OBSERVATION_ID")
|
|
138
|
-
or env.get("OBSERVATION_ID")
|
|
139
|
-
or env.get("OTEL_SPAN_ID")
|
|
140
|
-
or ""
|
|
141
|
-
)
|
|
142
|
-
tp_trace, tp_span = _traceparent_from_env(dict(env))
|
|
143
|
-
if not trace_id and tp_trace:
|
|
144
|
-
trace_id = tp_trace
|
|
145
|
-
if not observation_id and tp_span:
|
|
146
|
-
observation_id = tp_span
|
|
147
|
-
message_id = env.get("OPENCODE_MESSAGE_ID") or env.get("OHAI_MESSAGE_ID") or ""
|
|
148
|
-
opencode_subagent = env.get("OHAI_OPENCODE_SUBAGENT") or env.get("OPENCODE_SUBAGENT") or ""
|
|
149
|
-
model = env.get("OPENCODE_MODEL") or env.get("OHAI_MODEL") or ""
|
|
150
|
-
return {
|
|
151
|
-
"session_id": session_id,
|
|
152
|
-
"trace_id": trace_id,
|
|
153
|
-
"observation_id": observation_id,
|
|
154
|
-
"message_id": message_id,
|
|
155
|
-
"opencode_subagent": opencode_subagent,
|
|
156
|
-
"model": model,
|
|
157
|
-
"langfuse_url": env.get("LANGFUSE_URL") or env.get("LANGFUSE_TRACE_URL") or "",
|
|
158
|
-
"user": {
|
|
159
|
-
"id": tracing_user_id_from_env(env),
|
|
160
|
-
"email": tracing_user_email_from_env(env),
|
|
161
|
-
"name": (
|
|
162
|
-
env.get("OHAI_USER_NAME")
|
|
163
|
-
or env.get("OHAI_EMPLOYEE_ID")
|
|
164
|
-
or env.get("OPENCODE_EMPLOYEE_ID")
|
|
165
|
-
or env.get("USERNAME")
|
|
166
|
-
or env.get("USER")
|
|
167
|
-
or ""
|
|
168
|
-
),
|
|
169
|
-
"team": env.get("OHAI_TEAM") or env.get("TEAM") or "",
|
|
170
|
-
},
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _prefer_env_then_stored(env: dict[str, Any], stored: dict[str, Any], key: str) -> str:
|
|
175
|
-
"""进程内 Langfuse 注入优先于磁盘上可能过期的快照。"""
|
|
176
|
-
ev = str(env.get(key) or "").strip()
|
|
177
|
-
if ev:
|
|
178
|
-
return ev
|
|
179
|
-
return str(stored.get(key) or "").strip()
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _stored_session_max_age() -> dt.timedelta:
|
|
183
|
-
raw = (os.environ.get("OHAI_STORED_SESSION_MAX_AGE_SECONDS") or "").strip()
|
|
184
|
-
if raw:
|
|
185
|
-
try:
|
|
186
|
-
seconds = int(raw)
|
|
187
|
-
except ValueError:
|
|
188
|
-
seconds = DEFAULT_STORED_SESSION_MAX_AGE_SECONDS
|
|
189
|
-
if seconds > 0:
|
|
190
|
-
return dt.timedelta(seconds=seconds)
|
|
191
|
-
return dt.timedelta(seconds=DEFAULT_STORED_SESSION_MAX_AGE_SECONDS)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _parse_metadata_time(value: Any) -> dt.datetime | None:
|
|
195
|
-
if not isinstance(value, str) or not value.strip():
|
|
196
|
-
return None
|
|
197
|
-
try:
|
|
198
|
-
parsed = dt.datetime.fromisoformat(value.strip())
|
|
199
|
-
except ValueError:
|
|
200
|
-
return None
|
|
201
|
-
if parsed.tzinfo is None:
|
|
202
|
-
return parsed.astimezone()
|
|
203
|
-
return parsed
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def _same_stored_workspace(root: pathlib.Path, stored: dict[str, Any]) -> bool:
|
|
207
|
-
context = stored.get("context") if isinstance(stored.get("context"), dict) else {}
|
|
208
|
-
cwd = str(context.get("cwd") or "").strip()
|
|
209
|
-
if not cwd:
|
|
210
|
-
return True
|
|
211
|
-
try:
|
|
212
|
-
return pathlib.Path(cwd).resolve() == root.resolve()
|
|
213
|
-
except OSError:
|
|
214
|
-
return pathlib.Path(cwd) == root
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def _fresh_stored_session_id(root: pathlib.Path, stored: dict[str, Any]) -> str:
|
|
218
|
-
sid = str(stored.get("session_id") or "").strip()
|
|
219
|
-
if not sid or not _same_stored_workspace(root, stored):
|
|
220
|
-
return ""
|
|
221
|
-
|
|
222
|
-
updated_at = _parse_metadata_time(stored.get("updated_at"))
|
|
223
|
-
if updated_at is None:
|
|
224
|
-
return ""
|
|
225
|
-
age = dt.datetime.now(updated_at.tzinfo).astimezone(updated_at.tzinfo) - updated_at
|
|
226
|
-
if age < dt.timedelta(0) or age > _stored_session_max_age():
|
|
227
|
-
return ""
|
|
228
|
-
return sid
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def resolve_metadata(root: pathlib.Path, source: str, mode: str) -> dict[str, Any]:
|
|
232
|
-
base: dict[str, Any] = {
|
|
233
|
-
"source": source,
|
|
234
|
-
"agent": source,
|
|
235
|
-
"session_id": "",
|
|
236
|
-
"trace_id": "",
|
|
237
|
-
"observation_id": "",
|
|
238
|
-
"message_id": "",
|
|
239
|
-
"opencode_subagent": "",
|
|
240
|
-
"model": "",
|
|
241
|
-
"langfuse_url": "",
|
|
242
|
-
"user": {"id": "", "name": "", "team": "", "email": ""},
|
|
243
|
-
"context": git_context(root),
|
|
244
|
-
}
|
|
245
|
-
if mode != "auto":
|
|
246
|
-
return base
|
|
247
|
-
|
|
248
|
-
stored = read_json(data_dir(root) / "metadata.json")
|
|
249
|
-
env = env_metadata()
|
|
250
|
-
|
|
251
|
-
stored_session_id = _fresh_stored_session_id(root, stored) if source == "opencode" else ""
|
|
252
|
-
base["session_id"] = str(env.get("session_id") or "").strip() or stored_session_id
|
|
253
|
-
for key in ("trace_id", "observation_id", "message_id", "opencode_subagent", "model"):
|
|
254
|
-
base[key] = _prefer_env_then_stored(env, stored, key)
|
|
255
|
-
|
|
256
|
-
lu_stored = str(stored.get("langfuse_url") or "").strip()
|
|
257
|
-
lu_env = str(env.get("langfuse_url") or "").strip()
|
|
258
|
-
base["langfuse_url"] = lu_stored or lu_env
|
|
259
|
-
|
|
260
|
-
if base.get("trace_id") and not (base.get("langfuse_url") or "").strip():
|
|
261
|
-
built_url = trace_url_from_env(str(base["trace_id"]))
|
|
262
|
-
if built_url:
|
|
263
|
-
base["langfuse_url"] = built_url
|
|
264
|
-
|
|
265
|
-
stored_user = stored.get("user") if isinstance(stored.get("user"), dict) else {}
|
|
266
|
-
env_user = env.get("user") if isinstance(env.get("user"), dict) else {}
|
|
267
|
-
e_uid = str(env_user.get("id") or "").strip()
|
|
268
|
-
if e_uid and is_placeholder_tracing_user_id(e_uid):
|
|
269
|
-
e_uid = ""
|
|
270
|
-
s_uid = str(stored_user.get("id") or "").strip()
|
|
271
|
-
if s_uid and is_placeholder_tracing_user_id(s_uid):
|
|
272
|
-
s_uid = ""
|
|
273
|
-
e_mail = str(env_user.get("email") or "").strip()
|
|
274
|
-
s_mail = str(stored_user.get("email") or "").strip()
|
|
275
|
-
base["user"] = {
|
|
276
|
-
"id": e_uid or s_uid,
|
|
277
|
-
"name": str(stored_user.get("name") or env_user.get("name") or "").strip(),
|
|
278
|
-
"team": str(stored_user.get("team") or env_user.get("team") or "").strip(),
|
|
279
|
-
"email": e_mail or s_mail,
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if not str(base["user"].get("email") or "").strip():
|
|
283
|
-
fe = read_user_email_from_sidecar_json_files(root)
|
|
284
|
-
if fe:
|
|
285
|
-
base["user"]["email"] = fe
|
|
286
|
-
|
|
287
|
-
stored_context = stored.get("context") if isinstance(stored.get("context"), dict) else {}
|
|
288
|
-
base["context"].update({k: v for k, v in stored_context.items() if v})
|
|
289
|
-
return base
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
def update_metadata(root: pathlib.Path, value: MetadataUpdateInput) -> pathlib.Path:
|
|
293
|
-
path = data_dir(root) / "metadata.json"
|
|
294
|
-
stored = read_json(path)
|
|
295
|
-
context = stored.get("context") if isinstance(stored.get("context"), dict) else {}
|
|
296
|
-
context.update(git_context(root))
|
|
297
|
-
if value.cwd:
|
|
298
|
-
context["cwd"] = value.cwd
|
|
299
|
-
|
|
300
|
-
env_meta = env_metadata()
|
|
301
|
-
|
|
302
|
-
user = stored.get("user") if isinstance(stored.get("user"), dict) else {}
|
|
303
|
-
env_user = env_meta["user"]
|
|
304
|
-
user.update({k: v for k, v in env_user.items() if v})
|
|
305
|
-
if (value.user_id or "").strip():
|
|
306
|
-
tid = str(value.user_id).strip()
|
|
307
|
-
user["id"] = tid
|
|
308
|
-
if "@" in tid and "." in tid.split("@", 1)[-1]:
|
|
309
|
-
user["email"] = tid
|
|
310
|
-
|
|
311
|
-
sid = (value.session_id or "").strip() or str(env_meta.get("session_id") or "").strip()
|
|
312
|
-
if not sid and value.source == "opencode":
|
|
313
|
-
sid = _fresh_stored_session_id(root, stored)
|
|
314
|
-
tid = (value.trace_id or "").strip() or str(env_meta.get("trace_id") or "").strip()
|
|
315
|
-
if not tid:
|
|
316
|
-
tid = str(stored.get("trace_id") or "").strip()
|
|
317
|
-
oid = (value.observation_id or "").strip() or str(env_meta.get("observation_id") or "").strip()
|
|
318
|
-
if not oid:
|
|
319
|
-
oid = str(stored.get("observation_id") or "").strip()
|
|
320
|
-
if value.clear_message_subagent:
|
|
321
|
-
mid = ""
|
|
322
|
-
sub = ""
|
|
323
|
-
else:
|
|
324
|
-
mid = (value.message_id or "").strip() or str(env_meta.get("message_id") or "").strip()
|
|
325
|
-
if not mid:
|
|
326
|
-
mid = str(stored.get("message_id") or "").strip()
|
|
327
|
-
sub = (value.opencode_subagent or "").strip() or str(env_meta.get("opencode_subagent") or "").strip()
|
|
328
|
-
if not sub:
|
|
329
|
-
sub = str(stored.get("opencode_subagent") or "").strip()
|
|
330
|
-
|
|
331
|
-
mdl_cli = (value.model or "").strip()
|
|
332
|
-
if mdl_cli:
|
|
333
|
-
mdl = mdl_cli
|
|
334
|
-
else:
|
|
335
|
-
mdl = str(env_meta.get("model") or "").strip()
|
|
336
|
-
if not mdl:
|
|
337
|
-
mdl = str(stored.get("model") or "").strip()
|
|
338
|
-
|
|
339
|
-
if not str(user.get("email") or "").strip():
|
|
340
|
-
fe = read_user_email_from_sidecar_json_files(root)
|
|
341
|
-
if fe:
|
|
342
|
-
user["email"] = fe
|
|
343
|
-
|
|
344
|
-
next_value = {
|
|
345
|
-
**stored,
|
|
346
|
-
"source": value.source,
|
|
347
|
-
"agent": value.source,
|
|
348
|
-
"session_id": sid,
|
|
349
|
-
"trace_id": tid,
|
|
350
|
-
"observation_id": oid,
|
|
351
|
-
"message_id": mid,
|
|
352
|
-
"opencode_subagent": sub,
|
|
353
|
-
"model": mdl,
|
|
354
|
-
"langfuse_url": (value.langfuse_url or "").strip() or stored.get("langfuse_url", ""),
|
|
355
|
-
"user": user,
|
|
356
|
-
"context": context,
|
|
357
|
-
"updated_at": dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds"),
|
|
358
|
-
}
|
|
359
|
-
write_json(path, next_value)
|
|
360
|
-
return path
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Observability helpers (Langfuse URL generation, trace hints)."""
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"""Langfuse URL helpers — trace links without embedding logs in Issues."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import urllib.parse
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def build_trace_url(base_url: str, project_id: str, trace_id: str) -> str:
|
|
10
|
-
"""Return a canonical Langfuse trace URL for typical Cloud / self-hosted UI paths."""
|
|
11
|
-
root = base_url.strip().rstrip("/")
|
|
12
|
-
if not root or not project_id or not trace_id:
|
|
13
|
-
return ""
|
|
14
|
-
enc_trace = urllib.parse.quote(trace_id, safe="")
|
|
15
|
-
return f"{root}/project/{project_id}/traces/{enc_trace}"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def trace_url_from_env(trace_id: str) -> str:
|
|
19
|
-
"""If LANGFUSE_BASE_URL (or HOST) and LANGFUSE_PROJECT_ID are set, derive trace URL."""
|
|
20
|
-
if not trace_id:
|
|
21
|
-
return ""
|
|
22
|
-
raw_base = (
|
|
23
|
-
os.environ.get("LANGFUSE_BASE_URL")
|
|
24
|
-
or os.environ.get("LANGFUSE_HOST")
|
|
25
|
-
or os.environ.get("LANGFUSE_ORIGIN")
|
|
26
|
-
or ""
|
|
27
|
-
)
|
|
28
|
-
project_id = (
|
|
29
|
-
os.environ.get("LANGFUSE_PROJECT_ID")
|
|
30
|
-
or os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
31
|
-
or ""
|
|
32
|
-
).strip()
|
|
33
|
-
if not raw_base.strip() or not project_id:
|
|
34
|
-
return ""
|
|
35
|
-
root = raw_base.strip().rstrip("/")
|
|
36
|
-
if root.endswith("/traces"):
|
|
37
|
-
return f"{root}/{urllib.parse.quote(trace_id, safe='')}"
|
|
38
|
-
return build_trace_url(root, project_id, trace_id)
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Payload assembly for issue reports."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import datetime as dt
|
|
6
|
-
import pathlib
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from .metadata import resolve_metadata
|
|
10
|
-
from .schema import CreateIssueInput
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def generate_issue_id(now: dt.datetime) -> str:
|
|
14
|
-
return f"AI-DEV-{now.strftime('%Y%m%d%H%M%S%f')}"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def build_issue_payload(root: pathlib.Path, value: CreateIssueInput) -> dict[str, Any]:
|
|
18
|
-
now = dt.datetime.now(dt.timezone.utc).astimezone()
|
|
19
|
-
metadata = resolve_metadata(root, value.source, value.metadata)
|
|
20
|
-
issue = dict(value.issue)
|
|
21
|
-
if value.metadata == "auto":
|
|
22
|
-
om = str(metadata.get("model") or "").strip()
|
|
23
|
-
if om:
|
|
24
|
-
issue["model"] = om
|
|
25
|
-
user = metadata["user"] if isinstance(metadata.get("user"), dict) else {}
|
|
26
|
-
uid = str(user.get("id") or "").strip()
|
|
27
|
-
if not uid:
|
|
28
|
-
uid = str(issue.get("user_id") or "").strip()
|
|
29
|
-
uem = str(user.get("email") or "").strip()
|
|
30
|
-
if not uem:
|
|
31
|
-
uem = str(issue.get("user_email") or "").strip()
|
|
32
|
-
if not uem and uid and "@" in uid and "." in uid.split("@", 1)[-1]:
|
|
33
|
-
uem = uid
|
|
34
|
-
obs = str(metadata.get("observation_id") or "").strip()
|
|
35
|
-
mid = str(metadata.get("message_id") or "").strip()
|
|
36
|
-
sub = str(metadata.get("opencode_subagent") or "").strip()
|
|
37
|
-
user_out = dict(user)
|
|
38
|
-
user_out.setdefault("email", "")
|
|
39
|
-
if uem:
|
|
40
|
-
user_out["email"] = uem
|
|
41
|
-
return {
|
|
42
|
-
"issue_id": generate_issue_id(now),
|
|
43
|
-
"created_at": now.isoformat(timespec="seconds"),
|
|
44
|
-
"source": metadata["source"],
|
|
45
|
-
"agent": metadata["agent"],
|
|
46
|
-
"session_id": metadata["session_id"],
|
|
47
|
-
"trace_id": metadata["trace_id"],
|
|
48
|
-
"observation_id": obs,
|
|
49
|
-
"message_id": mid,
|
|
50
|
-
"opencode_subagent": sub,
|
|
51
|
-
"user_id": uid,
|
|
52
|
-
"user_email": uem,
|
|
53
|
-
"langfuse_url": metadata["langfuse_url"],
|
|
54
|
-
"user": user_out,
|
|
55
|
-
"issue": issue,
|
|
56
|
-
"context": metadata["context"],
|
|
57
|
-
"privacy": {
|
|
58
|
-
"uploads_logs": False,
|
|
59
|
-
"uploads_diff": False,
|
|
60
|
-
"uploads_transcript": False,
|
|
61
|
-
"note": "Issue only stores template fields and correlation IDs in the log section.",
|
|
62
|
-
},
|
|
63
|
-
"status": "created-local",
|
|
64
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
"""Data contracts and validation for ohai-report."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
import json
|
|
7
|
-
import pathlib
|
|
8
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def schema_path() -> pathlib.Path:
|
|
12
|
-
return pathlib.Path(__file__).resolve().parents[1] / "schemas" / "report_issue.schema.json"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def load_issue_schema() -> dict[str, Any]:
|
|
16
|
-
with schema_path().open("r", encoding="utf-8") as fp:
|
|
17
|
-
value = json.load(fp)
|
|
18
|
-
if not isinstance(value, dict) or not isinstance(value.get("fields"), dict):
|
|
19
|
-
raise ValueError("invalid issue schema: missing fields object")
|
|
20
|
-
return value
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def issue_fields() -> dict[str, dict[str, Any]]:
|
|
24
|
-
schema = load_issue_schema()
|
|
25
|
-
return schema["fields"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass(frozen=True)
|
|
29
|
-
class CreateIssueInput:
|
|
30
|
-
source: str
|
|
31
|
-
issue: dict[str, Any]
|
|
32
|
-
metadata: str
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
@dataclass(frozen=True)
|
|
36
|
-
class MetadataUpdateInput:
|
|
37
|
-
source: str
|
|
38
|
-
session_id: str = ""
|
|
39
|
-
trace_id: str = ""
|
|
40
|
-
langfuse_url: str = ""
|
|
41
|
-
observation_id: str = ""
|
|
42
|
-
user_id: str = ""
|
|
43
|
-
message_id: str = ""
|
|
44
|
-
opencode_subagent: str = ""
|
|
45
|
-
model: str = ""
|
|
46
|
-
clear_message_subagent: bool = False
|
|
47
|
-
cwd: str = ""
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def validate_issue(issue: dict[str, Any]) -> list[str]:
|
|
51
|
-
errors: list[str] = []
|
|
52
|
-
|
|
53
|
-
for name, spec in issue_fields().items():
|
|
54
|
-
item = issue.get(name)
|
|
55
|
-
if spec.get("required") and not item:
|
|
56
|
-
errors.append(f"missing required field: {name}")
|
|
57
|
-
continue
|
|
58
|
-
if not item:
|
|
59
|
-
continue
|
|
60
|
-
|
|
61
|
-
if spec.get("type") == "string" and not isinstance(item, str):
|
|
62
|
-
errors.append(f"invalid type for field {name}: expected string")
|
|
63
|
-
continue
|
|
64
|
-
enum = spec.get("enum")
|
|
65
|
-
if enum and item not in enum:
|
|
66
|
-
errors.append(f"invalid value for field {name}: {item}")
|
|
67
|
-
max_length = spec.get("maxLength")
|
|
68
|
-
if isinstance(max_length, int) and isinstance(item, str) and len(item) > max_length:
|
|
69
|
-
errors.append(f"field too long: {name} maxLength={max_length}")
|
|
70
|
-
return errors
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def validate_create_input(value: CreateIssueInput) -> list[str]:
|
|
74
|
-
errors: list[str] = []
|
|
75
|
-
if not value.source:
|
|
76
|
-
errors.append("missing required field: source")
|
|
77
|
-
errors.extend(validate_issue(value.issue))
|
|
78
|
-
if value.metadata not in {"auto", "none"}:
|
|
79
|
-
errors.append(f"invalid metadata mode: {value.metadata}")
|
|
80
|
-
return errors
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Issue sinks."""
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
"""Sink result type for issue persistence backends."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import pathlib
|
|
6
|
-
from dataclasses import dataclass, field
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass(frozen=True)
|
|
11
|
-
class SinkResult:
|
|
12
|
-
issue_id: str
|
|
13
|
-
path: pathlib.Path | None = None
|
|
14
|
-
url: str = ""
|
|
15
|
-
extra: dict[str, Any] = field(default_factory=dict)
|