fix(FN-1952): recover failed review tasks
This commit is contained in:
@@ -452,13 +452,15 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
||||
expect(AuthStorage.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates ModelRegistry with the authStorage instance", async () => {
|
||||
it("creates ModelRegistry with a merged auth storage reader", async () => {
|
||||
const { ModelRegistry } = await import("@mariozechner/pi-coding-agent");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(ModelRegistry).toHaveBeenCalledTimes(1);
|
||||
expect(ModelRegistry).toHaveBeenCalledWith(mockAuthStorage);
|
||||
const registryAuthStorage = (ModelRegistry as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(registryAuthStorage).not.toBe(mockAuthStorage);
|
||||
expect(registryAuthStorage.getApiKey).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("discovers extensions and registers extension providers", async () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -326,9 +326,10 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
|
||||
const modelRegistry = new ModelRegistry(mergedAuthStorage);
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
|
||||
|
||||
// PackageManager may be used for skills adapter even if extension loading fails
|
||||
let packageManager: DefaultPackageManager | undefined;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
|
||||
|
||||
// Re-export for backward compatibility with tests
|
||||
@@ -363,9 +363,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Passing these to createServer enables the dashboard's Authentication
|
||||
// tab (login/logout) and Model selector.
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
|
||||
const modelRegistry = new ModelRegistry(mergedAuthStorage);
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
|
||||
|
||||
// PackageManager may be used for skills adapter even if extension loading fails
|
||||
let packageManager: DefaultPackageManager | undefined;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
|
||||
function makeAuthStorage(credentials: Record<string, { type: string; key?: string }> = {}) {
|
||||
return {
|
||||
@@ -18,6 +18,8 @@ function makeAuthStorage(credentials: Record<string, { type: string; key?: strin
|
||||
delete credentials[provider];
|
||||
}),
|
||||
get: vi.fn((provider: string) => credentials[provider]),
|
||||
getAll: vi.fn(() => ({ ...credentials })),
|
||||
list: vi.fn(() => Object.keys(credentials)),
|
||||
getApiKey: vi.fn(async (provider: string) => credentials[provider]?.key),
|
||||
} as any;
|
||||
}
|
||||
@@ -67,6 +69,23 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
|
||||
expect(legacyAuth.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates an AuthStorage-compatible merged reader for ModelRegistry", async () => {
|
||||
const fusionAuth = makeAuthStorage({
|
||||
openrouter: { type: "api_key", key: "fusion-key" },
|
||||
});
|
||||
const legacyAuth = makeAuthStorage({
|
||||
minimax: { type: "api_key", key: "legacy-minimax-key" },
|
||||
});
|
||||
|
||||
const merged = mergeAuthStorageReads(fusionAuth, [legacyAuth]);
|
||||
|
||||
expect(await merged.getApiKey("openrouter")).toBe("fusion-key");
|
||||
expect(await merged.getApiKey("minimax")).toBe("legacy-minimax-key");
|
||||
expect(merged.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" });
|
||||
expect(merged.list()).toEqual(expect.arrayContaining(["openrouter", "minimax"]));
|
||||
});
|
||||
|
||||
|
||||
it("reads legacy auth JSON without creating missing files", async () => {
|
||||
const tempDir = join(tmpdir(), `fusion-provider-auth-${process.pid}-${Date.now()}`);
|
||||
const legacyAgentDir = join(tempDir, ".pi", "agent");
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ReadFallbackAuthStorage {
|
||||
hasAuth(provider: string): boolean;
|
||||
getApiKey(providerId: string): Promise<string | undefined>;
|
||||
get(providerId: string): { type?: string; key?: string } | undefined;
|
||||
getAll(): Record<string, { type?: string; key?: string }>;
|
||||
list(): string[];
|
||||
}
|
||||
|
||||
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
|
||||
@@ -54,32 +56,21 @@ export function wrapAuthStorageWithApiKeyProviders(
|
||||
modelRegistry: ModelRegistry,
|
||||
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
|
||||
): DashboardAuthStorage {
|
||||
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
|
||||
const getCredential = (providerId: string) => {
|
||||
for (const storage of readAuthStorages) {
|
||||
const credential = storage.get(providerId);
|
||||
if (credential) return credential;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, readFallbackAuthStorages);
|
||||
|
||||
return {
|
||||
reload: () => {
|
||||
for (const storage of readAuthStorages) {
|
||||
storage.reload();
|
||||
}
|
||||
},
|
||||
reload: () => mergedAuthStorage.reload(),
|
||||
getOAuthProviders: () =>
|
||||
authStorage
|
||||
mergedAuthStorage
|
||||
.getOAuthProviders()
|
||||
.map((provider) => ({ id: provider.id, name: provider.name })),
|
||||
hasAuth: (provider) => readAuthStorages.some((storage) => storage.hasAuth(provider)),
|
||||
hasAuth: (provider) => mergedAuthStorage.hasAuth(provider),
|
||||
login: (providerId, callbacks) =>
|
||||
authStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
|
||||
logout: (provider) => authStorage.logout(provider),
|
||||
mergedAuthStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
|
||||
logout: (provider) => mergedAuthStorage.logout(provider),
|
||||
getApiKeyProviders: () => {
|
||||
const oauthProviderIds = new Set(
|
||||
authStorage.getOAuthProviders().map((provider) => provider.id),
|
||||
mergedAuthStorage.getOAuthProviders().map((provider) => provider.id),
|
||||
);
|
||||
const providers = new Map<string, string>();
|
||||
|
||||
@@ -102,26 +93,84 @@ export function wrapAuthStorageWithApiKeyProviders(
|
||||
);
|
||||
},
|
||||
setApiKey: (providerId, apiKey) => {
|
||||
authStorage.set(providerId, { type: "api_key", key: apiKey });
|
||||
mergedAuthStorage.set(providerId, { type: "api_key", key: apiKey });
|
||||
},
|
||||
clearApiKey: (providerId) => {
|
||||
authStorage.remove(providerId);
|
||||
mergedAuthStorage.remove(providerId);
|
||||
},
|
||||
hasApiKey: (providerId) => {
|
||||
const credential = getCredential(providerId);
|
||||
const credential = mergedAuthStorage.get(providerId);
|
||||
return credential?.type === "api_key" && !!credential.key;
|
||||
},
|
||||
getApiKey: async (providerId) => {
|
||||
for (const storage of readAuthStorages) {
|
||||
const apiKey = await storage.getApiKey(providerId);
|
||||
if (apiKey) return apiKey;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
get: getCredential,
|
||||
getApiKey: (providerId) => mergedAuthStorage.getApiKey(providerId),
|
||||
get: (providerId) => mergedAuthStorage.get(providerId),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAuthStorageReads(
|
||||
authStorage: AuthStorage,
|
||||
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
|
||||
): AuthStorage {
|
||||
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
|
||||
const getCredential = (providerId: string) => {
|
||||
for (const storage of readAuthStorages) {
|
||||
const credential = storage.get(providerId);
|
||||
if (credential) return credential;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return new Proxy(authStorage, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "reload") {
|
||||
return () => {
|
||||
for (const storage of readAuthStorages) {
|
||||
storage.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (prop === "get") {
|
||||
return getCredential;
|
||||
}
|
||||
|
||||
if (prop === "has") {
|
||||
return (provider: string) => readAuthStorages.some((storage) => Boolean(storage.get(provider)));
|
||||
}
|
||||
|
||||
if (prop === "hasAuth") {
|
||||
return (provider: string) => readAuthStorages.some((storage) => storage.hasAuth(provider));
|
||||
}
|
||||
|
||||
if (prop === "getAll") {
|
||||
return () => ({
|
||||
...readFallbackAuthStorages.reduce(
|
||||
(merged, storage) => ({ ...merged, ...storage.getAll() }),
|
||||
{} as Record<string, { type?: string; key?: string }>,
|
||||
),
|
||||
...target.getAll(),
|
||||
});
|
||||
}
|
||||
|
||||
if (prop === "list") {
|
||||
return () => Array.from(new Set(readAuthStorages.flatMap((storage) => storage.list())));
|
||||
}
|
||||
|
||||
if (prop === "getApiKey") {
|
||||
return async (providerId: string) => {
|
||||
for (const storage of readAuthStorages) {
|
||||
const apiKey = await storage.getApiKey(providerId);
|
||||
if (apiKey) return apiKey;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as AuthStorage;
|
||||
}
|
||||
|
||||
export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallbackAuthStorage {
|
||||
let credentials: Record<string, { type?: string; key?: string }> = {};
|
||||
|
||||
@@ -149,6 +198,8 @@ export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallback
|
||||
reload,
|
||||
hasAuth: (provider) => Boolean(credentials[provider]),
|
||||
get: (provider) => credentials[provider],
|
||||
getAll: () => ({ ...credentials }),
|
||||
list: () => Object.keys(credentials),
|
||||
getApiKey: async (provider) => {
|
||||
const credential = credentials[provider];
|
||||
return credential?.type === "api_key" ? credential.key : undefined;
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "./task-lifecycle.js";
|
||||
import { promptForPort } from "./port-prompt.js";
|
||||
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "./provider-settings.js";
|
||||
import { createReadOnlyAuthFileStorage, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
|
||||
import { getFusionAuthPath, getLegacyAuthPaths } from "./auth-paths.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
@@ -385,9 +385,10 @@ export async function runServe(
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry, [legacyAuthStorage]);
|
||||
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
|
||||
const modelRegistry = new ModelRegistry(mergedAuthStorage);
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
|
||||
|
||||
// PackageManager may be used for skills adapter even if extension loading fails
|
||||
let packageManager: DefaultPackageManager | undefined;
|
||||
|
||||
@@ -1735,7 +1735,16 @@ describe("runTaskRetry", () => {
|
||||
await runTaskRetry("FN-001");
|
||||
|
||||
expect(mockGetTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
status: null,
|
||||
error: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
baseCommitSha: null,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
|
||||
|
||||
@@ -1786,7 +1795,16 @@ describe("runTaskRetry", () => {
|
||||
await runTaskRetry("FN-001");
|
||||
|
||||
expect(mockGetTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { status: null, error: null });
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
status: null,
|
||||
error: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
baseCommitSha: null,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(mockMoveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(mockLogEntry).toHaveBeenCalledWith("FN-001", "Retry requested from CLI", "Task reset to todo for retry");
|
||||
|
||||
|
||||
@@ -639,8 +639,17 @@ export async function runTaskRetry(id: string, projectName?: string) {
|
||||
throw new Error(`Task ${id} is not in a retryable state (status: ${task.status || 'none'})`);
|
||||
}
|
||||
|
||||
// Clear failure state
|
||||
await store.updateTask(id, { status: null, error: null });
|
||||
// Clear failure state and stale branch refs so retry can choose a fresh base.
|
||||
await store.updateTask(id, {
|
||||
status: null,
|
||||
error: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
baseBranch: null,
|
||||
baseCommitSha: null,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
|
||||
// Move to todo column
|
||||
await store.moveTask(id, 'todo');
|
||||
|
||||
Reference in New Issue
Block a user