mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
* fix(report): each scope reports only the dashboard sessions recorded in it (#785) Every scope read one machine-wide events.jsonl and picked its sessions out by cwd prefix. The user scope excluded nothing, so a user-scope pull reported every project's sessions (and, through the shared reported snapshots, took them from the project's own report); Copilot sends no cwd, so a project never reported its Copilot sessions; and a raw cwd under a symlink or /tmp never matched the realpath'd projectRoot. The hook now stamps each event's dataHome with the data home of the scope the dispatcher resolved (the key the per-scope usage file already uses), and a report keeps only its own scope's events, comparing realpath'd keys. A project also owns its in-repo .teamai key, where hooks record until migration moves it to a partition. Events written before this carry no dataHome: a project keeps those whose realpath'd cwd is under its root, the user scope never reports them. The log stays machine-wide for the dashboard UI, stats --by-repo, session save and the contribute check. Removes the excludeProjectRoots option, which pull only ever passed as [] (the user target exists only when no project config resolved), and the projectRoot option now carried by selfConfig. The usage guide documents how to remove by hand a skill an earlier release pushed into stats/<user>.yaml from another project. * fix(report): each scope keeps its own reported dashboard snapshots (#786) The report sends per-session deltas against reported-*.json snapshots that every scope shared. A session whose events belong to two scopes (a cd into another project mid-session) was then reported by the first scope, and the second compared its own part with the first scope's totals and sent nothing. Each scope now keeps its snapshots in <dataHome>/dashboard/, and the user scope, whose data home holds the shared files, in user-reported-*.json. The first time a scope needs one it copies the shared file, so the first report after the upgrade sends nothing already reported; after that it reads only its own. The user scope moves too, unlike the ticket proposed: had it kept writing the shared file, a project seeding later would copy the user scope's part of a split session and report nothing for its own. The shared file is no longer written, except by an earlier release after a rollback, which only a scope not yet seeded reads.
This commit is contained in:
@@ -37,6 +37,8 @@ All notable changes to this project will be documented in this file. See [standa
|
||||
- A `manifest/roles.yaml` that exists but does not parse now fails the pull for that scope instead of warning and syncing with no role filter at all, for a member with no role as much as for one with a role. The same applies to `init` and `push`, which each fell back to a guess at the namespaces when any error came out of the loader. The legacy role migration skips with a warning instead of failing, so every command, `pull` included, still loads the config and can fetch the fixed manifest; until it can run, the member holds no role rather than every role, so hooks, MCP servers and env variables scoped by `roles:` reach them no more than skills do. For a member with no active project that fallback meant an unfiltered sync, so a broken manifest delivered every namespace it was written to gate. Only an absent manifest still means "this team does not use roles"; an unreadable or empty file is an error, as it now is for `manifest/projects.yaml` too. `push` stops at its scan for such a manifest (exit 2) even with `--role <ns>`, since the scan needs it to tell which namespaces are the member's.
|
||||
- `teamai members list` and `teamai projects members` read the roster registered before the reports switch, so a team upgrading past the orphan-branch split no longer sees "No team members registered" while its `members/` still lives on the default branch. The default-branch copy becomes a read-only inherited root, the way learnings' already was: listed in union with the `teamai-reports` copy, with the branch copy winning when the same file exists on both; nothing is copied or deleted, and a cold `members list` still does not publish the reports branch. Member registration merges against the inherited copy too, so a re-init keeps the original `registeredAt` and projects. Fixes [#735](https://github.com/Tencent/teamai-cli/issues/735).
|
||||
- Cache GC now rejects partial integers such as `12abc`, decimals, zero and unsafe integers for `--max-bytes` and `--stale-days` before deleting anything. An invalid `TEAMAI_CACHE_MAX_BYTES` value falls back to the default 5 GB limit instead of using a numeric prefix.
|
||||
- Each scope reports only the dashboard sessions recorded in it. Every scope read one machine-wide event log and picked its sessions out by the `cwd` they started in, so a user-scope pull reported every project's sessions to the user team, a project never reported its Copilot sessions (Copilot sends no `cwd`), and a session started under a symlinked path (or `/tmp` for `/private/tmp` on macOS) never matched its project. Each event now records the data directory of the scope `hook-dispatch` resolved, and a report keeps only its own scope's. Events recorded by an earlier release go to the project whose root holds their `cwd`, never to the user scope. The usage guide explains how to remove by hand a skill an earlier release reported into `stats/<user>.yaml` from another project (for [#785](https://github.com/Tencent/teamai-cli/issues/785)).
|
||||
- A session whose events belong to two scopes is reported to both teams with the part recorded in each. The snapshots of what was already reported were machine-wide, so after a `cd` into another project mid-session, the second scope to report compared its part with the first scope's totals and sent nothing. Each scope now keeps its own (`<dataHome>/dashboard/reported-*.json`, and `~/.teamai/dashboard/user-reported-*.json` for the user scope), seeded the first time from the shared `~/.teamai/dashboard/reported-*.json`, so the first report after the upgrade sends nothing already reported. The shared file is no longer written; after a rollback an earlier release writes it again, and only a scope not yet seeded reads it (for [#786](https://github.com/Tencent/teamai-cli/issues/786)).
|
||||
- Usage reporting scopes sessions by path on Windows too. The project/user scope filter compared an event's `cwd` against `projectRoot` with a hard-coded `/` separator, so on Windows only a session started in the project root itself matched: every session started in a subdirectory was dropped from the project team's report and counted in the user scope's instead, which is the isolation the usage guide promises. Windows paths are also compared case-insensitively, so a drive letter or a directory name spelled with different case in the two sources no longer leaks a project session into the user scope. POSIX paths keep their own rules: case-sensitive, and a `\` in a filename stays part of the name.
|
||||
- `teamai tags subscribe` and `teamai tags unsubscribe` now invalidate the pull revision cache, as `teamai skill exclude` already does, so the next `teamai pull` applies the new subscriptions instead of reporting "Already synced" when the team repo has not changed.
|
||||
- `teamai pull` now deletes a tombstoned agent under all three render extensions, so the Codex `.toml` and Kiro `.json` copies of a removed agent no longer survive on other machines. The cleanup also runs when the team repo rev is unchanged, so an upgrade reaches machines that already pulled the tombstone with an older CLI. `teamai remove agents <name>` also honours `enabledAgents` and no longer deletes from excluded tools. Fixes [#576](https://github.com/Tencent/teamai-cli/issues/576).
|
||||
|
||||
@@ -312,9 +312,22 @@ already route through `getDataHome()`. Skill usage moved there too (#748):
|
||||
project's report carry every project's skills. The user scope records in
|
||||
`~/.teamai/user-usage.jsonl`, not that old shared `~/.teamai/usage.jsonl`, which
|
||||
an earlier release still writes after a rollback; the shared file is never
|
||||
read. The dashboard is likewise an A2 singleton
|
||||
(events carry `cwd`/`sessionId`); "two projects' events don't mix" is satisfied by
|
||||
`getEventsPath()` reading `HOME` at call time, not by per-project dirs.
|
||||
read. The dashboard stays an A2 singleton: `teamai dashboard`, `stats --by-repo`,
|
||||
`session save` and the contribute check read across scopes. Each event instead
|
||||
carries `dataHome`, the `getDataHome()` of the scope the hook resolved (#785), and a
|
||||
scope's report keeps only its own. An event written before that field existed is
|
||||
attributed by its `cwd`, realpath'd, to the project whose root holds it, never to
|
||||
the user scope. The snapshots of what was already reported are per scope too
|
||||
(#786), because a session can record events in two scopes (a `cd` mid-session):
|
||||
`<dataHome>/dashboard/reported-*.json`, and `~/.teamai/dashboard/user-reported-*.json`
|
||||
for the user scope. The first time a scope needs one it copies the shared
|
||||
`~/.teamai/dashboard/reported-*.json`, so nothing reported before the upgrade is
|
||||
sent again; after that it reads only its own. The shared file is no longer
|
||||
written, except by an earlier release after a rollback, so every scope seeds from
|
||||
what the machine had reported by then, never from another scope's later report.
|
||||
The seed holds a session's whole total, so a session still running at the upgrade
|
||||
that later records in a second scope reports nothing there until that scope's
|
||||
part exceeds it.
|
||||
|
||||
**`anchor` on save.** Previously only migration wrote a partition's `anchor`
|
||||
reverse-lookup file, so freshly-init'd partitions had none. `saveLocalConfigForScope`
|
||||
|
||||
+28
-1
@@ -384,6 +384,23 @@ branch, exactly where they are. Nothing is copied, deleted or migrated: that
|
||||
directory is still read, so every existing learning keeps coming back from
|
||||
`teamai recall`. New learnings go to `teamai-learnings`.
|
||||
|
||||
**Removing a skill another project reported into your `stats/`.** Before skill
|
||||
usage was kept per scope, whichever project pulled next reported every
|
||||
project's skills, so `stats/<user>.yaml` on `teamai-reports` can count a skill
|
||||
that belongs to an unrelated repo. Those events recorded no directory, so
|
||||
teamai cannot attribute them and never rewrites the file. Remove the entry by
|
||||
hand, from a clone of your own so teamai's `reports-wt/` checkout is untouched:
|
||||
|
||||
```bash
|
||||
git clone --branch teamai-reports --single-branch <team-repo-url> teamai-reports
|
||||
cd teamai-reports
|
||||
# delete the skill's entry under `skills:` in stats/<user>.yaml
|
||||
git commit -am "stats: remove <skill> reported from another project"
|
||||
git push origin teamai-reports
|
||||
```
|
||||
|
||||
The next report reads the branch first, so the entry does not come back.
|
||||
|
||||
**Minimum Git permissions with a protected default branch.**
|
||||
|
||||
A member needs to:
|
||||
@@ -1744,7 +1761,17 @@ Pull waits up to 5 seconds for the reporting batch, then continues its other
|
||||
work while reporting finishes. A late successful push still updates the local
|
||||
reported snapshots. Skill usage is recorded per scope, in the data directory of
|
||||
the project teamai is set up for where the session ran (or the user scope), so
|
||||
each target reports only its own; a directory without teamai records none. A
|
||||
each target reports only its own; a directory without teamai records none.
|
||||
Dashboard sessions stay in one machine-wide `~/.teamai/dashboard/events.jsonl`,
|
||||
but each event records the data home of its scope, so a scope reports only the
|
||||
sessions recorded in it: a user-scope pull no longer reports a project's
|
||||
sessions, and a project reports its Copilot sessions and sessions started under
|
||||
a symlinked path. Events recorded by an earlier release carry no data home: a
|
||||
project reports those whose directory lies under its root, the user scope
|
||||
reports none of them. Each scope also keeps its own snapshot of what it already
|
||||
reported, so a session that moved into another project mid-session reaches both
|
||||
teams with the part recorded in each; the first report after upgrading starts
|
||||
from the snapshot every scope used to share, so nothing is reported twice. A
|
||||
target removes its usage events only after it confirms success; failed pushes
|
||||
preserve them. The affected sync locks remain
|
||||
held until reporting finishes, preventing another pull from racing the report.
|
||||
|
||||
@@ -357,6 +357,21 @@ learnings 迁到独立分支之前团队已经写下的内容,原地留在默
|
||||
不迁移:该目录仍会被读取,所有既有 learning 依然能从 `teamai recall` 中找回。新的
|
||||
learning 写入 `teamai-learnings`。
|
||||
|
||||
**删除其他项目上报进你 `stats/` 的 skill。** 在 skill 使用按 scope 记录之前,下一个
|
||||
执行 pull 的项目会上报所有项目的 skill,因此 `teamai-reports` 上的 `stats/<user>.yaml` 可能
|
||||
统计了属于无关仓库的 skill。这些事件没有记录目录,teamai 无法归属,也不会改写该
|
||||
文件。请手动删除该条目,在单独的 clone 中操作,不要动 teamai 的 `reports-wt/` 检出:
|
||||
|
||||
```bash
|
||||
git clone --branch teamai-reports --single-branch <team-repo-url> teamai-reports
|
||||
cd teamai-reports
|
||||
# 删除 stats/<user>.yaml 中 `skills:` 下该 skill 的条目
|
||||
git commit -am "stats: remove <skill> reported from another project"
|
||||
git push origin teamai-reports
|
||||
```
|
||||
|
||||
下一次上报会先读取该分支,所以条目不会再出现。
|
||||
|
||||
**默认分支受保护时所需的最小 Git 权限。**
|
||||
|
||||
成员需要:
|
||||
@@ -1684,7 +1699,13 @@ teamai remove rules <name> --force # 跳过确认,用于脚本和 CI
|
||||
Pull 对整批统计上报最多等待 5 秒,之后继续其他工作,上报任务仍会完成。
|
||||
超时后推送成功,仍会更新本地已上报快照。skill 使用按 scope 记录:写入会话所在
|
||||
目录对应的已配置 teamai 项目的数据目录(或 user scope),因此每个目标只上报
|
||||
自己的使用;未配置 teamai 的目录不记录。目标确认成功后才清理自己的使用事件,
|
||||
自己的使用;未配置 teamai 的目录不记录。Dashboard 会话仍写入整机共用的
|
||||
`~/.teamai/dashboard/events.jsonl`,但每条事件都记下所属 scope 的数据目录(data home),因此每个 scope
|
||||
只上报在其中记录的会话:user scope 的 pull 不再上报项目的会话,项目会上报自己的
|
||||
Copilot 会话以及从软链接路径启动的会话。旧版本记录的事件没有数据目录:项目上报目录
|
||||
位于其根目录下的那些,user scope 一条也不上报。每个 scope 还各自保存已上报快照,
|
||||
因此会话中途切换到另一个项目时,两个团队各自收到在其中记录的那部分;升级后的首次上报
|
||||
从原先各 scope 共用的快照开始,不会重复上报。目标确认成功后才清理自己的使用事件,
|
||||
推送失败会保留事件。上报完成前继续持有相关同步锁,
|
||||
避免另一次 Pull 与尚未完成的上报竞争。
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Which scope reports a dashboard session (#785): the real dispatcher records
|
||||
* the sessions, the real report reads them, observed through the stats file
|
||||
* each scope pushes. Only the reports-branch push and the handlers that reach
|
||||
* the network or spawn processes are stubbed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
import type { LocalConfig } from '../types.js';
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:child_process')>()),
|
||||
// The detached background pass: run inline instead (bgOnly) so its writes are observable.
|
||||
spawn: vi.fn(() => ({ on: vi.fn(), stdin: { on: vi.fn(), end: vi.fn((_: string, done: () => void) => done()) }, unref: vi.fn() })),
|
||||
}));
|
||||
vi.mock('../pull.js', () => ({ pull: vi.fn(async () => undefined) }));
|
||||
vi.mock('../update.js', () => ({ doUpdate: vi.fn(async () => undefined) }));
|
||||
vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: vi.fn(async () => null) }));
|
||||
// Each scope's reports branch is a plain directory next to its team repo.
|
||||
vi.mock('../utils/reports-branch.js', () => ({
|
||||
updateReports: vi.fn(async (cfg: LocalConfig, write: (wt: string) => Promise<unknown>) => {
|
||||
const dir = path.join(path.dirname(cfg.repo.localPath), 'reports-wt');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return (await write(dir)) != null;
|
||||
}),
|
||||
}));
|
||||
|
||||
const { hookDispatchCli } = await import('../hook-dispatch-cli.js');
|
||||
const { resolveProjectDataHome, saveLocalConfigForScope, resolveConfigForDir, loadLocalConfig } = await import('../config.js');
|
||||
const { reportUsageToTeam } = await import('../team-push.js');
|
||||
|
||||
let tmp: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dashboard-scope-')));
|
||||
originalHome = process.env.HOME;
|
||||
originalCwd = process.cwd();
|
||||
process.env.HOME = path.join(tmp, 'home');
|
||||
fs.mkdirSync(process.env.HOME);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const teamaiHome = () => path.join(tmp, 'home', '.teamai');
|
||||
|
||||
/** Run one hook event the way a host does: foreground pass, then the background pass. */
|
||||
async function hook(event: string, tool: string, payload: Record<string, unknown>): Promise<void> {
|
||||
for (const bgOnly of [false, true]) {
|
||||
const stdinFile = path.join(tmp, `stdin-${Date.now()}-${Math.random()}.json`);
|
||||
fs.writeFileSync(stdinFile, JSON.stringify(payload));
|
||||
await hookDispatchCli(event, tool, '*', { bgOnly, stdinFile });
|
||||
}
|
||||
}
|
||||
|
||||
/** One complete session: start, a prompt, stop. */
|
||||
async function session(tool: string, base: Record<string, unknown>): Promise<void> {
|
||||
await hook('session-start', tool, { ...base, hook_event_name: 'SessionStart' });
|
||||
await hook('prompt-submit', tool, { ...base, hook_event_name: 'UserPromptSubmit', prompt: 'hello' });
|
||||
await hook('stop', tool, { ...base, hook_event_name: 'Stop' });
|
||||
}
|
||||
|
||||
async function setup(): Promise<{ root: string; user: LocalConfig; project: LocalConfig }> {
|
||||
const userRepo = path.join(teamaiHome(), 'team-repo');
|
||||
fs.mkdirSync(userRepo, { recursive: true });
|
||||
fs.writeFileSync(path.join(teamaiHome(), 'config.yaml'),
|
||||
`repo:\n localPath: ${userRepo}\n remote: https://example.test/acme/user-team.git\n kind: git\nusername: tester\nscope: user\n`);
|
||||
const root = path.join(tmp, 'project-p');
|
||||
fs.mkdirSync(root);
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
const dataHome = await resolveProjectDataHome(root);
|
||||
fs.mkdirSync(path.join(dataHome, 'team-repo'), { recursive: true });
|
||||
await saveLocalConfigForScope({
|
||||
repo: { localPath: path.join(dataHome, 'team-repo'), remote: 'https://example.test/acme/team-p.git', kind: 'git' },
|
||||
username: 'tester', scope: 'project', projectRoot: root, additionalRoles: [], dataHome,
|
||||
});
|
||||
// The configs as pull resolves them.
|
||||
const user = await loadLocalConfig();
|
||||
const project = await resolveConfigForDir(root);
|
||||
if (!user || !project) throw new Error('fixture configs did not resolve');
|
||||
return { root, user, project };
|
||||
}
|
||||
|
||||
/** Report the way pull does, and return the stats that scope has pushed so far. */
|
||||
async function report(config: LocalConfig): Promise<unknown> {
|
||||
await reportUsageToTeam(config.repo.localPath, config.username, { skipTruncate: true, selfConfig: config });
|
||||
const statsPath = path.join(path.dirname(config.repo.localPath), 'reports-wt', 'stats', `${config.username}.yaml`);
|
||||
return fs.existsSync(statsPath) ? YAML.parse(fs.readFileSync(statsPath, 'utf-8')) : null;
|
||||
}
|
||||
|
||||
/** Report the way pull does, and return the sessions that scope's stats now hold. */
|
||||
async function reportedSessions(config: LocalConfig): Promise<number> {
|
||||
const stats = await report(config);
|
||||
const daily = stats && typeof stats === 'object' && 'daily' in stats && stats.daily && typeof stats.daily === 'object' ? stats.daily : {};
|
||||
return Object.values(daily).reduce((sum: number, day: unknown) =>
|
||||
sum + (day && typeof day === 'object' && 'sessionsEnded' in day && typeof day.sessionsEnded === 'number' ? day.sessionsEnded : 0), 0);
|
||||
}
|
||||
|
||||
describe('each scope reports only the dashboard sessions recorded in it (#785)', () => {
|
||||
it('a user-scope report leaves out a session recorded in a project, and the project reports it', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
await session('claude', { session_id: 'sid-p', cwd: root });
|
||||
|
||||
expect(await reportedSessions(user)).toBe(0);
|
||||
expect(await reportedSessions(project)).toBe(1);
|
||||
});
|
||||
|
||||
it('a user-scope session is reported by the user scope only', async () => {
|
||||
const { user, project } = await setup();
|
||||
const elsewhere = path.join(tmp, 'elsewhere');
|
||||
fs.mkdirSync(elsewhere);
|
||||
await session('claude', { session_id: 'sid-u', cwd: elsewhere });
|
||||
|
||||
expect(await reportedSessions(project)).toBe(0);
|
||||
expect(await reportedSessions(user)).toBe(1);
|
||||
});
|
||||
|
||||
it('a Copilot session, which records no cwd, is reported by its project', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
await session('copilot', { session_id: 'copilot-p', cwd: root });
|
||||
|
||||
expect(await reportedSessions(user)).toBe(0);
|
||||
expect(await reportedSessions(project)).toBe(1);
|
||||
});
|
||||
|
||||
it('a session started under a symlinked path of the project is reported by the project', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
const link = path.join(tmp, 'link-p');
|
||||
fs.symlinkSync(root, link, 'dir');
|
||||
await session('claude', { session_id: 'sid-link', cwd: link });
|
||||
|
||||
expect(await reportedSessions(user)).toBe(0);
|
||||
expect(await reportedSessions(project)).toBe(1);
|
||||
});
|
||||
|
||||
it('events recorded before sessions carried a data home go to their project, never to the user scope', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
const link = path.join(tmp, 'link-p');
|
||||
fs.symlinkSync(root, link, 'dir');
|
||||
const timestamp = new Date().toISOString();
|
||||
const old = (sessionId: string, cwd: string | undefined) => [
|
||||
{ type: 'session_start', timestamp, sessionId, tool: 'claude', cwd },
|
||||
{ type: 'stop', timestamp, sessionId, tool: 'claude', cwd },
|
||||
];
|
||||
const eventsPath = path.join(teamaiHome(), 'dashboard', 'events.jsonl');
|
||||
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
||||
fs.writeFileSync(eventsPath, [
|
||||
...old('old-in-p', path.join(root, 'src')),
|
||||
...old('old-via-link', link),
|
||||
...old('old-elsewhere', path.join(tmp, 'elsewhere')),
|
||||
...old('old-no-cwd', undefined),
|
||||
].map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
|
||||
expect(await reportedSessions(user)).toBe(0);
|
||||
expect(await reportedSessions(project)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('each scope keeps its own reported snapshot (#786)', () => {
|
||||
/** The prompts a scope's stats hold, 0 before its first push. */
|
||||
async function reportedPrompts(config: LocalConfig): Promise<number> {
|
||||
const stats = await report(config);
|
||||
return stats && typeof stats === 'object' && 'prompts' in stats && typeof stats.prompts === 'number' ? stats.prompts : 0;
|
||||
}
|
||||
|
||||
const shared = (name: string) => path.join(teamaiHome(), 'dashboard', `reported-${name}.json`);
|
||||
const SNAPSHOTS = ['interventions', 'prompt-tokens', 'daily-sessions'];
|
||||
|
||||
/** The shared snapshots as a release before #786 left them, with `prompts` reported per session. */
|
||||
function writeSharedSnapshots(prompts: Record<string, number>, date: string): void {
|
||||
const entries = (value: (n: number) => unknown) =>
|
||||
Object.fromEntries(Object.entries(prompts).map(([sid, n]) => [sid, value(n)]));
|
||||
const values: Record<string, unknown> = {
|
||||
interventions: entries(() => ({ interrupt: 0, toolReject: 0, correction: 0 })),
|
||||
'prompt-tokens': entries((n) => ({ prompts: n, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } })),
|
||||
// Longer than any fixture session, so no duration is left to report.
|
||||
'daily-sessions': entries((n) => ({ date, prompts: n, durationMs: 3_600_000, succeeded: 1, corrected: 0 })),
|
||||
};
|
||||
fs.mkdirSync(path.join(teamaiHome(), 'dashboard'), { recursive: true });
|
||||
for (const name of SNAPSHOTS) fs.writeFileSync(shared(name), JSON.stringify(values[name]));
|
||||
}
|
||||
|
||||
async function prompts(sessionId: string, cwd: string, count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await hook('prompt-submit', 'claude', { session_id: sessionId, cwd, hook_event_name: 'UserPromptSubmit', prompt: `p${i}` });
|
||||
}
|
||||
}
|
||||
|
||||
it('a session split across the user scope and a project reaches both teams with its own counts', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
const elsewhere = path.join(tmp, 'elsewhere');
|
||||
fs.mkdirSync(elsewhere);
|
||||
await hook('session-start', 'claude', { session_id: 'split', cwd: elsewhere, hook_event_name: 'SessionStart' });
|
||||
await prompts('split', elsewhere, 3);
|
||||
await prompts('split', root, 2); // `cd` into the project mid-session
|
||||
await hook('stop', 'claude', { session_id: 'split', cwd: root, hook_event_name: 'Stop' });
|
||||
|
||||
expect(await reportedPrompts(user)).toBe(3);
|
||||
expect(await reportedPrompts(project)).toBe(2);
|
||||
});
|
||||
|
||||
it('a session split across two projects reaches both teams with its own counts', async () => {
|
||||
const { root, project } = await setup();
|
||||
const rootQ = path.join(tmp, 'project-q');
|
||||
fs.mkdirSync(rootQ);
|
||||
execFileSync('git', ['init', '-q'], { cwd: rootQ });
|
||||
const dataHomeQ = await resolveProjectDataHome(rootQ);
|
||||
fs.mkdirSync(path.join(dataHomeQ, 'team-repo'), { recursive: true });
|
||||
await saveLocalConfigForScope({
|
||||
repo: { localPath: path.join(dataHomeQ, 'team-repo'), remote: 'https://example.test/acme/team-q.git', kind: 'git' },
|
||||
username: 'tester', scope: 'project', projectRoot: rootQ, additionalRoles: [], dataHome: dataHomeQ,
|
||||
});
|
||||
const projectQ = await resolveConfigForDir(rootQ);
|
||||
if (!projectQ) throw new Error('fixture config Q did not resolve');
|
||||
await hook('session-start', 'claude', { session_id: 'split', cwd: root, hook_event_name: 'SessionStart' });
|
||||
await prompts('split', root, 3);
|
||||
await prompts('split', rootQ, 2);
|
||||
await hook('stop', 'claude', { session_id: 'split', cwd: rootQ, hook_event_name: 'Stop' });
|
||||
|
||||
expect(await reportedPrompts(project)).toBe(3);
|
||||
expect(await reportedPrompts(projectQ)).toBe(2);
|
||||
});
|
||||
|
||||
it('the first report after the upgrade sends nothing a shared snapshot already reported', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
const elsewhere = path.join(tmp, 'elsewhere');
|
||||
fs.mkdirSync(elsewhere);
|
||||
await session('claude', { session_id: 'old-p', cwd: root });
|
||||
await session('claude', { session_id: 'old-u', cwd: elsewhere });
|
||||
writeSharedSnapshots({ 'old-p': 1, 'old-u': 1 }, new Date().toISOString().slice(0, 10));
|
||||
|
||||
expect(await report(user)).toBeNull();
|
||||
expect(await report(project)).toBeNull();
|
||||
});
|
||||
|
||||
it('once seeded, a scope reads and writes only its own snapshot, even after a rollback rewrites the shared one', async () => {
|
||||
const { root, user, project } = await setup();
|
||||
const elsewhere = path.join(tmp, 'elsewhere');
|
||||
fs.mkdirSync(elsewhere);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
writeSharedSnapshots({ 'old-p': 1 }, today);
|
||||
const before = SNAPSHOTS.map((name) => fs.readFileSync(shared(name), 'utf-8'));
|
||||
|
||||
await session('claude', { session_id: 'new-p', cwd: root });
|
||||
expect(await reportedPrompts(project)).toBe(1);
|
||||
await session('claude', { session_id: 'new-u', cwd: elsewhere });
|
||||
expect(await reportedPrompts(user)).toBe(1);
|
||||
expect(SNAPSHOTS.map((name) => fs.readFileSync(shared(name), 'utf-8'))).toEqual(before);
|
||||
|
||||
// An earlier release, after a rollback, records and reports two more
|
||||
// sessions and writes the shared snapshots again.
|
||||
await session('claude', { session_id: 'rollback-p', cwd: root });
|
||||
await session('claude', { session_id: 'rollback-u', cwd: elsewhere });
|
||||
writeSharedSnapshots({ 'old-p': 1, 'rollback-p': 1, 'rollback-u': 1 }, today);
|
||||
|
||||
// Both scopes were seeded before the rollback and read only their own
|
||||
// snapshot, so each reports its session again, as the ticket asks.
|
||||
expect(await reportedPrompts(project)).toBe(2);
|
||||
expect(await reportedPrompts(user)).toBe(2);
|
||||
});
|
||||
|
||||
it('a scope first seeded after a rollback skips what the earlier release reported', async () => {
|
||||
const { root, project } = await setup();
|
||||
await session('claude', { session_id: 'rollback-p', cwd: root });
|
||||
writeSharedSnapshots({ 'rollback-p': 1 }, new Date().toISOString().slice(0, 10));
|
||||
|
||||
expect(await report(project)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -41,14 +41,16 @@ function fixture(agent: keyof typeof agents, provider: string) {
|
||||
const dashboard = path.join(home, '.teamai/dashboard');
|
||||
const timestamp = new Date().toISOString();
|
||||
const usageLine = JSON.stringify({ skill: 'review', tool: agent, timestamp }) + '\n';
|
||||
// A session the user scope recorded (#785).
|
||||
const dataHome = path.join(home, '.teamai');
|
||||
function seedEvents() {
|
||||
fs.mkdirSync(dashboard, { recursive: true });
|
||||
fs.writeFileSync(usage, usageLine);
|
||||
fs.writeFileSync(path.join(dashboard, 'events.jsonl'), [
|
||||
{ type: 'session_start', timestamp, sessionId: 's1', tool: agent, cwd: sandbox },
|
||||
{ type: 'prompt_submit', timestamp, sessionId: 's1', tool: agent, promptSummary: 'review' },
|
||||
{ type: 'session_start', timestamp, sessionId: 's1', tool: agent, cwd: sandbox, dataHome },
|
||||
{ type: 'prompt_submit', timestamp, sessionId: 's1', tool: agent, promptSummary: 'review', dataHome },
|
||||
{ type: 'stop', timestamp, sessionId: 's1', tool: agent, interventions: { interrupt: 1, toolReject: 0 },
|
||||
tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 } },
|
||||
tokens: { input: 10, output: 5, cacheRead: 0, cacheCreation: 0 }, dataHome },
|
||||
].map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
}
|
||||
function receiver(mode: 'slow' | 'reject' | 'normal') {
|
||||
@@ -84,7 +86,12 @@ function fixture(agent: keyof typeof agents, provider: string) {
|
||||
function stats() {
|
||||
return YAML.parse(git(['show', 'teamai-reports:stats/alice.yaml'], remote, env));
|
||||
}
|
||||
return { home, usage, usageLine, dashboard, seedEvents, receiver, pull, stats };
|
||||
/** The user scope's reported snapshot (#786), `{}` before the report writes one. */
|
||||
function snapshot(name: string): Record<string, unknown> {
|
||||
const p = path.join(dashboard, `user-reported-${name}.json`);
|
||||
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : {};
|
||||
}
|
||||
return { home, usage, usageLine, seedEvents, receiver, pull, stats, snapshot };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -103,7 +110,7 @@ describe('real CLI report completion', () => {
|
||||
f.receiver('slow');
|
||||
const output = await f.pull(() => {
|
||||
expect(fs.readFileSync(f.usage, 'utf8')).toBe(f.usageLine);
|
||||
expect(fs.existsSync(path.join(f.dashboard, 'reported-prompt-tokens.json'))).toBe(false);
|
||||
expect(f.snapshot('prompt-tokens').s1).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(f.home, '.teamai/.sync-lock'))).toBe(true);
|
||||
// An event arriving during the push must survive cleanup of the batch.
|
||||
fs.appendFileSync(f.usage, f.usageLine);
|
||||
@@ -112,7 +119,7 @@ describe('real CLI report completion', () => {
|
||||
expect(f.stats().skills.review.count).toBe(1);
|
||||
expect(fs.readFileSync(f.usage, 'utf8')).toBe(f.usageLine);
|
||||
for (const name of ['interventions', 'prompt-tokens', 'daily-sessions']) {
|
||||
expect(JSON.parse(fs.readFileSync(path.join(f.dashboard, `reported-${name}.json`), 'utf8')).s1).toBeDefined();
|
||||
expect(f.snapshot(name).s1).toBeDefined();
|
||||
}
|
||||
expect(fs.existsSync(path.join(f.home, '.teamai/.sync-lock'))).toBe(false);
|
||||
f.receiver('normal');
|
||||
@@ -136,7 +143,7 @@ describe('real CLI report completion', () => {
|
||||
f.receiver('reject');
|
||||
await f.pull();
|
||||
expect(fs.readFileSync(f.usage, 'utf8')).toBe(f.usageLine);
|
||||
expect(fs.existsSync(path.join(f.dashboard, 'reported-prompt-tokens.json'))).toBe(false);
|
||||
expect(f.snapshot('prompt-tokens').s1).toBeUndefined();
|
||||
f.receiver('normal');
|
||||
await f.pull();
|
||||
const stats = f.stats();
|
||||
|
||||
@@ -8,7 +8,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
import { getReportsDir, REPORTS_WORKTREE_DIRNAME, type LocalConfig } from '../types.js';
|
||||
import { getDataHome, getReportsDir, REPORTS_WORKTREE_DIRNAME, type LocalConfig } from '../types.js';
|
||||
import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js';
|
||||
import { pushRepoDirectly } from '../utils/git.js';
|
||||
import { reportUsageToTeam } from '../team-push.js';
|
||||
@@ -105,11 +105,13 @@ describe('git-kind reports branch', () => {
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const eventsDir = path.join(process.env.HOME!, '.teamai', 'dashboard');
|
||||
// A session the user scope recorded.
|
||||
const dataHome = getDataHome(cfg);
|
||||
fs.mkdirSync(eventsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(eventsDir, 'events.jsonl'),
|
||||
`${JSON.stringify({ type: 'session_start', timestamp: ts, sessionId: 's1', tool: 'claude', cwd: '/p' })}\n` +
|
||||
`${JSON.stringify({ type: 'stop', timestamp: ts, sessionId: 's1', tool: 'claude', interventions: { interrupt: 1, toolReject: 0 } })}\n`,
|
||||
`${JSON.stringify({ type: 'session_start', timestamp: ts, sessionId: 's1', tool: 'claude', cwd: '/p', dataHome })}\n` +
|
||||
`${JSON.stringify({ type: 'stop', timestamp: ts, sessionId: 's1', tool: 'claude', dataHome, interventions: { interrupt: 1, toolReject: 0 } })}\n`,
|
||||
);
|
||||
await reportUsageToTeam(clone, 'alice', { skipTruncate: true, selfConfig: cfg });
|
||||
|
||||
@@ -672,10 +674,10 @@ describe('skill usage stays in the scope that recorded it (#748)', () => {
|
||||
await useSkill(a.root, 'skill-a');
|
||||
await useSkill(c.root, 'skill-c');
|
||||
|
||||
await reportUsageToTeam(a.config.repo.localPath, 'alice', { projectRoot: a.root, selfConfig: a.config });
|
||||
await reportUsageToTeam(a.config.repo.localPath, 'alice', { selfConfig: a.config });
|
||||
expect(await reportedSkills(a.origin)).toEqual(['skill-a']);
|
||||
|
||||
await reportUsageToTeam(c.config.repo.localPath, 'alice', { projectRoot: c.root, selfConfig: c.config });
|
||||
await reportUsageToTeam(c.config.repo.localPath, 'alice', { selfConfig: c.config });
|
||||
expect(await reportedSkills(c.origin)).toEqual(['skill-c']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { filterEventsByScope } from '../team-push.js';
|
||||
import type { DashboardEvent } from '../types.js';
|
||||
import type { DashboardEvent, LocalConfig } from '../types.js';
|
||||
|
||||
function makeEvent(cwd: string | undefined, sessionId = 's1'): DashboardEvent {
|
||||
return { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId, tool: 'claude', cwd };
|
||||
/** An event; without `dataHome` it is one written before events carried one, attributed by cwd. */
|
||||
function makeEvent(cwd: string | undefined, sessionId = 's1', dataHome?: string): DashboardEvent {
|
||||
return { type: 'prompt_submit', timestamp: new Date().toISOString(), sessionId, tool: 'claude', cwd, dataHome };
|
||||
}
|
||||
|
||||
const repo = { localPath: '/unused/team-repo', remote: 'https://example.test/team.git' };
|
||||
|
||||
function projectScope(projectRoot: string, dataHome = '/home/jeff/.teamai/projects/p'): LocalConfig {
|
||||
return { repo, username: 'jeff', scope: 'project', additionalRoles: [], projectRoot, dataHome };
|
||||
}
|
||||
|
||||
function userScope(dataHome = '/home/jeff/.teamai'): LocalConfig {
|
||||
return { repo, username: 'jeff', scope: 'user', additionalRoles: [], dataHome };
|
||||
}
|
||||
|
||||
async function ids(events: DashboardEvent[], config?: LocalConfig): Promise<string[]> {
|
||||
return (await filterEventsByScope(events, config)).map((e) => e.sessionId);
|
||||
}
|
||||
|
||||
describe('filterEventsByScope', () => {
|
||||
@@ -15,50 +33,80 @@ describe('filterEventsByScope', () => {
|
||||
makeEvent(undefined, 's5'),
|
||||
];
|
||||
|
||||
it('returns all events when no filter is provided', () => {
|
||||
expect(filterEventsByScope(events)).toEqual(events);
|
||||
expect(filterEventsByScope(events, {})).toEqual(events);
|
||||
it('returns all events when no scope is given', async () => {
|
||||
expect(await filterEventsByScope(events)).toEqual(events);
|
||||
});
|
||||
|
||||
it('filters to projectRoot (exact match and subdirectories)', () => {
|
||||
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a' });
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['s1', 's2']);
|
||||
});
|
||||
describe('events that carry a data home', () => {
|
||||
const scoped: DashboardEvent[] = [
|
||||
makeEvent('/Users/jeff/project-a', 'p1', '/home/jeff/.teamai/projects/p'),
|
||||
makeEvent(undefined, 'p2', '/home/jeff/.teamai/projects/p'),
|
||||
makeEvent('/Users/jeff/project-a', 'u1', '/home/jeff/.teamai'),
|
||||
makeEvent('/Users/jeff/project-a', 'q1', '/home/jeff/.teamai/projects/q'),
|
||||
];
|
||||
|
||||
it('projectRoot with trailing slash works the same', () => {
|
||||
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a/' });
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('excludeProjectRoots removes matching events and keeps the rest', () => {
|
||||
const result = filterEventsByScope(events, { excludeProjectRoots: ['/Users/jeff/project-a'] });
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['s3', 's4', 's5']);
|
||||
});
|
||||
|
||||
it('excludeProjectRoots with multiple roots', () => {
|
||||
const result = filterEventsByScope(events, {
|
||||
excludeProjectRoots: ['/Users/jeff/project-a', '/Users/jeff/project-b'],
|
||||
it('a project keeps its own, whatever their cwd', async () => {
|
||||
expect(await ids(scoped, projectScope('/Users/jeff/project-a'))).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('the user scope keeps its own and no project\'s', async () => {
|
||||
expect(await ids(scoped, userScope())).toEqual(['u1']);
|
||||
});
|
||||
|
||||
it('a project keeps what it recorded under its in-repo .teamai before moving to a partition', async () => {
|
||||
const legacy = [makeEvent(undefined, 'l1', '/Users/jeff/project-a/.teamai')];
|
||||
expect(await ids(legacy, projectScope('/Users/jeff/project-a'))).toEqual(['l1']);
|
||||
expect(await ids(legacy, userScope())).toEqual([]);
|
||||
});
|
||||
|
||||
it('a project rooted at HOME does not take the user scope\'s events', async () => {
|
||||
const home = os.homedir();
|
||||
const evts = [makeEvent(undefined, 'u1', path.join(home, '.teamai'))];
|
||||
expect(await ids(evts, projectScope(home))).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches a data home reached through a symlink', async () => {
|
||||
const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-scope-key-')));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmp, 'real', '.teamai'), { recursive: true });
|
||||
fs.symlinkSync(path.join(tmp, 'real'), path.join(tmp, 'link'), 'dir');
|
||||
const evts = [makeEvent(undefined, 'k1', path.join(tmp, 'link', '.teamai'))];
|
||||
const config = projectScope(path.join(tmp, 'real'), path.join(tmp, 'real', '.teamai'));
|
||||
expect(await ids(evts, config)).toEqual(['k1']);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('a Windows data home matches whatever its case or separators', async () => {
|
||||
const win = [makeEvent(undefined, 'w1', 'C:\\Users\\Jeff\\.teamai'), makeEvent(undefined, 'w2', 'C:\\Users\\jeff\\.teamai\\projects\\p')];
|
||||
expect(await ids(win, userScope('c:/users/jeff/.teamai'))).toEqual(['w1']);
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['s3', 's5']);
|
||||
});
|
||||
|
||||
it('events with undefined cwd are kept by excludeProjectRoots', () => {
|
||||
const result = filterEventsByScope(events, { excludeProjectRoots: ['/Users/jeff/project-a'] });
|
||||
expect(result.find((e) => e.sessionId === 's5')).toBeDefined();
|
||||
it('the user scope never keeps events without a data home', async () => {
|
||||
expect(await ids(events, userScope())).toEqual([]);
|
||||
});
|
||||
|
||||
it('events with undefined cwd are excluded by projectRoot', () => {
|
||||
const result = filterEventsByScope(events, { projectRoot: '/Users/jeff/project-a' });
|
||||
expect(result.find((e) => e.sessionId === 's5')).toBeUndefined();
|
||||
// Events without a data home go to the project whose root holds their cwd.
|
||||
it('filters to projectRoot (exact match and subdirectories)', async () => {
|
||||
expect(await ids(events, projectScope('/Users/jeff/project-a'))).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('does not match partial directory name prefixes', () => {
|
||||
it('projectRoot with trailing slash works the same', async () => {
|
||||
expect(await ids(events, projectScope('/Users/jeff/project-a/'))).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('events with undefined cwd are excluded by projectRoot', async () => {
|
||||
expect(await ids(events, projectScope('/Users/jeff/project-a'))).not.toContain('s5');
|
||||
});
|
||||
|
||||
it('does not match partial directory name prefixes', async () => {
|
||||
const evts = [
|
||||
makeEvent('/Users/jeff/project-ab', 'x1'),
|
||||
makeEvent('/Users/jeff/project-a', 'x2'),
|
||||
];
|
||||
const result = filterEventsByScope(evts, { projectRoot: '/Users/jeff/project-a' });
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['x2']);
|
||||
expect(await ids(evts, projectScope('/Users/jeff/project-a'))).toEqual(['x2']);
|
||||
});
|
||||
|
||||
// Windows paths are plain strings here, so these run on the ubuntu CI too.
|
||||
@@ -73,83 +121,43 @@ describe('filterEventsByScope', () => {
|
||||
makeEvent('C:\\Users\\jeff\\other-work', 'w4'),
|
||||
];
|
||||
|
||||
it('filters to projectRoot including subdirectories', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
projectRoot: 'C:\\Users\\jeff\\project-a',
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w1', 'w2']);
|
||||
it('filters to projectRoot including subdirectories', async () => {
|
||||
expect(await ids(winEvents, projectScope('C:\\Users\\jeff\\project-a'))).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('excludeProjectRoots removes subdirectory sessions too', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
excludeProjectRoots: ['C:\\Users\\jeff\\project-a'],
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w3', 'w4']);
|
||||
it('matches a root and a cwd that disagree on separator style', async () => {
|
||||
expect(await ids(winEvents, projectScope('C:/Users/jeff/project-a'))).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('matches a root and a cwd that disagree on separator style', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
projectRoot: 'C:/Users/jeff/project-a',
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w1', 'w2']);
|
||||
it('trailing backslash on the root works the same', async () => {
|
||||
expect(await ids(winEvents, projectScope('C:\\Users\\jeff\\project-a\\'))).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('trailing backslash on the root works the same', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
projectRoot: 'C:\\Users\\jeff\\project-a\\',
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w1', 'w2']);
|
||||
it('ignores drive-letter and directory casing', async () => {
|
||||
expect(await ids(winEvents, projectScope('c:\\users\\JEFF\\Project-A'))).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('ignores drive-letter and directory casing', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
projectRoot: 'c:\\users\\JEFF\\Project-A',
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w1', 'w2']);
|
||||
});
|
||||
|
||||
it('excludeProjectRoots ignores casing too', () => {
|
||||
const result = filterEventsByScope(winEvents, {
|
||||
excludeProjectRoots: ['c:/users/jeff/project-a'],
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['w3', 'w4']);
|
||||
});
|
||||
|
||||
it('matches a UNC root whatever its case or separators', () => {
|
||||
it('matches a UNC root whatever its case or separators', async () => {
|
||||
const uncEvents: DashboardEvent[] = [
|
||||
makeEvent('\\\\Server\\Share\\Proj', 'u1'),
|
||||
makeEvent('\\\\server\\share\\proj\\src', 'u2'),
|
||||
makeEvent('\\\\server\\share\\other', 'u3'),
|
||||
];
|
||||
const result = filterEventsByScope(uncEvents, {
|
||||
projectRoot: '\\\\SERVER\\SHARE\\proj',
|
||||
});
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['u1', 'u2']);
|
||||
expect(await ids(uncEvents, projectScope('\\\\SERVER\\SHARE\\proj'))).toEqual(['u1', 'u2']);
|
||||
});
|
||||
});
|
||||
|
||||
// A POSIX path is case-sensitive, and `\` is a legal character in a POSIX
|
||||
// filename, so neither folding may be applied to one.
|
||||
describe('POSIX paths keep their own rules', () => {
|
||||
it('does not fold case', () => {
|
||||
const result = filterEventsByScope(events, { projectRoot: '/users/jeff/PROJECT-A' });
|
||||
expect(result.map((e) => e.sessionId)).toEqual([]);
|
||||
it('does not fold case', async () => {
|
||||
expect(await ids(events, projectScope('/users/jeff/PROJECT-A'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a backslash in a filename as part of the name', () => {
|
||||
it('treats a backslash in a filename as part of the name', async () => {
|
||||
const evts = [makeEvent('/work/a\\b', 'p1'), makeEvent('/work/a/b', 'p2')];
|
||||
expect(
|
||||
filterEventsByScope(evts, { projectRoot: '/work/a/b' }).map((e) => e.sessionId),
|
||||
).toEqual(['p2']);
|
||||
expect(
|
||||
filterEventsByScope(evts, { projectRoot: '/work/a\\b' }).map((e) => e.sessionId),
|
||||
).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('does not let a backslash filename escape an excluded root', () => {
|
||||
const evts = [makeEvent('/work/a\\b', 'p1'), makeEvent('/work/a/b', 'p2')];
|
||||
const result = filterEventsByScope(evts, { excludeProjectRoots: ['/work/a/b'] });
|
||||
expect(result.map((e) => e.sessionId)).toEqual(['p1']);
|
||||
expect(await ids(evts, projectScope('/work/a/b'))).toEqual(['p2']);
|
||||
expect(await ids(evts, projectScope('/work/a\\b'))).toEqual(['p1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,11 +95,13 @@ describe('E2E self-mode: business repo working tree is never reset', () => {
|
||||
|
||||
const ts = new Date().toISOString();
|
||||
const eventsDir = path.join(process.env.HOME!, '.teamai', 'dashboard');
|
||||
// A session the user scope recorded (#785).
|
||||
const dataHome = path.join(tmp, 'home', '.teamai');
|
||||
fs.mkdirSync(eventsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(eventsDir, 'events.jsonl'),
|
||||
`${JSON.stringify({ type: 'session_start', timestamp: ts, sessionId: 's1', tool: 'claude', cwd: '/p' })}\n` +
|
||||
`${JSON.stringify({ type: 'stop', timestamp: ts, sessionId: 's1', tool: 'claude', interventions: { interrupt: 1, toolReject: 0 } })}\n`,
|
||||
`${JSON.stringify({ type: 'session_start', timestamp: ts, sessionId: 's1', tool: 'claude', cwd: '/p', dataHome })}\n` +
|
||||
`${JSON.stringify({ type: 'stop', timestamp: ts, sessionId: 's1', tool: 'claude', dataHome, interventions: { interrupt: 1, toolReject: 0 } })}\n`,
|
||||
);
|
||||
|
||||
const cfg: LocalConfig = {
|
||||
|
||||
@@ -54,6 +54,15 @@ function reportsStatsPath(): string {
|
||||
return path.join(tmpDir, 'reports-wt', 'stats', 'me.yaml');
|
||||
}
|
||||
|
||||
/**
|
||||
* A reported snapshot as the report left it, `{}` when it wrote none: the user
|
||||
* scope's own (#786), or the shared one for a caller without a scope config.
|
||||
*/
|
||||
function reportedSnapshot(name: string, scoped = true): Record<string, unknown> {
|
||||
const p = path.join(tmpDir, '.teamai', 'dashboard', `${scoped ? 'user-' : ''}reported-${name}.json`);
|
||||
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-tp-iv-'));
|
||||
originalHome = process.env.HOME ?? '';
|
||||
@@ -75,10 +84,12 @@ afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Sessions the user scope recorded (#785), the scope `gitConfig()` reports. */
|
||||
function writeDashboardEvents(lines: object[]): void {
|
||||
const p = path.join(tmpDir, '.teamai', 'dashboard', 'events.jsonl');
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
||||
const dataHome = path.join(tmpDir, '.teamai');
|
||||
fs.writeFileSync(p, lines.map((l) => JSON.stringify({ ...l, dataHome })).join('\n') + '\n');
|
||||
}
|
||||
|
||||
describe('reportUsageToTeam — intervention reporting', () => {
|
||||
@@ -122,8 +133,7 @@ describe('reportUsageToTeam — intervention reporting', () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await timeout;
|
||||
expect(fs.readFileSync(usagePath, 'utf-8')).toContain('review');
|
||||
const dashboard = path.join(tmpDir, '.teamai', 'dashboard');
|
||||
expect(fs.existsSync(path.join(dashboard, 'reported-prompt-tokens.json'))).toBe(false);
|
||||
expect(reportedSnapshot('prompt-tokens', backend === 'reports').slow).toBeUndefined();
|
||||
|
||||
finish(true);
|
||||
expect(await operation).toBe(true);
|
||||
@@ -131,7 +141,7 @@ describe('reportUsageToTeam — intervention reporting', () => {
|
||||
// so it leaves the user-scope file for the scope that owns it.
|
||||
expect(fs.readFileSync(usagePath, 'utf-8')).toBe(backend === 'reports' ? '' : seeded);
|
||||
for (const name of ['interventions', 'prompt-tokens', 'daily-sessions']) {
|
||||
expect(JSON.parse(fs.readFileSync(path.join(dashboard, `reported-${name}.json`), 'utf-8')).slow).toBeDefined();
|
||||
expect(reportedSnapshot(name, backend === 'reports').slow).toBeDefined();
|
||||
}
|
||||
const statsPath = backend === 'reports' ? reportsStatsPath() : path.join(repoDir, 'stats', 'me.yaml');
|
||||
const before = fs.readFileSync(statsPath, 'utf-8');
|
||||
@@ -147,7 +157,7 @@ describe('reportUsageToTeam — intervention reporting', () => {
|
||||
expect(await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() })).toBe(false);
|
||||
expect(fs.readFileSync(usagePath, 'utf-8')).toBe(before);
|
||||
for (const name of ['interventions', 'prompt-tokens', 'daily-sessions']) {
|
||||
expect(fs.existsSync(path.join(tmpDir, '.teamai', 'dashboard', `reported-${name}.json`))).toBe(false);
|
||||
expect(reportedSnapshot(name).slow).toBeUndefined();
|
||||
}
|
||||
expect(await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() })).toBe(true);
|
||||
const stats = YAML.parse(fs.readFileSync(reportsStatsPath(), 'utf-8'));
|
||||
@@ -176,12 +186,10 @@ describe('reportUsageToTeam — intervention reporting', () => {
|
||||
expect(reportsMocks.updateReports).toHaveBeenCalledTimes(1);
|
||||
|
||||
// reported snapshot persisted so a second run reports nothing new
|
||||
const reportedPath = path.join(tmpDir, '.teamai', 'dashboard', 'reported-interventions.json');
|
||||
expect(JSON.parse(fs.readFileSync(reportedPath, 'utf-8'))).toEqual({
|
||||
expect(reportedSnapshot('interventions')).toEqual({
|
||||
s1: { interrupt: 2, toolReject: 1, correction: 0 },
|
||||
});
|
||||
const dailyPath = path.join(tmpDir, '.teamai', 'dashboard', 'reported-daily-sessions.json');
|
||||
expect(JSON.parse(fs.readFileSync(dailyPath, 'utf-8')).s1.date).toBe(ts.slice(0, 10));
|
||||
expect(reportedSnapshot('daily-sessions').s1).toMatchObject({ date: ts.slice(0, 10) });
|
||||
|
||||
reportsMocks.updateReports.mockClear();
|
||||
await reportUsageToTeam(repoDir, 'me', { selfConfig: gitConfig() });
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
TRANSCRIPT_REJECT_MARKERS,
|
||||
COPILOT_TOOL_ID,
|
||||
getCopilotHome,
|
||||
getDataHome,
|
||||
emptyTokenUsage,
|
||||
addTokenUsage,
|
||||
type DashboardEvent,
|
||||
@@ -46,7 +47,7 @@ import { estimateClaudeRequest } from './model-pricing.js';
|
||||
// │ extract: session_id / cwd / tool_name / prompt
|
||||
// ▼
|
||||
// DashboardEvent
|
||||
// │
|
||||
// │ dataHome = data home of the scope the hook resolved
|
||||
// ▼
|
||||
// appendEvent(event) → events.jsonl
|
||||
//
|
||||
@@ -1957,7 +1958,8 @@ export async function dashboardReport(toolArg?: string): Promise<void> {
|
||||
// parseHookEvent reports the malformed payload below; nothing to gate on yet.
|
||||
}
|
||||
const { resolveConfigForDir } = await import('./config.js');
|
||||
if (!(await resolveConfigForDir(resolveHookCwd(hookData)))) {
|
||||
const config = await resolveConfigForDir(resolveHookCwd(hookData));
|
||||
if (!config) {
|
||||
log.debug('dashboard-report: teamai is not set up here, skipping');
|
||||
return;
|
||||
}
|
||||
@@ -1965,6 +1967,7 @@ export async function dashboardReport(toolArg?: string): Promise<void> {
|
||||
const event = await parseHookEvent(raw, toolArg ?? 'claude');
|
||||
if (!event) return;
|
||||
|
||||
event.dataHome = getDataHome(config);
|
||||
await appendEvent(event);
|
||||
|
||||
// Trigger compaction check (non-blocking)
|
||||
|
||||
@@ -123,6 +123,8 @@ export async function startDashboard(port?: number): Promise<void> {
|
||||
sessionId: session.sessionId,
|
||||
tool: session.tool,
|
||||
cwd: session.cwd,
|
||||
// The session's own data home, so its scope's report still sees it end.
|
||||
dataHome: events.find((e) => e.sessionId === session.sessionId && e.dataHome)?.dataHome,
|
||||
};
|
||||
await appendEvent(exitEvent);
|
||||
log.info(
|
||||
|
||||
@@ -180,13 +180,19 @@ async function userModelAliases(stdin: Record<string, unknown>): Promise<Record<
|
||||
const dashboardReportHandler: HookHandler = {
|
||||
name: 'dashboard-report',
|
||||
async execute(stdin, tool, config) {
|
||||
// Registered with requiresConfig: a session outside any scope is not recorded.
|
||||
if (!config) return null;
|
||||
const { parseHookEvent, appendEvent, compactEvents } = await import('./dashboard-collector.js');
|
||||
const { getDataHome } = await import('./types.js');
|
||||
const raw = JSON.stringify(stdin);
|
||||
const event = await parseHookEvent(raw, tool, {
|
||||
correctionKeywords: await teamCorrectionKeywords(stdin, config),
|
||||
modelAliases: await userModelAliases(stdin),
|
||||
});
|
||||
if (event) {
|
||||
// The dispatcher's scope, which knows the project even when the host
|
||||
// sends no cwd (Copilot) or a symlinked one (#785).
|
||||
event.dataHome = getDataHome(config);
|
||||
await appendEvent(event);
|
||||
// Non-blocking compaction
|
||||
compactEvents().catch(() => {});
|
||||
|
||||
+3
-5
@@ -2002,14 +2002,14 @@ export async function pull(
|
||||
// scope's own file (`<dataHome>/usage.jsonl`, the user scope's
|
||||
// `~/.teamai/user-usage.jsonl`), so each target reports and then
|
||||
// truncates only its own file. Dashboard sessions live in one shared file
|
||||
// and are filtered instead: project scope only gets sessions whose cwd is
|
||||
// under projectRoot; user scope excludes those sessions.
|
||||
// and are filtered instead: each target gets the sessions its scope
|
||||
// recorded (#785).
|
||||
if (!options.dryRun && !pendingUsageReport) {
|
||||
pendingUsageReport = (async () => {
|
||||
try {
|
||||
const { reportUsageToTeam } = await import('./team-push.js');
|
||||
const { truncateUsageAfterReport, readUsageEvents } = await import('./usage-tracker.js');
|
||||
const targets: Array<{ repoPath: string; username: string; opts: { skipTruncate: true; projectRoot?: string; excludeProjectRoots?: string[]; selfConfig: LocalConfig } }> = [];
|
||||
const targets: Array<{ repoPath: string; username: string; opts: { skipTruncate: true; selfConfig: LocalConfig } }> = [];
|
||||
// Per-target opt-out (teamai.yaml `usageReport: false`): a repo that
|
||||
// disables stat commits is dropped from the targets — e.g. teams
|
||||
// pulling from a read-only remote never accumulate unpushable commits.
|
||||
@@ -2020,7 +2020,6 @@ export async function pull(
|
||||
username: reconcileProject.username,
|
||||
opts: {
|
||||
skipTruncate: true,
|
||||
projectRoot: reconcileProject.projectRoot,
|
||||
// Non-HTTP repos route stats/votes to the teamai-reports orphan branch.
|
||||
selfConfig: reconcileProject,
|
||||
},
|
||||
@@ -2033,7 +2032,6 @@ export async function pull(
|
||||
username: reconcileUser.username,
|
||||
opts: {
|
||||
skipTruncate: true,
|
||||
excludeProjectRoots: projectConfig?.projectRoot ? [projectConfig.projectRoot] : [],
|
||||
// Non-HTTP repos route stats/votes to the teamai-reports orphan branch —
|
||||
// never reset/pull the default branch (or, in self mode, the business tree).
|
||||
selfConfig: reconcileUser,
|
||||
|
||||
+106
-66
@@ -1,4 +1,5 @@
|
||||
import YAML from 'yaml';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readUsageEvents, truncateUsageAfterReport } from './usage-tracker.js';
|
||||
import { aggregateUsage } from './stats.js';
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import { writeFile, readFileSafe, ensureDir, pathExists, readJson, writeJson } from './utils/fs.js';
|
||||
import { log } from './utils/logger.js';
|
||||
import type { UserStats, UserInterventionStats, SessionMetrics, TokenUsage, DashboardEvent, LocalConfig } from './types.js';
|
||||
import { getUserVotesDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js';
|
||||
import { getUserVotesDir, getDataHome, getTeamaiHomeDir, emptyTokenUsage, addTokenUsage, usesBranchWorktree } from './types.js';
|
||||
import { getUserHome } from './utils/home.js';
|
||||
import {
|
||||
aggregateDailySessions,
|
||||
@@ -136,19 +137,58 @@ export function mergeStats(
|
||||
// double-counts a session, since we only add the positive change since last report.
|
||||
//
|
||||
|
||||
/** Path to the local reported-interventions snapshot (evaluated at call time for tests). */
|
||||
function getReportedInterventionsPath(): string {
|
||||
return path.join(getUserHome(), '.teamai', 'dashboard', 'reported-interventions.json');
|
||||
// ─── Reported snapshots, one set per scope (#786) ──────
|
||||
//
|
||||
// <dataHome>/dashboard/reported-<name>.json project scope
|
||||
// ~/.teamai/dashboard/user-reported-<name>.json user scope
|
||||
// ~/.teamai/dashboard/reported-<name>.json shared, written before #786
|
||||
//
|
||||
// A session can record events in two scopes (a `cd` mid-session), so each
|
||||
// scope compares against what it reported itself. The first time a scope needs
|
||||
// a snapshot, it copies the shared one, so nothing an earlier release reported
|
||||
// is sent again; after that only its own file is read. No scope writes the
|
||||
// shared file any more, only an earlier release after a rollback (and a caller
|
||||
// without a scope config, which reads the whole log and reports into it).
|
||||
//
|
||||
|
||||
type ReportedSnapshotName = 'interventions' | 'prompt-tokens' | 'daily-sessions';
|
||||
|
||||
/** The machine-level snapshot every scope shared before #786 (evaluated at call time for tests). */
|
||||
function sharedSnapshotPath(name: ReportedSnapshotName): string {
|
||||
return path.join(getTeamaiHomeDir(), 'dashboard', `reported-${name}.json`);
|
||||
}
|
||||
|
||||
async function readReportedInterventions(): Promise<ReportedInterventions> {
|
||||
const parsed = await readJson<ReportedInterventions>(getReportedInterventionsPath());
|
||||
/** A scope's own snapshot. The user scope's data home holds the shared one, hence its prefix. */
|
||||
function scopeSnapshotPath(name: ReportedSnapshotName, config: LocalConfig | undefined): string {
|
||||
if (!config) return sharedSnapshotPath(name);
|
||||
const dataHome = getDataHome(config);
|
||||
const file = `reported-${name}.json`;
|
||||
if (path.resolve(dataHome) !== path.resolve(getTeamaiHomeDir())) return path.join(dataHome, 'dashboard', file);
|
||||
return path.join(dataHome, 'dashboard', `user-${file}`);
|
||||
}
|
||||
|
||||
/** A scope's snapshot, seeded from the shared one when the scope has none yet. */
|
||||
async function readSnapshot<T>(name: ReportedSnapshotName, config: LocalConfig | undefined): Promise<T | null> {
|
||||
const own = scopeSnapshotPath(name, config);
|
||||
if (!config || await pathExists(own)) return readJson<T>(own);
|
||||
const seed = await readJson<T>(sharedSnapshotPath(name));
|
||||
try {
|
||||
await writeJson(own, seed ?? {});
|
||||
} catch (e) {
|
||||
// Seeded again next time: the shared file is not written any more.
|
||||
log.debug(`Could not seed ${own}: ${(e as Error).message}`);
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
async function readReportedInterventions(config: LocalConfig | undefined): Promise<ReportedInterventions> {
|
||||
const parsed = await readSnapshot<ReportedInterventions>('interventions', config);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
}
|
||||
|
||||
async function writeReportedInterventions(data: ReportedInterventions): Promise<void> {
|
||||
async function writeReportedInterventions(data: ReportedInterventions, config: LocalConfig | undefined): Promise<void> {
|
||||
try {
|
||||
await writeJson(getReportedInterventionsPath(), data);
|
||||
await writeJson(scopeSnapshotPath('interventions', config), data);
|
||||
} catch (e) {
|
||||
log.error(`Failed to persist reported interventions: ${(e as Error).message}`);
|
||||
}
|
||||
@@ -207,27 +247,14 @@ function hasInterventionDelta(d: UserInterventionStats): boolean {
|
||||
// Separate snapshot from interventions so each metric stays independently idempotent.
|
||||
//
|
||||
|
||||
/** Path to the local prompt/token reported snapshot (evaluated at call time for tests). */
|
||||
function getReportedPromptTokensPath(): string {
|
||||
return path.join(getUserHome(), '.teamai', 'dashboard', 'reported-prompt-tokens.json');
|
||||
async function readReportedPromptTokens(config: LocalConfig | undefined): Promise<ReportedPromptTokens> {
|
||||
const parsed = await readSnapshot<ReportedPromptTokens>('prompt-tokens', config);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
}
|
||||
|
||||
async function readReportedPromptTokens(): Promise<ReportedPromptTokens> {
|
||||
async function writeReportedPromptTokens(data: ReportedPromptTokens, config: LocalConfig | undefined): Promise<void> {
|
||||
try {
|
||||
const content = await readFileSafe(getReportedPromptTokensPath());
|
||||
if (!content) return {};
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeReportedPromptTokens(data: ReportedPromptTokens): Promise<void> {
|
||||
try {
|
||||
const p = getReportedPromptTokensPath();
|
||||
await ensureDir(path.dirname(p));
|
||||
await writeFile(p, JSON.stringify(data));
|
||||
await writeJson(scopeSnapshotPath('prompt-tokens', config), data);
|
||||
} catch (e) {
|
||||
log.error(`Failed to persist reported prompt/token snapshot: ${(e as Error).message}`);
|
||||
}
|
||||
@@ -284,16 +311,12 @@ function hasPromptTokenDelta(d: PromptTokenDelta): boolean {
|
||||
|| d.tokens.cacheRead > 0 || d.tokens.cacheCreation > 0;
|
||||
}
|
||||
|
||||
function getReportedDailySessionsPath(): string {
|
||||
return path.join(getUserHome(), '.teamai', 'dashboard', 'reported-daily-sessions.json');
|
||||
async function readReportedDailySessions(config: LocalConfig | undefined): Promise<ReportedDailySessions> {
|
||||
return (await readSnapshot<ReportedDailySessions>('daily-sessions', config)) ?? {};
|
||||
}
|
||||
|
||||
async function readReportedDailySessions(): Promise<ReportedDailySessions> {
|
||||
return (await readJson<ReportedDailySessions>(getReportedDailySessionsPath())) ?? {};
|
||||
}
|
||||
|
||||
async function writeReportedDailySessions(data: ReportedDailySessions): Promise<void> {
|
||||
await writeJson(getReportedDailySessionsPath(), data);
|
||||
async function writeReportedDailySessions(data: ReportedDailySessions, config: LocalConfig | undefined): Promise<void> {
|
||||
await writeJson(scopeSnapshotPath('daily-sessions', config), data);
|
||||
}
|
||||
|
||||
function hasDailyDelta(delta: ReturnType<typeof computeDailyStatsDelta>['delta']): boolean {
|
||||
@@ -352,28 +375,46 @@ function isUnderScopeRoot(cwd: string, root: ScopeRoot): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter dashboard events by scope:
|
||||
* - projectRoot set: keep only events whose cwd is under that root.
|
||||
* - excludeProjectRoots set: exclude events whose cwd is under any listed root.
|
||||
* - Neither: return all events (backward-compatible).
|
||||
* The dashboard events a scope reports (#785): those recorded in it, keyed by
|
||||
* its data home. A project also owns the key of its in-repo `.teamai`, where a
|
||||
* hook recorded until migration moved the project to a partition. An event
|
||||
* written before events carried a data home belongs to the project whose root holds
|
||||
* its cwd, never to the user scope. `projectRoot` is realpath'd, but a cwd is
|
||||
* raw as the host sent it (a symlinked checkout, macOS `/tmp` vs
|
||||
* `/private/tmp`) and so is a non-git project's data home, so both are
|
||||
* realpath'd while they still exist. A caller without a scope config reads the
|
||||
* whole log.
|
||||
*/
|
||||
export function filterEventsByScope(
|
||||
export async function filterEventsByScope(
|
||||
events: DashboardEvent[],
|
||||
opts?: { projectRoot?: string; excludeProjectRoots?: string[] },
|
||||
): DashboardEvent[] {
|
||||
if (!opts) return events;
|
||||
if (opts.projectRoot) {
|
||||
const root = scopeRoot(opts.projectRoot);
|
||||
return events.filter((e) => !!e.cwd && isUnderScopeRoot(e.cwd, root));
|
||||
config?: LocalConfig,
|
||||
): Promise<DashboardEvent[]> {
|
||||
if (!config) return events;
|
||||
const realPaths = new Map<string, Promise<string>>();
|
||||
const realPath = (dir: string): Promise<string> => {
|
||||
let real = realPaths.get(dir);
|
||||
if (!real) {
|
||||
real = fs.promises.realpath(dir).catch(() => dir);
|
||||
realPaths.set(dir, real);
|
||||
}
|
||||
return real;
|
||||
};
|
||||
const keyOf = async (home: string): Promise<ScopeRoot> => scopeRoot(await realPath(home));
|
||||
const homeRoots = [await keyOf(getDataHome(config))];
|
||||
if (config.projectRoot) {
|
||||
// Unless the project is rooted at HOME, where that is the user scope's.
|
||||
const legacy = await keyOf(path.join(config.projectRoot, '.teamai'));
|
||||
if (legacy.key !== (await keyOf(path.join(getUserHome(), '.teamai'))).key) homeRoots.push(legacy);
|
||||
}
|
||||
if (opts.excludeProjectRoots && opts.excludeProjectRoots.length > 0) {
|
||||
const roots = opts.excludeProjectRoots.map(scopeRoot);
|
||||
return events.filter((e) => {
|
||||
if (!e.cwd) return true;
|
||||
return !roots.some((root) => isUnderScopeRoot(e.cwd!, root));
|
||||
});
|
||||
}
|
||||
return events;
|
||||
const root = config.projectRoot ? scopeRoot(config.projectRoot) : undefined;
|
||||
const kept = await Promise.all(events.map(async (e) => {
|
||||
if (e.dataHome !== undefined) {
|
||||
const dataHome = await realPath(e.dataHome);
|
||||
return homeRoots.some((home) => scopeKey(dataHome, home.windows) === home.key);
|
||||
}
|
||||
return !!root && !!e.cwd && isUnderScopeRoot(await realPath(e.cwd), root);
|
||||
}));
|
||||
return events.filter((_, i) => kept[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,7 +429,7 @@ export function filterEventsByScope(
|
||||
export async function reportUsageToTeam(
|
||||
repoPath: string,
|
||||
username: string,
|
||||
options?: { skipTruncate?: boolean; projectRoot?: string; excludeProjectRoots?: string[]; selfConfig?: LocalConfig },
|
||||
options?: { skipTruncate?: boolean; selfConfig?: LocalConfig },
|
||||
): Promise<boolean> {
|
||||
// Non-HTTP repos: stats + votes are report data → the teamai-reports orphan
|
||||
// branch (isolated worktree). We must NOT resetToCleanMaster / pullRepo /
|
||||
@@ -409,26 +450,25 @@ export async function reportUsageToTeam(
|
||||
|
||||
// Fold the local dashboard event log into per-session metrics once, then derive
|
||||
// both the intervention delta and the prompt-count/token delta from it.
|
||||
// Filter by scope so project repos only receive project sessions and vice versa.
|
||||
const allDashboardEvents = await readEvents();
|
||||
const dashboardEvents = filterEventsByScope(allDashboardEvents, options);
|
||||
// Only the sessions recorded in this scope (#785).
|
||||
const dashboardEvents = await filterEventsByScope(await readEvents(), reportsConfig);
|
||||
const metrics = aggregateSessionMetrics(dashboardEvents);
|
||||
|
||||
const currentInterventions = new Map(
|
||||
[...metrics].map(([sid, m]) => [sid, { interrupt: m.interrupt, toolReject: m.toolReject, correction: m.correction }]),
|
||||
);
|
||||
const reportedInterventions = await readReportedInterventions();
|
||||
const reportedInterventions = await readReportedInterventions(reportsConfig);
|
||||
const { delta: interventionDelta, nextReported } = computeInterventionDelta(
|
||||
currentInterventions,
|
||||
reportedInterventions,
|
||||
);
|
||||
|
||||
const reportedPromptTokens = await readReportedPromptTokens();
|
||||
const reportedPromptTokens = await readReportedPromptTokens(reportsConfig);
|
||||
const { delta: promptTokenDelta, nextReported: nextReportedPromptTokens } = computePromptTokenDelta(
|
||||
metrics,
|
||||
reportedPromptTokens,
|
||||
);
|
||||
const reportedDailySessions = await readReportedDailySessions();
|
||||
const reportedDailySessions = await readReportedDailySessions(reportsConfig);
|
||||
const { delta: dailyDelta, nextReported: nextReportedDailySessions } = computeDailyStatsDelta(
|
||||
aggregateDailySessions(dashboardEvents),
|
||||
reportedDailySessions,
|
||||
@@ -584,18 +624,18 @@ export async function reportUsageToTeam(
|
||||
// Success — advance the reported snapshots so we don't re-count.
|
||||
// Merge (not overwrite) because each scope only touches its own sessions.
|
||||
if (hasInterventions) {
|
||||
const existingIv = await readReportedInterventions();
|
||||
await writeReportedInterventions({ ...existingIv, ...nextReported });
|
||||
const existingIv = await readReportedInterventions(reportsConfig);
|
||||
await writeReportedInterventions({ ...existingIv, ...nextReported }, reportsConfig);
|
||||
log.debug(`Reported intervention delta (${interventionDelta.sessions} new sessions) to team repo`);
|
||||
}
|
||||
if (hasPromptTokens) {
|
||||
const existingPt = await readReportedPromptTokens();
|
||||
await writeReportedPromptTokens({ ...existingPt, ...nextReportedPromptTokens });
|
||||
const existingPt = await readReportedPromptTokens(reportsConfig);
|
||||
await writeReportedPromptTokens({ ...existingPt, ...nextReportedPromptTokens }, reportsConfig);
|
||||
log.debug(`Reported prompt/token delta (${promptTokenDelta.prompts} prompts) to team repo`);
|
||||
}
|
||||
if (hasDaily) {
|
||||
const existingDaily = await readReportedDailySessions();
|
||||
await writeReportedDailySessions({ ...existingDaily, ...nextReportedDailySessions });
|
||||
const existingDaily = await readReportedDailySessions(reportsConfig);
|
||||
await writeReportedDailySessions({ ...existingDaily, ...nextReportedDailySessions }, reportsConfig);
|
||||
log.debug(`Reported daily session trends (${Object.keys(dailyDelta).length} UTC day buckets) to team repo`);
|
||||
}
|
||||
if (!hasUsage && !hasInterventions && !hasPromptTokens && !hasDaily) {
|
||||
|
||||
+9
-1
@@ -1219,6 +1219,12 @@ export interface DashboardEvent {
|
||||
tool: string;
|
||||
/** Working directory of the session */
|
||||
cwd?: string;
|
||||
/**
|
||||
* Data home (`getDataHome`) of the scope that recorded the event, the key its
|
||||
* report filters on (#785). Absent on events written before this field
|
||||
* existed; the report then attributes them by `cwd`.
|
||||
*/
|
||||
dataHome?: string;
|
||||
/** First user prompt (captured from UserPromptSubmit) */
|
||||
promptSummary?: string;
|
||||
/**
|
||||
@@ -1333,7 +1339,9 @@ export interface DashboardSession {
|
||||
// code consumed — dashboard read/write go through runtime helpers that inline
|
||||
// getUserHome() (dashboard-collector.ts getEventsPath, dashboard.ts), so HOME
|
||||
// isolation already works there. Removed (issue #374 P3). The dashboard is an
|
||||
// A2 machine-level singleton keyed by event cwd/sessionId, not per-project.
|
||||
// A2 machine-level singleton keyed by sessionId, not per-project; each event
|
||||
// carries the data home of the scope that recorded it, which is what a scope's
|
||||
// report filters on.
|
||||
export const DASHBOARD_DEFAULT_PORT = 3721;
|
||||
/** Sessions with no activity for this long (ms) are marked idle */
|
||||
export const DASHBOARD_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
Reference in New Issue
Block a user