chore: baseline pre-piloto 0120 — apps_subrepo rule + http/sse hardening

WIP previo al lanzamiento de fn-orquestador piloto.
Commit como baseline para que /autonomous-task 0120 arranque con master limpio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:40:52 +02:00
parent c52846d475
commit 2e5c630d38
6 changed files with 303 additions and 27 deletions
+1 -1
View File
@@ -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/<basename>` 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/<basename>` con branch `master` (ver ADR 0002). `apps/*` y `analysis/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps/<name>/.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/<name>/` 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`.
+1
View File
@@ -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) |
+74
View File
@@ -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/<app_name>` con su `.git/` dentro de `apps/<app_name>/`. Esto significa:
- Cuando un agente trabaja en un git **worktree** del repo padre y crea `apps/<nueva_app>/`, 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/<slug>/`, 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/<slug>
# 2. Scaffold la app via pipeline canonico
./fn run init_cpp_app <name> # apps C++
# o ./fn run init_jupyter_analysis ... # analysis
# o crear apps/<name>/ a mano (Go service, etc.)
# 3. ANTES de salir del worktree: inicializa la app como sub-repo
cd apps/<name>
git init -b master
git add -A
git -c user.email="agent@fn_registry" -c user.name="agent" \
commit -m "feat: initial scaffold of <name>"
# 4. Trabajo continua en sub-repo (commits dentro de apps/<name>/.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/<name>/.git` existe + no tiene remote + crea repo Gitea en `dataforge/<name>` + pushea master.
### Que ESTA SI versionado en el repo padre
- `cpp/CMakeLists.txt` (el `if(EXISTS ...) add_subdirectory(apps/<name>) endif()`).
- `dev/issues/completed/<NNNN>-<slug>.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/<name>/.git` independiente.
### Sintomas de la perdida
Si limpias el worktree y luego corres `ls apps/<name>/`, 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/<name>/.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/<name>/`, inicializa el sub-repo (`cd apps/<name> && 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.
@@ -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"
+118 -7
View File
@@ -13,12 +13,36 @@
#include <string>
#include <vector>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <io.h>
# include <fcntl.h>
#else
# include <unistd.h>
#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);
+104 -12
View File
@@ -25,6 +25,13 @@
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#else
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <io.h>
# include <fcntl.h>
#endif
namespace fn_sse {
@@ -142,6 +149,12 @@ struct Client::Impl {
#ifndef _WIN32
std::atomic<pid_t> 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 <windows.h> 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();