Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 1b192f697a test(usage): reproduce a retained agent account after login switching
Diagnostic-only branch: both cases intentionally fail. The registry retains an agent account while its session key stays the same, even after a forced refresh.
2026-11-03 08:45:44 +00:00
paseo-bot[bot]andJiayang Wu bd5671fd49 fix(usage): discover migrated database logins and relative paths
Match the source application’s migration and database-path rules so usage discovery does not keep reporting a stale file login after migration.

Co-authored-by: Jiayang Wu <651475459@qq.com>
2026-11-03 08:29:14 +00:00
Jiayang Wu d5f19aed37 fix(usage): read the OpenCode v2 login store in the Codex usage source
OpenCode v2 keeps its credentials in its own database and no longer writes
the v1 credential file. The Codex usage source only knew the v1 stores, so
on a v2 host it kept reporting the last file login it ever saw, with that
file's expiry date, instead of the ChatGPT login the host actually uses.

Read the active OpenAI credential from the OpenCode v2 database when it
holds one, and keep the v1 file as the source for installations without
that database. The database wins when both exist, because the file login
is the one that goes stale.

Report the v2 store with the same OpenCode harness and refresh command as
the v1 file store, so a rejected or expired login still names opencode.

Cover discovery, store precedence, expiry, token rotation and read-only
access with tests against real temporary SQLite databases.

Related to getpaseo/paseo#8645
2026-11-03 15:31:15 +08:00
25 changed files with 664 additions and 894 deletions
+12 -8
View File
@@ -97,15 +97,19 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup matches the branch and repository for both open and
terminal requests. Prefer an open request, then a terminal request at the local
HEAD, then the most recently updated terminal request. A reused branch can show
its previous request until a new one opens. Auto-archive separately requires an
observed open-to-merged transition and a fresh exact match between checkout HEAD
and the PR head, along with the existing workspace safety checks.
Current change-request lookup uses two identities deliberately:
Keep checkout HEAD in adapter cache and poll identities so checkout changes
refresh the facts used by archive eligibility.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
@@ -32,405 +32,6 @@ import {
toAgentUsage,
} from "./codex-app-server-agent.js";
describe("Codex prompt hook rejection", () => {
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"shows a $status queued follow-up without failing accepted work in the same turn",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.steerActiveTurn!("Allowed follow-up", { expectedTurnId: paseoTurnId });
await session.steerActiveTurn!("Rejected follow-up", { expectedTurnId: paseoTurnId });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "completed",
entries: [],
});
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Follow-up rejected" }],
});
appServer.says({ threadId: "thread-1", text: "Accepted work completed" });
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "assistant_message",
text: "Accepted work completed",
}),
}),
);
expect(events).toContainEqual({
type: "timeline",
provider: "codex",
item: { type: "notification", level: "error", message: "Follow-up rejected" },
turnId: paseoTurnId,
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each(["before", "after"])(
"keeps an accepted input successful when rejected input arrives %s it, even without a reply",
async (order) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
function rejectInput(): void {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected follow-up" }],
});
}
if (order === "before") rejectInput();
appServer.acceptsUserMessage({
threadId: "thread-1",
turnId: "native-A",
itemId: "accepted-input",
text: "Accepted prompt",
});
if (order === "after") rejectInput();
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "interrupted", expected: { type: "turn_canceled", reason: "interrupted" } },
{ status: "failed", expected: { type: "turn_failed", error: "Native failure" } },
] as const)(
"preserves native $status outcomes after a rejected prompt",
async ({ status, expected }) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn({ status, error: { message: "Native failure" } });
await expect(completion).resolves.toMatchObject(expected);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ threadId: "thread-1", turnId: "older-turn" },
{ threadId: "child-thread", turnId: "native-A" },
])("does not count foreign input as accepted work: $threadId $turnId", async (input) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.acceptsUserMessage({ ...input, itemId: "foreign-input", text: "Unrelated input" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected prompt",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each(["compaction", "hook context"])(
"does not count pre-prompt %s as accepted task work",
async (housekeeping) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
if (housekeeping === "compaction") {
appServer.startsCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
appServer.completesCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
} else {
appServer.addsHookContext({
threadId: "thread-1",
turnId: "native-A",
itemId: "hook-context",
text: "Housekeeping context",
});
}
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected after compaction" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected after compaction",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"fails a fully $status manager run and permits a corrected submission",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const client: AgentClient = {
provider: "codex",
capabilities: provider.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: (...args) => provider.createSession(...args),
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agent = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "thread-1" },
createConfig({ cwd: process.cwd() }),
);
try {
const run = manager.runAgent(agent.id, "Disallowed prompt", {
clientMessageId: "blocked-prompt",
});
const failure = expect(run).rejects.toThrow("Remove the disallowed text and retry.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Remove the disallowed text and retry." }],
});
appServer.completeTurn();
await failure;
expect(manager.getTimeline(agent.id)).toEqual([
{
type: "user_message",
text: "Disallowed prompt",
messageId: "blocked-prompt",
clientMessageId: "blocked-prompt",
},
{
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
{
type: "assistant_message",
text: "[System Error] Remove the disallowed text and retry.",
},
]);
expect(manager.hasInFlightRun(agent.id)).toBe(false);
expect(manager.getAgent(agent.id)?.lastError).toBe("Remove the disallowed text and retry.");
const retry = manager.runAgent(agent.id, "Corrected prompt", {
clientMessageId: "corrected-prompt",
});
await appServer.waitForTurnStart(2);
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
appServer.completeTurn();
await expect(retry).resolves.toMatchObject({ finalText: "Accepted", canceled: false });
expect(manager.hasInFlightRun(agent.id)).toBe(false);
appServer.assertNoErrors();
} finally {
await manager.closeAgent(agent.id);
}
},
);
test("shows blocking feedback and accepts the next turn", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Remove the disallowed text and retry." }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Remove the disallowed text and retry.",
turnId: paseoTurnId,
},
]);
events.length = 0;
const replacement = await session.startTurn("Allowed replacement prompt");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
const replacementCompletion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await replacementCompletion;
expect(events.filter((event) => event.type.startsWith("turn_"))).toEqual([
{ type: "turn_started", provider: "codex", turnId: replacement.turnId },
{ type: "turn_completed", provider: "codex", usage: undefined, turnId: replacement.turnId },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ eventName: "preToolUse", status: "blocked", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "completed",
turnId: "native-A",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "failed", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "older-turn",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "blocked", turnId: null, threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "native-A",
threadId: "child-thread",
},
])(
"does not fail the foreground turn for an unrelated hook: $eventName $status $turnId $threadId",
async ({ eventName, status, turnId, threadId }) => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId,
turnId,
eventName,
status,
entries: [{ kind: "feedback", text: "Unrelated feedback" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{ type: "turn_completed", provider: "codex", turnId: paseoTurnId, usage: undefined },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("provides an actionable failure when the blocking hook has no feedback", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Prompt rejected by a UserPromptSubmit hook.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Prompt rejected by a UserPromptSubmit hook.",
turnId: paseoTurnId,
},
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
});
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
expect(
@@ -490,7 +91,6 @@ import {
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextEvent,
waitForNextTurnCompletion,
waitForNextPermission,
waitForNextTimelineItem,
waitForProviderSubagent,
@@ -2574,14 +2574,6 @@ const CodexEventThreadRolledBackNotificationSchema = z
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string; threadId: string | null }
| {
kind: "hook_completed";
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
| {
kind: "turn_completed";
status: string;
@@ -2702,36 +2694,6 @@ function isCodexDeltaNotification(
}
const CodexNotificationSchema = z.union([
z
.object({
method: z.literal("hook/completed"),
params: z.object({
threadId: z.string(),
turnId: z.string().nullish(),
run: z.object({
eventName: z.string(),
status: z.string(),
entries: z.array(z.object({ kind: z.string(), text: z.string() })),
}),
}),
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "hook_completed",
threadId: params.threadId,
turnId: params.turnId ?? null,
eventName: params.run.eventName,
status: params.run.status,
entries: params.run.entries,
}),
),
z.object({ method: z.literal("hook/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
.transform(
@@ -3521,7 +3483,6 @@ export class CodexAppServerAgentSession implements AgentSession {
// in this app-server, so no other app-server can resume it and it is not reported.
private savedThreadId: string | null = null;
private currentTurnId: string | null = null;
private turnPromptOutcome: "unobserved" | "accepted" | { rejection: string } = "unobserved";
private pendingForegroundTurnIdentification: {
foregroundTurnId: string;
promise: Promise<string | null>;
@@ -5817,9 +5778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
case "turn_completed":
this.handleTurnCompletedNotification(parsed);
return;
case "hook_completed":
this.handleHookCompletedNotification(parsed);
return;
case "plan_updated":
this.handlePlanUpdatedNotification(parsed);
return;
@@ -5847,11 +5805,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.recordAcceptedTurnWork(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.recordAcceptedTurnWork(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
@@ -6437,34 +6393,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current });
}
private handleHookCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "hook_completed" }>,
): void {
const rejectsCurrentPrompt =
parsed.eventName === "userPromptSubmit" &&
(parsed.status === "blocked" || parsed.status === "stopped") &&
this.currentTurnId !== null &&
parsed.turnId === this.currentTurnId;
if (!rejectsCurrentPrompt) return;
const feedback = parsed.entries
.filter(
(entry) => entry.kind === "feedback" || entry.kind === "stop" || entry.kind === "error",
)
.map((entry) => entry.text.trim())
.filter((text) => text.length > 0)
.join("\n\n");
const message = feedback || "Prompt rejected by a UserPromptSubmit hook.";
if (this.turnPromptOutcome !== "accepted") {
this.turnPromptOutcome = { rejection: message };
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "notification", level: "error", message },
});
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {
@@ -6495,12 +6423,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else if (typeof this.turnPromptOutcome === "object") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: this.turnPromptOutcome.rejection,
});
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
@@ -6520,24 +6442,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resetTurnTrackingState();
}
private recordAcceptedTurnWork(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
// Pre-prompt housekeeping can precede a rejected input. Only admitted input or
// task work keeps a rejected follow-up from failing the native turn.
const itemType = normalizeCodexThreadItemType(parsed.item.type);
if (itemType === CODEX_CONTEXT_COMPACTION_TYPE || itemType === "hookPrompt") return;
if (
this.currentTurnId !== null &&
parsed.threadId === this.currentThreadId &&
(parsed.turnId === null || parsed.turnId === this.currentTurnId)
) {
this.turnPromptOutcome = "accepted";
}
}
private resetTurnTrackingState(): void {
this.turnPromptOutcome = "unobserved";
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
@@ -56,13 +56,6 @@ interface FakeLegacyPatch {
path: string;
diff: string;
}
interface FakeHookCompletion {
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
@@ -74,21 +67,13 @@ export interface FakeCodexAppServer {
readonly recordedRollbacks: JsonObject[];
requests(): readonly JsonObject[];
assertNoErrors(): void;
waitForTurnStart(occurrence?: number): Promise<JsonObject>;
waitForTurnStart(): Promise<JsonObject>;
waitForRequest(method: string): Promise<JsonObject>;
disconnect(): void;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
startsCompaction(params: { threadId: string; itemId: string }): void;
updatesPlan(params: { threadId: string; steps: string[] }): void;
acceptsUserMessage(params: {
threadId: string;
turnId: string;
itemId: string;
text: string;
}): void;
addsHookContext(params: { threadId: string; turnId: string; itemId: string; text: string }): void;
completesHook(params: FakeHookCompletion): void;
completeTurn(params?: {
threadId?: string;
status?: "completed" | "failed" | "interrupted";
@@ -383,11 +368,9 @@ export function createFakeCodexAppServer(
throw errors[0];
}
},
async waitForTurnStart(occurrence = 1) {
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) =>
messages.filter((request) => request.method === "turn/start")[occurrence - 1] ===
candidate,
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
@@ -435,27 +418,6 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
acceptsUserMessage({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "userMessage", id: itemId, content: [{ type: "text", text }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
addsHookContext({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "hookPrompt", id: itemId, fragments: [{ text, hookRunId: "hook-run" }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
completesHook({ threadId, turnId, ...run }) {
writeNotification("hook/completed", { threadId, turnId, run });
},
completeTurn(params = {}) {
child.stdout.write(
`${JSON.stringify({
@@ -727,17 +689,16 @@ type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType | readonly TType[],
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
const types: readonly StreamEventType[] = typeof type === "string" ? [type] : type;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for ${types.join(" or ")}`));
reject(new Error(`Timed out waiting for ${type}`));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (!types.includes(event.type)) {
if (event.type !== type) {
return;
}
const typedEvent = event as StreamEventOfType<TType>;
@@ -751,10 +712,6 @@ export function waitForNextEvent<TType extends StreamEventType>(
});
}
export function waitForNextTurnCompletion(session: AgentSession) {
return waitForNextEvent(session, ["turn_completed", "turn_failed", "turn_canceled"]);
}
type TimelineEvent = StreamEventOfType<"timeline">;
type ProviderSubagentEvent = StreamEventOfType<"provider_subagent">;
@@ -30,7 +30,6 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -47,7 +46,6 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -348,25 +346,6 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("denies archive when checkout facts do not establish safety", async () => {
for (const git of [{ headSha: undefined }, { behindOfOrigin: 1 }, { isDirty: null }]) {
const harness = createHarness({ snapshot: createSnapshot({ git }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
const harness = createHarness({
snapshot: createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("keeps the workspace active when the merged PR head differs from checkout", async () => {
const harness = createHarness({ snapshot: createSnapshot({ git: { headSha: "local-head" } }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -59,21 +59,13 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (
!pullRequest?.isMerged ||
!snapshot.git.headSha ||
snapshot.git.headSha !== pullRequest.headSha ||
snapshot.forge.error
) {
if (!pullRequest?.isMerged) {
return;
}
if (snapshot.git.isDirty !== false) {
if (snapshot.git.isDirty === true) {
return;
}
if (
(snapshot.git.aheadOfOrigin !== null && snapshot.git.aheadOfOrigin !== 0) ||
(snapshot.git.behindOfOrigin !== null && snapshot.git.behindOfOrigin !== 0)
) {
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
return;
}
@@ -161,12 +161,7 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(journeyOptions?: {
archiveBarrier?: Promise<void>;
changeBeforeArchiveRead?: boolean;
}) {
const { archiveBarrier, changeBeforeArchiveRead: shouldChange = false } = journeyOptions ?? {};
let changeBeforeArchiveRead = shouldChange;
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
@@ -178,10 +173,8 @@ async function createUnobservedWorkspace(journeyOptions?: {
runner: async () => ({ stdout: "", stderr: "" }),
});
let observation: PullRequestObservation | null = null;
let remoteHeadSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim();
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let archiveDecisions = 0;
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
@@ -193,7 +186,6 @@ async function createUnobservedWorkspace(journeyOptions?: {
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
@@ -210,29 +202,15 @@ async function createUnobservedWorkspace(journeyOptions?: {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: {
onSnapshotUpdated: service.onSnapshotUpdated.bind(service),
getSnapshot: async (
snapshotCwd: string,
readOptions: { force: true; afterInFlight?: boolean; reason: string },
) => {
if (changeBeforeArchiveRead && readOptions.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed before archive\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "new commit"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
workspaceGitService: service,
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
resolvePath: path.resolve,
archiveIfSafe: async (input) => {
await archiveIfSafe({
archiveIfSafe: async (input) =>
archiveIfSafe({
...input,
deps: {
isPaseoOwnedWorktreeCwd,
@@ -249,19 +227,13 @@ async function createUnobservedWorkspace(journeyOptions?: {
};
},
},
});
archiveDecisions++;
},
}),
});
return {
archives,
archiveDecisions: () => archiveDecisions,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -298,33 +270,12 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks checkout changes made after the merge notification", async () => {
const workspace = await createUnobservedWorkspace({ changeBeforeArchiveRead: true });
await workspace.observe("open");
await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged PR visible and the workspace active when its remote head advances", async () => {
const workspace = await createUnobservedWorkspace();
const open = await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
await vi.waitFor(() => expect(workspace.archiveDecisions()).toBe(1));
expect(merged.forge.pullRequest?.url).toBe(open.forge.pullRequest?.url);
expect(merged.forge.pullRequest?.headSha).not.toBe(merged.git.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
releaseArchive = resolve;
});
const workspace = await createUnobservedWorkspace({ archiveBarrier });
const workspace = await createUnobservedWorkspace(archiveBarrier);
try {
await workspace.observe("open");
expect(workspace.archives).toEqual([]);
@@ -341,10 +292,10 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -90,11 +90,6 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,8 +63,6 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -224,7 +224,7 @@ async function fetchApi(url, init) {
return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } }));
}
if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 });
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-opencode-db': 23, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55;
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
}
export default function contribute(server) {
@@ -270,6 +270,31 @@ export default function contribute(server) {
(await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true }))
.reports[0];
expect((await refresh())?.report.windows[0]?.usedPct).toBe(22);
const openCodeDatabase = path.join(path.dirname(openCodePath), "opencode.db");
const openCodeDb = new DatabaseSync(openCodeDatabase);
openCodeDb.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
// OpenCode's v1 migration leaves active NULL and does not update auth.json.
openCodeDb.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, NULL, 1, 1)").run(
"migrated-login",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
access: "fixture-opencode-db",
expires: Date.now() + 60_000,
metadata: { accountID: "chatgpt-one" },
}),
);
openCodeDb.close();
const rediscover = async () =>
(await client.listUsageReports({ forceRefresh: true })).reports.find(
(entry) => entry.id === "codex:chatgpt-one",
);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(23);
await rm(openCodeDatabase);
expect((await rediscover())!.report.windows[0]!.usedPct).toBe(22);
await rm(openCodePath);
expect((await refresh())?.report.windows[0]?.usedPct).toBe(33);
await json(piPath, {
@@ -155,7 +155,6 @@ export interface WorkspaceGitRuntimeSnapshot {
repoRoot: string | null;
mainRepoRoot: string | null;
currentBranch: string | null;
headSha?: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
isDirty: boolean | null;
@@ -186,7 +185,6 @@ export interface WorkspaceGitRuntimeSnapshot {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -322,7 +320,6 @@ export type WorkspaceGitSnapshotOptions =
| {
force: true;
includeForge?: boolean;
afterInFlight?: boolean;
reason: string;
};
@@ -3253,7 +3250,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: force && options?.afterInFlight === true,
queueIfBusy: false,
movedRemoteRefs: new Set(),
};
}
@@ -3553,7 +3550,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: refreshWorktree
@@ -128,7 +128,6 @@ export interface CurrentPullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable: PullRequestMergeable;
@@ -2064,7 +2064,7 @@ describe("createGiteaService", () => {
]);
});
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,7 +2086,7 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({ number: 8, state: "merged" });
).resolves.toBeNull();
});
it("returns null when no PR matches the current branch", async () => {
+1 -10
View File
@@ -162,7 +162,6 @@ const GiteaPrListItemSchema = z
updated: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
headSha: z.string().optional(),
})
.passthrough();
@@ -1011,7 +1010,6 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1084,7 +1082,6 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1656,7 +1653,6 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
candidates[0] ??
null;
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1722,12 +1718,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(
{ ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
} catch {
return status;
}
@@ -23,7 +23,7 @@ const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
@@ -3219,7 +3219,7 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
@@ -3243,9 +3243,8 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
it("does not attach a stale merged PR after a same-name branch advances", async () => {
const stale = currentPullRequestJson({
headRepositoryOwner: { login: "parentOwner" },
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
@@ -3266,105 +3265,12 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({ state: "merged" });
).resolves.toBeNull();
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it("prefers an exact terminal head and otherwise the most recently updated terminal PR", async () => {
const owner = { login: "forkOwner" };
const terminal = ({
number,
headRefOid,
updatedAt,
state,
}: {
number: number;
headRefOid: string;
updatedAt: string;
state: string;
}) =>
JSON.parse(
currentPullRequestJson({
number,
headRefOid,
updatedAt,
state,
mergedAt: state === "MERGED" ? updatedAt : null,
headRepositoryOwner: owner,
}),
);
const older = terminal({
number: 41,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
state: "MERGED",
});
const newer = terminal({
number: 42,
headRefOid: "new",
updatedAt: "2026-07-02T00:00:00Z",
state: "CLOSED",
});
for (const [headSha, expected] of [
["old", 41],
["other", 42],
] as const) {
const runner = createScriptedRunner([JSON.stringify(older), JSON.stringify([older, newer])]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha,
});
expect(status?.number).toBe(expected);
}
});
it("prefers an exact merged PR over a closed draft", async () => {
const owner = { login: "forkOwner" };
const closedDraft = JSON.parse(
currentPullRequestJson({
number: 1,
state: "CLOSED",
isDraft: true,
headRefOid: "old",
updatedAt: "2026-07-01T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const merged = JSON.parse(
currentPullRequestJson({
number: 2,
state: "MERGED",
mergedAt: "2026-07-02T00:00:00Z",
headRefOid: "exact",
updatedAt: "2026-07-02T00:00:00Z",
headRepositoryOwner: owner,
}),
);
const runner = createScriptedRunner([
JSON.stringify(closedDraft),
JSON.stringify([closedDraft, merged]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "forkOwner",
headSha: "exact",
});
expect(status).toMatchObject({ number: 2, state: "merged" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3407,84 +3313,6 @@ describe("ForgeService", () => {
expect(status).toMatchObject({ number: 43, state: "open" });
});
it("keeps a merged PR targeting the fork itself", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "checkout-head",
headRepositoryOwner: { login: "alice" },
}),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
"[]",
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout-head",
});
expect(status).toMatchObject({ number: 7, repoOwner: "alice", state: "merged" });
});
it("compares the fork's terminal PR against parent PRs by state, head and update time", async () => {
const local = JSON.parse(
currentPullRequestJson({
number: 7,
url: "https://github.com/alice/app/pull/7",
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
updatedAt: "2026-07-17T12:00:00Z",
headRefOid: "local-head",
headRepositoryOwner: { login: "alice" },
}),
);
for (const [state, headSha, updatedAt, expected] of [
["OPEN", "other", "2026-07-16T12:00:00Z", 8],
["CLOSED", "other", "2026-07-18T12:00:00Z", 7],
["CLOSED", "other", "2026-07-18T12:00:00Z", 8],
] as const) {
const parent = {
...local,
number: 8,
url: "https://github.com/acme/app/pull/8",
state,
mergedAt: null,
headRefOid: headSha,
updatedAt,
};
const runner = createScriptedRunner([
JSON.stringify(local),
JSON.stringify({
owner: { login: "alice" },
name: "app",
parent: { owner: { login: "acme" }, name: "app" },
}),
JSON.stringify([{ number: 8 }]),
JSON.stringify(parent),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: state === "OPEN" || expected === 7 ? "local-head" : "neither",
});
expect(status?.number).toBe(expected);
}
});
it("resolves fork PR heads to the parent repository when gh pr view returns a stale branch match", async () => {
const runner = createScriptedRunner([
currentPullRequestJson({
+20 -26
View File
@@ -346,7 +346,6 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -525,7 +524,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -641,7 +640,6 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -746,7 +744,6 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
@@ -1121,7 +1118,6 @@ interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
headRepositoryOwner?: string;
updatedAt?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -2972,7 +2968,7 @@ async function resolveCurrentPullRequestView(options: {
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch?.status.state === "open") {
if (viewMatch) {
return viewMatch.status;
}
@@ -2984,13 +2980,12 @@ async function resolveCurrentPullRequestView(options: {
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return viewMatch?.status ?? null;
return null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
viewMatch,
repo: `${parentOwner}/${parentName}`,
});
}
@@ -3003,7 +2998,7 @@ async function resolveCurrentPullRequestView(options: {
run: options.run,
});
const match = pickPullRequestCandidate({
candidates: viewMatch ? [...candidates, viewMatch] : candidates,
candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
@@ -3024,7 +3019,6 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
viewMatch: ResolvedPullRequestCandidate | null;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3040,15 +3034,12 @@ async function resolveForkPullRequestView(options: {
),
);
const match = pickPullRequestCandidate({
candidates: [
...candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate =>
candidate !== null && candidate.headRepositoryOwner === options.forkOwner,
),
...(options.viewMatch ? [options.viewMatch] : []),
],
candidates: candidates.filter(
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
});
return match?.status ?? null;
}
@@ -3346,7 +3337,6 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
updatedAt: node.updatedAt,
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
@@ -3367,7 +3357,6 @@ function toCurrentPullRequestCandidate(
const headSha = item.headRefOid;
return {
status,
updatedAt: item.updatedAt,
...(headSha ? { headSha } : {}),
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
};
@@ -3391,6 +3380,12 @@ function pickPullRequestCandidate(options: {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
return false;
}
if (!options.headRepositoryOwner) {
return true;
}
@@ -3416,17 +3411,17 @@ function comparePullRequestCandidatePreference(
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return (
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
return 0;
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open") {
if (status.state === "open" || status.isDraft) {
return 0;
}
return 1;
if (status.state === "merged") {
return 1;
}
return 2;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3974,7 +3969,6 @@ function toCurrentPullRequestStatus(
state,
baseRefName: item.baseRefName,
headRefName: item.headRefName || fallbackHeadRefName,
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
isMerged: mergedAt !== null,
isDraft: item.isDraft ?? false,
mergeable: item.mergeable,
@@ -357,16 +357,14 @@ describe("createGitLabService", () => {
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
});
it("keeps a merged MR when its remote head advances beyond the checkout", async () => {
it("does not attach the latest historical merge request after a reused branch advances", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [stale] : stale)),
);
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
await expect(
service.getCurrentPullRequestStatus({
@@ -374,8 +372,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({ state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
@@ -728,7 +728,6 @@ function toCurrentPullRequestStatus(
state: mapMergeRequestState(mr.state),
baseRefName: mr.target_branch,
headRefName: mr.source_branch,
...(mr.sha ? { headSha: mr.sha } : {}),
isMerged: mr.state === "merged" || mr.merged_at != null,
isDraft: mr.draft ?? mr.work_in_progress ?? false,
mergeable: mapMergeable(mr),
@@ -988,7 +987,6 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
candidates[0] ??
null;
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -4223,7 +4223,6 @@ export interface PullRequestStatus {
state: string;
baseRefName: string;
headRefName: string;
headSha?: string;
isMerged: boolean;
isDraft?: boolean;
mergeable?: PullRequestMergeable;
@@ -0,0 +1,82 @@
import { expect, test } from "vitest";
import { mkdir, mkdtemp, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js";
import { discover, fetchUsage } from "./usage.js";
import { inputSchema } from "../shared/input.js";
test.each(["opencode", "codex"])(
"agent usage refresh follows a changed %s login identity",
async (provider) => {
await mkdir(join(process.cwd(), ".artifacts"), { recursive: true });
const home = await mkdtemp(join(process.cwd(), ".artifacts/account-switch-"));
const databasePath = join(home, "opencode.db");
const env = { HOME: home, CODEX_HOME: home, OPENCODE_DB: databasePath };
const lookup = { home, env };
const db = new DatabaseSync(databasePath);
try {
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, 1, 1)");
for (const account of ["a", "b"])
insert.run(
account,
"openai",
JSON.stringify({
type: "oauth",
access: `fixture-${account}`,
metadata: { accountID: account },
expires: Date.now() + 60000,
}),
account === "a" ? 1 : 0,
);
await writeFile(
join(home, "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-a", account_id: "a" } }),
);
const session = { provider, model: "openai/gpt-5", sessionKey: "same-session", env };
const registry = new UsageSourceRegistry(Date.now, 300000, undefined, {
hasAgent: (id) => id === "agent",
usageSession: () => session,
});
registry.register({
id: "codex",
label: "Codex",
discover: (scope) =>
scope.kind === "session" ? discover(scope, lookup) : Promise.resolve([]),
fetch: (input) =>
fetchUsage(
inputSchema.parse(input),
async () =>
Response.json({
plan_type: "plus",
rate_limit: { primary_window: { used_percent: 10, limit_window_seconds: 18000 } },
}),
lookup,
),
});
expect(
(await registry.listReports({ agentId: "agent" })).map((entry) => ({
id: entry.id,
status: entry.report.status,
})),
).toEqual([{ id: "codex:a", status: "available" }]);
db.exec("UPDATE credential SET active = CASE WHEN id = 'b' THEN 1 ELSE 0 END");
await writeFile(
join(home, "auth.json"),
JSON.stringify({ tokens: { access_token: "fixture-b", account_id: "b" } }),
);
expect(
(await registry.listReports({ agentId: "agent", forceRefresh: true })).map((entry) => ({
id: entry.id,
status: entry.report.status,
})),
).toEqual([{ id: "codex:b", status: "available" }]);
} finally {
db.close();
await rm(home, { recursive: true, force: true });
}
},
);
@@ -78,6 +78,27 @@ async function database(path: string) {
db.close();
}
async function opencodeDatabase(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
return db;
}
function opencodeOauth(access: string, expires: number, accountId?: string) {
return JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access,
refresh: "fixture-refresh",
expires,
...(accountId ? { metadata: { accountID: accountId } } : {}),
});
}
test("Pi uses its own OAuth identity and re-reads rotated tokens without writing", async () => {
const path = join(home, ".pi", "agent", "auth.json");
await json(path, {
@@ -260,3 +281,269 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
),
).rejects.toThrow("login store no longer exists");
});
function opencodeUsageResponse() {
return Response.json({
plan_type: "plus",
rate_limit: {
primary_window: { used_percent: 30, limit_window_seconds: 18000 },
secondary_window: { used_percent: 81, limit_window_seconds: 604800 },
},
});
}
test("OpenCode v2 database login replaces a stale v1 file login", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
await json(join(data, "opencode", "auth.json"), {
openai: { type: "oauth", access: "fixture-expired", expires: 500 },
});
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } },
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({ key: "db-account" });
const report = await fetchUsage(
inputs[0]!,
async (_url, init) => {
const headers = new Headers(init?.headers);
expect(headers.get("Authorization")).toBe("Bearer fixture-db");
expect(headers.get("ChatGPT-Account-Id")).toBe("db-account");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report).toMatchObject({
status: "available",
planLabel: "plus",
windows: [
{ id: "five_hour", usedPct: 30 },
{ id: "weekly", usedPct: 81 },
],
});
});
test("OpenCode v2 database login with an expired token is unavailable without a request", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-expired", 500, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
expect(
await fetchUsage(
input!,
async () => {
throw new Error("must not fetch an expired token");
},
lookup(env),
),
).toEqual({
status: "unavailable",
problem: { kind: "expired", expiresAt: new Date(500).toISOString(), refreshedBy: "opencode" },
});
});
test("OpenCode v2 database is unchanged after its login is read", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const before = await readFile(databasePath);
const [input] = await logins(lookup(env));
await fetchUsage(input!, async () => opencodeUsageResponse(), lookup(env));
expect(await readFile(databasePath)).toEqual(before);
});
test("OpenCode v2 database login is re-read after the stored token rotates", async () => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-first", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const [input] = await logins(lookup(env));
const { DatabaseSync } = await import("node:sqlite");
const writer = new DatabaseSync(databasePath);
writer
.prepare("UPDATE credential SET value = ? WHERE id = 'cred_1'")
.run(opencodeOauth("fixture-rotated", 2000, "db-account"));
writer.close();
const report = await fetchUsage(
input!,
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-rotated");
return opencodeUsageResponse();
},
lookup(env),
);
expect(report.status).toBe("available");
});
test.each([
["there is no v2 database", null],
[
"the v2 database holds no OpenAI login",
{ integration: "zenai", value: JSON.stringify({ type: "key", key: "fixture-key" }) },
],
[
"the v2 OpenAI login is not active",
{
integration: "openai",
value: opencodeOauth("fixture-inactive", 2000, "db-account"),
active: 0,
},
],
])("OpenCode v1 file login stays when %s", async (_name, row) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const file = join(data, "opencode", "auth.json");
await json(file, { openai: { type: "oauth", access: "fixture-file", expires: 2000 } });
if (row) {
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
row.integration,
"OpenAI",
row.value,
null,
"chatgpt-headless",
"active" in row ? row.active : 1,
1,
1,
);
db.close();
}
expect(await logins(lookup(env))).toEqual([{ route: { store: "opencode", path: file } }]);
});
test.each(["absolute", "relative"])(
"OpenCode v2 database path honours %s OPENCODE_DB",
async (kind) => {
const data = join(home, "data");
const databasePath = join(data, "opencode", "custom-opencode.db");
const db = await opencodeDatabase(databasePath);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
opencodeOauth("fixture-db", 2000, "db-account"),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
expect(
await logins(
lookup({
XDG_DATA_HOME: data,
OPENCODE_DB: kind === "absolute" ? databasePath : "custom-opencode.db",
}),
),
).toEqual([{ route: { store: "opencode-db", path: databasePath, credentialId: "cred_1" } }]);
},
);
test.each([false, true])(
"OpenCode v2 selects its current login after legacy migration (explicit selection: %s)",
async (selected) => {
const data = join(home, "data");
const env = { XDG_DATA_HOME: data };
const databasePath = join(data, "opencode", "opencode.db");
const db = await opencodeDatabase(databasePath);
const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
// The v1 migration leaves active NULL; OpenCode uses creation order until a login is selected.
insert.run(
"older",
"openai",
"OpenAI",
opencodeOauth("fixture-older", 2000, "older-account"),
null,
"chatgpt-headless",
selected ? 1 : null,
1,
3,
);
insert.run(
"newer",
"openai",
"OpenAI",
opencodeOauth("fixture-newer", 2000, "newer-account"),
null,
"chatgpt-headless",
null,
2,
2,
);
db.close();
const inputs = await logins(lookup(env));
expect(inputs).toEqual([
{
route: {
store: "opencode-db",
path: databasePath,
credentialId: selected ? "older" : "newer",
},
},
]);
expect(await accountIdentity(inputs[0]!, env)).toEqual({
key: selected ? "older-account" : "newer-account",
});
},
);
@@ -27,6 +27,22 @@ export const oauthSchema = z.object({
const rowSchema = z.object({ id: z.number().int().positive(), data: z.string() });
export type Route = z.infer<typeof routeSchema>;
export interface OpencodeOauthLogin {
id: string;
access: string;
accountId?: string;
expires?: number;
}
const opencodeRowSchema = z.object({ id: z.string(), value: z.string() });
const opencodeColumnSchema = z.object({ name: z.string() });
const opencodeCredentialSchema = z.object({
type: z.literal("oauth"),
access: z.string().min(1),
expires: z.number().optional(),
metadata: z.object({ accountID: z.string().min(1).optional() }).optional(),
});
export async function readJson(path: string): Promise<unknown> {
try {
return JSON.parse(await readFile(path, "utf8"));
@@ -47,6 +63,20 @@ export function piAuthPath(lookup: StoreLookup): string {
return join(directory, "auth.json");
}
export interface OpencodeStorePaths {
file: string;
database: string;
}
export function opencodeStorePaths(env: NodeJS.ProcessEnv, home: string): OpencodeStorePaths {
const dataHome = env.XDG_DATA_HOME || join(home, ".local", "share");
const directory = join(dataHome, "opencode");
return {
file: join(directory, "auth.json"),
database: resolve(directory, env.OPENCODE_DB || "opencode.db"),
};
}
function ompDatabasePath(lookup: StoreLookup): string {
const home = lookup.home ?? homedir();
const env = lookup.env ?? process.env;
@@ -118,6 +148,61 @@ export function readOmp(route: Extract<Route, { store: "omp" }>, lookup: StoreLo
return ompRows(route.path, lookup).find((row) => row.id === route.credentialId)?.oauth ?? null;
}
function parseCredential(value: string): unknown | null {
try {
return JSON.parse(value);
} catch (error) {
if (error instanceof SyntaxError) return null;
throw error;
}
}
/**
* OpenCode v2 keeps its logins in its own database and no longer writes the v1
* credential file, so the file alone cannot describe the active ChatGPT login.
*/
export function readOpencodeOauth(path: string, lookup: StoreLookup = {}): OpencodeOauthLogin[] {
const sqlite = (lookup.sqlite ?? builtinSqlite)();
if (!sqlite || !existsSync(path)) return [];
let db: Database | undefined;
try {
db = new sqlite.DatabaseSync(path, { readOnly: true });
const columns = db
.prepare("PRAGMA table_info(credential)")
.all()
.map((value) => opencodeColumnSchema.safeParse(value));
if (!columns.some((column) => column.success && column.data.name === "integration_id")) {
return [];
}
const rows = db
.prepare(
// Migrated v1 rows have no active flag. OpenCode selects the newest
// created login until an explicit selection pins another one.
"SELECT id, value FROM credential WHERE integration_id = ? AND (active = 1 OR active IS NULL) ORDER BY active DESC, time_created DESC, id DESC LIMIT 1",
)
.all("openai");
return rows.flatMap((value) => {
const row = opencodeRowSchema.safeParse(value);
if (!row.success) return [];
const credential = opencodeCredentialSchema.safeParse(parseCredential(row.data.value));
if (!credential.success) return [];
const accountId = credential.data.metadata?.accountID;
return [
{
id: row.data.id,
access: credential.data.access,
...(accountId ? { accountId } : {}),
...(credential.data.expires !== undefined ? { expires: credential.data.expires } : {}),
},
];
});
} catch {
return [];
} finally {
db?.close();
}
}
export async function readHarness(
route: Extract<Route, { path: string }>,
lookup: StoreLookup = {},
@@ -230,6 +230,72 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
}
});
test("OpenCode v2 database login reports usage while the v1 file login expired", async () => {
const directory = await mkdtemp(join(tmpdir(), "usage-opencode-v2-"));
try {
const data = join(directory, "data");
await mkdir(join(data, "opencode"), { recursive: true });
await writeFile(
join(data, "opencode", "auth.json"),
JSON.stringify({ openai: { type: "oauth", access: "fixture-expired", expires: 500 } }),
);
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(join(data, "opencode", "opencode.db"));
db.exec(
"CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, value TEXT, connector_id TEXT, method_id TEXT, active INTEGER, time_created INTEGER, time_updated INTEGER)",
);
db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(
"cred_1",
"openai",
"OpenAI",
JSON.stringify({
type: "oauth",
methodID: "chatgpt-headless",
access: "fixture-db",
expires: 2000,
metadata: { accountID: "db-account" },
}),
null,
"chatgpt-headless",
1,
1,
1,
);
db.close();
const lookup = {
home: directory,
env: { XDG_DATA_HOME: data },
platform: "linux" as const,
now: () => 1000,
};
const accounts = await discover({ kind: "global" }, lookup);
expect(accounts.map((account) => account.input)).toEqual([
{
route: {
store: "opencode-db",
path: join(data, "opencode", "opencode.db"),
credentialId: "cred_1",
},
},
]);
const report = await fetchUsage(
inputSchema.parse(accounts[0]!.input),
async (_url, init) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer fixture-db");
return Response.json({
plan_type: "plus",
rate_limit: { primary_window: { used_percent: 30, limit_window_seconds: 18000 } },
});
},
lookup,
);
expect(report).toMatchObject({ status: "available", planLabel: "plus" });
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function accountFor(input: CodexUsageInput) {
const accounts = await discover(
{ kind: "global" },
+51 -17
View File
@@ -1,4 +1,12 @@
import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js";
import {
discoverOmp,
opencodeStorePaths,
piAuthPath,
readHarness,
readJson,
readOpencodeOauth,
type StoreLookup,
} from "./stores.js";
import { homedir } from "node:os";
import { join } from "node:path";
import {
@@ -88,19 +96,33 @@ function globalRoutes(lookup: StoreLookup): CodexUsageInput[] {
const candidates: CodexUsageInput[] = [...new Set(paths)].map((path) => ({
route: { store: "codex", path },
}));
candidates.push(...opencodeRoutes(env, home, lookup));
candidates.push(
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
{ route: { store: "pi", path: piAuthPath(lookup) } },
...discoverOmp(lookup).map((route) => ({ route })),
);
return candidates;
}
/**
* OpenCode v2 stores its logins in a database and leaves the v1 credential file
* untouched, so a file login can be stale while the database login is current.
* The database wins whenever it holds an OpenAI login; the file stays the
* source for v1 installations that have no such database.
*/
function opencodeRoutes(
env: NodeJS.ProcessEnv,
home: string,
lookup: StoreLookup,
): CodexUsageInput[] {
const { file, database } = opencodeStorePaths(env, home);
const logins = readOpencodeOauth(database, lookup);
if (!logins.length) return [{ route: { store: "opencode", path: file } }];
return logins.map((login) => ({
route: { store: "opencode-db", path: database, credentialId: login.id },
}));
}
function sessionRoutes(
scope: Extract<UsageScope, { kind: "session" }>,
lookup: StoreLookup,
@@ -119,14 +141,7 @@ function sessionRoutes(
if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }];
if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route }));
if (scope.provider === "opencode")
return [
{
route: {
store: "opencode",
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
},
},
];
return opencodeRoutes(scope.env, lookup.home ?? homedir(), lookup);
return [];
}
@@ -135,6 +150,14 @@ export async function readAuth(
lookup: StoreLookup = {},
): Promise<Auth | null> {
const route = input.route;
if (route.store === "opencode-db") {
const login = readOpencodeOauth(route.path, lookup).find(
(candidate) => candidate.id === route.credentialId,
);
return login
? { token: login.access, accountId: login.accountId, expires: login.expires }
: null;
}
if (route.store !== "codex") {
const oauth = await readHarness(route, lookup);
return oauth
@@ -191,7 +214,7 @@ export async function fetchUsage(
): Promise<UsageReport> {
const auth = await readAuth(input, lookup);
if (!auth) throw new Error("Codex login store no longer exists");
const refreshedBy = input.route.store;
const refreshedBy = refreshCommand(input.route.store);
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
return unavailable({
kind: "expired",
@@ -297,6 +320,17 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab
}
function harnessLabel(input: CodexUsageInput): string {
const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" };
const labels = {
codex: "Codex",
opencode: "OpenCode",
"opencode-db": "OpenCode",
pi: "Pi",
omp: "OMP",
};
return labels[input.route.store];
}
/** The CLI that refreshes the login behind each store. */
function refreshCommand(store: CodexUsageInput["route"]["store"]): string {
return store === "opencode-db" ? "opencode" : store;
}
@@ -2,6 +2,13 @@ import { z } from "zod";
export const routeSchema = z.discriminatedUnion("store", [
z.object({ store: z.enum(["codex", "opencode", "pi"]), path: z.string().min(1) }).strict(),
z
.object({
store: z.literal("opencode-db"),
path: z.string().min(1),
credentialId: z.string().min(1),
})
.strict(),
z
.object({
store: z.literal("omp"),