mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
798b546e73 | ||
|
|
6301490a07 | ||
|
|
16f688b726 | ||
|
|
b507fc1986 | ||
|
|
e546497a21 |
+8
-12
@@ -97,19 +97,15 @@ Register the adapter in `defaultForgeRegistry` with:
|
||||
- `matchesHost` from manifest `cloudHosts`
|
||||
- `probeHost` when self-hosted/Enterprise detection is supported
|
||||
|
||||
Current change-request lookup uses two identities deliberately:
|
||||
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.
|
||||
|
||||
- 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.
|
||||
Keep checkout HEAD in adapter cache and poll identities so checkout changes
|
||||
refresh the facts used by archive eligibility.
|
||||
|
||||
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
|
||||
|
||||
@@ -111,6 +111,11 @@ import {
|
||||
mapOmpRpcUiPermissionRequest,
|
||||
} from "./rpc-ui-permission-mapper.js";
|
||||
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
|
||||
import {
|
||||
OmpMountNoticeLedger,
|
||||
OmpMountNoticeLedgers,
|
||||
parseOmpMountNotice,
|
||||
} from "./mount-notice.js";
|
||||
|
||||
const OMP_PROVIDER = "omp";
|
||||
const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -208,6 +213,10 @@ interface OmpAgentSessionOptions {
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
providerIdleDeadlineMs?: number;
|
||||
/** Shared by every session of the client so a relaunched agent keeps its ledger. */
|
||||
mountNotices: OmpMountNoticeLedgers;
|
||||
/** The Paseo agent whose projection this session feeds; absent for direct client use. */
|
||||
agentId?: string;
|
||||
/**
|
||||
* When false (resumed sessions), replayed session events are dropped until
|
||||
* the first prompt or agent_start so history is not re-emitted as live
|
||||
@@ -620,6 +629,8 @@ class OmpHistorySession implements AgentSession {
|
||||
private readonly sessionFile: string,
|
||||
provider: AgentProvider,
|
||||
private readonly bridgedTools: ReadonlyMap<string, OmpBridgedToolIdentity>,
|
||||
private readonly mountNotices: OmpMountNoticeLedgers,
|
||||
private readonly agentId: string | undefined,
|
||||
) {
|
||||
this.provider = provider;
|
||||
}
|
||||
@@ -641,6 +652,8 @@ class OmpHistorySession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
// This history seeds a fresh projection that holds no live mount notices.
|
||||
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this);
|
||||
yield* streamOmpHistory({
|
||||
sessionFile: this.sessionFile,
|
||||
provider: this.provider,
|
||||
@@ -718,6 +731,11 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly subagentIndex = new OmpSubagentIndex();
|
||||
private readonly subagentCardTracker: OmpSubagentCardTracker;
|
||||
private lastTodoItem: Extract<AgentTimelineItem, { type: "todo" }> | null = null;
|
||||
private readonly mountNotices: OmpMountNoticeLedgers;
|
||||
private readonly agentId: string | undefined;
|
||||
private readonly sessionMountNotices = new OmpMountNoticeLedger();
|
||||
// The client-wide ledger outlives this session; retain identity without its runtime resources.
|
||||
private readonly mountNoticeDeliverer = {};
|
||||
private state: OmpSessionState;
|
||||
private currentModeId: string | null;
|
||||
private runtimeDead = false;
|
||||
@@ -753,6 +771,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.logger = options.logger;
|
||||
this.live = options.live ?? true;
|
||||
this.mountNotices = options.mountNotices;
|
||||
this.agentId = options.agentId;
|
||||
this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler();
|
||||
this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS;
|
||||
this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler();
|
||||
@@ -999,6 +1019,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
// This history seeds a fresh projection: it keeps the mount notices this session already
|
||||
// delivered into it, and none from earlier sessions.
|
||||
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this.mountNoticeDeliverer);
|
||||
else this.sessionMountNotices.retainDeliveredBy(this.mountNoticeDeliverer);
|
||||
yield* streamOmpHistory({
|
||||
sessionFile: this.state.sessionFile,
|
||||
runtimeSession: this.runtimeSession,
|
||||
@@ -1014,6 +1038,23 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private mountNoticeLedger(): OmpMountNoticeLedger {
|
||||
return this.agentId ? this.mountNotices.forAgent(this.agentId) : this.sessionMountNotices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a mount notice whose devices already have that state in the projected timeline.
|
||||
* A notice no subscriber can receive is not recorded, so it cannot hide a later one.
|
||||
*/
|
||||
private isRedundantMountNotice(event: OmpRuntimeEvent): boolean {
|
||||
const notice = parseOmpMountNotice(event);
|
||||
if (!notice || this.subscribers.size === 0) return false;
|
||||
const ledger = this.mountNoticeLedger();
|
||||
if (!ledger.announces(notice)) return true;
|
||||
ledger.recordDelivered(notice, this.mountNoticeDeliverer);
|
||||
return false;
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
await this.refreshState();
|
||||
return this.runtimeInfoFromState();
|
||||
@@ -1170,6 +1211,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
await this.runtimeSession.branch(target);
|
||||
await this.refreshState();
|
||||
this.activeToolCalls.clear();
|
||||
// A conversation rewind replaces the whole projection, rows this session delivered included.
|
||||
if (this.agentId) this.mountNotices.forget(this.agentId);
|
||||
else this.sessionMountNotices.clear();
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
@@ -1718,6 +1762,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (this.isRedundantMountNotice(event)) {
|
||||
this.logger.debug({ event }, "Dropped OMP mount notice already shown in this timeline");
|
||||
return true;
|
||||
}
|
||||
const mappedEvent = mapOmpRuntimeEventToTimelineItem(event);
|
||||
if (!mappedEvent.handled) {
|
||||
return false;
|
||||
@@ -2246,6 +2294,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
||||
private readonly providerIdleDeadlineMs?: number;
|
||||
private readonly runtime?: OmpRuntime;
|
||||
private readonly mountNotices = new OmpMountNoticeLedgers();
|
||||
|
||||
constructor(options: OmpAgentClientOptions) {
|
||||
const runtimeSettings = mergeOmpRuntimeSettings(
|
||||
@@ -2346,6 +2395,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
|
||||
mountNotices: this.mountNotices,
|
||||
agentId: launchContext?.agentId,
|
||||
});
|
||||
} catch (error) {
|
||||
await hostTools?.close();
|
||||
@@ -2375,6 +2426,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
sessionFile,
|
||||
this.provider,
|
||||
persistenceMetadata.bridgedTools,
|
||||
this.mountNotices,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2417,6 +2470,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
|
||||
mountNotices: this.mountNotices,
|
||||
agentId: launchContext?.agentId,
|
||||
live: false,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { AgentManager, type AgentManagerEvent } from "../../agent-manager.js";
|
||||
import { OmpAgentClient } from "./agent.js";
|
||||
import { parseOmpMountNotice } from "./mount-notice.js";
|
||||
import type { OmpRuntimeEvent } from "./rpc-types.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
|
||||
const AGENT_ID = "8b0c7a52-6f4e-4c4b-9f55-6a5f0f2f6b11";
|
||||
const BROWSER_AND_LINEAR = "xd://: mounted browser, mcp__linear__search";
|
||||
|
||||
type NoticeLevel = "info" | "warning" | "error";
|
||||
|
||||
function notice(
|
||||
message: string,
|
||||
options: { level?: NoticeLevel; source?: string | null } = {},
|
||||
): OmpRuntimeEvent {
|
||||
const source = options.source === undefined ? "xdev" : options.source;
|
||||
return {
|
||||
type: "notice",
|
||||
level: options.level ?? "info",
|
||||
message,
|
||||
...(source === null ? {} : { source }),
|
||||
};
|
||||
}
|
||||
|
||||
function notifications(items: readonly AgentTimelineItem[]): string[] {
|
||||
return items.flatMap((item) => (item.type === "notification" ? [item.message] : []));
|
||||
}
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
async function writeSessionFile(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-mount-notice-"));
|
||||
cleanups.push(() => rm(directory, { recursive: true, force: true }));
|
||||
const sessionFile = join(directory, "session.jsonl");
|
||||
const entries = [
|
||||
{ type: "session", id: "root", parentId: null },
|
||||
{ type: "message", id: "u1", parentId: "root", message: { role: "user", content: "hi" } },
|
||||
];
|
||||
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
function persistenceHandle(sessionFile: string): AgentPersistenceHandle {
|
||||
return {
|
||||
provider: "omp",
|
||||
sessionId: "omp-session",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd: process.cwd() },
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(history: AsyncGenerator<AgentStreamEvent>): Promise<void> {
|
||||
for await (const _event of history) {
|
||||
// Consumed the way the manager seeds a projection.
|
||||
}
|
||||
}
|
||||
|
||||
async function closeQuietly(manager: AgentManager, agentId: string): Promise<void> {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** One Paseo agent whose OMP process the daemon relaunches, as idle collection and reload do. */
|
||||
class MountNoticeHarness {
|
||||
readonly omp = new FakeOmp();
|
||||
readonly client = new OmpAgentClient({ logger: createTestLogger(), runtime: this.omp });
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private session: AgentSession | null = null;
|
||||
|
||||
constructor(private readonly sessionFile: string) {}
|
||||
|
||||
handle(): AgentPersistenceHandle {
|
||||
return persistenceHandle(this.sessionFile);
|
||||
}
|
||||
|
||||
async relaunch(): Promise<void> {
|
||||
await this.session?.close();
|
||||
const session = await this.client.resumeSession(this.handle(), undefined, {
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
this.session = session;
|
||||
session.subscribe((event) => this.events.push(event));
|
||||
cleanups.push(() => session.close());
|
||||
}
|
||||
|
||||
async announce(event: OmpRuntimeEvent | string): Promise<void> {
|
||||
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
/** The current session seeds a fresh projection from provider history. */
|
||||
async seedFromHistory(): Promise<void> {
|
||||
if (!this.session) throw new Error("No OMP session");
|
||||
await drain(this.session.streamHistory());
|
||||
}
|
||||
|
||||
async readArchivedHistory(agentId = AGENT_ID): Promise<void> {
|
||||
const history = await this.client.resumeSession(
|
||||
this.handle(),
|
||||
undefined,
|
||||
{ agentId },
|
||||
{
|
||||
purpose: "history",
|
||||
},
|
||||
);
|
||||
await drain(history.streamHistory());
|
||||
await history.close();
|
||||
}
|
||||
|
||||
notifications(): string[] {
|
||||
return notifications(
|
||||
this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(): Promise<MountNoticeHarness> {
|
||||
const omp = new MountNoticeHarness(await writeSessionFile());
|
||||
await omp.relaunch();
|
||||
return omp;
|
||||
}
|
||||
|
||||
describe("parseOmpMountNotice", () => {
|
||||
test("reads mounted and unmounted devices", () => {
|
||||
expect(parseOmpMountNotice(notice("xd://: mounted a, b; unmounted c"))).toEqual({
|
||||
mounted: ["a", "b"],
|
||||
unmounted: ["c"],
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts OMP builds that omit the notice source", () => {
|
||||
expect(parseOmpMountNotice(notice("xd://: unmounted a", { source: null }))).toEqual({
|
||||
mounted: [],
|
||||
unmounted: ["a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves other notices alone", () => {
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { source: "btw" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "warning" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "error" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("xd://: rescanned devices"))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("xd://: mounted a, "))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("Session saved"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OMP xd:// mount notices", () => {
|
||||
test("retaining announced mounts does not retain closed OMP sessions", () => {
|
||||
const fixture = fileURLToPath(new URL("./test-utils/mount-notice-memory.ts", import.meta.url));
|
||||
const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
closedWithoutNoticeRetained: false,
|
||||
closedWithNoticeRetained: false,
|
||||
noticesAfterRelaunch: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("a relaunched OMP process does not repeat a mount set the timeline already shows", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.relaunch();
|
||||
await omp.announce("xd://: mounted mcp__linear__search");
|
||||
await omp.announce("xd://: mounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a changed mount set is shown as OMP reported it", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
const withGithub = "xd://: mounted browser, mcp__linear__search, mcp__github__search";
|
||||
await omp.announce(withGithub);
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
await omp.relaunch();
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
withGithub,
|
||||
"xd://: unmounted browser",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a device mounted again after an unmount is shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
await omp.announce("xd://: mounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
"xd://: unmounted browser",
|
||||
"xd://: mounted browser",
|
||||
]);
|
||||
});
|
||||
|
||||
test("identical ordinary notices are all shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(notice("Session saved", { source: "session-persistence" }));
|
||||
await omp.relaunch();
|
||||
await omp.announce(notice("Session saved", { source: "session-persistence" }));
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
"Session saved",
|
||||
"Session saved",
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
]);
|
||||
});
|
||||
|
||||
test("identical xd:// warnings and errors are all shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
for (const level of ["warning", "error"] as const) {
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
|
||||
await omp.relaunch();
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
|
||||
}
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a projection seeded from history drops mounts earlier sessions showed", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.seedFromHistory();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a projection seeded from history keeps mounts its own session already showed", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.seedFromHistory();
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reading the agent's archived history drops the mounts it no longer shows", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.readArchivedHistory();
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reading another agent's history of the same session file keeps this agent's mounts", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.readArchivedHistory("2d1e4f7a-0c3b-4d8e-a9f6-5b7c8d9e0f12");
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a notice nobody received does not hide the next one", async () => {
|
||||
const omp = new MountNoticeHarness(await writeSessionFile());
|
||||
const unobserved = await omp.client.resumeSession(omp.handle(), undefined, {
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
cleanups.push(() => unobserved.close());
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await unobserved.close();
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("an approval mode change keeps the mount set the timeline already shows", async () => {
|
||||
const omp = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime: omp });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
cleanups.push(() => session.close());
|
||||
const items: AgentTimelineItem[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
});
|
||||
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
|
||||
await waitForImmediate();
|
||||
|
||||
await session.setMode("ask");
|
||||
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.recordedLaunches).toHaveLength(2);
|
||||
expect(notifications(items)).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A daemon AgentManager driving one OMP agent through FakeOmp. */
|
||||
class DaemonTimelineHarness {
|
||||
readonly omp = new FakeOmp();
|
||||
readonly manager = new AgentManager({
|
||||
clients: {
|
||||
omp: new OmpAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: this.omp,
|
||||
// FakeOmp supplies the runtime; the manager still checks command availability.
|
||||
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
|
||||
}),
|
||||
},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
readonly live: Array<{
|
||||
epoch: string | undefined;
|
||||
seq: number | undefined;
|
||||
item: AgentTimelineItem;
|
||||
}> = [];
|
||||
|
||||
constructor(readonly agentId: string) {
|
||||
cleanups.push(() => closeQuietly(this.manager, agentId));
|
||||
this.manager.subscribe(
|
||||
(event: AgentManagerEvent) => {
|
||||
if (event.type === "agent_stream" && event.event.type === "timeline") {
|
||||
this.live.push({ epoch: event.epoch, seq: event.seq, item: event.event.item });
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
}
|
||||
|
||||
/** What the agent loader does when a chat opens an agent the daemon has not loaded. */
|
||||
async load(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.manager.resumeAgentFromPersistence(handle, undefined, this.agentId);
|
||||
await this.manager.hydrateTimelineFromProvider(this.agentId);
|
||||
}
|
||||
|
||||
async announce(event: OmpRuntimeEvent | string): Promise<void> {
|
||||
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
async prompt(text: string): Promise<void> {
|
||||
await this.manager.appendTimelineItem(this.agentId, { type: "user_message", text });
|
||||
}
|
||||
|
||||
fetchAll() {
|
||||
return this.manager.fetchTimeline(this.agentId, { direction: "tail", limit: 0 });
|
||||
}
|
||||
|
||||
fetchedNotifications(): string[] {
|
||||
return notifications(this.fetchAll().rows.map((row) => row.item));
|
||||
}
|
||||
}
|
||||
|
||||
describe("OMP mount notices in the daemon timeline", () => {
|
||||
test("live delivery and fetched history agree after idle collection relaunches OMP", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
await daemon.load(persistenceHandle(await writeSessionFile()));
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.prompt("first prompt");
|
||||
const handle = daemon.manager.getAgent(AGENT_ID)?.persistence;
|
||||
if (!handle) throw new Error("OMP agent has no persistence handle");
|
||||
const epoch = daemon.fetchAll().epoch;
|
||||
const historyRows = daemon.fetchAll().rows.length - 2;
|
||||
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.announce(notice("Session saved", { source: null }));
|
||||
await daemon.prompt("second prompt");
|
||||
await daemon.manager.flush();
|
||||
|
||||
const fetched = daemon.fetchAll();
|
||||
expect(daemon.omp.recordedLaunches.at(-1)?.session).toBe(handle.nativeHandle);
|
||||
expect(fetched.epoch).toBe(epoch);
|
||||
expect(fetched.rows.slice(historyRows).map((row) => row.item)).toEqual([
|
||||
{ type: "notification", level: "info", message: BROWSER_AND_LINEAR },
|
||||
expect.objectContaining({ type: "user_message", text: "first prompt" }),
|
||||
{ type: "notification", level: "info", message: "Session saved" },
|
||||
expect.objectContaining({ type: "user_message", text: "second prompt" }),
|
||||
]);
|
||||
const liveRows = daemon.live.filter((entry) => entry.epoch === epoch);
|
||||
expect(liveRows.map((entry) => entry.item)).toEqual(
|
||||
fetched.rows.slice(historyRows).map((row) => row.item),
|
||||
);
|
||||
expect(liveRows.map((entry) => entry.seq)).toEqual(
|
||||
fetched.rows.slice(historyRows).map((row) => row.seqEnd),
|
||||
);
|
||||
const seqs = fetched.rows.map((row) => row.seqEnd);
|
||||
expect(seqs).toEqual(seqs.map((_seq, index) => index + 1));
|
||||
expect(fetched.hasOlder).toBe(false);
|
||||
expect(fetched.hasNewer).toBe(false);
|
||||
|
||||
const afterFirstPrompt = daemon.manager.fetchTimeline(AGENT_ID, {
|
||||
direction: "after",
|
||||
cursor: { epoch, seq: historyRows + 2 },
|
||||
limit: 0,
|
||||
});
|
||||
expect(afterFirstPrompt.gap).toBe(false);
|
||||
expect(afterFirstPrompt.rows.map((row) => row.item)).toEqual(
|
||||
fetched.rows.slice(historyRows + 2).map((row) => row.item),
|
||||
);
|
||||
});
|
||||
|
||||
test("a mount notice that lands before history seeding still suppresses the next relaunch", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
const handle = persistenceHandle(await writeSessionFile());
|
||||
await daemon.manager.resumeAgentFromPersistence(handle, undefined, AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
|
||||
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a conversation rewind starts a projection that shows the mount set once", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
const handle = persistenceHandle(await writeSessionFile());
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
const userMessage = daemon
|
||||
.fetchAll()
|
||||
.rows.map((row) => row.item)
|
||||
.find((item) => item.type === "user_message");
|
||||
if (userMessage?.type !== "user_message" || !userMessage.messageId) {
|
||||
throw new Error("History has no user message to rewind to");
|
||||
}
|
||||
|
||||
await daemon.manager.rewind(AGENT_ID, userMessage.messageId, "conversation");
|
||||
expect(daemon.fetchedNotifications()).toEqual([]);
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reloading from disk starts a projection that shows the mount set once", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
await daemon.load(persistenceHandle(await writeSessionFile()));
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
const epoch = daemon.fetchAll().epoch;
|
||||
|
||||
await daemon.manager.reloadAgentSession(AGENT_ID, undefined, { rehydrateFromDisk: true });
|
||||
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.manager.reloadAgentSession(AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchAll().epoch).not.toBe(epoch);
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { OmpNoticeEventSchema } from "./rpc-types.js";
|
||||
|
||||
// OMP 18.x announces device mounts as an info notice `xd://: mounted a, b; unmounted c` with
|
||||
// source `xdev`. Each OMP process diffs against the tools it had at startup, so every relaunch of
|
||||
// the same session (idle collection, reload, mode change) announces again every MCP server that
|
||||
// connects after OMP's startup wait. A relaunch never reports a device that went missing while
|
||||
// no process ran, and has no RPC that lists mounted devices, so a device that vanishes and later
|
||||
// remounts in a new process looks identical to one that was present all along.
|
||||
const MOUNT_NOTICE_PREFIX = "xd://: ";
|
||||
const MOUNT_NOTICE_SOURCE = "xdev";
|
||||
const MOUNT_NOTICE_LEVEL = "info";
|
||||
const MOUNTED_PREFIX = "mounted ";
|
||||
const UNMOUNTED_PREFIX = "unmounted ";
|
||||
|
||||
export interface OmpMountNotice {
|
||||
mounted: readonly string[];
|
||||
unmounted: readonly string[];
|
||||
}
|
||||
|
||||
function parseNames(list: string): string[] | null {
|
||||
const names = list.split(",").map((name) => name.trim());
|
||||
return names.every((name) => name.length > 0) ? names : null;
|
||||
}
|
||||
|
||||
/** Reads an OMP `notice` event as an xd:// mount change, or null for any other notice. */
|
||||
export function parseOmpMountNotice(event: unknown): OmpMountNotice | null {
|
||||
const parsed = OmpNoticeEventSchema.safeParse(event);
|
||||
if (!parsed.success) return null;
|
||||
const { level, message, source } = parsed.data;
|
||||
if (level !== MOUNT_NOTICE_LEVEL) return null;
|
||||
if (source !== undefined && source !== MOUNT_NOTICE_SOURCE) return null;
|
||||
if (!message.startsWith(MOUNT_NOTICE_PREFIX)) return null;
|
||||
|
||||
const mounted: string[] = [];
|
||||
const unmounted: string[] = [];
|
||||
for (const segment of message.slice(MOUNT_NOTICE_PREFIX.length).split(";")) {
|
||||
const part = segment.trim();
|
||||
let target: string[];
|
||||
let names: string[] | null;
|
||||
if (part.startsWith(UNMOUNTED_PREFIX)) {
|
||||
target = unmounted;
|
||||
names = parseNames(part.slice(UNMOUNTED_PREFIX.length));
|
||||
} else if (part.startsWith(MOUNTED_PREFIX)) {
|
||||
target = mounted;
|
||||
names = parseNames(part.slice(MOUNTED_PREFIX.length));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!names) return null;
|
||||
target.push(...names);
|
||||
}
|
||||
return mounted.length > 0 || unmounted.length > 0 ? { mounted, unmounted } : null;
|
||||
}
|
||||
|
||||
type OmpMountState = "mounted" | "unmounted";
|
||||
|
||||
/** The session that delivered a timeline row. A ledger keeps only rows its projection holds. */
|
||||
export type OmpMountNoticeDeliverer = object;
|
||||
|
||||
interface OmpAnnouncedMount {
|
||||
state: OmpMountState;
|
||||
deliverer: OmpMountNoticeDeliverer;
|
||||
}
|
||||
|
||||
/** The last xd:// state one agent's projected timeline shows for each device. */
|
||||
export class OmpMountNoticeLedger {
|
||||
private readonly announced = new Map<string, OmpAnnouncedMount>();
|
||||
|
||||
/** Reports whether the notice changes any device state the projection shows. */
|
||||
announces(notice: OmpMountNotice): boolean {
|
||||
return (
|
||||
notice.mounted.some((name) => this.announced.get(name)?.state !== "mounted") ||
|
||||
notice.unmounted.some((name) => this.announced.get(name)?.state !== "unmounted")
|
||||
);
|
||||
}
|
||||
|
||||
/** Records a row the deliverer added to the projection; it shows every device it names. */
|
||||
recordDelivered(notice: OmpMountNotice, deliverer: OmpMountNoticeDeliverer): void {
|
||||
for (const name of notice.mounted) this.announced.set(name, { state: "mounted", deliverer });
|
||||
for (const name of notice.unmounted) {
|
||||
this.announced.set(name, { state: "unmounted", deliverer });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only rows the deliverer added. A session streams provider history only to seed a fresh
|
||||
* projection, which holds that history plus the rows this session already delivered into it.
|
||||
*/
|
||||
retainDeliveredBy(deliverer: OmpMountNoticeDeliverer): void {
|
||||
for (const [name, mount] of this.announced) {
|
||||
if (mount.deliverer !== deliverer) this.announced.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.announced.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.announced.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount ledgers keyed by Paseo agent id. The agent's retained projection outlives one OMP
|
||||
* process and one Paseo session, so its ledger must too.
|
||||
*/
|
||||
export class OmpMountNoticeLedgers {
|
||||
private readonly byAgentId = new Map<string, OmpMountNoticeLedger>();
|
||||
|
||||
forAgent(agentId: string): OmpMountNoticeLedger {
|
||||
let ledger = this.byAgentId.get(agentId);
|
||||
if (!ledger) {
|
||||
ledger = new OmpMountNoticeLedger();
|
||||
this.byAgentId.set(agentId, ledger);
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/** The agent's whole projection is about to be replaced from provider history. */
|
||||
forget(agentId: string): void {
|
||||
this.byAgentId.delete(agentId);
|
||||
}
|
||||
|
||||
/** The agent's projection is being seeded from provider history by this deliverer. */
|
||||
seedFromHistory(agentId: string, deliverer: OmpMountNoticeDeliverer): void {
|
||||
const ledger = this.byAgentId.get(agentId);
|
||||
if (!ledger) return;
|
||||
ledger.retainDeliveredBy(deliverer);
|
||||
if (ledger.size === 0) this.byAgentId.delete(agentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { setImmediate as nextTask } from "node:timers/promises";
|
||||
import pino from "pino";
|
||||
import { OmpAgentClient } from "../agent.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
if (!global.gc) throw new Error("Run with --expose-gc");
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: pino({ level: "silent" }), runtime });
|
||||
const notices: string[] = [];
|
||||
const notice = {
|
||||
type: "notice",
|
||||
level: "info",
|
||||
source: "xdev",
|
||||
message: "xd://: mounted browser",
|
||||
} as const;
|
||||
const agentId = "a7969ccf-b200-429a-a36a-e61ac01e59ab";
|
||||
|
||||
async function closedSession(announce: boolean, id: string) {
|
||||
const session = await client.createSession(
|
||||
{ provider: "omp", cwd: process.cwd() },
|
||||
{ agentId: id },
|
||||
);
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "timeline" && event.item.type === "notification") {
|
||||
notices.push(event.item.message);
|
||||
}
|
||||
});
|
||||
if (announce) runtime.latestSession().emit(notice);
|
||||
await nextTask();
|
||||
const persistence = session.describePersistence();
|
||||
if (!persistence) throw new Error("Session has no persistence handle");
|
||||
await session.close();
|
||||
return { reference: new WeakRef(session), persistence };
|
||||
}
|
||||
|
||||
const withoutNotice = await closedSession(false, "cd39924e-3984-4d10-b2ec-8a4e4ba029f5");
|
||||
const withNotice = await closedSession(true, agentId);
|
||||
// Move to another job before collecting: WeakRef targets survive their creation job.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await nextTask();
|
||||
global.gc();
|
||||
}
|
||||
const retained = {
|
||||
closedWithoutNoticeRetained: withoutNotice.reference.deref() !== undefined,
|
||||
closedWithNoticeRetained: withNotice.reference.deref() !== undefined,
|
||||
};
|
||||
|
||||
const resumed = await client.resumeSession(withNotice.persistence, undefined, { agentId });
|
||||
try {
|
||||
resumed.subscribe((event) => {
|
||||
if (event.type === "timeline" && event.item.type === "notification") {
|
||||
notices.push(event.item.message);
|
||||
}
|
||||
});
|
||||
runtime.latestSession().emit(notice);
|
||||
await nextTask();
|
||||
console.log(JSON.stringify({ ...retained, noticesAfterRelaunch: notices.length }));
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
@@ -30,6 +30,7 @@ function createPullRequest(
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
headSha: "same-head",
|
||||
isMerged: true,
|
||||
...overrides,
|
||||
};
|
||||
@@ -46,6 +47,7 @@ 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,
|
||||
@@ -346,6 +348,25 @@ 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,13 +59,21 @@ export async function archiveIfSafe(input: {
|
||||
const cwd = snapshot.cwd;
|
||||
const pullRequest = snapshot.forge.pullRequest;
|
||||
|
||||
if (!pullRequest?.isMerged) {
|
||||
if (
|
||||
!pullRequest?.isMerged ||
|
||||
!snapshot.git.headSha ||
|
||||
snapshot.git.headSha !== pullRequest.headSha ||
|
||||
snapshot.forge.error
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.git.isDirty === true) {
|
||||
if (snapshot.git.isDirty !== false) {
|
||||
return;
|
||||
}
|
||||
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
|
||||
if (
|
||||
(snapshot.git.aheadOfOrigin !== null && snapshot.git.aheadOfOrigin !== 0) ||
|
||||
(snapshot.git.behindOfOrigin !== null && snapshot.git.behindOfOrigin !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,12 @@ async function createWorkspaceJourney() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
||||
async function createUnobservedWorkspace(journeyOptions?: {
|
||||
archiveBarrier?: Promise<void>;
|
||||
changeBeforeArchiveRead?: boolean;
|
||||
}) {
|
||||
const { archiveBarrier, changeBeforeArchiveRead: shouldChange = false } = journeyOptions ?? {};
|
||||
let changeBeforeArchiveRead = shouldChange;
|
||||
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
|
||||
const cwd = worktree.worktreePath;
|
||||
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
|
||||
@@ -173,8 +178,10 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
||||
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,
|
||||
@@ -186,6 +193,7 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
||||
return {
|
||||
status: observation && {
|
||||
...observation,
|
||||
headSha: remoteHeadSha,
|
||||
title: observation.headRefName,
|
||||
baseRefName: "main",
|
||||
isMerged: observation.state === "merged",
|
||||
@@ -202,15 +210,29 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
||||
logger,
|
||||
paseoHome,
|
||||
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
||||
workspaceGitService: service,
|
||||
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);
|
||||
},
|
||||
},
|
||||
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) =>
|
||||
archiveIfSafe({
|
||||
archiveIfSafe: async (input) => {
|
||||
await archiveIfSafe({
|
||||
...input,
|
||||
deps: {
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
@@ -227,13 +249,19 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
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");
|
||||
@@ -270,12 +298,33 @@ 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([]);
|
||||
@@ -292,10 +341,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,6 +90,11 @@ 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,6 +63,8 @@ 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) {
|
||||
|
||||
@@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("returns 404 for missing metadata while serving web UI deep links", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: true,
|
||||
webUi: { enabled: true, distDir },
|
||||
});
|
||||
const origin = `http://127.0.0.1:${daemonHandle.port}`;
|
||||
const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`);
|
||||
expect(metadata.status).toBe(404);
|
||||
expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||
expect(await metadata.json()).toEqual({ error: "Not found" });
|
||||
|
||||
const deepLink = await fetch(`${origin}/h/example/agent/123`);
|
||||
expect(deepLink.status).toBe(200);
|
||||
expect(await deepLink.text()).toContain("<body>app</body>");
|
||||
});
|
||||
|
||||
test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => {
|
||||
daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
|
||||
|
||||
@@ -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-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;
|
||||
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;
|
||||
return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } }));
|
||||
}
|
||||
export default function contribute(server) {
|
||||
@@ -270,31 +270,6 @@ 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, {
|
||||
|
||||
@@ -187,6 +187,33 @@ describe("daemon web UI route module", () => {
|
||||
expect(res.body).toContain("app");
|
||||
});
|
||||
|
||||
test.each([
|
||||
"/.well-known/oauth-protected-resource",
|
||||
"/.well-known/oauth-protected-resource/mcp/agents",
|
||||
"/.well-known/oauth-authorization-server",
|
||||
"/.well-known/openid-configuration",
|
||||
])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => {
|
||||
const app = createApp({ enabled: true, distDir, publicDir });
|
||||
|
||||
const res = await request(app, "GET", requestPath);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
|
||||
expect(JSON.parse(res.body)).toEqual({ error: "Not found" });
|
||||
});
|
||||
|
||||
test("serves well-known files that exist in the dist directory", async () => {
|
||||
await mkdir(path.join(distDir, ".well-known"), { recursive: true });
|
||||
await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}');
|
||||
const app = createApp({ enabled: true, distDir, publicDir });
|
||||
|
||||
const res = await request(app, "GET", "/.well-known/security.json");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
|
||||
expect(res.body).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
test("serves static assets", async () => {
|
||||
const app = createApp({ enabled: true, distDir, publicDir });
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean {
|
||||
return EXCLUDED_PATHS.has(requestPath);
|
||||
}
|
||||
|
||||
// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth
|
||||
// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not
|
||||
// serve; the SPA's index.html with a 200 reads as malformed metadata.
|
||||
function isWellKnownPath(requestPath: string): boolean {
|
||||
return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/");
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
@@ -71,7 +78,14 @@ interface ResolvedTarget {
|
||||
isIndexHtml: boolean;
|
||||
}
|
||||
|
||||
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
|
||||
interface ResolveTargetFileOptions {
|
||||
distDir: string;
|
||||
requestPath: string;
|
||||
spaFallback: boolean;
|
||||
}
|
||||
|
||||
function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null {
|
||||
const { distDir, requestPath, spaFallback } = options;
|
||||
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
|
||||
let filePath = path.join(distDir, safePath);
|
||||
|
||||
@@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget
|
||||
|
||||
const finalStat = safeStat(filePath);
|
||||
if (!finalStat?.isFile()) {
|
||||
if (!spaFallback) {
|
||||
return null;
|
||||
}
|
||||
filePath = path.join(distDir, "index.html");
|
||||
const fallbackStat = safeStat(filePath);
|
||||
if (!fallbackStat?.isFile()) {
|
||||
@@ -190,8 +207,13 @@ interface ServeWebUiFileOptions {
|
||||
function serveWebUiFile(options: ServeWebUiFileOptions): void {
|
||||
const { distDir, requestPath, label, req, res } = options;
|
||||
|
||||
const target = resolveTargetFile(distDir, requestPath);
|
||||
const wellKnown = isWellKnownPath(requestPath);
|
||||
const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown });
|
||||
if (!target) {
|
||||
if (wellKnown) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
repoRoot: string | null;
|
||||
mainRepoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
headSha?: string;
|
||||
remoteUrl: string | null;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isDirty: boolean | null;
|
||||
@@ -185,6 +186,7 @@ export interface WorkspaceGitRuntimeSnapshot {
|
||||
state: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
headSha?: string;
|
||||
isMerged: boolean;
|
||||
isDraft?: boolean;
|
||||
mergeable?: PullRequestMergeable;
|
||||
@@ -320,6 +322,7 @@ export type WorkspaceGitSnapshotOptions =
|
||||
| {
|
||||
force: true;
|
||||
includeForge?: boolean;
|
||||
afterInFlight?: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
@@ -3250,7 +3253,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
refreshWorktree: true,
|
||||
includeForge: options?.includeForge ?? true,
|
||||
reason: options?.reason ?? defaultReason,
|
||||
queueIfBusy: false,
|
||||
queueIfBusy: force && options?.afterInFlight === true,
|
||||
movedRemoteRefs: new Set(),
|
||||
};
|
||||
}
|
||||
@@ -3550,6 +3553,7 @@ 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
|
||||
|
||||
@@ -550,14 +550,17 @@ function createGitHubPrRemoteRepo() {
|
||||
|
||||
describe("runWorktreeSetupInBackground", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
let stopSetup: (() => Promise<void>) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await stopSetup?.();
|
||||
stopSetup = undefined;
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
function startSubdirectorySetup(command: string) {
|
||||
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
|
||||
cleanupPaths.push(tempDir);
|
||||
const repoDir = path.join(tempDir, "source");
|
||||
@@ -578,13 +581,14 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
path.join(workspaceCwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["pwd > setup-cwd.txt"],
|
||||
setup: [command],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
const controller = new AbortController();
|
||||
const completion = runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
@@ -603,10 +607,56 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
const stop = async () => {
|
||||
controller.abort();
|
||||
await completion;
|
||||
};
|
||||
stopSetup = stop;
|
||||
return { tempDir, workspaceCwd, worktreePath: worktree.worktreePath, completion, stop };
|
||||
}
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const setup = startSubdirectorySetup("pwd > setup-cwd.txt");
|
||||
await setup.completion;
|
||||
|
||||
expect(existsSync(path.join(setup.workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(setup.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("teardown stops unfinished setup before removing its workspace", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const setup = startSubdirectorySetup(
|
||||
`node -e "require('fs').writeFileSync('setup-pid.txt', String(process.pid)); setInterval(()=>{},1000)"`,
|
||||
);
|
||||
let pid = 0;
|
||||
const probeChild = () => process.kill(pid, 0);
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
expect(existsSync(setup.tempDir)).toBe(false);
|
||||
expect(probeChild).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||
} finally {
|
||||
// Keep a failing cleanup regression from leaking its deliberately live child.
|
||||
await setup.stop();
|
||||
}
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
try {
|
||||
pid = Number(readFileSync(path.join(setup.workspaceCwd, "setup-pid.txt"), "utf8"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
pid = 0;
|
||||
}
|
||||
return pid;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||
|
||||
@@ -128,6 +128,7 @@ export interface CurrentPullRequestStatus {
|
||||
state: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
headSha?: string;
|
||||
isMerged: boolean;
|
||||
isDraft?: boolean;
|
||||
mergeable: PullRequestMergeable;
|
||||
|
||||
@@ -2064,7 +2064,7 @@ describe("createGiteaService", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
|
||||
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
|
||||
const stale = currentPullRequestApi({
|
||||
number: 8,
|
||||
state: "closed",
|
||||
@@ -2086,7 +2086,7 @@ describe("createGiteaService", () => {
|
||||
headRef: "dev",
|
||||
headSha: "2222222222222222222222222222222222222222",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
).resolves.toMatchObject({ number: 8, state: "merged" });
|
||||
});
|
||||
|
||||
it("returns null when no PR matches the current branch", async () => {
|
||||
|
||||
@@ -162,6 +162,7 @@ const GiteaPrListItemSchema = z
|
||||
updated: z.string().optional(),
|
||||
labels: z.string().optional(),
|
||||
ci: z.string().optional(),
|
||||
headSha: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1010,6 +1011,7 @@ 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(","),
|
||||
};
|
||||
@@ -1082,6 +1084,7 @@ 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: [],
|
||||
@@ -1653,6 +1656,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
input.headSha !== undefined &&
|
||||
item.head.sha === input.headSha,
|
||||
) ??
|
||||
candidates[0] ??
|
||||
null;
|
||||
return match ? currentPullRequestApiToListItem(match) : null;
|
||||
}
|
||||
@@ -1718,7 +1722,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
|
||||
combined,
|
||||
actionTasks,
|
||||
);
|
||||
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
|
||||
return applyGiteaChecks(
|
||||
{ ...status, headSha: pr.headSha },
|
||||
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,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
||||
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,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 only when headRefOid matches the checkout HEAD", async () => {
|
||||
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
|
||||
const headSha = "2222222222222222222222222222222222222222";
|
||||
const runner = createRunner([
|
||||
currentPullRequestJson({
|
||||
@@ -3243,8 +3243,9 @@ describe("ForgeService", () => {
|
||||
expect(status?.state).toBe("merged");
|
||||
});
|
||||
|
||||
it("does not attach a stale merged PR after a same-name branch advances", async () => {
|
||||
it("keeps a merged PR when its remote head advances beyond the checkout", async () => {
|
||||
const stale = currentPullRequestJson({
|
||||
headRepositoryOwner: { login: "parentOwner" },
|
||||
state: "MERGED",
|
||||
mergedAt: "2026-07-17T12:00:00Z",
|
||||
headRefOid: "1111111111111111111111111111111111111111",
|
||||
@@ -3265,12 +3266,105 @@ describe("ForgeService", () => {
|
||||
headRef: "feature/fork",
|
||||
headSha: "2222222222222222222222222222222222222222",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
).resolves.toMatchObject({ state: "merged" });
|
||||
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" };
|
||||
@@ -3313,6 +3407,84 @@ 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({
|
||||
|
||||
@@ -346,6 +346,7 @@ 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(),
|
||||
@@ -524,7 +525,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
|
||||
}`;
|
||||
|
||||
const CURRENT_PR_STATUS_BASE_FIELDS =
|
||||
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
||||
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
|
||||
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
|
||||
|
||||
const PULL_REQUEST_STATUS_FACTS_QUERY = `
|
||||
@@ -640,6 +641,7 @@ 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(),
|
||||
@@ -744,6 +746,7 @@ fragment PaseoPollPullRequest on PullRequest {
|
||||
baseRefName
|
||||
headRefName
|
||||
headRefOid
|
||||
updatedAt
|
||||
mergedAt
|
||||
mergeable
|
||||
reviewDecision
|
||||
@@ -1118,6 +1121,7 @@ interface ResolvedPullRequestCandidate {
|
||||
status: CurrentPullRequestStatus;
|
||||
headSha?: string;
|
||||
headRepositoryOwner?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
|
||||
@@ -2968,7 +2972,7 @@ async function resolveCurrentPullRequestView(options: {
|
||||
headRepositoryOwner: options.headRepositoryOwner,
|
||||
})
|
||||
: null;
|
||||
if (viewMatch) {
|
||||
if (viewMatch?.status.state === "open") {
|
||||
return viewMatch.status;
|
||||
}
|
||||
|
||||
@@ -2980,12 +2984,13 @@ async function resolveCurrentPullRequestView(options: {
|
||||
const parentOwner = repo?.parent?.owner?.login;
|
||||
const parentName = repo?.parent?.name;
|
||||
if (!forkOwner) {
|
||||
return null;
|
||||
return viewMatch?.status ?? null;
|
||||
}
|
||||
if (parentOwner && parentName) {
|
||||
return resolveForkPullRequestView({
|
||||
...options,
|
||||
forkOwner,
|
||||
viewMatch,
|
||||
repo: `${parentOwner}/${parentName}`,
|
||||
});
|
||||
}
|
||||
@@ -2998,7 +3003,7 @@ async function resolveCurrentPullRequestView(options: {
|
||||
run: options.run,
|
||||
});
|
||||
const match = pickPullRequestCandidate({
|
||||
candidates,
|
||||
candidates: viewMatch ? [...candidates, viewMatch] : candidates,
|
||||
headRef: options.headRef,
|
||||
headSha: options.headSha,
|
||||
headRepositoryOwner,
|
||||
@@ -3019,6 +3024,7 @@ 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> {
|
||||
@@ -3034,12 +3040,15 @@ async function resolveForkPullRequestView(options: {
|
||||
),
|
||||
);
|
||||
const match = pickPullRequestCandidate({
|
||||
candidates: candidates.filter(
|
||||
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
|
||||
),
|
||||
candidates: [
|
||||
...candidates.filter(
|
||||
(candidate): candidate is ResolvedPullRequestCandidate =>
|
||||
candidate !== null && candidate.headRepositoryOwner === options.forkOwner,
|
||||
),
|
||||
...(options.viewMatch ? [options.viewMatch] : []),
|
||||
],
|
||||
headRef: options.headRef,
|
||||
headSha: options.headSha,
|
||||
headRepositoryOwner: options.forkOwner,
|
||||
});
|
||||
return match?.status ?? null;
|
||||
}
|
||||
@@ -3337,6 +3346,7 @@ 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,
|
||||
@@ -3357,6 +3367,7 @@ function toCurrentPullRequestCandidate(
|
||||
const headSha = item.headRefOid;
|
||||
return {
|
||||
status,
|
||||
updatedAt: item.updatedAt,
|
||||
...(headSha ? { headSha } : {}),
|
||||
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
|
||||
};
|
||||
@@ -3380,12 +3391,6 @@ 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;
|
||||
}
|
||||
@@ -3411,17 +3416,17 @@ function comparePullRequestCandidatePreference(
|
||||
if (leftExact !== rightExact) {
|
||||
return leftExact ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
return (
|
||||
(right.updatedAt ?? "").localeCompare(left.updatedAt ?? "") ||
|
||||
(right.status.number ?? 0) - (left.status.number ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
|
||||
if (status.state === "open" || status.isDraft) {
|
||||
if (status.state === "open") {
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "merged") {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
type GitHubCloneProtocol = "https" | "ssh";
|
||||
@@ -3969,6 +3974,7 @@ 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,14 +357,16 @@ describe("createGitLabService", () => {
|
||||
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
|
||||
});
|
||||
|
||||
it("does not attach the latest historical merge request after a reused branch advances", async () => {
|
||||
it("keeps a merged MR when its remote head advances beyond the checkout", async () => {
|
||||
const stale = {
|
||||
...OPEN_MR,
|
||||
state: "merged",
|
||||
source_branch: "dev",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
};
|
||||
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
|
||||
const { service, calls } = makeService((args) =>
|
||||
ok(JSON.stringify(args[1] === "list" ? [stale] : stale)),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({
|
||||
@@ -372,8 +374,8 @@ describe("createGitLabService", () => {
|
||||
headRef: "dev",
|
||||
headSha: "2222222222222222222222222222222222222222",
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(calls).toEqual([currentMrListArgs("dev")]);
|
||||
).resolves.toMatchObject({ state: "merged" });
|
||||
expect(calls[0]).toEqual(currentMrListArgs("dev"));
|
||||
});
|
||||
|
||||
it("looks up a numeric current branch through the source-branch list filter", async () => {
|
||||
|
||||
@@ -728,6 +728,7 @@ 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),
|
||||
@@ -987,6 +988,7 @@ 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,6 +4223,7 @@ export interface PullRequestStatus {
|
||||
state: string;
|
||||
baseRefName: string;
|
||||
headRefName: string;
|
||||
headSha?: string;
|
||||
isMerged: boolean;
|
||||
isDraft?: boolean;
|
||||
mergeable?: PullRequestMergeable;
|
||||
|
||||
@@ -78,27 +78,6 @@ 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, {
|
||||
@@ -281,269 +260,3 @@ 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,22 +27,6 @@ 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"));
|
||||
@@ -63,20 +47,6 @@ 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;
|
||||
@@ -148,61 +118,6 @@ 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,72 +230,6 @@ 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" },
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
discoverOmp,
|
||||
opencodeStorePaths,
|
||||
piAuthPath,
|
||||
readHarness,
|
||||
readJson,
|
||||
readOpencodeOauth,
|
||||
type StoreLookup,
|
||||
} from "./stores.js";
|
||||
import { discoverOmp, piAuthPath, readHarness, readJson, type StoreLookup } from "./stores.js";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -96,33 +88,19 @@ 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,
|
||||
@@ -141,7 +119,14 @@ 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 opencodeRoutes(scope.env, lookup.home ?? homedir(), lookup);
|
||||
return [
|
||||
{
|
||||
route: {
|
||||
store: "opencode",
|
||||
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
|
||||
},
|
||||
},
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -150,14 +135,6 @@ 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
|
||||
@@ -214,7 +191,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 = refreshCommand(input.route.store);
|
||||
const refreshedBy = input.route.store;
|
||||
if (auth.expires !== undefined && auth.expires <= (lookup.now ?? Date.now)())
|
||||
return unavailable({
|
||||
kind: "expired",
|
||||
@@ -320,17 +297,6 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab
|
||||
}
|
||||
|
||||
function harnessLabel(input: CodexUsageInput): string {
|
||||
const labels = {
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode",
|
||||
"opencode-db": "OpenCode",
|
||||
pi: "Pi",
|
||||
omp: "OMP",
|
||||
};
|
||||
const labels = { codex: "Codex", opencode: "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,13 +2,6 @@ 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"),
|
||||
|
||||
Reference in New Issue
Block a user