mirror of https://github.com/garrytan/gstack.git
Merge b729817888 into 2beb636f7c
This commit is contained in:
commit
7451b35ed3
|
|
@ -16,9 +16,22 @@ import { homedir } from "os";
|
|||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Codex `payload.originator` values map to four buckets so /retro global can
|
||||
// distinguish real codex dev (Codex Desktop) from subagent invocations
|
||||
// (codex_exec) and CC-driven calls (Claude Code). See issue #1315.
|
||||
type CodexOriginator = "desktop" | "exec" | "claude_code" | "other";
|
||||
|
||||
interface Session {
|
||||
tool: "claude_code" | "codex" | "gemini";
|
||||
cwd: string;
|
||||
codexOriginator?: CodexOriginator;
|
||||
}
|
||||
|
||||
interface CodexOriginatorCounts {
|
||||
desktop: number;
|
||||
exec: number;
|
||||
claude_code: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
interface Repo {
|
||||
|
|
@ -26,6 +39,7 @@ interface Repo {
|
|||
remote: string;
|
||||
paths: string[];
|
||||
sessions: { claude_code: number; codex: number; gemini: number };
|
||||
codex_originators: CodexOriginatorCounts;
|
||||
}
|
||||
|
||||
interface DiscoveryResult {
|
||||
|
|
@ -34,7 +48,7 @@ interface DiscoveryResult {
|
|||
repos: Repo[];
|
||||
tools: {
|
||||
claude_code: { total_sessions: number; repos: number };
|
||||
codex: { total_sessions: number; repos: number };
|
||||
codex: { total_sessions: number; repos: number; originators: CodexOriginatorCounts };
|
||||
gemini: { total_sessions: number; repos: number };
|
||||
};
|
||||
total_sessions: number;
|
||||
|
|
@ -304,6 +318,24 @@ export function extractCwdFromJsonl(filePath: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Codex rollouts ship a free-form `payload.originator` string. Real values
|
||||
// seen in the wild: "Codex Desktop" (interactive dev), "codex_exec" (cron /
|
||||
// scripted / subagent), "Claude Code" (CC's MCP / subagent integration).
|
||||
// Anything else lands in `other` rather than being silently dropped. We trim
|
||||
// before lowercasing so a stray trailing space ("Codex Desktop ") still maps.
|
||||
function normalizeCodexOriginator(raw: unknown): CodexOriginator {
|
||||
if (typeof raw !== "string") return "other";
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "codex desktop" || v === "codex_desktop") return "desktop";
|
||||
if (v === "codex_exec" || v === "codex exec") return "exec";
|
||||
if (v === "claude code" || v === "claude_code") return "claude_code";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function emptyOriginatorCounts(): CodexOriginatorCounts {
|
||||
return { desktop: 0, exec: 0, claude_code: 0, other: 0 };
|
||||
}
|
||||
|
||||
function scanCodex(since: Date): Session[] {
|
||||
const sessionsDir = process.env.CODEX_SESSIONS_DIR || join(homedir(), ".codex", "sessions");
|
||||
if (!existsSync(sessionsDir)) return [];
|
||||
|
|
@ -353,7 +385,11 @@ function scanCodex(since: Date): Session[] {
|
|||
if (!firstLine) continue;
|
||||
const meta = JSON.parse(firstLine);
|
||||
if (meta.type === "session_meta" && meta.payload?.cwd) {
|
||||
sessions.push({ tool: "codex", cwd: meta.payload.cwd });
|
||||
sessions.push({
|
||||
tool: "codex",
|
||||
cwd: meta.payload.cwd,
|
||||
codexOriginator: normalizeCodexOriginator(meta.payload.originator),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
console.error(`Warning: could not parse Codex session ${filePath}`);
|
||||
|
|
@ -515,8 +551,12 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
|
|||
}
|
||||
|
||||
const sessionCounts = { claude_code: 0, codex: 0, gemini: 0 };
|
||||
const codexOriginators = emptyOriginatorCounts();
|
||||
for (const s of data.sessions) {
|
||||
sessionCounts[s.tool]++;
|
||||
if (s.tool === "codex") {
|
||||
codexOriginators[s.codexOriginator ?? "other"]++;
|
||||
}
|
||||
}
|
||||
|
||||
repos.push({
|
||||
|
|
@ -524,6 +564,7 @@ async function resolveAndDeduplicate(sessions: Session[]): Promise<Repo[]> {
|
|||
remote,
|
||||
paths: data.paths,
|
||||
sessions: sessionCounts,
|
||||
codex_originators: codexOriginators,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -566,13 +607,18 @@ async function main() {
|
|||
const codexRepos = new Set(repos.filter((r) => r.sessions.codex > 0).map((r) => r.remote)).size;
|
||||
const geminiRepos = new Set(repos.filter((r) => r.sessions.gemini > 0).map((r) => r.remote)).size;
|
||||
|
||||
const codexOriginatorTotals = emptyOriginatorCounts();
|
||||
for (const s of codexSessions) {
|
||||
codexOriginatorTotals[s.codexOriginator ?? "other"]++;
|
||||
}
|
||||
|
||||
const result: DiscoveryResult = {
|
||||
window: since,
|
||||
start_date: startDate,
|
||||
repos,
|
||||
tools: {
|
||||
claude_code: { total_sessions: ccSessions.length, repos: ccRepos },
|
||||
codex: { total_sessions: codexSessions.length, repos: codexRepos },
|
||||
codex: { total_sessions: codexSessions.length, repos: codexRepos, originators: codexOriginatorTotals },
|
||||
gemini: { total_sessions: geminiSessions.length, repos: geminiRepos },
|
||||
},
|
||||
total_sessions: allSessions.length,
|
||||
|
|
@ -585,13 +631,22 @@ async function main() {
|
|||
// Summary format
|
||||
console.log(`Window: ${since} (since ${startDate})`);
|
||||
console.log(`Sessions: ${allSessions.length} total (CC: ${ccSessions.length}, Codex: ${codexSessions.length}, Gemini: ${geminiSessions.length})`);
|
||||
if (codexSessions.length > 0) {
|
||||
const o = codexOriginatorTotals;
|
||||
console.log(` Codex originators: desktop=${o.desktop}, exec=${o.exec}, claude_code=${o.claude_code}, other=${o.other}`);
|
||||
}
|
||||
console.log(`Repos: ${repos.length} unique`);
|
||||
console.log("");
|
||||
for (const repo of repos) {
|
||||
const total = repo.sessions.claude_code + repo.sessions.codex + repo.sessions.gemini;
|
||||
const tools = [];
|
||||
if (repo.sessions.claude_code > 0) tools.push(`CC:${repo.sessions.claude_code}`);
|
||||
if (repo.sessions.codex > 0) tools.push(`Codex:${repo.sessions.codex}`);
|
||||
if (repo.sessions.codex > 0) {
|
||||
const o = repo.codex_originators;
|
||||
// Show the desktop/exec split inline when codex sessions are present —
|
||||
// a single number hid real-dev vs subagent activity in /retro global.
|
||||
tools.push(`Codex:${repo.sessions.codex} (desktop=${o.desktop}, exec=${o.exec}, cc=${o.claude_code}${o.other > 0 ? `, other=${o.other}` : ""})`);
|
||||
}
|
||||
if (repo.sessions.gemini > 0) tools.push(`Gemini:${repo.sessions.gemini}`);
|
||||
console.log(` ${repo.name} (${total} sessions) — ${tools.join(", ")}`);
|
||||
console.log(` Remote: ${repo.remote}`);
|
||||
|
|
|
|||
|
|
@ -290,6 +290,140 @@ describe("gstack-global-discover", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("codex originator bucketing (issue #1315)", () => {
|
||||
let tmpDir: string;
|
||||
let codexDir: string;
|
||||
let repoDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "gstack-codex-orig-"));
|
||||
const now = new Date();
|
||||
const y = now.getFullYear().toString();
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getDate()).padStart(2, "0");
|
||||
codexDir = join(tmpDir, "codex-home", "sessions", y, m, d);
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
repoDir = join(tmpDir, "fake-repo");
|
||||
mkdirSync(repoDir);
|
||||
spawnSync("git", ["init"], { cwd: repoDir, stdio: "pipe" });
|
||||
spawnSync("git", ["commit", "--allow-empty", "-m", "init"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// `originator` is optional in the payload: pass `undefined` to omit it,
|
||||
// or a non-string value to exercise the type guard.
|
||||
function writeCodex(originator?: unknown) {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: `t-${Math.random()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: repoDir,
|
||||
};
|
||||
if (originator !== undefined) payload.originator = originator;
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "session_meta",
|
||||
payload,
|
||||
});
|
||||
const name = `rollout-${new Date().toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2)}.jsonl`;
|
||||
writeFileSync(join(codexDir, name), line + "\n");
|
||||
}
|
||||
|
||||
function discover() {
|
||||
const r = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "1h", "--format", "json"],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
env: { ...process.env, CODEX_SESSIONS_DIR: join(tmpDir, "codex-home", "sessions") },
|
||||
}
|
||||
);
|
||||
expect(r.status).toBe(0);
|
||||
return JSON.parse(r.stdout);
|
||||
}
|
||||
|
||||
test("'Codex Desktop' originator → desktop bucket", () => {
|
||||
writeCodex("Codex Desktop");
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.desktop).toBe(1);
|
||||
expect(json.tools.codex.originators.exec).toBe(0);
|
||||
expect(json.tools.codex.originators.claude_code).toBe(0);
|
||||
});
|
||||
|
||||
test("'codex_exec' originator → exec bucket", () => {
|
||||
writeCodex("codex_exec");
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.exec).toBe(1);
|
||||
expect(json.tools.codex.originators.desktop).toBe(0);
|
||||
});
|
||||
|
||||
test("'Claude Code' originator → claude_code bucket", () => {
|
||||
writeCodex("Claude Code");
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.claude_code).toBe(1);
|
||||
expect(json.tools.codex.originators.desktop).toBe(0);
|
||||
expect(json.tools.codex.originators.exec).toBe(0);
|
||||
});
|
||||
|
||||
test("originator with stray surrounding whitespace still maps", () => {
|
||||
writeCodex(" Codex Desktop ");
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.desktop).toBe(1);
|
||||
expect(json.tools.codex.originators.other).toBe(0);
|
||||
});
|
||||
|
||||
test("unknown originator → other bucket (not silently dropped)", () => {
|
||||
writeCodex("future-agent-name-not-yet-mapped");
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.other).toBe(1);
|
||||
expect(json.tools.codex.total_sessions).toBe(1);
|
||||
});
|
||||
|
||||
test("missing originator → other bucket (still counted)", () => {
|
||||
writeCodex(undefined);
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.other).toBe(1);
|
||||
expect(json.tools.codex.total_sessions).toBe(1);
|
||||
});
|
||||
|
||||
test("null originator → other bucket", () => {
|
||||
writeCodex(null);
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.other).toBe(1);
|
||||
expect(json.tools.codex.total_sessions).toBe(1);
|
||||
});
|
||||
|
||||
test("non-string originator → other bucket", () => {
|
||||
writeCodex(42);
|
||||
const json = discover();
|
||||
expect(json.tools.codex.originators.other).toBe(1);
|
||||
expect(json.tools.codex.total_sessions).toBe(1);
|
||||
});
|
||||
|
||||
test("per-repo codex_originators sums to per-repo codex count", () => {
|
||||
writeCodex("Codex Desktop");
|
||||
writeCodex("codex_exec");
|
||||
writeCodex("codex_exec");
|
||||
writeCodex("Claude Code");
|
||||
const json = discover();
|
||||
// The fake repo's normalized remote will be local: form; just find it.
|
||||
const repo = json.repos.find((r: any) => r.paths.includes(repoDir));
|
||||
expect(repo).toBeDefined();
|
||||
const o = repo.codex_originators;
|
||||
expect(o.desktop + o.exec + o.claude_code + o.other).toBe(repo.sessions.codex);
|
||||
expect(o.desktop).toBe(1);
|
||||
expect(o.exec).toBe(2);
|
||||
expect(o.claude_code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discovery output structure", () => {
|
||||
test("repos have required fields", () => {
|
||||
const result = spawnSync(
|
||||
|
|
@ -329,6 +463,26 @@ describe("gstack-global-discover", () => {
|
|||
expect(json.total_sessions).toBe(toolTotal);
|
||||
});
|
||||
|
||||
test("repos expose codex_originators breakdown", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
["run", scriptPath, "--since", "30d", "--format", "json"],
|
||||
{ encoding: "utf-8", timeout: 30000 }
|
||||
);
|
||||
const json = JSON.parse(result.stdout);
|
||||
expect(json.tools.codex).toHaveProperty("originators");
|
||||
const o = json.tools.codex.originators;
|
||||
for (const k of ["desktop", "exec", "claude_code", "other"]) {
|
||||
expect(o).toHaveProperty(k);
|
||||
expect(typeof o[k]).toBe("number");
|
||||
}
|
||||
// Sum of originators must equal codex total_sessions.
|
||||
expect(o.desktop + o.exec + o.claude_code + o.other).toBe(json.tools.codex.total_sessions);
|
||||
for (const repo of json.repos) {
|
||||
expect(repo).toHaveProperty("codex_originators");
|
||||
}
|
||||
});
|
||||
|
||||
test("deduplicates Conductor workspaces by remote", () => {
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
|
|
|
|||
Loading…
Reference in New Issue