ec-web 0.0.1 → 0.0.3
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 +15 -3
- package/dist/debug-log.js +7 -0
- package/dist/fs-utils.js +189 -0
- package/dist/index.js +137 -0
- package/dist/ipc-client.js +40 -0
- package/dist/paths.js +55 -0
- package/dist/process-utils.js +241 -0
- package/dist/server.js +820 -0
- package/dist/sources/aid.js +139 -0
- package/dist/sources/baseagent-detector.js +72 -0
- package/dist/sources/cache.js +43 -0
- package/dist/sources/gateway.js +44 -0
- package/dist/sources/logs.js +396 -0
- package/dist/sources/models.js +75 -0
- package/dist/sources/monitor.js +179 -0
- package/dist/sources/msg.js +625 -0
- package/dist/sources/parent-package.js +77 -0
- package/dist/sources/relation-roles.js +903 -0
- package/dist/sources/role-definitions.js +1195 -0
- package/dist/sources/session-codex.js +731 -0
- package/dist/sources/session.js +489 -0
- package/dist/sources/stats.js +481 -0
- package/dist/sources/system.js +83 -0
- package/dist/sources/triggers.js +138 -0
- package/dist/sources/types.js +10 -0
- package/dist/static/app.js +10753 -0
- package/dist/static/index.html +386 -0
- package/dist/static/style.css +4138 -0
- package/dist/types/cli-permissions.js +90 -0
- package/package.json +20 -11
- package/index.js +0 -7
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AID 数据源 — 复用 daemon 的 IPC socket(与 evolcore `watch aid` 同源)。
|
|
3
|
+
*
|
|
4
|
+
* daemon 运行时:拉 aun-aids / aun-aid-stats / agent-stats / status / evolagent.list。
|
|
5
|
+
* daemon 未运行时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
|
|
6
|
+
* IPC 无推送能力,故 1s 轮询 + JSON diff,仅在变化时 push。
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { resolvePaths } from '../paths.js';
|
|
11
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
12
|
+
/** 精简版实例扫描:仅在 daemon 离线时降级用,读各 alive main 进程的 aid 活动日志 */
|
|
13
|
+
function scanAidActivity() {
|
|
14
|
+
const result = new Map();
|
|
15
|
+
const dir = resolvePaths().instanceDir;
|
|
16
|
+
let files;
|
|
17
|
+
try {
|
|
18
|
+
files = fs.readdirSync(dir);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
if (!file.startsWith('main-') || !file.endsWith('.json'))
|
|
25
|
+
continue;
|
|
26
|
+
let rec;
|
|
27
|
+
try {
|
|
28
|
+
rec = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!rec?.pid)
|
|
34
|
+
continue;
|
|
35
|
+
// 进程存活检测(轻量:signal 0)
|
|
36
|
+
try {
|
|
37
|
+
process.kill(rec.pid, 0);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// 读该 pid 的 aid-<pid>.jsonl,按 aid 取最后活动
|
|
43
|
+
const aidLog = path.join(dir, `aid-${rec.pid}.jsonl`);
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = fs.readFileSync(aidLog, 'utf-8');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const line of content.trim().split('\n')) {
|
|
52
|
+
if (!line)
|
|
53
|
+
continue;
|
|
54
|
+
try {
|
|
55
|
+
const ev = JSON.parse(line);
|
|
56
|
+
if (ev.aid && ev.ts) {
|
|
57
|
+
const prev = result.get(ev.aid);
|
|
58
|
+
if (!prev || ev.ts > prev.ts)
|
|
59
|
+
result.set(ev.aid, { ts: ev.ts, event: ev.event });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
// evolcore 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
|
|
68
|
+
// 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
|
|
69
|
+
let _cachedVersion = null;
|
|
70
|
+
async function fetchVersion(socket) {
|
|
71
|
+
if (_cachedVersion)
|
|
72
|
+
return _cachedVersion;
|
|
73
|
+
try {
|
|
74
|
+
const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' }, auth: { source: 'ecweb', localDirect: true } }, 4000);
|
|
75
|
+
const v = r?.ok ? r.response?.data?.version : null;
|
|
76
|
+
if (v)
|
|
77
|
+
_cachedVersion = v;
|
|
78
|
+
return v ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function buildSnapshot() {
|
|
85
|
+
const p = resolvePaths();
|
|
86
|
+
const [aidsResp, statsResp, agentStatsResp, statusResp, agentsResp, version] = await Promise.all([
|
|
87
|
+
ipcQuery(p.socket, { type: 'aun-aids' }),
|
|
88
|
+
ipcQuery(p.socket, { type: 'aun-aid-stats' }),
|
|
89
|
+
ipcQuery(p.socket, { type: 'agent-stats' }),
|
|
90
|
+
ipcQuery(p.socket, { type: 'status' }),
|
|
91
|
+
ipcQuery(p.socket, { type: 'evolagent.list' }),
|
|
92
|
+
fetchVersion(p.socket),
|
|
93
|
+
]);
|
|
94
|
+
const daemonRunning = aidsResp !== null || statusResp !== null;
|
|
95
|
+
if (!daemonRunning) {
|
|
96
|
+
const activity = scanAidActivity();
|
|
97
|
+
const aids = Array.from(activity.entries()).map(([aid, info]) => ({
|
|
98
|
+
aid,
|
|
99
|
+
status: info.event === 'disconnected' ? 'disconnected' : 'offline',
|
|
100
|
+
lastActivity: info.ts,
|
|
101
|
+
lastEvent: info.event,
|
|
102
|
+
}));
|
|
103
|
+
return { daemonRunning: false, aids, stats: [], agents: [] };
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
daemonRunning: true,
|
|
107
|
+
aids: aidsResp?.aids ?? [],
|
|
108
|
+
stats: statsResp?.stats ?? [],
|
|
109
|
+
agentStats: agentStatsResp?.stats ?? [],
|
|
110
|
+
status: statusResp ?? null,
|
|
111
|
+
agents: agentsResp?.agents ?? [],
|
|
112
|
+
version: version ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export const aidSource = {
|
|
116
|
+
kind: 'agents',
|
|
117
|
+
async snapshot() {
|
|
118
|
+
return buildSnapshot();
|
|
119
|
+
},
|
|
120
|
+
subscribe(_params, push) {
|
|
121
|
+
let lastJson = '';
|
|
122
|
+
let stopped = false;
|
|
123
|
+
const tick = async () => {
|
|
124
|
+
if (stopped)
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
const snap = await buildSnapshot();
|
|
128
|
+
const json = JSON.stringify(snap);
|
|
129
|
+
if (json !== lastJson) {
|
|
130
|
+
lastJson = json;
|
|
131
|
+
push(snap);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch { /* ignore transient IPC errors */ }
|
|
135
|
+
};
|
|
136
|
+
const timer = setInterval(tick, 1000);
|
|
137
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
138
|
+
},
|
|
139
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base agent 环境检测 — 检测 Claude 和 Codex 的可用性。
|
|
3
|
+
*
|
|
4
|
+
* 检测条件:
|
|
5
|
+
* - Claude: ~/.claude/projects/ 目录存在
|
|
6
|
+
* - Codex: ~/.codex/sessions/ 和 ~/.codex/state_*.sqlite 存在,且 Node 版本 >= 22.5
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
13
|
+
let _cached = null;
|
|
14
|
+
/**
|
|
15
|
+
* 检测 node:sqlite 模块是否可用(Node 22.5+)
|
|
16
|
+
*/
|
|
17
|
+
function checkSqliteAvailable() {
|
|
18
|
+
try {
|
|
19
|
+
requireFromHere('node:sqlite');
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 检测 Codex state_*.sqlite 文件是否存在
|
|
28
|
+
*/
|
|
29
|
+
function checkCodexStateDb() {
|
|
30
|
+
const codexHome = path.join(os.homedir(), '.codex');
|
|
31
|
+
if (!fs.existsSync(codexHome))
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
const files = fs.readdirSync(codexHome).filter(f => /^state_\d+\.sqlite$/.test(f));
|
|
35
|
+
return files.length > 0;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 检测 Claude projects 目录是否存在
|
|
43
|
+
*/
|
|
44
|
+
function checkClaudeProjects() {
|
|
45
|
+
const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
|
|
46
|
+
return fs.existsSync(claudeProjects);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 检测 Codex sessions 目录是否存在
|
|
50
|
+
*/
|
|
51
|
+
function checkCodexSessions() {
|
|
52
|
+
const codexSessions = path.join(os.homedir(), '.codex', 'sessions');
|
|
53
|
+
return fs.existsSync(codexSessions);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 检测所有 base agent 的可用性
|
|
57
|
+
* 结果会缓存,避免重复检测
|
|
58
|
+
*/
|
|
59
|
+
export function detectBaseAgents() {
|
|
60
|
+
if (_cached)
|
|
61
|
+
return _cached;
|
|
62
|
+
const claude = checkClaudeProjects();
|
|
63
|
+
const codex = checkCodexSessions() && checkCodexStateDb() && checkSqliteAvailable();
|
|
64
|
+
_cached = { claude, codex };
|
|
65
|
+
return _cached;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 重置缓存(测试用)
|
|
69
|
+
*/
|
|
70
|
+
export function resetDetectionCache() {
|
|
71
|
+
_cached = null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache 数据源 — daemon 统一 FileCache 的运行统计(命中率/读盘/驱逐/失效)。
|
|
3
|
+
*
|
|
4
|
+
* 复用 daemon 的 IPC socket:拉 `cache-stats`(只读)。IPC 无推送,
|
|
5
|
+
* 故 1s 轮询 + JSON diff,仅在变化时 push(与 aidSource 同款)。
|
|
6
|
+
* daemon 未运行(ipcQuery 返回 null)→ { daemonRunning:false, stats:null }。
|
|
7
|
+
*/
|
|
8
|
+
import { resolvePaths } from '../paths.js';
|
|
9
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
10
|
+
async function buildSnapshot() {
|
|
11
|
+
const p = resolvePaths();
|
|
12
|
+
const resp = await ipcQuery(p.socket, { type: 'cache-stats' });
|
|
13
|
+
if (resp === null || !resp.ok) {
|
|
14
|
+
// daemon 离线,或旧 daemon 不认识 cache-stats(回 {error:...},ok 为 undefined)
|
|
15
|
+
return { daemonRunning: resp !== null, supported: !!resp?.ok, stats: null };
|
|
16
|
+
}
|
|
17
|
+
return { daemonRunning: true, supported: true, stats: resp.stats };
|
|
18
|
+
}
|
|
19
|
+
export const cacheSource = {
|
|
20
|
+
kind: 'cache',
|
|
21
|
+
async snapshot() {
|
|
22
|
+
return buildSnapshot();
|
|
23
|
+
},
|
|
24
|
+
subscribe(_params, push) {
|
|
25
|
+
let lastJson = '';
|
|
26
|
+
let stopped = false;
|
|
27
|
+
const tick = async () => {
|
|
28
|
+
if (stopped)
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
const snap = await buildSnapshot();
|
|
32
|
+
const json = JSON.stringify(snap);
|
|
33
|
+
if (json !== lastJson) {
|
|
34
|
+
lastJson = json;
|
|
35
|
+
push(snap);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore transient IPC errors */ }
|
|
39
|
+
};
|
|
40
|
+
const timer = setInterval(tick, 1000);
|
|
41
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gateway.ts — 网关(baseagent 后端)配置数据源(只读代理)。
|
|
3
|
+
*
|
|
4
|
+
* ECWeb 不直接读写配置文件。snapshot 通过 daemon 的 menu.exec IPC 调
|
|
5
|
+
* menu.query name=gateway,由 daemon 端的 command-handler-gateway-control 统一
|
|
6
|
+
* 出数据(apiKey 已掩码)。写操作(update/test/delete)走前端 menuSend → menu IPC,
|
|
7
|
+
* 不经过本 source。
|
|
8
|
+
*
|
|
9
|
+
* 连不上 daemon 时返回 { gateways: [], error } 让前端提示。
|
|
10
|
+
*/
|
|
11
|
+
import { resolvePaths } from '../paths.js';
|
|
12
|
+
import { ipcQuery } from '../ipc-client.js';
|
|
13
|
+
async function getSnapshot() {
|
|
14
|
+
const sock = resolvePaths().socket;
|
|
15
|
+
const resp = await ipcQuery(sock, { type: 'menu.exec', payload: { type: 'menu.query', name: 'gateway', id: 'ecw-gateway-snap' }, auth: { source: 'ecweb', localDirect: true } }, 5000);
|
|
16
|
+
if (!resp) {
|
|
17
|
+
return { gateways: [], scopes: [], types: [], error: 'evolcore 未运行或 socket 不可达' };
|
|
18
|
+
}
|
|
19
|
+
if (!resp.ok) {
|
|
20
|
+
return { gateways: [], scopes: [], types: [], error: resp.error || 'daemon 返回失败' };
|
|
21
|
+
}
|
|
22
|
+
const inner = resp.response;
|
|
23
|
+
if (inner?.error) {
|
|
24
|
+
return { gateways: [], scopes: [], types: [], error: inner.error.message };
|
|
25
|
+
}
|
|
26
|
+
const data = inner?.data ?? {};
|
|
27
|
+
return {
|
|
28
|
+
gateways: Array.isArray(data.gateways) ? data.gateways : [],
|
|
29
|
+
scopes: Array.isArray(data.scopes) ? data.scopes : [],
|
|
30
|
+
types: Array.isArray(data.types) ? data.types : [],
|
|
31
|
+
effective: Array.isArray(data.effective) ? data.effective : [],
|
|
32
|
+
envMismatch: data.envMismatch || { hasMismatch: false, mismatches: [] },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const gatewaySource = {
|
|
36
|
+
kind: 'gateway',
|
|
37
|
+
async snapshot() {
|
|
38
|
+
return getSnapshot();
|
|
39
|
+
},
|
|
40
|
+
subscribe(_params, _push) {
|
|
41
|
+
// 网关配置变更不频繁,无文件监听;前端通过操作后主动 re-subscribe 刷新。
|
|
42
|
+
return () => { };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolvePaths } from '../paths.js';
|
|
4
|
+
const LOG_SOURCES = [
|
|
5
|
+
{ id: 'daemon', label: 'Daemon', pattern: /^daemon(?:-.+)?\.log$/ },
|
|
6
|
+
{ id: 'ecweb', label: 'ECWeb', pattern: /^(?:watch-web|ecweb[^/]*)\.(?:log|out)$/ },
|
|
7
|
+
{ id: 'stdout', label: 'Standard output', pattern: /^stdout\.log(?:\..+)?$/ },
|
|
8
|
+
{ id: 'aun', label: 'AUN', pattern: /^aun(?:-.+)?\.log$/ },
|
|
9
|
+
{ id: 'sdk', label: 'TypeScript SDK', pattern: /^ts-sdk-.+\.log$/ },
|
|
10
|
+
{ id: 'events', label: 'Events', pattern: /^events-.+\.log$/ },
|
|
11
|
+
{ id: 'channel-in', label: 'Channel · inbound', pattern: /^channel-in-.+\.log$/ },
|
|
12
|
+
{ id: 'channel-out', label: 'Channel · outbound', pattern: /^channel-out-.+\.log$/ },
|
|
13
|
+
{ id: 'commands', label: 'Commands', pattern: /^commands-.+\.jsonl$/ },
|
|
14
|
+
{ id: 'triggers', label: 'Trigger runs', pattern: /^trigger-runs(?:-.+)?\.(?:log|jsonl)$/ },
|
|
15
|
+
{ id: 'restart', label: 'Restart', pattern: /^restart\.log$/ },
|
|
16
|
+
{ id: 'menu-cli', label: 'Menu CLI', pattern: /^menu-cli-exec\.log$/ },
|
|
17
|
+
{ id: 'causation', label: 'Causation spans', pattern: /^causation-spans(?:-.+)?\.log$/ },
|
|
18
|
+
];
|
|
19
|
+
const DEFAULT_LIMIT = 500;
|
|
20
|
+
const MAX_LIMIT = 1000;
|
|
21
|
+
const MAX_FILE_TAIL_BYTES = 384 * 1024;
|
|
22
|
+
const MAX_LINE_CHARS = 12_000;
|
|
23
|
+
function listLogFiles() {
|
|
24
|
+
const logsDir = resolvePaths().logs;
|
|
25
|
+
try {
|
|
26
|
+
return fs.readdirSync(logsDir, { withFileTypes: true })
|
|
27
|
+
.filter(entry => entry.isFile())
|
|
28
|
+
.map(entry => {
|
|
29
|
+
const filePath = path.join(logsDir, entry.name);
|
|
30
|
+
const stat = fs.statSync(filePath);
|
|
31
|
+
return { name: entry.name, path: filePath, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function availableSources(files) {
|
|
39
|
+
return LOG_SOURCES.map(source => {
|
|
40
|
+
const matches = files.filter(file => source.pattern.test(file.name));
|
|
41
|
+
return {
|
|
42
|
+
id: source.id,
|
|
43
|
+
label: source.label,
|
|
44
|
+
fileCount: matches.length,
|
|
45
|
+
size: matches.reduce((total, file) => total + file.size, 0),
|
|
46
|
+
updatedAt: matches.reduce((latest, file) => Math.max(latest, file.mtimeMs), 0),
|
|
47
|
+
};
|
|
48
|
+
}).filter(source => source.fileCount > 0)
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
const ai = LOG_SOURCES.findIndex(source => source.id === a.id);
|
|
51
|
+
const bi = LOG_SOURCES.findIndex(source => source.id === b.id);
|
|
52
|
+
return ai - bi;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function readTail(file, maxLines) {
|
|
56
|
+
if (file.size <= 0 || maxLines <= 0)
|
|
57
|
+
return [];
|
|
58
|
+
const bytes = Math.min(file.size, MAX_FILE_TAIL_BYTES);
|
|
59
|
+
const buffer = Buffer.allocUnsafe(bytes);
|
|
60
|
+
let handle = null;
|
|
61
|
+
try {
|
|
62
|
+
handle = fs.openSync(file.path, 'r');
|
|
63
|
+
fs.readSync(handle, buffer, 0, bytes, file.size - bytes);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (handle != null)
|
|
70
|
+
try {
|
|
71
|
+
fs.closeSync(handle);
|
|
72
|
+
}
|
|
73
|
+
catch { }
|
|
74
|
+
}
|
|
75
|
+
let text = buffer.toString('utf8');
|
|
76
|
+
if (bytes < file.size) {
|
|
77
|
+
const firstBreak = text.indexOf('\n');
|
|
78
|
+
text = firstBreak >= 0 ? text.slice(firstBreak + 1) : '';
|
|
79
|
+
}
|
|
80
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-maxLines);
|
|
81
|
+
}
|
|
82
|
+
function shortLogName(file) {
|
|
83
|
+
return path.basename(file)
|
|
84
|
+
.replace(/\.(?:log|jsonl|out)(?:\..+)?$/, '')
|
|
85
|
+
.replace(/-\d{8}-\d{2}$/, '')
|
|
86
|
+
.replace(/-\d{8}$/, '')
|
|
87
|
+
.replace(/-\d{4}-\d{2}-\d{2}(?:-\d{2})?$/, '');
|
|
88
|
+
}
|
|
89
|
+
function normalizeTimestamp(value) {
|
|
90
|
+
if (typeof value === 'string' && value.trim())
|
|
91
|
+
return value.trim();
|
|
92
|
+
const millis = Number(value);
|
|
93
|
+
return Number.isFinite(millis) && millis > 0 ? new Date(millis).toISOString() : null;
|
|
94
|
+
}
|
|
95
|
+
function fieldValue(value) {
|
|
96
|
+
if (typeof value === 'string')
|
|
97
|
+
return value;
|
|
98
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
99
|
+
return String(value);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function collectFields(value, excluded, limit = 10, prefix = '') {
|
|
103
|
+
const fields = [];
|
|
104
|
+
for (const [key, rawValue] of Object.entries(value)) {
|
|
105
|
+
if (excluded.has(key) || fields.length >= limit)
|
|
106
|
+
continue;
|
|
107
|
+
const scalar = fieldValue(rawValue);
|
|
108
|
+
if (scalar != null) {
|
|
109
|
+
fields.push({ key: prefix ? `${prefix}.${key}` : key, value: scalar });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
|
|
113
|
+
fields.push(...collectFields(rawValue, new Set(), limit - fields.length, prefix ? `${prefix}.${key}` : key));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return fields.slice(0, limit);
|
|
117
|
+
}
|
|
118
|
+
function parseJsonLine(raw, file) {
|
|
119
|
+
let value;
|
|
120
|
+
try {
|
|
121
|
+
value = JSON.parse(raw);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
127
|
+
return null;
|
|
128
|
+
const timestamp = normalizeTimestamp(value.ts ?? value.timestamp ?? value.startedAt ?? value.firedAt ?? value.finishedAt);
|
|
129
|
+
let level = null;
|
|
130
|
+
if (value.status === 'failed' || value.error)
|
|
131
|
+
level = 'error';
|
|
132
|
+
else if (value.status === 'completed')
|
|
133
|
+
level = 'info';
|
|
134
|
+
else if (value.status === 'started')
|
|
135
|
+
level = 'debug';
|
|
136
|
+
if (value.msgId && value.status) {
|
|
137
|
+
const direction = value.dir === 'inbound' ? 'in' : (value.dir === 'outbound' ? 'out' : null);
|
|
138
|
+
const peer = String(value.msgId).replace(/_\d+(_reply)?$/, '').replace(/^[^_]+_/, '');
|
|
139
|
+
return {
|
|
140
|
+
timestamp,
|
|
141
|
+
level,
|
|
142
|
+
file,
|
|
143
|
+
module: 'message',
|
|
144
|
+
actor: peer || null,
|
|
145
|
+
direction,
|
|
146
|
+
event: String(value.status),
|
|
147
|
+
fields: collectFields(value, new Set(['ts', 'timestamp', 'msgId', 'status', 'dir']), 8),
|
|
148
|
+
format: 'json',
|
|
149
|
+
userMessage: false,
|
|
150
|
+
message: peer ? `${peer}:` : '',
|
|
151
|
+
raw,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (value.dir && value.event) {
|
|
155
|
+
const directionValue = String(value.dir).toLowerCase();
|
|
156
|
+
const direction = directionValue === 'in' || directionValue === 'inbound'
|
|
157
|
+
? 'in'
|
|
158
|
+
: (directionValue === 'out' || directionValue === 'outbound' ? 'out' : null);
|
|
159
|
+
const event = String(value.event);
|
|
160
|
+
const fields = value.data && typeof value.data === 'object'
|
|
161
|
+
? collectFields(value.data, new Set(), 10)
|
|
162
|
+
: collectFields(value, new Set(['ts', 'timestamp', 'dir', 'event', 'self_aid', 'data']), 10);
|
|
163
|
+
return {
|
|
164
|
+
timestamp,
|
|
165
|
+
level,
|
|
166
|
+
file,
|
|
167
|
+
module: event.split(/[.:]/)[0] || 'event',
|
|
168
|
+
actor: value.self_aid ? String(value.self_aid) : null,
|
|
169
|
+
direction,
|
|
170
|
+
event,
|
|
171
|
+
fields,
|
|
172
|
+
format: 'json',
|
|
173
|
+
userMessage: false,
|
|
174
|
+
message: '',
|
|
175
|
+
raw,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (value.runId && value.triggerId) {
|
|
179
|
+
const startedAt = Number(value.startedAt);
|
|
180
|
+
const finishedAt = Number(value.finishedAt);
|
|
181
|
+
const duration = Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt >= startedAt
|
|
182
|
+
? finishedAt - startedAt
|
|
183
|
+
: null;
|
|
184
|
+
const fields = [
|
|
185
|
+
{ key: 'runId', value: String(value.runId) },
|
|
186
|
+
{ key: 'triggerId', value: String(value.triggerId) },
|
|
187
|
+
...(duration == null ? [] : [{ key: 'duration', value: `${duration}ms` }]),
|
|
188
|
+
...(value.source?.type ? [{ key: 'source', value: String(value.source.type) }] : []),
|
|
189
|
+
];
|
|
190
|
+
return {
|
|
191
|
+
timestamp,
|
|
192
|
+
level,
|
|
193
|
+
file,
|
|
194
|
+
module: 'trigger',
|
|
195
|
+
actor: value.agentAid ? String(value.agentAid) : null,
|
|
196
|
+
direction: null,
|
|
197
|
+
event: value.status ? String(value.status) : null,
|
|
198
|
+
fields,
|
|
199
|
+
format: 'json',
|
|
200
|
+
userMessage: false,
|
|
201
|
+
message: String(value.definition?.name || value.reason || value.error?.message || ''),
|
|
202
|
+
raw,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (value.command) {
|
|
206
|
+
return {
|
|
207
|
+
timestamp,
|
|
208
|
+
level,
|
|
209
|
+
file,
|
|
210
|
+
module: 'command',
|
|
211
|
+
actor: value.aid ? String(value.aid) : null,
|
|
212
|
+
direction: null,
|
|
213
|
+
event: value.cmd ? String(value.cmd) : null,
|
|
214
|
+
fields: collectFields(value, new Set(['ts', 'timestamp', 'cmd', 'command', 'aid']), 8),
|
|
215
|
+
format: 'json',
|
|
216
|
+
userMessage: false,
|
|
217
|
+
message: String(value.command),
|
|
218
|
+
raw,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (value.type) {
|
|
222
|
+
const event = String(value.type);
|
|
223
|
+
return {
|
|
224
|
+
timestamp,
|
|
225
|
+
level,
|
|
226
|
+
file,
|
|
227
|
+
module: event.split(/[.:]/)[0] || 'event',
|
|
228
|
+
actor: value.self_aid ? String(value.self_aid) : (value.agentAid ? String(value.agentAid) : null),
|
|
229
|
+
direction: null,
|
|
230
|
+
event,
|
|
231
|
+
fields: collectFields(value, new Set(['ts', 'timestamp', 'type', 'self_aid', 'agentAid']), 10),
|
|
232
|
+
format: 'json',
|
|
233
|
+
userMessage: false,
|
|
234
|
+
message: '',
|
|
235
|
+
raw,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
timestamp,
|
|
240
|
+
level,
|
|
241
|
+
file,
|
|
242
|
+
module: shortLogName(file),
|
|
243
|
+
actor: value.aid ? String(value.aid) : null,
|
|
244
|
+
direction: null,
|
|
245
|
+
event: value.status ? String(value.status) : null,
|
|
246
|
+
fields: collectFields(value, new Set(['ts', 'timestamp', 'aid', 'status']), 10),
|
|
247
|
+
format: 'json',
|
|
248
|
+
userMessage: false,
|
|
249
|
+
message: '',
|
|
250
|
+
raw,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function parseTextLine(raw, file) {
|
|
254
|
+
let message = raw;
|
|
255
|
+
let timestamp = null;
|
|
256
|
+
let level = null;
|
|
257
|
+
let module = null;
|
|
258
|
+
let actor = null;
|
|
259
|
+
const fields = [];
|
|
260
|
+
const structured = message.match(/^\[([^\]]+)]\s*\[(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)]\s*/i);
|
|
261
|
+
if (structured) {
|
|
262
|
+
timestamp = structured[1];
|
|
263
|
+
level = structured[2].toLowerCase();
|
|
264
|
+
message = message.slice(structured[0].length);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const timeOnly = message.match(/^(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\s+/);
|
|
268
|
+
if (timeOnly) {
|
|
269
|
+
timestamp = timeOnly[1];
|
|
270
|
+
message = message.slice(timeOnly[0].length);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!level) {
|
|
274
|
+
const levelFirst = message.match(/^\[(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)]\s*/i);
|
|
275
|
+
if (levelFirst) {
|
|
276
|
+
level = levelFirst[1].toLowerCase();
|
|
277
|
+
message = message.slice(levelFirst[0].length);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const sdkFirst = !level ? message.match(/^\[([^\]]+)]\s+\[(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)]\s*/i) : null;
|
|
281
|
+
if (sdkFirst) {
|
|
282
|
+
module = sdkFirst[1];
|
|
283
|
+
level = sdkFirst[2].toLowerCase();
|
|
284
|
+
message = message.slice(sdkFirst[0].length);
|
|
285
|
+
}
|
|
286
|
+
const tags = [];
|
|
287
|
+
while (message.startsWith('[')) {
|
|
288
|
+
const tag = message.match(/^\[([^\]]+)]\s*/);
|
|
289
|
+
if (!tag)
|
|
290
|
+
break;
|
|
291
|
+
tags.push(tag[1]);
|
|
292
|
+
message = message.slice(tag[0].length);
|
|
293
|
+
}
|
|
294
|
+
if (!module && tags.length)
|
|
295
|
+
module = tags.shift() || null;
|
|
296
|
+
for (const tag of tags) {
|
|
297
|
+
const equals = tag.indexOf('=');
|
|
298
|
+
if (equals > 0)
|
|
299
|
+
fields.push({ key: tag.slice(0, equals), value: tag.slice(equals + 1) });
|
|
300
|
+
else if (/\.agentid\.pub$/.test(tag))
|
|
301
|
+
actor = tag;
|
|
302
|
+
else
|
|
303
|
+
fields.push({ key: 'context', value: tag });
|
|
304
|
+
}
|
|
305
|
+
if (!level) {
|
|
306
|
+
const detectedLevel = message.match(/\b(TRACE|DEBUG|INFO|WARN|WARNING|ERROR|FATAL)\b/i)?.[1];
|
|
307
|
+
if (detectedLevel)
|
|
308
|
+
level = detectedLevel.toLowerCase() === 'warning' ? 'warn' : detectedLevel.toLowerCase();
|
|
309
|
+
}
|
|
310
|
+
if (!module && timestamp)
|
|
311
|
+
module = shortLogName(file);
|
|
312
|
+
return {
|
|
313
|
+
timestamp,
|
|
314
|
+
level,
|
|
315
|
+
file,
|
|
316
|
+
module,
|
|
317
|
+
actor,
|
|
318
|
+
direction: null,
|
|
319
|
+
event: null,
|
|
320
|
+
fields,
|
|
321
|
+
format: 'text',
|
|
322
|
+
userMessage: !!module && /^\S+: .+$/.test(message),
|
|
323
|
+
message,
|
|
324
|
+
raw,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function parseLine(rawLine, file) {
|
|
328
|
+
const raw = rawLine.length > MAX_LINE_CHARS
|
|
329
|
+
? `${rawLine.slice(0, MAX_LINE_CHARS)} …`
|
|
330
|
+
: rawLine;
|
|
331
|
+
return raw.startsWith('{') ? (parseJsonLine(raw, file) || parseTextLine(raw, file)) : parseTextLine(raw, file);
|
|
332
|
+
}
|
|
333
|
+
function linesFor(files, source, limit) {
|
|
334
|
+
const matching = files
|
|
335
|
+
.filter(file => source.pattern.test(file.name))
|
|
336
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
337
|
+
let rawLines = [];
|
|
338
|
+
for (const file of matching) {
|
|
339
|
+
const remaining = limit - rawLines.length;
|
|
340
|
+
if (remaining <= 0)
|
|
341
|
+
break;
|
|
342
|
+
const fileLines = readTail(file, remaining).map(line => ({ file: file.name, line }));
|
|
343
|
+
rawLines = [...fileLines, ...rawLines];
|
|
344
|
+
}
|
|
345
|
+
return rawLines.slice(-limit).map(item => parseLine(item.line, item.file));
|
|
346
|
+
}
|
|
347
|
+
function buildSnapshot(params = {}) {
|
|
348
|
+
const files = listLogFiles();
|
|
349
|
+
const sources = availableSources(files);
|
|
350
|
+
const requested = String(params.source || '');
|
|
351
|
+
const selectedSource = sources.some(source => source.id === requested)
|
|
352
|
+
? requested
|
|
353
|
+
: (sources.some(source => source.id === 'daemon') ? 'daemon' : (sources[0]?.id || null));
|
|
354
|
+
const source = LOG_SOURCES.find(item => item.id === selectedSource);
|
|
355
|
+
const limit = Math.min(MAX_LIMIT, Math.max(100, Number(params.limit) || DEFAULT_LIMIT));
|
|
356
|
+
const lines = source ? linesFor(files, source, limit) : [];
|
|
357
|
+
const selectedMeta = sources.find(item => item.id === selectedSource) || null;
|
|
358
|
+
return {
|
|
359
|
+
sources,
|
|
360
|
+
selectedSource,
|
|
361
|
+
selectedMeta,
|
|
362
|
+
limit,
|
|
363
|
+
lines,
|
|
364
|
+
updatedAt: Date.now(),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
export const logsSource = {
|
|
368
|
+
kind: 'logs',
|
|
369
|
+
async snapshot(params = {}) {
|
|
370
|
+
return buildSnapshot(params);
|
|
371
|
+
},
|
|
372
|
+
subscribe(params, push) {
|
|
373
|
+
let stopped = false;
|
|
374
|
+
let lastSignature = '';
|
|
375
|
+
const tick = () => {
|
|
376
|
+
if (stopped)
|
|
377
|
+
return;
|
|
378
|
+
try {
|
|
379
|
+
const snapshot = buildSnapshot(params);
|
|
380
|
+
const signature = JSON.stringify({
|
|
381
|
+
source: snapshot.selectedSource,
|
|
382
|
+
meta: snapshot.selectedMeta,
|
|
383
|
+
count: snapshot.lines.length,
|
|
384
|
+
last: snapshot.lines.at(-1)?.raw || '',
|
|
385
|
+
});
|
|
386
|
+
if (signature !== lastSignature) {
|
|
387
|
+
lastSignature = signature;
|
|
388
|
+
push(snapshot);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch { }
|
|
392
|
+
};
|
|
393
|
+
const timer = setInterval(tick, 2000);
|
|
394
|
+
return () => { stopped = true; clearInterval(timer); };
|
|
395
|
+
},
|
|
396
|
+
};
|