Building Greenhouse: la bóveda en disco
El último tramo del trabajo en Greenhouse fue puras auditorías, leer código que alguien (yo, en una sesión anterior) ya había escrito y encontrar los puntos donde se contradecía en silencio. Útil, pero no es construir. Esta vez sí se construye. La primera funcionalidad en la que convertí una carpeta vacía en algo.
Greenhouse guarda los proyectos creativos en una carpeta simple en disco. La base de datos SQLite es la fuente de verdad del estado: qué está enfriándose, qué está maduro, en qué se puede trabajar hoy, pero la carpeta es lo que importa: es portátil, pertenece a quien la usa, y se supone que sobrevive a la app. Si Greenhouse desaparece mañana, los proyectos siguen estando ahí, en directorios con nombres sensatos. Así que la estructura de carpetas no es un detalle. Es la parte que sobrevive.
La forma
Esta es la estructura, según el PRD:
/CreativeVault
/.greenhouse/ state.db + config.yaml
/00-ideas/
/10-active/
/20-explore/
...
/60-released/
/90-vault/
Los prefijos numéricos son todo el truco. Al abrir esto en Finder y ordenar por nombre, las carpetas quedan alineadas en el orden del pipeline: ideas primero, publicado casi al final, bóveda al final. No hace falta ninguna app para entenderlo. Esa es la promesa de “sobrevivir a la app” hecha concreta.
Etapas versus zonas
La primera decisión real: ¿de dónde salen esos nombres de carpeta? Las del medio, de 10-active a 60-released, son etapas del pipeline, y las etapas ya viven en la configuración (cada una lleva un folder_prefix). Así que esas salen de la configuración, recorridas en un loop.
Pero 00-ideas y 90-vault no son etapas. Las ideas todavía no entraron al pipeline, la bóveda es donde las cosas van a descansar. Son zonas, no pasos. Ponerlas en la lista de etapas se habría visto prolijo, pero habría estado mal: no tienen nombres de planta, no progresan, encierran el pipeline en vez de pertenecer a él. Así que son constantes en el módulo de layout, y los directorios de etapa vienen de la configuración:
std::fs::create_dir_all(ideas_dir(root))?;
for stage in &config.stages {
std::fs::create_dir_all(stage_dir(root, stage))?;
}
std::fs::create_dir_all(vault_dir(root))?;
La decisión que más costó pensar: no destruir nada
La función que construye todo esto, init_vault, corre en el primer lanzamiento. Pero “primer lanzamiento” es una mentira cómoda, porque se va a volver a llamar. Reinicios de la app, el usuario reabre una bóveda existente, algún flujo de onboarding futuro que se vuelve a ejecutar. Así que la pregunta no es “crear la bóveda”, es “crear la bóveda, posiblemente sobre una bóveda que ya existe, sin arruinarla”.
De ahí salieron dos reglas:
Los directorios usan create_dir_all, que no hace nada si la carpeta ya está ahí. Volver a correrlo no cuesta nada.
Y config.yaml se escribe solo si falta:
let config_path = greenhouse_dir(root).join(CONFIG_FILE);
if !config_path.exists() {
let yaml = serde_yaml::to_string(config)?;
std::fs::write(&config_path, yaml)?;
}
Ese if !exists es la línea más importante del archivo. En v1 la configuración está oculta para el usuario, pero la carpeta es portátil e inspeccionable, alguien va a terminar abriendo config.yaml y cambiando un número. Si init escribiera los valores por defecto a ciegas en cada lanzamiento, borraría esa edición en silencio, y el usuario nunca sabría por qué su configuración seguía revirtiéndose. La prueba de la que más orgulloso estoy escribe una configuración con cooldown_days: 99, vuelve a correr init, y verifica que el 99 sigue ahí. Una funcionalidad definida por lo que se niega a sobrescribir.
Dos cosas que deliberadamente no hice: no creé state.db acá (el módulo de la base de datos ya lo genera al abrir, y ambos simplemente llaman a create_dir_all sobre .greenhouse, algo seguro de hacer dos veces), y no fijé de forma rígida dónde vive la bóveda. Cada punto de entrada (init, apertura de la base de datos, carga de configuración) recibe la ruta raíz como argumento. El PRD dice /CreativeVault, pero eso es un ejemplo, no una constante. Lo que significa que la bóveda puede vivir en cualquier lado, y el usuario puede arrastrar toda la carpeta a otro sitio y todo sigue funcionando, porque todo adentro es relativo. La única pieza que falta es que la app recuerde dónde la puso, y eso es un estado de capa de aplicación que no puede vivir dentro de la bóveda a la que apunta, así que es un problema para otro issue.
Un archivo que es una impresión, no un documento
Siguiente pieza: cada carpeta de proyecto recibe un project.md, un resumen en lenguaje llano del proyecto que se puede leer en cualquier editor de texto, sin necesidad de la app. Título, en qué etapa está, cuándo se tocó por última vez, el registro de notas de traspaso que se fueron dejando. Es la cara legible para humanos de lo que sabe la base de datos.
La palabra clave es espejo. Este archivo no es donde vive el proyecto, la base de datos es la fuente de verdad. project.md es un reflejo de ella, regenerado cada vez que algo cambia. Lo cual plantea de inmediato la pregunta que el propio PRD marca como sin resolver: ¿qué pasa cuando el usuario edita el espejo? Dos respuestas. Unidireccional: se regenera, se sobrescribe, el archivo es una impresión y las ediciones se pierden. O reconciliación: leer los cambios de vuelta, integrarlos a la base de datos, manejar conflictos.
Para v1 fui unidireccional, sin concesiones. La función que lo escribe hace exactamente una cosa:
std::fs::write(project_dir.join(PROJECT_MD), contents)?;
Sin lectura, sin fusión, sin diff. Regenerar y reemplazar. La reconciliación suena más amigable, pero es una trampa en esta etapa: se estaría inventando un protocolo de sincronización en ambas direcciones y una interfaz de conflictos para un archivo que la mayoría de los usuarios nunca va a tocar, para proteger ediciones que el propio encabezado del archivo debería decirles que no hagan. La versión honesta de “espejo legible para humanos” es “esto es una impresión de la base de datos: el proyecto se edita desde la app.” Lo unidireccional es la funcionalidad, no un atajo alrededor de ella.
El renderizado en sí es una función pura: el ítem más su historial de toques más la configuración como entrada, una cadena como salida. Sin reloj, sin disco:
pub fn render_project_md(item: &Item, touches: &[Touch], config: &Config) -> String
Esa pureza significa que puedo probar la salida exacta sin escribir un solo archivo, y la función de escritura es un trivial envoltorio de dos líneas alrededor de ella. También forzó una pequeña aclaración: el issue pedía un “historial de notas”, y fui a buscar un campo de notas. No hay ninguno. Las notas son las notas de traspaso, los pequeños mensajes de “acá quedé” que se agregan al dejar de trabajar. Así que la sección de historial es simplemente el registro de toques, mostrado del más reciente al más antiguo, cada línea con una fecha y la nota que se dejó (o un discreto “sin nota” para las veces que no se dejó ninguna). Los datos ya estaban ahí, “historial de notas” era solo un segundo nombre para lo mismo.
Lo que otra vez no hice fue decidir a dónde va el archivo. write_project_md recibe el directorio de destino como argumento. Porque acá está lo que seguía encontrándome: todavía no existe el concepto de dónde está la carpeta de un proyecto. Promover una idea a proyecto pone una bandera de estado y… eso es todo. No se crea ninguna carpeta. La función que se supone debería hacerlo incluso tiene un parámetro sin usar dando vueltas ahí, un id de etapa que recibe e ignora. Así que el espejo sabe cómo renderizarse y cómo escribirse en una carpeta, pero “cuál carpeta” es una pregunta que el código todavía no puede responder. Anoté ese vacío como su propio issue en vez de adivinarlo acá. El espejo está listo para el día en que los proyectos realmente tengan un hogar.
Encontrar a la gente donde ya está
Una herramienta que exige empezar de cero es una herramienta que la mayoría cierra. Cualquiera que quisiera Greenhouse ya tiene en algún lado una carpeta de proyectos a medio terminar. Así que la última pieza del trabajo de carpetas fue adoptar/importar: se apunta la app a una carpeta existente, y esta trae las subcarpetas como proyectos, sin necesidad de empezar de cero.
La mitad del motor son dos funciones. Scan, que lista las subcarpetas que vale la pena ofrecer:
for entry in std::fs::read_dir(dir)? {
if !entry.file_type()?.is_dir() { continue; } // dirs only
if name.starts_with('.') { continue; } // skip .git, .greenhouse
candidates.push(ImportCandidate { name, path });
}
E import, que convierte una carpeta elegida más una etapa en un registro de proyecto y siembra su espejo project.md. Ambas pequeñas. Las partes interesantes fueron dos decisiones.
Primero: ¿cómo se evita importar la misma carpeta dos veces? Alguien va a escanear, importar la mitad de las carpetas, y volver a escanear la semana que viene. Así que un proyecto necesita recordar de qué carpeta vino, y import tiene que rechazar una carpeta que ya está mapeada. Eso significó finalmente agregar una pieza que llevaba dos issues postergando: un folder_name guardado en cada proyecto, el vínculo durable entre una fila de la base de datos y un directorio en disco. Import fue la primera funcionalidad que genuinamente no podía funcionar sin eso, así que ahí fue donde aterrizó. Volver a escanear, intentar importar una carpeta que ya está adentro: rechazada, con un error que dice exactamente eso.
Segundo, y en el que dudé bastante: cuando se importa una carpeta y se indica que “este es un proyecto en etapa Build”, ¿debería la app mover la carpeta al directorio Build? La estructura dice que los proyectos viven en carpetas de etapa numeradas, así que mover mantendría todo prolijo. Pero mover es meterse en el sistema de archivos de alguien y reordenarlo en el momento en que prueba la app. Esa es una primera impresión hostil para una herramienta cuyo argumento de venta completo es “esta carpeta le pertenece a quien la usa”.
Así que v1 adopta en el lugar. La carpeta se queda exactamente donde está, la base de datos registra en qué etapa se encuentra. La estructura en disco no va a coincidir perfectamente con el ideal de carpetas numeradas, y ese es el trade-off correcto: una importación no destructiva le gana a una importación prolija cuando se le está pidiendo a alguien que confíe su trabajo por primera vez. La reorganización puede llegar después, como algo que el usuario elige activamente, no algo que import hace a sus espaldas.
El parámetro que no hacía nada
Había estado construyendo todas estas piezas de carpetas (la bóveda, el espejo, el import) alrededor de un hueco que seguía esquivando. Promover una idea a proyecto es el momento en que nace un proyecto. Y la función que lo hacía, promote_idea, se veía así:
pub fn promote_idea(db, item_id, _first_stage_id: &str, now) -> Result<()> {
db.update_item_status(item_id, &ItemStatus::Active)?;
// record a touch, start the cooldown
}
Basta con mirar _first_stage_id. El guion bajo es Rust diciendo, en voz alta, “este argumento se acepta y se ignora.” Quien llama pasa en qué etapa debería empezar el proyecto, y la función lo descarta. Solo cambia una bandera de estado y nada más. No se fija ninguna etapa, no se crea ninguna carpeta. Un proyecto “promovido” estaba Activo solo de nombre: sin hogar, sin etapa, sin ningún project.md a su nombre. Cada funcionalidad de carpetas que había construido venía escribiendo cheques que esta función no podía cobrar.
Así que este fue el issue que conectó el cableado. Ahora la promoción hace lo que su nombre promete:
let folder_name = unique_folder_name(db, &item.name)?; // stable slug
let project_dir = vault::stage_dir(root, first_stage).join(&folder_name);
std::fs::create_dir_all(&project_dir)?;
db.update_item_stage(item_id, &first_stage.id)?;
db.update_item_folder_name(item_id, &folder_name)?;
mirror::write_project_md(&project_dir, &updated, &touches, config)?;
Cada primitiva de las secciones anteriores encaja acá: stage_dir del trabajo de la bóveda, write_project_md del espejo, folder_name de import. Toda la plomería ya estaba tendida, esto solo abrió la válvula.
Un detalle que me alegra haber pensado: el nombre de la carpeta es algo separado del nombre visible del proyecto. Se titula un proyecto “Untitled Sketch” y recibe la carpeta untitled-sketch. Más adelante se le cambia el nombre a “The Real Thing”. ¿Debería moverse la carpeta? No. El nombre de la carpeta se fija una sola vez, al nacer, y nunca cambia: renombrar el proyecto actualiza el título en la base de datos y en project.md, pero el directorio en disco se queda quieto. Atar la identidad de una carpeta a un nombre que la gente puede cambiar libremente es firmar para toda una vida de mover directorios y esquivar colisiones cada vez que alguien edita un campo de texto. La carpeta recibe un nombre estable y lo conserva.
La otra mitad es la imagen espejo: cuando un proyecto sí legítimamente cambia de etapa (terminó de explorar, pasa a construir), la carpeta lo sigue, deslizándose de 20-explore a 40-build con un simple movimiento de sistema de archivos, y project.md se regenera para reflejar la nueva etapa. Cambio de nombre: la carpeta se queda. Cambio de etapa: la carpeta se mueve. Dos tipos de cambio, dos respuestas opuestas, y invertirlas sería un desastre en cámara lenta.
Con eso el back end queda terminado. Una idea ahora puede recorrer todo el camino (capturada, dejada madurar, promovida a una carpeta real en disco, trabajada y enfriada, avanzada por las etapas, y eventualmente guardada en la bóveda), con la base de datos sosteniendo la verdad y la carpeta sosteniendo el trabajo. Nada en lo que se pueda hacer clic todavía. Pero por debajo, Greenhouse finalmente hace lo que tiene que hacer. El próximo capítulo es enseñarle a mostrar la cara.
Lecturas relacionadas
La base de datos vacía que parecía perfectamente sana
SQLite trata un archivo de cero bytes como una base de datos nueva y válida, así que todas las verificaciones de corrupción daban bien, y la poda de respaldos habría borrado las copias buenas en una semana.
Construyendo Greenhouse: el motor de reglas que llegó antes que la app
Escribir un motor de reglas completo en Rust y 22 pruebas antes de que Greenhouse tuviera interfaz, o incluso un toolchain de Rust funcionando en la máquina que lo construía, y lo que se rompió en el momento en que por fin compiló.
El estado que nada llegó a escribir (bitácora del motor de Greenhouse)
Un estado que ningún código llegó a fijar, un parser que adivinaba, temporizadores en desacuerdo sobre qué significa 'después', y la cola larga de una revisión de motor: ordenamientos, restricciones, decisiones a medio escribir, y un archivo de configuración que podía dejar la app sin arrancar.