Эх сурвалжийг харах

fix(studio): align reject rollback and auto-open browser

Ma 7 сар өмнө
parent
commit
87ce60189d

+ 24 - 0
packages/cli/src/__tests__/studio-runtime.test.ts

@@ -95,4 +95,28 @@ describe("studio runtime resolution", () => {
       args: [`${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`, "/repo/test-project"],
       args: [`${cliPackageRoot}/node_modules/@actalk/inkos-studio/dist/api/index.js`, "/repo/test-project"],
     });
     });
   });
   });
+
+  it("returns a browser launch spec for macOS", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("darwin", "http://localhost:4567")).toEqual({
+      command: "open",
+      args: ["http://localhost:4567"],
+    });
+  });
+
+  it("returns a browser launch spec for Windows", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("win32", "http://localhost:4567")).toEqual({
+      command: "cmd",
+      args: ["/c", "start", "", "http://localhost:4567"],
+    });
+  });
+
+  it("returns a browser launch spec for Linux", async () => {
+    const { resolveBrowserLaunch } = await import("../commands/studio.js");
+    expect(resolveBrowserLaunch("linux", "http://localhost:4567")).toEqual({
+      command: "xdg-open",
+      args: ["http://localhost:4567"],
+    });
+  });
 });
 });

+ 31 - 1
packages/cli/src/commands/studio.ts

@@ -11,6 +11,11 @@ export interface StudioLaunchSpec {
   readonly args: string[];
   readonly args: string[];
 }
 }
 
 
+export interface BrowserLaunchSpec {
+  readonly command: string;
+  readonly args: string[];
+}
+
 async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
 async function firstAccessiblePath(paths: readonly string[]): Promise<string | undefined> {
   for (const path of paths) {
   for (const path of paths) {
     try {
     try {
@@ -25,6 +30,19 @@ async function firstAccessiblePath(paths: readonly string[]): Promise<string | u
 
 
 const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
 const cliPackageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
 
 
+export function resolveBrowserLaunch(
+  platform: NodeJS.Platform,
+  url: string,
+): BrowserLaunchSpec {
+  if (platform === "darwin") {
+    return { command: "open", args: [url] };
+  }
+  if (platform === "win32") {
+    return { command: "cmd", args: ["/c", "start", "", url] };
+  }
+  return { command: "xdg-open", args: [url] };
+}
+
 export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
 export async function resolveStudioLaunch(root: string): Promise<StudioLaunchSpec | null> {
   const sourceEntry = await firstAccessiblePath([
   const sourceEntry = await firstAccessiblePath([
     join(root, "packages", "studio", "src", "api", "index.ts"),
     join(root, "packages", "studio", "src", "api", "index.ts"),
@@ -86,6 +104,7 @@ export const studioCommand = new Command("studio")
   .action(async (opts) => {
   .action(async (opts) => {
     const root = findProjectRoot();
     const root = findProjectRoot();
     const port = opts.port;
     const port = opts.port;
+    const url = `http://localhost:${port}`;
     const launch = await resolveStudioLaunch(root);
     const launch = await resolveStudioLaunch(root);
 
 
     if (!launch) {
     if (!launch) {
@@ -97,7 +116,7 @@ export const studioCommand = new Command("studio")
       process.exit(1);
       process.exit(1);
     }
     }
 
 
-    log(`Starting InkOS Studio on http://localhost:${port}`);
+    log(`Starting InkOS Studio on ${url}`);
 
 
     const child = spawn(launch.command, launch.args, {
     const child = spawn(launch.command, launch.args, {
       cwd: root,
       cwd: root,
@@ -110,6 +129,17 @@ export const studioCommand = new Command("studio")
       process.exit(1);
       process.exit(1);
     });
     });
 
 
+    const browserLaunch = resolveBrowserLaunch(process.platform, url);
+    const browser = spawn(browserLaunch.command, browserLaunch.args, {
+      cwd: root,
+      stdio: "ignore",
+      detached: true,
+    });
+    browser.on("error", () => {
+      // Best effort only — server startup should not fail just because browser open failed.
+    });
+    browser.unref();
+
     child.on("exit", (code) => {
     child.on("exit", (code) => {
       process.exit(code ?? 0);
       process.exit(code ?? 0);
     });
     });

+ 63 - 2
packages/studio/src/api/server.test.ts

@@ -6,6 +6,9 @@ import { tmpdir } from "node:os";
 const schedulerStartMock = vi.fn<() => Promise<void>>();
 const schedulerStartMock = vi.fn<() => Promise<void>>();
 const initBookMock = vi.fn();
 const initBookMock = vi.fn();
 const runRadarMock = vi.fn();
 const runRadarMock = vi.fn();
+const rollbackToChapterMock = vi.fn();
+const saveChapterIndexMock = vi.fn();
+const loadChapterIndexMock = vi.fn();
 const createLLMClientMock = vi.fn(() => ({}));
 const createLLMClientMock = vi.fn(() => ({}));
 const chatCompletionMock = vi.fn();
 const chatCompletionMock = vi.fn();
 const loadProjectConfigMock = vi.fn();
 const loadProjectConfigMock = vi.fn();
@@ -30,8 +33,16 @@ vi.mock("@actalk/inkos-core", () => {
       throw new Error("not implemented");
       throw new Error("not implemented");
     }
     }
 
 
-    async loadChapterIndex(): Promise<[]> {
-      return [];
+    async loadChapterIndex(bookId: string): Promise<[]> {
+      return (await loadChapterIndexMock(bookId)) as [];
+    }
+
+    async saveChapterIndex(bookId: string, index: unknown): Promise<void> {
+      await saveChapterIndexMock(bookId, index);
+    }
+
+    async rollbackToChapter(bookId: string, chapterNumber: number): Promise<number[]> {
+      return (await rollbackToChapterMock(bookId, chapterNumber)) as number[];
     }
     }
 
 
     async getNextChapterNumber(): Promise<number> {
     async getNextChapterNumber(): Promise<number> {
@@ -125,6 +136,9 @@ describe("createStudioServer daemon lifecycle", () => {
     schedulerStartMock.mockReset();
     schedulerStartMock.mockReset();
     initBookMock.mockReset();
     initBookMock.mockReset();
     runRadarMock.mockReset();
     runRadarMock.mockReset();
+    rollbackToChapterMock.mockReset();
+    saveChapterIndexMock.mockReset();
+    loadChapterIndexMock.mockReset();
     runRadarMock.mockResolvedValue({
     runRadarMock.mockResolvedValue({
       marketSummary: "Fresh market summary",
       marketSummary: "Fresh market summary",
       recommendations: [],
       recommendations: [],
@@ -154,6 +168,9 @@ describe("createStudioServer daemon lifecycle", () => {
         notify: (raw.notify ?? []) as unknown[],
         notify: (raw.notify ?? []) as unknown[],
       };
       };
     });
     });
+    loadChapterIndexMock.mockResolvedValue([]);
+    saveChapterIndexMock.mockResolvedValue(undefined);
+    rollbackToChapterMock.mockResolvedValue([]);
     pipelineConfigs.length = 0;
     pipelineConfigs.length = 0;
   });
   });
 
 
@@ -382,4 +399,48 @@ describe("createStudioServer daemon lifecycle", () => {
       error: "INKOS_LLM_API_KEY not set",
       error: "INKOS_LLM_API_KEY not set",
     });
     });
   });
   });
+
+  it("uses rollback semantics for chapter rejection instead of only flipping status", async () => {
+    loadChapterIndexMock.mockResolvedValue([
+      {
+        number: 3,
+        title: "Broken Chapter",
+        status: "ready-for-review",
+        wordCount: 1800,
+        createdAt: "2026-04-07T00:00:00.000Z",
+        updatedAt: "2026-04-07T00:00:00.000Z",
+        auditIssues: ["continuity"],
+        lengthWarnings: [],
+      },
+      {
+        number: 4,
+        title: "Downstream Chapter",
+        status: "ready-for-review",
+        wordCount: 1900,
+        createdAt: "2026-04-07T00:00:00.000Z",
+        updatedAt: "2026-04-07T00:00:00.000Z",
+        auditIssues: [],
+        lengthWarnings: [],
+      },
+    ]);
+    rollbackToChapterMock.mockResolvedValue([3, 4]);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/books/demo-book/chapters/3/reject", {
+      method: "POST",
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      ok: true,
+      chapterNumber: 3,
+      status: "rejected",
+      rolledBackTo: 2,
+      discarded: [3, 4],
+    });
+    expect(rollbackToChapterMock).toHaveBeenCalledWith("demo-book", 2);
+    expect(saveChapterIndexMock).not.toHaveBeenCalled();
+  });
 });
 });

+ 14 - 5
packages/studio/src/api/server.ts

@@ -347,11 +347,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
 
     try {
     try {
       const index = await state.loadChapterIndex(id);
       const index = await state.loadChapterIndex(id);
-      const updated = index.map((ch) =>
-        ch.number === num ? { ...ch, status: "rejected" as const } : ch,
-      );
-      await state.saveChapterIndex(id, updated);
-      return c.json({ ok: true, chapterNumber: num, status: "rejected" });
+      const target = index.find((ch) => ch.number === num);
+      if (!target) {
+        return c.json({ error: `Chapter ${num} not found` }, 404);
+      }
+
+      const rollbackTarget = num - 1;
+      const discarded = await state.rollbackToChapter(id, rollbackTarget);
+      return c.json({
+        ok: true,
+        chapterNumber: num,
+        status: "rejected",
+        rolledBackTo: rollbackTarget,
+        discarded,
+      });
     } catch (e) {
     } catch (e) {
       return c.json({ error: String(e) }, 500);
       return c.json({ error: String(e) }, 500);
     }
     }