Pular para o conteúdo
Development

Construindo o Greenhouse: o vault em disco

Por Victor Da Luz
rusttaurisqlitedev-loggreenhouse

O último trecho de trabalho no Greenhouse foi todo auditoria: ler código que alguém (eu, numa sessão anterior) já tinha escrito e achar os pontos onde ele silenciosamente se contradizia. Útil, mas não é construir. Este aqui é construir. A primeira funcionalidade em que fiz uma pasta vazia virar algo.

O Greenhouse guarda seus projetos criativos numa pasta simples em disco. O banco SQLite é a fonte da verdade para o estado: o que está resfriando, o que está maduro, no que você pode trabalhar hoje, mas a pasta é a coisa que importa: é portátil, você é dono dela, e ela deve sobreviver ao app. Se o Greenhouse desaparecer amanhã, você ainda tem seus projetos em diretórios com nomes sensatos. Então o layout de pastas não é um detalhe. É a parte que sobrevive.

A forma

Aqui está o layout, tirado do PRD:

/CreativeVault
  /.greenhouse/      state.db + config.yaml
  /00-ideas/
  /10-active/
  /20-explore/
  ...
  /60-released/
  /90-vault/

Os prefixos numéricos são todo o truque. Abra isso no Finder, ordene por nome, e as pastas se alinham na ordem do pipeline: ideias primeiro, lançados perto do fim, vault por último. Nenhum app necessário pra fazer sentido disso. Essa é a promessa de “sobreviver ao app” em forma concreta.

Estágios versus zonas

Primeira decisão de verdade: de onde vêm esses nomes de pasta? Os do meio, de 10-active até 60-released, são estágios do pipeline, e estágios já vivem na config (cada um carrega um folder_prefix). Então esses vêm da config, num loop.

Mas 00-ideas e 90-vault não são estágios. Ideias ainda não entraram no pipeline; o vault é onde as coisas vão para descansar. São zonas, não passos. Colocá-las na lista de estágios pareceria organizado e estaria errado: elas não têm nomes de planta, não progridem, elas emolduram o pipeline em vez de pertencer a ele. Então são constantes no módulo de layout, e os diretórios de estágio vêm da config:

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))?;

A decisão que exigiu mais reflexão: não destruir nada

A função que constrói tudo isso, init_vault, roda no primeiro lançamento. Mas “primeiro lançamento” é uma mentira que você conta pra si mesmo, vai ser chamada de novo. Reinício do app, o usuário reabrindo um vault existente, algum fluxo de onboarding futuro rodando outra vez. Então a pergunta não é “criar o vault”, é “criar o vault, possivelmente por cima de um vault que já existe, sem destruí-lo”.

Duas regras saíram disso.

Diretórios usam create_dir_all, que simplesmente ignora se a pasta já está lá. Rodar de novo é de graça.

E o config.yaml só é escrito se estiver faltando:

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)?;
}

Esse if !exists é a linha mais importante do arquivo. A config fica escondida do usuário na v1, mas a pasta é portátil e inspecionável, alguém vai eventualmente abrir o config.yaml e mudar um número. Se o init escrevesse os padrões de novo em todo lançamento, apagaria essa edição silenciosamente, e o usuário nunca saberia por que sua configuração voltava ao normal sozinha. O teste do qual mais me orgulho escreve uma config com cooldown_days: 99, roda o init de novo, e confirma que o 99 continua lá. Uma funcionalidade definida pelo que ela se recusa a sobrescrever.

Duas coisas que deliberadamente não fiz: não criei o state.db aqui (o módulo do banco já cria isso ao abrir, e os dois só chamam create_dir_all em .greenhouse, o que é seguro fazer duas vezes), e não fixei onde o vault mora. Todo ponto de entrada, init, abertura do banco, carregamento da config, recebe o caminho raiz como argumento. O PRD diz /CreativeVault, mas isso é um exemplo, não uma constante. O que significa que o vault pode morar em qualquer lugar, e um usuário pode arrastar a pasta inteira pra outro lugar e ela continua funcionando, porque tudo dentro dela é relativo. A única peça que falta é o app lembrar onde a pessoa colocou o vault, e isso é estado de nível de app que não pode morar dentro do próprio vault que ele aponta, então é um problema pra outra issue.

Um arquivo que é um retrato impresso, não um documento

Próxima peça: cada pasta de projeto ganha um project.md, um resumo em linguagem simples do projeto que você consegue ler em qualquer editor de texto, sem precisar do app. Título, em que estágio está, quando você tocou nele pela última vez, o histórico de anotações de handoff que você deixou pra si mesmo. É a face legível por humanos do que o banco sabe.

A palavra-chave é espelho. Esse arquivo não é onde seu projeto mora, o banco é a fonte da verdade. project.md é um reflexo dele, regenerado sempre que algo muda. O que levanta na hora a pergunta que o próprio PRD marca como em aberto: o que acontece quando o usuário edita o espelho? Duas respostas. Via única: regenera, sobrescreve, o arquivo é um retrato impresso e suas edições se perdem. Ou reconciliação: lê as mudanças de volta, mescla no banco, trata conflitos.

Para a v1 fui de via única, sem meio-termo. A função que escreve o arquivo faz exatamente uma coisa:

std::fs::write(project_dir.join(PROJECT_MD), contents)?;

Sem leitura, sem mesclagem, sem diff. Regenera e substitui. Reconciliação parece mais simpática, mas é uma armadilha nesse estágio: você estaria inventando um protocolo de sincronização em dois sentidos e uma UI de conflitos pra um arquivo que a maioria dos usuários nunca vai tocar, pra proteger edições que o próprio cabeçalho do arquivo já devia dizer pra não fazer. A versão honesta de “espelho legível por humanos” é “isto é um retrato impresso do banco, edite o projeto no app”. Via única é a funcionalidade, não um atalho em volta dela.

A renderização em si é uma função pura: item mais seu histórico de touches mais config entram, uma string sai:

pub fn render_project_md(item: &Item, touches: &[Touch], config: &Config) -> String

Essa pureza significa que consigo testar a saída exata sem escrever um único arquivo, e a função de escrita é um simples wrapper de duas linhas em volta dela. Também forçou uma pequena clarificação: a issue pedia “histórico de notas”, e eu fui procurar um campo de notas. Não existe. As notas são as anotações de handoff, aquelas mensagens curtas de “foi aqui que parei” que você anexa quando para de trabalhar. Então a seção de histórico é só o log de touches, renderizado do mais novo pro mais antigo, cada linha uma data e a nota que você deixou (ou um discreto “sem nota” pras vezes em que não deixou). O dado já existia; “histórico de notas” era só um segundo nome pra ele.

O que também não fiz foi decidir para onde o arquivo vai. write_project_md recebe o diretório de destino como argumento. Porque aqui está a coisa em que eu ficava esbarrando: ainda não existe o conceito de onde fica a pasta de um projeto. Promover uma ideia a projeto define uma flag de status e… é só isso. Nenhuma pasta é criada. A função que deveria fazer isso até tem um parâmetro não usado sobrando ali, um id de estágio que ela recebe e ignora. Então o espelho sabe como se renderizar e como se escrever numa pasta, mas “qual pasta” é uma pergunta que o código ainda não consegue responder. Escrevi essa lacuna como sua própria issue em vez de chutar aqui. O espelho está pronto para o dia em que os projetos realmente tiverem um lar.

Encontrando as pessoas onde elas já estão

Uma ferramenta que exige começar do zero é uma ferramenta que a maioria das pessoas fecha. Quem quer que fosse usar o Greenhouse já tem uma pasta de projetos pela metade em algum lugar. Então a última peça do trabalho de pastas foi adotar/importar: apontar o app pra uma pasta existente, e ele puxa as subpastas como projetos, sem precisar de um começo do zero.

O lado do motor são duas funções. Scan, que lista as subpastas que vale a pena oferecer:

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 transforma uma pasta escolhida mais um estágio num registro de projeto e semeia seu espelho project.md. As duas são pequenas. As partes interessantes foram duas decisões.

Primeira: como você evita importar a mesma pasta duas vezes? Alguém vai escanear, importar metade das pastas, e escanear de novo na semana seguinte. Então um projeto precisa lembrar de qual pasta ele veio, e o import precisa recusar uma pasta que já está mapeada. Isso significou finalmente adicionar uma peça em torno da qual eu vinha rodeando havia duas issues: um folder_name guardado em cada projeto, o vínculo durável entre uma linha do banco e um diretório em disco. Import foi a primeira funcionalidade que genuinamente não conseguia funcionar sem isso. Reescaneie, tente importar uma pasta que já está dentro: rejeitado, com um erro que diz exatamente isso.

Segunda, e essa foi a que fiquei mais indeciso: quando você importa uma pasta e diz “isto é um projeto no estágio Build”, o app deveria mover a pasta pro diretório Build? O layout diz que projetos moram em pastas numeradas por estágio, então mover manteria tudo organizado. Mas mover é entrar no sistema de arquivos de alguém e reorganizá-lo no momento em que a pessoa experimenta o app. Essa é uma primeira impressão hostil pra uma ferramenta cujo discurso inteiro é “você é dono dessa pasta”.

Então a v1 adota no lugar. A pasta fica exatamente onde está; o banco registra em que estágio ela está. O layout em disco não vai bater perfeitamente com o ideal de pastas numeradas, e essa é a troca certa: import não destrutivo vale mais que import organizado quando você está pedindo pra alguém confiar em você com o trabalho dela pela primeira vez. A reorganização pode vir depois, como algo que o usuário escolhe fazer, não algo que o import faz nas costas dele.

O parâmetro que não fazia nada

Eu vinha construindo todas essas peças de pasta, o vault, o espelho, o import, em torno de um buraco que eu ficava pulando por cima. Promover uma ideia a projeto é o momento em que um projeto nasce. E a função que fazia isso, promote_idea, era assim:

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
}

Olhe pra _first_stage_id. O underscore é o Rust dizendo, em voz alta, “esse argumento é aceito e ignorado”. Quem chama passa em que estágio o projeto deveria começar, e a função joga isso fora. Ela só vira uma flag de status e nada mais. Nenhum estágio é definido, nenhuma pasta é criada. Um projeto “promovido” era Active só no nome, sem lar, sem estágio, sem nenhum project.md em seu nome. Toda funcionalidade de pasta que eu tinha construído estava escrevendo cheques que essa função não conseguia pagar.

Então essa foi a issue que conectou a fiação. Promoção agora faz o que o nome 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)?;

Toda peça das seções anteriores se encaixa aqui: stage_dir do trabalho de vault, write_project_md do espelho, folder_name do import. A tubulação toda já estava montada; isso só abriu a válvula.

Um detalhe do qual estou satisfeito de ter pensado: o nome da pasta é uma coisa separada do nome de exibição do projeto. Você intitula um projeto “Rascunho Sem Título”, e ele ganha a pasta rascunho-sem-titulo. Depois você renomeia pra “A Coisa de Verdade”. A pasta deveria se mover? Não. O nome da pasta é definido uma vez, no nascimento, e nunca muda: renomear o projeto atualiza o título no banco e no project.md, mas o diretório em disco fica onde está. Amarre a identidade de uma pasta a um nome que as pessoas são livres pra mudar e você acabou de se inscrever pra uma vida inteira de mover diretórios e desviar de colisões toda vez que alguém edita um campo de texto. A pasta ganha um nome estável e o mantém.

A outra metade é a imagem espelhada: quando um projeto legitimamente muda de estágio, terminou de explorar, foi pra construção, a pasta acompanha, deslizando de 20-explore pra 40-build com um simples move de sistema de arquivos, e o project.md regenera pra refletir o novo estágio. Renomear: a pasta fica. Mudança de estágio: a pasta se move. Dois tipos de mudança, duas respostas opostas, e trocá-las seria uma bagunça em câmera lenta.

Isso fecha o back end. Uma ideia agora pode percorrer o caminho inteiro, capturada, deixada pra amadurecer, promovida pra uma pasta real em disco, trabalhada e resfriada, avançada pelos estágios, e eventualmente guardada no vault, com o banco segurando a verdade e a pasta segurando o trabalho. Nada em que você ainda possa clicar. Mas por baixo, o Greenhouse finalmente faz a coisa pra qual ele existe. O próximo capítulo é ensiná-lo a mostrar a cara.

Leitura relacionada

Development

O scan que se ofereceu para importar a si mesmo

Um scanner de importação que encontrou a própria estrutura interna do app, e depois voltou a oferecer uma pasta que tinha acabado de importar, duas versões da mesma conversa que faltava entre camadas.

Ler

Você também pode achar útil

NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais