Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)).
- The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)).
- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for hooks and skill usage an unreadable project config never falls back to the user scope; `teamai doctor` reports it. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)).
- Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`<dataHome>/usage.jsonl`: the project partition, `<repo>/.teamai` in single-repo mode, `~/.teamai` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. A machine's first user scope starts with an empty file, since the events it held by then cannot be attributed; on a machine with only project scopes, events recorded before the upgrade stay unreported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)).
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/conversation-token-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-ct-e2e-'));
originalHome = process.env.HOME ?? '';
process.env.HOME = tmpDir;
// A configured user scope: the payload cwd below is a fixture path that need
// not exist, and resolveConfigForDir then falls back to this config — the
// installed-team case the legacy dashboard-report gate lets through (#768).
fs.mkdirSync(path.join(tmpDir, '.teamai'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.teamai', 'config.yaml'),
[
'username: jeff',
'scope: user',
'repo:',
' kind: git',
` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`,
' remote: https://example.test/acme/team.git',
'additionalRoles: []',
'',
].join('\n'),
);
});

afterEach(() => {
Expand Down
132 changes: 132 additions & 0 deletions src/__tests__/dashboard-report-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { dashboardReport } from '../dashboard-collector.js';
import { _setLogFilePath, _resetState } from '../utils/logger.js';

// ─── legacy `dashboard-report` config gate ───
//
// `teamai dashboard-report --stdin` is a legacy subcommand: current installs
// only ever write `teamai hook-dispatch`, whose dashboard-report handler is
// registered with `requiresConfig`. The old command stayed ungated, so a hook
// left behind by an earlier install kept recording dashboard events for every
// directory it fired in — including projects that never set up teamai, whose
// sessions then showed up in whatever scope reported next (#768).
//
// `contribute-check`, the other legacy command older installs still call, was
// gated in #748 with `resolveConfigForDir(cwd)`. These tests pin the same gate
// onto dashboard-report.

let tmpDir: string;
let originalHome: string;
let consoleLog: ReturnType<typeof vi.spyOn>;

/** The machine-wide dashboard event log — one file, not per-scope. */
function eventsPath(): string {
return path.join(tmpDir, '.teamai', 'dashboard', 'events.jsonl');
}

function readEventLines(): Record<string, unknown>[] {
if (!fs.existsSync(eventsPath())) return [];
return fs.readFileSync(eventsPath(), 'utf-8')
.split('\n')
.filter((l) => l.trim())
.map((l) => JSON.parse(l) as Record<string, unknown>);
}

/** Run the legacy subcommand the way a hook does, with `payload` on STDIN. */
async function runLegacyCommand(payload: Record<string, unknown>, tool = 'claude'): Promise<void> {
const fake = Readable.from([Buffer.from(JSON.stringify(payload), 'utf-8')]) as Readable & { isTTY?: boolean };
fake.isTTY = false;
const orig = Object.getOwnPropertyDescriptor(process, 'stdin')!;
Object.defineProperty(process, 'stdin', { value: fake, configurable: true });
try {
await dashboardReport(tool);
} finally {
Object.defineProperty(process, 'stdin', orig);
}
}

/** A minimal SessionStart payload naming `cwd`. */
function sessionStartPayload(cwd: string): Record<string, unknown> {
return {
hook_event_name: 'SessionStart',
session_id: 'sess-legacy-1',
cwd,
tool_name: 'ClaudeCode',
};
}

beforeEach(() => {
tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-dash-gate-')));
originalHome = process.env.HOME ?? '';
process.env.HOME = tmpDir;
_setLogFilePath(path.join(tmpDir, '.teamai', 'debug.log'));
consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);
});

afterEach(() => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
_resetState();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe('legacy dashboard-report config gate', () => {
it('records nothing for a directory that never set up teamai', async () => {
// A plain project on the machine: no .teamai, no user config.
const plainProject = path.join(tmpDir, 'plain-project');
fs.mkdirSync(plainProject, { recursive: true });

await runLegacyCommand(sessionStartPayload(plainProject));

expect(readEventLines()).toEqual([]);
expect(fs.existsSync(eventsPath())).toBe(false);
});

it('still records for a directory teamai is set up in', async () => {
// The user scope: a config under HOME is enough to resolve a config.
const userConfigDir = path.join(tmpDir, '.teamai');
fs.mkdirSync(userConfigDir, { recursive: true });
fs.writeFileSync(
path.join(userConfigDir, 'config.yaml'),
[
'username: tester',
'scope: user',
'repo:',
' kind: git',
` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`,
' remote: https://example.test/acme/team.git',
'additionalRoles: []',
'',
].join('\n'),
);

await runLegacyCommand(sessionStartPayload(userConfigDir));

const events = readEventLines();
expect(events).toHaveLength(1);
expect(events[0].type).toBe('session_start');
expect(events[0].sessionId).toBe('sess-legacy-1');
});

it('records nothing when no cwd can be resolved from the payload', async () => {
// A host that sends neither cwd nor workspace_roots: resolveConfigForDir
// then falls back to the directory the hook process runs in, the same
// semantics #748 gave contribute-check. From a plain directory there is no
// team, so nothing is recorded.
const plainDir = path.join(tmpDir, 'hook-proc-dir');
fs.mkdirSync(plainDir, { recursive: true });
const cwd = process.cwd();
process.chdir(plainDir);
try {
await runLegacyCommand({ hook_event_name: 'SessionStart', session_id: 'sess-no-cwd' });
} finally {
process.chdir(cwd);
}

expect(readEventLines()).toEqual([]);
});
});
17 changes: 17 additions & 0 deletions src/__tests__/intervention-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-iv-e2e-'));
originalHome = process.env.HOME ?? '';
process.env.HOME = tmpDir;
// A configured user scope: the payload cwd below is a fixture path that need
// not exist, and resolveConfigForDir then falls back to this config — the
// installed-team case the legacy dashboard-report gate lets through (#768).
fs.mkdirSync(path.join(tmpDir, '.teamai'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.teamai', 'config.yaml'),
[
'username: jeff',
'scope: user',
'repo:',
' kind: git',
` localPath: ${path.join(tmpDir, '.teamai', 'team-repo')}`,
' remote: https://example.test/acme/team.git',
'additionalRoles: []',
'',
].join('\n'),
);
});

afterEach(() => {
Expand Down
22 changes: 22 additions & 0 deletions src/dashboard-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,12 @@ export async function compactEvents(eventsPath?: string): Promise<void> {
/**
* Handle `teamai dashboard-report --stdin --tool <name>`.
* Called by dashboard hooks in Claude Code / other AI tools.
*
* Legacy entry point: a current install only writes `teamai hook-dispatch`,
* whose dashboard-report handler is registered with `requiresConfig`. Hooks
* left behind by an earlier install still call this command in every
* directory, so it applies the same gate itself — a directory with no teamai
* config has no team to report its sessions to (#768).
*/
export async function dashboardReport(toolArg?: string): Promise<void> {
const raw = await readStdin();
Expand All @@ -1940,6 +1946,22 @@ export async function dashboardReport(toolArg?: string): Promise<void> {
return;
}

// Asked about the session's cwd, never the directory this process started in.
let hookData: Record<string, unknown> = {};
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
hookData = parsed as Record<string, unknown>;
}
} catch {
// parseHookEvent reports the malformed payload below; nothing to gate on yet.
}
const { resolveConfigForDir } = await import('./config.js');
if (!(await resolveConfigForDir(resolveHookCwd(hookData)))) {
log.debug('dashboard-report: teamai is not set up here, skipping');
return;
}

const event = await parseHookEvent(raw, toolArg ?? 'claude');
if (!event) return;

Expand Down
Loading