first commit
This commit is contained in:
206
async-agent-lab/live/evidence.ts
Normal file
206
async-agent-lab/live/evidence.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, isAbsolute, join, relative } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const home = homedir();
|
||||
const project = fileURLToPath(new URL('../..', import.meta.url));
|
||||
export const aiwork = process.env.AIWORK_REPO || join(home, 'Desktop/EMdev/dongdong-cowork');
|
||||
const discoveryRoots = [project, join(home, 'Desktop/EMdev'), join(home, '.codex/worktrees'), aiwork];
|
||||
const day = 86_400_000;
|
||||
const shanghai = 8 * 3_600_000;
|
||||
|
||||
export function weekWindow(now = Date.now()) {
|
||||
const today = new Date(now + shanghai);
|
||||
const midnight = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
||||
const start = midnight - ((today.getUTCDay() + 6) % 7) * day - shanghai;
|
||||
const end = Math.min(now, start + 5 * day - 1);
|
||||
const local = (t: number) => new Date(t + shanghai).toISOString().replace('Z', '+08:00');
|
||||
return { start, end, from: local(start), through: local(end), timezone: 'Asia/Shanghai' };
|
||||
}
|
||||
|
||||
export function within(root: string, path: string) {
|
||||
const rel = relative(root, path);
|
||||
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
||||
}
|
||||
|
||||
export function childEnvironment() {
|
||||
// Git, rg and the evidence command only need local paths and locale.
|
||||
return {
|
||||
HOME: home, PATH: process.env.PATH, LANG: process.env.LANG,
|
||||
CODEX_HOME: process.env.CODEX_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
||||
AIWORK_REPO: process.env.AIWORK_REPO, WEEKLY_AUTHOR_EMAILS: process.env.WEEKLY_AUTHOR_EMAILS,
|
||||
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
}
|
||||
|
||||
export function git(cwd: string, ...args: string[]) {
|
||||
return execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
|
||||
encoding: 'utf8', env: childEnvironment(), timeout: 8000, maxBuffer: 24 * 1024 * 1024,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
type Window = ReturnType<typeof weekWindow>;
|
||||
type Session = { cwd: string; time: number };
|
||||
type Source = { source: string; status: string; sessions: Session[] };
|
||||
|
||||
function fromDatabase(source: string, path: string, sql: string, window: Window): Source {
|
||||
if (!existsSync(path)) return { source, status: '本机未找到会话库', sessions: [] };
|
||||
try {
|
||||
const db = new DatabaseSync(path, { readOnly: true, timeout: 300 });
|
||||
try {
|
||||
const sessions = db.prepare(sql).all(window.start, window.end) as unknown as Session[];
|
||||
return { source, status: '已查询', sessions };
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return { source, status: '会话库无法读取', sessions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function fromDSH(window: Window): Source {
|
||||
const source = 'DSH';
|
||||
const dir = join(home, '.dsh/storages/session_projcache/sessions');
|
||||
if (!existsSync(dir)) return { source, status: '本机未找到会话缓存', sessions: [] };
|
||||
const sessions: Session[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||
try {
|
||||
const path = join(dir, entry.name);
|
||||
if (statSync(path).size > 2_000_000) continue;
|
||||
const record = JSON.parse(readFileSync(path, 'utf8')).record;
|
||||
const cwd = record?.identity?.cwd;
|
||||
const time = Number(record?.rows?.sessionListMetadata?.val?.lastPromptAt);
|
||||
if (typeof cwd === 'string' && time >= window.start && time <= window.end) sessions.push({ cwd, time });
|
||||
} catch {
|
||||
// A cache entry may be replaced while DSH is running.
|
||||
}
|
||||
}
|
||||
return { source, status: '已查询', sessions };
|
||||
}
|
||||
|
||||
function sessionSources(window: Window): Source[] {
|
||||
const codexHome = process.env.CODEX_HOME || join(home, '.codex');
|
||||
const codexDB = existsSync(codexHome)
|
||||
? readdirSync(codexHome).filter((file) => /^state_\d+\.sqlite$/.test(file)).sort((a, b) =>
|
||||
Number(b.match(/\d+/)?.[0]) - Number(a.match(/\d+/)?.[0]))[0]
|
||||
: undefined;
|
||||
return [
|
||||
fromDatabase('Codex', join(codexHome, codexDB || 'state_5.sqlite'),
|
||||
'SELECT cwd, COALESCE(updated_at_ms, updated_at * 1000) AS time FROM threads WHERE COALESCE(updated_at_ms, updated_at * 1000) BETWEEN ? AND ?', window),
|
||||
fromDSH(window),
|
||||
fromDatabase('OpenCode', join(process.env.XDG_DATA_HOME || join(home, '.local/share'), 'opencode/opencode.db'),
|
||||
'SELECT directory AS cwd, time_updated AS time FROM session WHERE parent_id IS NULL AND time_updated BETWEEN ? AND ?', window),
|
||||
];
|
||||
}
|
||||
|
||||
export function verifiedEmails() {
|
||||
const configured = process.env.WEEKLY_AUTHOR_EMAILS;
|
||||
if (configured) return configured.split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||
try {
|
||||
return git(aiwork, 'config', '--get', 'user.email').toLowerCase().split(/\r?\n/).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function commits(cwd: string, window: Window, emails: string[]) {
|
||||
if (!emails.length) return [];
|
||||
const detachedHeads = git(cwd, 'worktree', 'list', '--porcelain').split('\n\n')
|
||||
.filter((entry) => entry.split('\n').includes('detached'))
|
||||
.flatMap((entry) => entry.match(/^HEAD ([0-9a-f]{40,64})$/m)?.[1] || []);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = git(cwd, 'log', '--all', ...detachedHeads, '--no-merges', '--fixed-strings',
|
||||
...emails.map((email) => `--author=<${email}>`), '--format=%H%x1f%aI%x1f%ae%x1f%s%x00', '--');
|
||||
} catch {
|
||||
if (!git(cwd, 'for-each-ref', '--count=1', '--format=%(refname)') && !detachedHeads.length) return [];
|
||||
throw new Error(`无法读取 ${basename(cwd)} 的 Git 历史`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return raw.split('\0').flatMap((line) => {
|
||||
const [sha, authored, email, subject] = line.trim().split('\x1f');
|
||||
const timestamp = Date.parse(authored);
|
||||
if (!emails.includes(email?.toLowerCase()) || !(timestamp >= window.start && timestamp <= window.end)) return [];
|
||||
// Same author, author time and subject typically identify a replayed commit.
|
||||
const identity = `${email}\0${authored}\0${subject}`;
|
||||
if (seen.has(identity)) return [];
|
||||
seen.add(identity);
|
||||
return [{ sha: sha.slice(0, 12), authored, subject: subject.slice(0, 250) }];
|
||||
}).sort((a, b) => Date.parse(b.authored) - Date.parse(a.authored)).slice(0, 20);
|
||||
}
|
||||
|
||||
export function repositoryLog(cwd: string) {
|
||||
const window = weekWindow();
|
||||
const emails = verifiedEmails();
|
||||
return { repository: cwd, window, verifiedAuthors: emails.length, commits: commits(cwd, window, emails) };
|
||||
}
|
||||
|
||||
function artifacts(cwd: string, window: Window) {
|
||||
const paths: { path: string; modified: string }[] = [];
|
||||
for (const folder of ['research', 'explainers', 'docs']) {
|
||||
try {
|
||||
for (const entry of readdirSync(join(cwd, folder), { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !/^[\w\u4e00-\u9fa5-]+\.(md|html)$/i.test(entry.name)) continue;
|
||||
const path = join(cwd, folder, entry.name);
|
||||
const stat = statSync(path);
|
||||
if (stat.mtimeMs >= window.start && stat.mtimeMs <= window.end && stat.size < 300_000) {
|
||||
paths.push({ path: `${folder}/${entry.name}`, modified: stat.mtime.toISOString() });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return paths.sort((a, b) => b.modified.localeCompare(a.modified)).slice(0, 8);
|
||||
}
|
||||
|
||||
export function collectEvidence() {
|
||||
const window = weekWindow();
|
||||
const sources = sessionSources(window);
|
||||
const groups = new Map<string, { path: string; worktrees: Set<string>; clues: Set<string> }>();
|
||||
const candidates = new Map<string, Set<string>>();
|
||||
for (const source of sources) {
|
||||
for (const { cwd } of source.sessions) {
|
||||
if (!candidates.has(cwd)) candidates.set(cwd, new Set());
|
||||
candidates.get(cwd)?.add(source.source);
|
||||
}
|
||||
}
|
||||
for (const [cwd, clues] of candidates) {
|
||||
try {
|
||||
if (!isAbsolute(cwd)) continue;
|
||||
const real = realpathSync(cwd);
|
||||
if (!discoveryRoots.some((root) => within(root, real))) continue;
|
||||
const root = git(real, 'rev-parse', '--show-toplevel');
|
||||
if (!discoveryRoots.some((allowed) => within(allowed, root))) continue;
|
||||
const common = git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir');
|
||||
const group = groups.get(common) ?? { path: root, worktrees: new Set(), clues: new Set() };
|
||||
group.worktrees.add(root);
|
||||
for (const clue of clues) group.clues.add(clue);
|
||||
groups.set(common, group);
|
||||
} catch {
|
||||
// Some recent sessions have no Git repo or refer to a removed worktree.
|
||||
}
|
||||
}
|
||||
const emails = verifiedEmails();
|
||||
return {
|
||||
window,
|
||||
sources: sources.map(({ source, status, sessions }) => ({ source, status, sessions: sessions.length })),
|
||||
verifiedAuthors: emails.length,
|
||||
repositories: [...groups.values()].map(({ path, worktrees, clues }) => ({
|
||||
path, sources: [...clues], worktrees: [...worktrees],
|
||||
commits: commits(path, window, emails),
|
||||
artifacts: [...worktrees].flatMap((cwd) => artifacts(cwd, window).map((entry) => ({ cwd, ...entry }))),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const repo = process.argv[2];
|
||||
const evidence = repo ? repositoryLog(repo) : collectEvidence();
|
||||
process.stdout.write(JSON.stringify(evidence) + '\n');
|
||||
}
|
||||
Reference in New Issue
Block a user