From e54209eb5ad005482baadbf26d43d73791eafcd0 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 17:14:33 +0000 Subject: [PATCH] Story 32: Multi-Instance Worktree Support Add configurable port via STORYKIT_PORT env var (default 3001). Server prints machine-readable STORYKIT_PORT= on startup and writes .story_kit_port file for discovery. Frontend proxy and WebSocket read VITE_STORYKIT_PORT env var instead of hardcoding port 3001. 7 new tests (4 backend, 3 frontend) all passing. Co-Authored-By: Claude Opus 4.6 --- .../32_multi_instance_worktree_support.md | 2 +- frontend/src/api/client.test.ts | 20 ++++- frontend/src/api/client.ts | 16 +++- frontend/vite.config.ts | 31 ++++---- server/src/main.rs | 76 +++++++++++++++++-- 5 files changed, 120 insertions(+), 25 deletions(-) rename .story_kit/stories/{current => archived}/32_multi_instance_worktree_support.md (98%) diff --git a/.story_kit/stories/current/32_multi_instance_worktree_support.md b/.story_kit/stories/archived/32_multi_instance_worktree_support.md similarity index 98% rename from .story_kit/stories/current/32_multi_instance_worktree_support.md rename to .story_kit/stories/archived/32_multi_instance_worktree_support.md index 1a92410..a758711 100644 --- a/.story_kit/stories/current/32_multi_instance_worktree_support.md +++ b/.story_kit/stories/archived/32_multi_instance_worktree_support.md @@ -1,6 +1,6 @@ --- name: "Multi-Instance Worktree Support" -test_plan: pending +test_plan: approved --- # Story 32: Multi-Instance Worktree Support diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 81f933c..07caf6c 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { api } from "./client"; +import { api, resolveWsHost } from "./client"; const mockFetch = vi.fn(); @@ -135,4 +135,22 @@ describe("api client", () => { expect(result.exit_code).toBe(0); }); }); + + describe("resolveWsHost", () => { + it("uses env port in dev mode", () => { + expect(resolveWsHost(true, "4200", "example.com")).toBe("127.0.0.1:4200"); + }); + + it("defaults to 3001 in dev mode when no env port", () => { + expect(resolveWsHost(true, undefined, "example.com")).toBe( + "127.0.0.1:3001", + ); + }); + + it("uses location host in production", () => { + expect(resolveWsHost(false, "4200", "myapp.com:8080")).toBe( + "myapp.com:8080", + ); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2fa846c..14c6d45 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,6 +57,14 @@ export interface CommandOutput { const DEFAULT_API_BASE = "/api"; const DEFAULT_WS_PATH = "/ws"; +export function resolveWsHost( + isDev: boolean, + envPort: string | undefined, + locationHost: string, +): string { + return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; +} + function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { return `${baseUrl}${path}`; } @@ -225,9 +233,11 @@ export class ChatWebSocket { ChatWebSocket.refCount += 1; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsHost = import.meta.env.DEV - ? "127.0.0.1:3001" - : window.location.host; + const wsHost = resolveWsHost( + import.meta.env.DEV, + import.meta.env.VITE_STORYKIT_PORT, + window.location.host, + ); const wsUrl = `${protocol}://${wsHost}${wsPath}`; if ( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c203979..106910b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,19 +2,22 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; // https://vite.dev/config/ -export default defineConfig(() => ({ - plugins: [react()], - server: { - port: 5173, - proxy: { - "/api": { - target: "http://127.0.0.1:3001", - timeout: 120000, +export default defineConfig(() => { + const backendPort = process.env.VITE_STORYKIT_PORT || "3001"; + return { + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: `http://127.0.0.1:${backendPort}`, + timeout: 120000, + }, }, }, - }, - build: { - outDir: "dist", - emptyOutDir: true, - }, -})); + build: { + outDir: "dist", + emptyOutDir: true, + }, + }; +}); diff --git a/server/src/main.rs b/server/src/main.rs index 5dd32ce..5f0e182 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,9 +14,31 @@ use crate::store::JsonFileStore; use crate::workflow::WorkflowState; use poem::Server; use poem::listener::TcpListener; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +const DEFAULT_PORT: u16 = 3001; + +fn parse_port(value: Option) -> u16 { + value + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_PORT) +} + +fn resolve_port() -> u16 { + parse_port(std::env::var("STORYKIT_PORT").ok()) +} + +fn write_port_file(dir: &Path, port: u16) -> Option { + let path = dir.join(".story_kit_port"); + std::fs::write(&path, port.to_string()).ok()?; + Some(path) +} + +fn remove_port_file(path: &Path) { + let _ = std::fs::remove_file(path); +} + #[tokio::main] async fn main() -> Result<(), std::io::Error> { let app_state = Arc::new(SessionState::default()); @@ -35,13 +57,55 @@ async fn main() -> Result<(), std::io::Error> { let app = build_routes(ctx); + let port = resolve_port(); + let addr = format!("127.0.0.1:{port}"); + println!( "\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m" ); - println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://127.0.0.1:3001\x1b[0m"); - println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://127.0.0.1:3001/docs\x1b[0m"); + println!("STORYKIT_PORT={port}"); + println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m"); + println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m"); - Server::new(TcpListener::bind("127.0.0.1:3001")) - .run(app) - .await + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let port_file = write_port_file(&cwd, port); + + let result = Server::new(TcpListener::bind(&addr)).run(app).await; + + if let Some(ref path) = port_file { + remove_port_file(path); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_port_defaults_to_3001() { + assert_eq!(parse_port(None), 3001); + } + + #[test] + fn parse_port_reads_valid_value() { + assert_eq!(parse_port(Some("4200".to_string())), 4200); + } + + #[test] + fn parse_port_ignores_invalid_value() { + assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); + } + + #[test] + fn write_and_remove_port_file() { + let tmp = tempfile::tempdir().unwrap(); + + let path = write_port_file(tmp.path(), 4567).expect("should write port file"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567"); + + remove_port_file(&path); + assert!(!path.exists()); + } }