Kaynağa Gözat

fix(studio): surface async book creation failures

Ma 7 ay önce
ebeveyn
işleme
904006baf9

+ 28 - 0
packages/studio/src/api/server.test.ts

@@ -236,4 +236,32 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(initBookMock).not.toHaveBeenCalled();
     await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
   });
+
+  it("reports async create failures through the create-status endpoint", async () => {
+    initBookMock.mockRejectedValueOnce(new Error("INKOS_LLM_API_KEY not set"));
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/create", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        title: "Broken Book",
+        genre: "xuanhuan",
+        platform: "qidian",
+        language: "zh",
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    await Promise.resolve();
+
+    const status = await app.request("http://localhost/api/books/broken-book/create-status");
+    expect(status.status).toBe(200);
+    await expect(status.json()).resolves.toMatchObject({
+      status: "error",
+      error: "INKOS_LLM_API_KEY not set",
+    });
+  });
 });

+ 20 - 2
packages/studio/src/api/server.ts

@@ -23,6 +23,7 @@ import { buildStudioBookConfig } from "./book-create.js";
 
 type EventHandler = (event: string, data: unknown) => void;
 const subscribers = new Set<EventHandler>();
+const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
 
 function broadcast(event: string, data: unknown): void {
   for (const handler of subscribers) {
@@ -164,16 +165,33 @@ export function createStudioServer(config: ProjectConfig, root: string) {
     }
 
     broadcast("book:creating", { bookId, title: body.title });
+    bookCreateStatus.set(bookId, { status: "creating" });
 
     const pipeline = new PipelineRunner(buildPipelineConfig());
     pipeline.initBook(bookConfig).then(
-      () => broadcast("book:created", { bookId }),
-      (e) => broadcast("book:error", { bookId, error: e instanceof Error ? e.message : String(e) }),
+      () => {
+        bookCreateStatus.delete(bookId);
+        broadcast("book:created", { bookId });
+      },
+      (e) => {
+        const error = e instanceof Error ? e.message : String(e);
+        bookCreateStatus.set(bookId, { status: "error", error });
+        broadcast("book:error", { bookId, error });
+      },
     );
 
     return c.json({ status: "creating", bookId });
   });
 
+  app.get("/api/books/:id/create-status", async (c) => {
+    const id = c.req.param("id");
+    const status = bookCreateStatus.get(id);
+    if (!status) {
+      return c.json({ status: "missing" }, 404);
+    }
+    return c.json(status);
+  });
+
   // --- Chapters ---
 
   app.get("/api/books/:id/chapters/:num", async (c) => {

+ 12 - 0
packages/studio/src/pages/BookCreate.tsx

@@ -52,6 +52,7 @@ export function platformOptionsForLanguage(language: "zh" | "en"): ReadonlyArray
 
 interface WaitForBookReadyOptions {
   readonly fetchBook?: (bookId: string) => Promise<unknown>;
+  readonly fetchStatus?: (bookId: string) => Promise<{ status: string; error?: string }>;
   readonly maxAttempts?: number;
   readonly delayMs?: number;
   readonly waitImpl?: (ms: number) => Promise<void>;
@@ -62,6 +63,7 @@ export async function waitForBookReady(
   options: WaitForBookReadyOptions = {},
 ): Promise<void> {
   const fetchBook = options.fetchBook ?? ((id: string) => fetchJson(`/books/${id}`));
+  const fetchStatus = options.fetchStatus ?? ((id: string) => fetchJson<{ status: string; error?: string }>(`/books/${id}/create-status`));
   const maxAttempts = options.maxAttempts ?? 20;
   const delayMs = options.delayMs ?? 150;
   const waitImpl = options.waitImpl ?? ((ms: number) => new Promise<void>((resolve) => {
@@ -76,6 +78,16 @@ export async function waitForBookReady(
       return;
     } catch (error) {
       lastError = error;
+      try {
+        const status = await fetchStatus(bookId);
+        if (status.status === "error") {
+          throw new Error(status.error ?? `Book "${bookId}" failed to create`);
+        }
+      } catch (statusError) {
+        if (statusError instanceof Error && statusError.message !== "404 Not Found") {
+          throw statusError;
+        }
+      }
       if (attempt === maxAttempts - 1) {
         throw error;
       }

+ 13 - 0
packages/studio/src/pages/page-state.test.ts

@@ -44,6 +44,7 @@ describe("waitForBookReady", () => {
           throw new Error("Book not found");
         }
       },
+      fetchStatus: async () => ({ status: "creating" }),
       delayMs: 0,
       waitImpl: async () => undefined,
     })).resolves.toBeUndefined();
@@ -56,9 +57,21 @@ describe("waitForBookReady", () => {
       fetchBook: async () => {
         throw new Error("Book not found");
       },
+      fetchStatus: async () => ({ status: "creating" }),
       maxAttempts: 2,
       delayMs: 0,
       waitImpl: async () => undefined,
     })).rejects.toThrow("Book not found");
   });
+
+  it("prefers the server-reported create failure over a polling timeout", async () => {
+    await expect(waitForBookReady("broken-book", {
+      fetchBook: async () => {
+        throw new Error("Book not found");
+      },
+      fetchStatus: async () => ({ status: "error", error: "INKOS_LLM_API_KEY not set" }),
+      delayMs: 0,
+      waitImpl: async () => undefined,
+    })).rejects.toThrow("INKOS_LLM_API_KEY not set");
+  });
 });