diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d1540ff9..a358051c 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -21,7 +21,7 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E **Sync entre PCs:** `fn sync` sincroniza datos no regenerables (proposals, apps, projects, analysis, vaults, pc_locations) contra `registry_api` en `https://registry.organic-machine.com`. Config: `~/.fn_pc` (identidad del PC), `FN_REGISTRY_API` (URL con basicAuth), `REGISTRY_API_TOKEN` (token). -**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/` con branch `master` (ver ADR 0002). Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. +**Sub-repos:** cada app y cada analysis es su propio repo Gitea en `dataforge/` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps//.git/`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps//` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). Ver `.claude/rules/apps_subrepo.md`. **Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`. diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index 6d0e71bc..1654accc 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -21,6 +21,7 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente. | 15 | [projects.md](projects.md) | Projects: agrupar apps, analysis y vaults bajo un tema | | 16 | [kiss.md](kiss.md) | KISS en proyectos y apps: cuestionar herramientas externas, sin abstracciones especulativas | | 17 | [apps_tbd.md](apps_tbd.md) | Trunk-based development obligatorio en apps generadas con `fn` (registry exento) | +| 17b | [apps_subrepo.md](apps_subrepo.md) | Apps son sub-repos Gitea (apps/* gitignored). `git init` dentro de cada app nueva ANTES de limpiar worktree, sino se pierde el codigo | | 18 | [uses_functions.md](uses_functions.md) | Convencion de uses_functions para C++: el .md del consumidor declara las dependencias | | 19 | [cpp_apps.md](cpp_apps.md) | Estandarizacion de apps C++: estructura, CMake, app.md, sub-repo, runtime — apunta a cpp/PATTERNS.md y cpp/DESIGN_SYSTEM.md como autoritativas | | 20 | [artefactos.md](artefactos.md) | Termino paraguas para apps, analysis, vaults, projects y playgrounds (todo lo que no es codigo reutilizable) | diff --git a/.claude/rules/apps_subrepo.md b/.claude/rules/apps_subrepo.md new file mode 100644 index 00000000..a7c26362 --- /dev/null +++ b/.claude/rules/apps_subrepo.md @@ -0,0 +1,74 @@ +## Apps son sub-repos Gitea independientes — gotcha al usar worktrees + +**Regla operativa critica** descubierta el 2026-05-18 durante implementacion del flow 0008. + +### El gotcha + +`apps/*/` esta en `.gitignore` del repo `fn_registry`. Cada app es **su propio repo Gitea** en `dataforge/` con su `.git/` dentro de `apps//`. Esto significa: + +- Cuando un agente trabaja en un git **worktree** del repo padre y crea `apps//`, los archivos viven SOLO en el working directory del worktree. +- Como `apps/*/` esta gitignored en el repo padre, los archivos **no se pueden commitear** al worktree del repo padre. +- Cuando se hace `git worktree remove --force worktrees//`, el working directory entero se borra — **el codigo de la app desaparece**. + +**Consecuencia**: una app creada dentro de un worktree del repo padre se pierde al limpiar el worktree salvo que se haya promovido a su propio sub-repo Gitea ANTES. + +### El patron correcto al crear apps en worktrees + +```bash +# 1. Agente trabaja en worktree del repo padre +cd /home/lucas/fn_registry/worktrees/ + +# 2. Scaffold la app via pipeline canonico +./fn run init_cpp_app # apps C++ +# o ./fn run init_jupyter_analysis ... # analysis +# o crear apps// a mano (Go service, etc.) + +# 3. ANTES de salir del worktree: inicializa la app como sub-repo +cd apps/ +git init -b master +git add -A +git -c user.email="agent@fn_registry" -c user.name="agent" \ + commit -m "feat: initial scaffold of " + +# 4. Trabajo continua en sub-repo (commits dentro de apps//.git) +# 5. Cerrar issue en repo padre (mv .md a completed/), commit del padre con cambios en cpp/CMakeLists.txt, etc. +``` + +Cuando el humano corre `/full-git-push` despues del merge, el script `ensure_repo_synced_bash_infra` detecta que `apps//.git` existe + no tiene remote + crea repo Gitea en `dataforge/` + pushea master. + +### Que ESTA SI versionado en el repo padre + +- `cpp/CMakeLists.txt` (el `if(EXISTS ...) add_subdirectory(apps/) endif()`). +- `dev/issues/completed/-.md` (cierre del issue). +- `docs/capabilities/*.md` si la app aporta a un capability group. +- `dev/feature_flags.json` si introduce flags. + +Todo lo demas (codigo de la app + app.md + appicon + service unit + tests propios de la app) vive en `apps//.git` independiente. + +### Sintomas de la perdida + +Si limpias el worktree y luego corres `ls apps//`, devuelve "No such file or directory" pese a que el issue aparece cerrado en `dev/issues/completed/`. **Patron** = scaffold sin sub-repo init = trabajo perdido. + +### Recovery si pasa + +1. Re-crear worktree desde master. +2. Re-spawn agente con instruccion explicita: **`git init` dentro de la app antes de terminar**. +3. NO eliminar el worktree hasta confirmar que `apps//.git` esta inicializado con al menos un commit. + +### Aplica tambien a analysis + +`analysis/*/` y `projects/*/analysis/*/` siguen mismo patron (cada analysis es repo Gitea). El pipeline `init_jupyter_analysis_bash_pipelines` ya hace `git init` automatico — por eso no hubo perdidas alli. Las apps C++/Go scaffolded a mano NO inicializan el sub-repo automaticamente — es responsabilidad del agente. + +### Lo que aprende `parallel-fix-issues` + +El template del prompt de cada agente DEBE incluir la instruccion: + +> "Si tu issue crea una app nueva en `apps//`, inicializa el sub-repo (`cd apps/ && git init -b master && git add -A && git commit ...`) antes de terminar. Sin esto, `apps/*` esta gitignored y el codigo se perdera cuando el orquestador limpie el worktree." + +Aplicar este parrafo al template del skill — ver `.claude/skills/parallel-fix-issues/SKILL.md` (o equivalente). + +### Relacion con otras reglas + +- [[apps_tbd]] — TBD en apps, esta regla complementa con el patron de sub-repo init. +- [[artefactos]] — apps son artefactos, esta regla especifica gotcha de su sub-repo. +- [[apps_vs_functions]] — apps en `apps/`, esta regla refuerza por que apps/* gitignored. diff --git a/.claude/skills/parallel-fix-issues/scripts/setup-worktrees.sh b/.claude/skills/parallel-fix-issues/scripts/setup-worktrees.sh index a8854ec2..00d58f95 100755 --- a/.claude/skills/parallel-fix-issues/scripts/setup-worktrees.sh +++ b/.claude/skills/parallel-fix-issues/scripts/setup-worktrees.sh @@ -19,16 +19,14 @@ if [ $# -eq 0 ]; then exit 1 fi -# Asegurar que master está actualizado -echo "=== Actualizando master ===" +# Verificar master (NO pull --rebase: rompe merges locales convirtiendolos +# en cherry-picks contra origin/master viejo). Detectado 2026-05-18. +echo "=== Verificando master ===" CURRENT_BRANCH="$(git branch --show-current)" -git checkout master 2>/dev/null -git pull --rebase 2>/dev/null || echo "WARN: no se pudo pull (sin remote o sin conexión)" - -# Volver a la rama original si no era master if [ "$CURRENT_BRANCH" != "master" ] && [ -n "$CURRENT_BRANCH" ]; then - git checkout "$CURRENT_BRANCH" 2>/dev/null + echo "WARN: estas en branch '${CURRENT_BRANCH}', no master. Worktrees nuevos saldran de master ref de todos modos." fi +# NO auto-pull. Usuario decide sync con remote. mkdir -p "$WORKTREE_DIR" diff --git a/cpp/functions/core/http_request.cpp b/cpp/functions/core/http_request.cpp index d684503d..bb196165 100644 --- a/cpp/functions/core/http_request.cpp +++ b/cpp/functions/core/http_request.cpp @@ -13,12 +13,36 @@ #include #include +#ifdef _WIN32 +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include +#else +# include +#endif + namespace fn_http { namespace { -// Shell-escape single argument for a POSIX-ish shell. Wraps in single quotes -// and escapes embedded single quotes via '\''. Used for URL + header values. +// Shell-escape single argument. POSIX uses single quotes; Windows uses +// double quotes since cmd.exe doesn't interpret single quotes. +#ifdef _WIN32 +std::string sh_q(const std::string& s) { + std::string o; + o.reserve(s.size() + 2); + o += '"'; + for (char c : s) { + if (c == '"' || c == '\\') o += '\\'; + o += c; + } + o += '"'; + return o; +} +#else std::string sh_q(const std::string& s) { std::string o; o.reserve(s.size() + 2); @@ -30,6 +54,87 @@ std::string sh_q(const std::string& s) { o += '\''; return o; } +#endif + +// Make a cross-platform temp file path. On Windows uses GetTempPathW + +// GetTempFileNameW (creates a 0-byte file in %TEMP%). On POSIX uses mkstemp. +std::string make_tmp_path(const char* prefix) { +#ifdef _WIN32 + wchar_t dir_w[MAX_PATH]; + DWORD n = GetTempPathW(MAX_PATH, dir_w); + if (n == 0 || n > MAX_PATH) return std::string(prefix) + "_fallback.tmp"; + + wchar_t path_w[MAX_PATH]; + UINT u = GetTempFileNameW(dir_w, L"fn_", 0, path_w); + if (u == 0) return std::string(prefix) + "_fallback.tmp"; + + int need = WideCharToMultiByte(CP_UTF8, 0, path_w, -1, + nullptr, 0, nullptr, nullptr); + if (need <= 0) return std::string(prefix) + "_fallback.tmp"; + std::string out(need - 1, '\0'); + WideCharToMultiByte(CP_UTF8, 0, path_w, -1, out.data(), need, + nullptr, nullptr); + return out; +#else + (void)prefix; + char tmpl[] = "/tmp/fn_XXXXXX"; + int fd = mkstemp(tmpl); + if (fd >= 0) ::close(fd); + return std::string(tmpl); +#endif +} + +#ifdef _WIN32 +// Run a command line without spawning a visible console window. Captures +// stdout+stderr combined into out_combined. Returns the child's exit code, +// or -1 on spawn failure. +int run_no_console(const std::string& cmdline_utf8, std::string& out_combined) { + int need = MultiByteToWideChar(CP_UTF8, 0, cmdline_utf8.c_str(), -1, + nullptr, 0); + if (need <= 0) return -1; + std::wstring wcmd(need, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, cmdline_utf8.c_str(), -1, + wcmd.data(), need); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE rd = nullptr, wr = nullptr; + if (!CreatePipe(&rd, &wr, &sa, 0)) return -1; + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + si.hStdError = wr; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + BOOL ok = CreateProcessW(nullptr, wcmd.data(), nullptr, nullptr, + TRUE, CREATE_NO_WINDOW, nullptr, nullptr, + &si, &pi); + CloseHandle(wr); + if (!ok) { + CloseHandle(rd); + return -1; + } + + char buf[4096]; + DWORD got = 0; + while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0) + out_combined.append(buf, buf + got); + CloseHandle(rd); + + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD ec = 0; + GetExitCodeProcess(pi.hProcess, &ec); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return (int)ec; +} +#endif // Read entire file into string. Empty on missing. std::string slurp(const std::string& path) { @@ -115,9 +220,9 @@ Response request(const Request& req) { std::string method = req.method.empty() ? std::string("GET") : req.method; // Tmp files: request body + response body + response headers. - std::string tmp_body_in = std::tmpnam(nullptr); - std::string tmp_body_out = std::tmpnam(nullptr); - std::string tmp_hdr_out = std::tmpnam(nullptr); + std::string tmp_body_in = make_tmp_path("body_in"); + std::string tmp_body_out = make_tmp_path("body_out"); + std::string tmp_hdr_out = make_tmp_path("hdr"); bool have_body = !req.body.empty(); if (have_body) { @@ -168,13 +273,19 @@ Response request(const Request& req) { << " 2>&1"; // Capture stderr (curl prints transport errors to stderr with -sS). - FILE* p = popen(cmd.str().c_str(), "r"); std::string curl_stderr; + int rc; +#ifdef _WIN32 + // Use CreateProcessW with CREATE_NO_WINDOW: no console pop-up per call. + rc = run_no_console(cmd.str(), curl_stderr); +#else + FILE* p = popen(cmd.str().c_str(), "r"); if (p) { char buf[1024]; while (fgets(buf, sizeof(buf), p)) curl_stderr.append(buf); } - int rc = p ? pclose(p) : -1; + rc = p ? pclose(p) : -1; +#endif // Read response files. r.body = slurp(tmp_body_out); diff --git a/cpp/functions/core/sse_client.cpp b/cpp/functions/core/sse_client.cpp index 98773d55..408b077c 100644 --- a/cpp/functions/core/sse_client.cpp +++ b/cpp/functions/core/sse_client.cpp @@ -25,6 +25,13 @@ #include #include #include +#else +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include #endif namespace fn_sse { @@ -142,6 +149,12 @@ struct Client::Impl { #ifndef _WIN32 std::atomic curl_pid_{0}; +#else + // HANDLEs to the curl child process (and its primary thread) so stop() + // can TerminateProcess and the cleanup block can close them. void* to + // avoid leaking into the header struct via friend lookup. + void* curl_proc_handle_ = nullptr; // HANDLE + void* curl_proc_thread_ = nullptr; // HANDLE #endif void run(Config cfg, EventHandler on_event, StatusHandler on_status) { @@ -234,20 +247,86 @@ struct Client::Impl { continue; } #else - // Windows: popen fallback (no reliable PID, stop() may be slow). - std::ostringstream cmd; - cmd << "curl -N -sS --max-time 0 --connect-timeout " - << (cfg.connect_timeout_ms / 1000 + 1) - << " -H \"Accept: text/event-stream\"" - << " -H \"Cache-Control: no-cache\""; + // Windows: CreateProcessW with CREATE_NO_WINDOW so no console + // pop-up per call. We keep a HANDLE to the child for stop(). + std::ostringstream cmd_n; + cmd_n << "curl.exe -N -sS --max-time 0 --connect-timeout " + << (cfg.connect_timeout_ms / 1000 + 1) + << " -H \"Accept: text/event-stream\"" + << " -H \"Cache-Control: no-cache\""; if (!cfg.bearer_token.empty()) - cmd << " -H \"Authorization: Bearer " + cfg.bearer_token + "\""; + cmd_n << " -H \"Authorization: Bearer " << cfg.bearer_token << "\""; if (!last_id.empty()) - cmd << " -H \"Last-Event-ID: " + last_id + "\""; - cmd << " \"" << cfg.url << "\" 2>NUL"; - FILE* pipe_file = ::popen(cmd.str().c_str(), "r"); + cmd_n << " -H \"Last-Event-ID: " << last_id << "\""; + cmd_n << " \"" << cfg.url << "\""; + + std::string cmdu = cmd_n.str(); + int needw = MultiByteToWideChar(CP_UTF8, 0, cmdu.c_str(), + -1, nullptr, 0); + if (needw <= 0) { + emit_status("error: MultiByteToWideChar failed"); + if (!cfg.auto_reconnect || stop_requested_) break; + sleep_ms(backoff_ms); + backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms); + continue; + } + std::wstring wcmd(needw, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, cmdu.c_str(), -1, + wcmd.data(), needw); + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE rd = nullptr, wr = nullptr; + if (!CreatePipe(&rd, &wr, &sa, 0)) { + emit_status("error: CreatePipe failed"); + if (!cfg.auto_reconnect || stop_requested_) break; + sleep_ms(backoff_ms); + backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms); + continue; + } + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + // Discard stderr to /dev/null equivalent: route to our own + // STDERR (parent process) which on Windows GUI apps is detached. + si.hStdError = GetStdHandle(STD_ERROR_HANDLE); + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + BOOL ok = CreateProcessW(nullptr, wcmd.data(), nullptr, nullptr, + TRUE, CREATE_NO_WINDOW, nullptr, nullptr, + &si, &pi); + CloseHandle(wr); + if (!ok) { + CloseHandle(rd); + emit_status("error: CreateProcessW failed"); + if (!cfg.auto_reconnect || stop_requested_) break; + sleep_ms(backoff_ms); + backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms); + continue; + } + + curl_proc_handle_ = pi.hProcess; + curl_proc_thread_ = pi.hThread; + + // Wrap read-end HANDLE as a FILE* so the existing fgets-based + // SSE parser loop keeps working unchanged. + int fd = _open_osfhandle((intptr_t)rd, _O_RDONLY); + FILE* pipe_file = (fd >= 0) ? _fdopen(fd, "r") : nullptr; if (!pipe_file) { - emit_status("error: popen() failed"); + if (fd >= 0) _close(fd); + else CloseHandle(rd); + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + curl_proc_handle_ = nullptr; + curl_proc_thread_ = nullptr; + emit_status("error: _fdopen failed"); if (!cfg.auto_reconnect || stop_requested_) break; sleep_ms(backoff_ms); backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms); @@ -305,7 +384,15 @@ struct Client::Impl { } } #else - ::pclose(pipe_file); + if (curl_proc_handle_) { + TerminateProcess((HANDLE)curl_proc_handle_, 0); + WaitForSingleObject((HANDLE)curl_proc_handle_, 1000); + CloseHandle((HANDLE)curl_proc_handle_); + CloseHandle((HANDLE)curl_proc_thread_); + curl_proc_handle_ = nullptr; + curl_proc_thread_ = nullptr; + } + fclose(pipe_file); // also closes the underlying HANDLE via fd #endif if (stop_requested_) break; @@ -348,6 +435,11 @@ void Client::stop() { #ifndef _WIN32 pid_t p = impl_->curl_pid_.exchange(0); if (p > 0) ::kill(p, SIGTERM); +#else + // TerminateProcess so the blocking ReadFile/fgets returns and the + // worker thread can exit promptly. The cleanup block closes handles. + if (impl_->curl_proc_handle_) + TerminateProcess((HANDLE)impl_->curl_proc_handle_, 0); #endif if (impl_->thread_.joinable()) impl_->thread_.join();