xonecode 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -7
- package/THIRD_PARTY_NOTICES.md +128 -0
- package/apps/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/apps/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/apps/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/apps/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/apps/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/apps/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/apps/web/dist/assets/c-BIGW1oBm.js +1 -0
- package/apps/web/dist/assets/cpp-DddhVWhK.js +1 -0
- package/apps/web/dist/assets/csharp-DSvCPggb.js +1 -0
- package/apps/web/dist/assets/css-CLj8gQPS.js +1 -0
- package/apps/web/dist/assets/go-C27-OAKa.js +1 -0
- package/apps/web/dist/assets/html-CfGypltT.js +1 -0
- package/apps/web/dist/assets/index-BB3SamhR.css +1 -0
- package/apps/web/dist/assets/index-CFYAoHdw.js +477 -0
- package/apps/web/dist/assets/ini-BEwlwnbL.js +1 -0
- package/apps/web/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/apps/web/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/apps/web/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/apps/web/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/apps/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/apps/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/apps/web/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/apps/web/dist/assets/java-CylS5w8V.js +1 -0
- package/apps/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/apps/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/apps/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/apps/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/apps/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/apps/web/dist/assets/kotlin-BdnUsdx6.js +1 -0
- package/apps/web/dist/assets/less-B1dDrJ26.js +1 -0
- package/apps/web/dist/assets/lua-BaeVxFsk.js +1 -0
- package/apps/web/dist/assets/markdown-Cvjx9yec.js +1 -0
- package/apps/web/dist/assets/mdx-Cmh6b_Ma.js +1 -0
- package/apps/web/dist/assets/php-D_2--9WK.js +1 -0
- package/apps/web/dist/assets/python-B6aJPvgy.js +1 -0
- package/apps/web/dist/assets/ruby-DSHSDbk1.js +1 -0
- package/apps/web/dist/assets/rust-B1yitclQ.js +1 -0
- package/apps/web/dist/assets/scss-D5BDwBP9.js +1 -0
- package/apps/web/dist/assets/sql-CRqJ_cUM.js +1 -0
- package/apps/web/dist/assets/swift-C2oV4EkX.js +1 -0
- package/apps/web/dist/assets/toml-vGWfd6FD.js +1 -0
- package/apps/web/dist/assets/xml-sdJ4AIDG.js +1 -0
- package/apps/web/dist/assets/yaml-Buea-lGh.js +1 -0
- package/apps/web/dist/iconos/xonecode.png +0 -0
- package/apps/web/dist/index.html +20 -0
- package/dist/agent/agentesEnDisco.js +539 -0
- package/dist/agent/agentesEnDisco.js.map +1 -0
- package/dist/agent/arbolDeProyecto.js +245 -0
- package/dist/agent/arbolDeProyecto.js.map +1 -0
- package/dist/agent/artefactosEnDisco.js +139 -0
- package/dist/agent/artefactosEnDisco.js.map +1 -0
- package/dist/agent/aumentador.js +175 -0
- package/dist/agent/aumentador.js.map +1 -0
- package/dist/agent/authEnDisco.js +76 -1
- package/dist/agent/authEnDisco.js.map +1 -1
- package/dist/agent/busquedaRegex.js +92 -0
- package/dist/agent/busquedaRegex.js.map +1 -0
- package/dist/agent/catalogoModelos.js +345 -0
- package/dist/agent/catalogoModelos.js.map +1 -0
- package/dist/agent/checkpointer.js +127 -0
- package/dist/agent/checkpointer.js.map +1 -0
- package/dist/agent/cloudstudioClient.js +194 -0
- package/dist/agent/cloudstudioClient.js.map +1 -0
- package/dist/agent/cloudstudioMcp.js +722 -0
- package/dist/agent/cloudstudioMcp.js.map +1 -0
- package/dist/agent/configEnDisco.js +309 -11
- package/dist/agent/configEnDisco.js.map +1 -1
- package/dist/agent/consumoExterno.js +99 -0
- package/dist/agent/consumoExterno.js.map +1 -0
- package/dist/agent/descarga.js +149 -0
- package/dist/agent/descarga.js.map +1 -0
- package/dist/agent/diagnosticoDeTools.js +51 -0
- package/dist/agent/diagnosticoDeTools.js.map +1 -0
- package/dist/agent/dispositivosEnMaquina.js +596 -0
- package/dist/agent/dispositivosEnMaquina.js.map +1 -0
- package/dist/agent/escrituraDeCodex.js +134 -0
- package/dist/agent/escrituraDeCodex.js.map +1 -0
- package/dist/agent/escrituraDeOpencode.js +80 -0
- package/dist/agent/escrituraDeOpencode.js.map +1 -0
- package/dist/agent/escrituraExterna.js +742 -0
- package/dist/agent/escrituraExterna.js.map +1 -0
- package/dist/agent/gemini.js +81 -0
- package/dist/agent/gemini.js.map +1 -0
- package/dist/agent/git.js +83 -0
- package/dist/agent/git.js.map +1 -0
- package/dist/agent/gitSync.js +441 -0
- package/dist/agent/gitSync.js.map +1 -0
- package/dist/agent/hotswap.js +448 -0
- package/dist/agent/hotswap.js.map +1 -0
- package/dist/agent/instalacionEnMaquina.js +252 -0
- package/dist/agent/instalacionEnMaquina.js.map +1 -0
- package/dist/agent/instantanea.js +7 -10
- package/dist/agent/instantanea.js.map +1 -1
- package/dist/agent/interrupts.js +11 -2
- package/dist/agent/interrupts.js.map +1 -1
- package/dist/agent/juezDeTarea.js +345 -0
- package/dist/agent/juezDeTarea.js.map +1 -0
- package/dist/agent/lanzamientoEnMaquina.js +377 -0
- package/dist/agent/lanzamientoEnMaquina.js.map +1 -0
- package/dist/agent/manifiesto.js +59 -0
- package/dist/agent/manifiesto.js.map +1 -0
- package/dist/agent/memoriaDeProyecto.js +60 -0
- package/dist/agent/memoriaDeProyecto.js.map +1 -0
- package/dist/agent/modelos.js +105 -17
- package/dist/agent/modelos.js.map +1 -1
- package/dist/agent/modelosDeMotor.js +204 -0
- package/dist/agent/modelosDeMotor.js.map +1 -0
- package/dist/agent/paqueteDelProyecto.js +181 -0
- package/dist/agent/paqueteDelProyecto.js.map +1 -0
- package/dist/agent/perfiles.js +92 -31
- package/dist/agent/perfiles.js.map +1 -1
- package/dist/agent/persona.js +40 -0
- package/dist/agent/persona.js.map +1 -0
- package/dist/agent/procesosEnMaquina.js +204 -0
- package/dist/agent/procesosEnMaquina.js.map +1 -0
- package/dist/agent/proyecto.js +311 -13
- package/dist/agent/proyecto.js.map +1 -1
- package/dist/agent/puente.js +63 -6
- package/dist/agent/puente.js.map +1 -1
- package/dist/agent/resumenDeContexto.js +30 -0
- package/dist/agent/resumenDeContexto.js.map +1 -0
- package/dist/agent/resumenDeTool.js +64 -18
- package/dist/agent/resumenDeTool.js.map +1 -1
- package/dist/agent/sesionGit.js +481 -0
- package/dist/agent/sesionGit.js.map +1 -0
- package/dist/agent/settingsEnDisco.js +189 -0
- package/dist/agent/settingsEnDisco.js.map +1 -0
- package/dist/agent/subagenteCodex.js +368 -0
- package/dist/agent/subagenteCodex.js.map +1 -0
- package/dist/agent/subagenteExterno.js +423 -0
- package/dist/agent/subagenteExterno.js.map +1 -0
- package/dist/agent/subagenteOpencode.js +389 -0
- package/dist/agent/subagenteOpencode.js.map +1 -0
- package/dist/agent/subida.js +202 -0
- package/dist/agent/subida.js.map +1 -0
- package/dist/agent/tareasEnDisco.js +466 -0
- package/dist/agent/tareasEnDisco.js.map +1 -0
- package/dist/agent/turnoReal.js +663 -50
- package/dist/agent/turnoReal.js.map +1 -1
- package/dist/agent/versionEnDisco.js +48 -0
- package/dist/agent/versionEnDisco.js.map +1 -0
- package/dist/agent/xoneAgent.js +290 -49
- package/dist/agent/xoneAgent.js.map +1 -1
- package/dist/agent/zip.js +52 -0
- package/dist/agent/zip.js.map +1 -0
- package/dist/cli/acuseDeModelo.js +30 -0
- package/dist/cli/acuseDeModelo.js.map +1 -0
- package/dist/cli/aprobar.js +55 -2
- package/dist/cli/aprobar.js.map +1 -1
- package/dist/cli/config.js +9 -13
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/consola.js +750 -19
- package/dist/cli/consola.js.map +1 -1
- package/dist/cli/main.js +740 -81
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/markdown.js +188 -2
- package/dist/cli/markdown.js.map +1 -1
- package/dist/cli/run.js +32 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/stdio.js +46 -19
- package/dist/cli/stdio.js.map +1 -1
- package/dist/cli/tema.js +96 -8
- package/dist/cli/tema.js.map +1 -1
- package/dist/cli/tokens.js +29 -0
- package/dist/cli/tokens.js.map +1 -0
- package/dist/cli/tui/app.js +171 -0
- package/dist/cli/tui/app.js.map +1 -0
- package/dist/cli/tui/aprobarTui.js +80 -0
- package/dist/cli/tui/aprobarTui.js.map +1 -0
- package/dist/cli/tui/barra.js +37 -0
- package/dist/cli/tui/barra.js.map +1 -0
- package/dist/cli/tui/correrTui.js +478 -0
- package/dist/cli/tui/correrTui.js.map +1 -0
- package/dist/cli/tui/entrada.js +166 -0
- package/dist/cli/tui/entrada.js.map +1 -0
- package/dist/cli/tui/filas.js +24 -0
- package/dist/cli/tui/filas.js.map +1 -0
- package/dist/cli/tui/logo.js +49 -0
- package/dist/cli/tui/logo.js.map +1 -0
- package/dist/cli/tui/pielTui.js +14 -0
- package/dist/cli/tui/pielTui.js.map +1 -0
- package/dist/cli/tui/raton.js +126 -0
- package/dist/cli/tui/raton.js.map +1 -0
- package/dist/cli/tui/sidebar.js +86 -0
- package/dist/cli/tui/sidebar.js.map +1 -0
- package/dist/cli/tui/store.js +143 -0
- package/dist/cli/tui/store.js.map +1 -0
- package/dist/cli/tui/tarjeta.js +19 -0
- package/dist/cli/tui/tarjeta.js.map +1 -0
- package/dist/cli/tui/temaInk.js +114 -0
- package/dist/cli/tui/temaInk.js.map +1 -0
- package/dist/cli/tui/transcript.js +254 -0
- package/dist/cli/tui/transcript.js.map +1 -0
- package/dist/cli/wizardInicial.js +240 -0
- package/dist/cli/wizardInicial.js.map +1 -0
- package/dist/core/actos.js +113 -0
- package/dist/core/actos.js.map +1 -0
- package/dist/core/adjuntos.js +97 -0
- package/dist/core/adjuntos.js.map +1 -0
- package/dist/core/agentes.js +227 -0
- package/dist/core/agentes.js.map +1 -0
- package/dist/core/artefactos.js +149 -0
- package/dist/core/artefactos.js.map +1 -0
- package/dist/core/cloudstudio.js +7 -0
- package/dist/core/cloudstudio.js.map +1 -0
- package/dist/core/config.js +212 -5
- package/dist/core/config.js.map +1 -1
- package/dist/core/contextos.js +53 -1
- package/dist/core/contextos.js.map +1 -1
- package/dist/core/descargas.js +106 -0
- package/dist/core/descargas.js.map +1 -0
- package/dist/core/descriptoresDeApp.js +279 -0
- package/dist/core/descriptoresDeApp.js.map +1 -0
- package/dist/core/dispositivos.js +466 -0
- package/dist/core/dispositivos.js.map +1 -0
- package/dist/core/entrega.js +141 -0
- package/dist/core/entrega.js.map +1 -0
- package/dist/core/entrelazar.js +90 -0
- package/dist/core/entrelazar.js.map +1 -0
- package/dist/core/esqueleto.js +10 -0
- package/dist/core/esqueleto.js.map +1 -1
- package/dist/core/modelos.js +235 -3
- package/dist/core/modelos.js.map +1 -1
- package/dist/core/notify.js +27 -9
- package/dist/core/notify.js.map +1 -1
- package/dist/core/planDeSubida.js +127 -0
- package/dist/core/planDeSubida.js.map +1 -0
- package/dist/core/ports.js +189 -1
- package/dist/core/ports.js.map +1 -1
- package/dist/core/puedeLanzarse.js +239 -0
- package/dist/core/puedeLanzarse.js.map +1 -0
- package/dist/core/rutaVirtual.js +42 -0
- package/dist/core/rutaVirtual.js.map +1 -0
- package/dist/core/settings.js +246 -0
- package/dist/core/settings.js.map +1 -0
- package/dist/core/tareas.js +230 -0
- package/dist/core/tareas.js.map +1 -0
- package/dist/core/textos.js +36 -0
- package/dist/core/textos.js.map +1 -0
- package/dist/core/turno.js +70 -10
- package/dist/core/turno.js.map +1 -1
- package/dist/core/version.js +31 -0
- package/dist/core/version.js.map +1 -0
- package/dist/vendor/tokenTracking.js +22 -1
- package/dist/vendor/tokenTracking.js.map +1 -1
- package/dist/web/servidor/arranque.js +3964 -0
- package/dist/web/servidor/arranque.js.map +1 -0
- package/dist/web/servidor/consolaDeTarea.js +161 -0
- package/dist/web/servidor/consolaDeTarea.js.map +1 -0
- package/dist/web/servidor/consolaWeb.js +389 -0
- package/dist/web/servidor/consolaWeb.js.map +1 -0
- package/dist/web/servidor/corredorDeTareas.js +1119 -0
- package/dist/web/servidor/corredorDeTareas.js.map +1 -0
- package/dist/web/servidor/pielWeb.js +269 -0
- package/dist/web/servidor/pielWeb.js.map +1 -0
- package/dist/web/servidor/servidor.js +257 -0
- package/dist/web/servidor/servidor.js.map +1 -0
- package/dist/web/servidor/sesiones.js +440 -0
- package/dist/web/servidor/sesiones.js.map +1 -0
- package/dist/web/servidor/transporte.js +169 -0
- package/dist/web/servidor/transporte.js.map +1 -0
- package/dist/web/servidor/vestibulo.js +1276 -0
- package/dist/web/servidor/vestibulo.js.map +1 -0
- package/package.json +30 -8
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +109 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +56 -0
- package/skills/archify/assets/template.html +14787 -0
- package/skills/archify/bin/archify.mjs +1990 -0
- package/skills/archify/bin/open-artifact.mjs +86 -0
- package/skills/archify/bin/preview.mjs +648 -0
- package/skills/archify/bin/visual-check.mjs +829 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1214 -0
- package/skills/archify/examples/agent-run.lifecycle.json +71 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +94 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +61 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +47 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +81 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +31 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +31 -0
- package/skills/archify/examples/dataflow-product-analytics.html +14898 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +49 -0
- package/skills/archify/examples/event-stream.dataflow.json +57 -0
- package/skills/archify/examples/incident-response.workflow.json +64 -0
- package/skills/archify/examples/lifecycle-agent-run.html +14847 -0
- package/skills/archify/examples/product-analytics.dataflow.json +76 -0
- package/skills/archify/examples/production-deployment.architecture.json +71 -0
- package/skills/archify/examples/release-delivery.workflow.json +62 -0
- package/skills/archify/examples/sequence-cache-miss-request.html +14913 -0
- package/skills/archify/examples/web-app-rendered.html +14862 -0
- package/skills/archify/examples/web-app.architecture.json +46 -0
- package/skills/archify/examples/workflow-agent-tool-call-rendered.html +14904 -0
- package/skills/archify/migrations/workflow-v2.mjs +279 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +391 -0
- package/skills/archify/references/authoring-contract.md +198 -0
- package/skills/archify/references/brand-marks.md +69 -0
- package/skills/archify/references/delivery-contract.md +116 -0
- package/skills/archify/references/viewer-runtime.md +45 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1078 -0
- package/skills/archify/renderers/dataflow/README.md +104 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- package/skills/archify/renderers/lifecycle/README.md +115 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- package/skills/archify/renderers/sequence/README.md +114 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +453 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +563 -0
- package/skills/archify/renderers/shared/cli.mjs +218 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +127 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +13 -0
- package/skills/archify/renderers/shared/geometry.mjs +1423 -0
- package/skills/archify/renderers/shared/i18n.mjs +594 -0
- package/skills/archify/renderers/shared/layout-report.mjs +40 -0
- package/skills/archify/renderers/shared/legend.mjs +217 -0
- package/skills/archify/renderers/shared/output-path.mjs +321 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/skills/archify/renderers/shared/text-fit.mjs +49 -0
- package/skills/archify/renderers/shared/utils.mjs +232 -0
- package/skills/archify/renderers/shared/validator.mjs +86 -0
- package/skills/archify/renderers/workflow/README.md +223 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/skills/archify/schemas/README.md +211 -0
- package/skills/archify/schemas/architecture.schema.json +176 -0
- package/skills/archify/schemas/common.schema.json +115 -0
- package/skills/archify/schemas/dataflow.schema.json +243 -0
- package/skills/archify/schemas/lifecycle.schema.json +266 -0
- package/skills/archify/schemas/sequence.schema.json +223 -0
- package/skills/archify/schemas/workflow.schema.json +428 -0
- package/skills/archify/scripts/check-render-output.mjs +835 -0
- package/skills/archify/scripts/check-update.mjs +1667 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/scripts/update-contract.mjs +182 -0
- package/skills/archify/skill-release.json +10 -0
- package/skills/artifacts-builder/SKILL.md +120 -0
- package/skills/artifacts-builder/reference/diagramas.md +123 -0
- package/skills/artifacts-builder/reference/estilo.md +123 -0
- package/skills/artifacts-builder/reference/exportar-pdf.md +56 -0
- package/skills/artifacts-builder/reference/graficas.md +22 -0
- package/skills/artifacts-builder/scripts/bundle_artifact.py +109 -0
- package/skills/artifacts-builder/scripts/validate_artifact.py +345 -0
- package/skills/xone-hotswap/SKILL.md +185 -0
- package/skills/xone-hotswap/references/comandos.md +706 -0
- package/skills/xone-hotswap/references/conexion-y-despliegue.md +226 -0
|
@@ -0,0 +1,3964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El arranque de la consola web: las comprobaciones, el servidor, las RUTAS y el navegador.
|
|
3
|
+
*
|
|
4
|
+
* Vive aquí y no en `cli/main.ts` —que ya pasa de mil líneas— por lo mismo que
|
|
5
|
+
* `cli/tui/correrTui.ts`: el despachador decide QUÉ piel arranca, y la piel se monta en su
|
|
6
|
+
* propia casa. `main.ts` lo carga con un import dinámico, así que `run`, `describe` y
|
|
7
|
+
* cualquier tubería no pagan el vestíbulo entero.
|
|
8
|
+
*
|
|
9
|
+
* **Aquí es donde `registrarRuta` deja de ser código muerto.** `web/servidor/servidor.ts`
|
|
10
|
+
* la exportaba desde la Task 5 y hasta ahora no la llamaba nadie fuera de sus tests: los
|
|
11
|
+
* dos extremos del cable estaban escritos y sin conectar, de modo que la consola web no se
|
|
12
|
+
* podía abrir aunque `decidirPiel` devolviera «web». Las dos rutas son las que el cliente
|
|
13
|
+
* ya pedía (`apps/web/src/conexion.ts`): el SSE de `/eventos` y el `POST /accion`.
|
|
14
|
+
*
|
|
15
|
+
* **A qué consola habla el cable, y por qué se re-adjunta.** El vestíbulo tiene DOS
|
|
16
|
+
* consolas: la suya (sin raíz, la del alta) y la del proyecto abierto. `consolaWeb.eof()`
|
|
17
|
+
* es `!transporte.conectado()`, y sin cliente conectado toda aprobación sale rechazada y
|
|
18
|
+
* todo `preguntar` responde cadena vacía. Si el SSE se quedara enganchado a la consola del
|
|
19
|
+
* vestíbulo después de abrir un proyecto, el usuario vería su transcript y NADA de lo que
|
|
20
|
+
* decidiera llegaría: fail-closed mudo. Por eso al abrir proyecto se desconecta la anterior
|
|
21
|
+
* y se conecta la nueva, con reemisión entera del transcript.
|
|
22
|
+
*
|
|
23
|
+
* **La clave de API no pasa por el mensaje de alta.** El paso de cuenta lo conduce
|
|
24
|
+
* `vestibulo.pasoDeCuenta()` —o sea `cli/wizardInicial.ts#asistenteDeModelo` sin tocar—
|
|
25
|
+
* sobre `seleccionar` y `leerSecreto`, que el cliente ya pinta. Así la clave sigue viajando
|
|
26
|
+
* por el único mensaje del cable que la lleva y, de propina, el asistente elige TAMBIÉN el
|
|
27
|
+
* modelo y lo guarda: un paso de cuenta que solo guardara la credencial dejaría `trabajo`
|
|
28
|
+
* en la omisión (Ollama local) con una clave de Anthropic recién escrita al lado.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
31
|
+
import { lineaDeVersion } from "../../core/version.js";
|
|
32
|
+
import { basename, dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { randomUUID } from "node:crypto";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { escribirAgente, leerAgente } from "../../core/agentes.js";
|
|
37
|
+
import { borrarAgente, cargarAgentes, guardarAgente } from "../../agent/agentesEnDisco.js";
|
|
38
|
+
import { detectarDispositivos, frameworkEnDispositivo } from "../../agent/dispositivosEnMaquina.js";
|
|
39
|
+
/**
|
|
40
|
+
* El veredicto de «¿se puede lanzar?» y el lanzamiento entero.
|
|
41
|
+
*
|
|
42
|
+
* `lanzarEnDispositivo` se pasa al cable como FUNCIÓN y no como dependencias: construye las
|
|
43
|
+
* suyas por dentro —el `https`, el ZIP en memoria, `frameworkEnDispositivo`, `process.kill(-pid)`
|
|
44
|
+
* y el WebSocket del hotswap—, así que repetir ese montaje en el cableado sería un segundo sitio
|
|
45
|
+
* donde puede dejar de estar montado, que es el patrón de fallo de esta casa. Lo único que este
|
|
46
|
+
* lado aporta es `alFase`, que es cómo se cuenta el recorrido.
|
|
47
|
+
*/
|
|
48
|
+
import { lanzarEnDispositivo, } from "../../agent/lanzamientoEnMaquina.js";
|
|
49
|
+
import { motivoDeBloqueo, puedeLanzarse, } from "../../core/puedeLanzarse.js";
|
|
50
|
+
import { PLATAFORMAS_DE_DISPOSITIVO } from "../../core/settings.js";
|
|
51
|
+
import { instalarHerramientaDeDispositivos, verificarDispositivo } from "../../agent/dispositivosEnMaquina.js";
|
|
52
|
+
import { correrPasoDeReceta } from "../../agent/instalacionEnMaquina.js";
|
|
53
|
+
import { modelosDeMotor } from "../../agent/modelosDeMotor.js";
|
|
54
|
+
import { parsear, compatibleConOpenAi, esProveedorPersonalizado, idDeProveedorPersonalizado, motivoDeEndpointInaceptable, motivoDeSlugInaceptable, nombreDeProveedor, PROVEEDORES, slugDesdeNombre, resolver, SIN_CREDENCIAL, PAPELES, } from "../../core/modelos.js";
|
|
55
|
+
import { hayCredencial } from "../../cli/consola.js";
|
|
56
|
+
import { motivoDeClaveInaceptable } from "../../core/config.js";
|
|
57
|
+
import { aplicarAuth, aplicarCredencialAlProceso, cargar, guardarModeloGlobal, } from "../../agent/configEnDisco.js";
|
|
58
|
+
import { borrarCredencial, guardarCredencial } from "../../agent/authEnDisco.js";
|
|
59
|
+
import { borrarProveedorPersonalizado, guardarProveedorPersonalizado, proveedoresPersonalizados, } from "../../agent/configEnDisco.js";
|
|
60
|
+
import { crearCheckpointerDeProyecto, hayCheckpoint, olvidarHilo, } from "../../agent/checkpointer.js";
|
|
61
|
+
import { cargarSettings, guardarConcurrenciaDeTareas, guardarDispositivos, guardarEntorno as guardarEntornoEnDisco, } from "../../agent/settingsEnDisco.js";
|
|
62
|
+
import { dentroDelWorkspace, seAplicaSinAprobacion } from "../../core/settings.js";
|
|
63
|
+
import { cloudstudioDelProyecto } from "../../agent/configEnDisco.js";
|
|
64
|
+
import { abrirEnSistema } from "../../agent/cloudstudioMcp.js";
|
|
65
|
+
import { nombreDePersona } from "../../agent/persona.js";
|
|
66
|
+
import { cambiosDeSesion, fotoDeApertura, olvidarSesion, parcheDeSesion } from "../../agent/sesionGit.js";
|
|
67
|
+
import { commitDeTurno, cambiosPendientes, trabajoSinCommitear } from "../../agent/gitSync.js";
|
|
68
|
+
import { marcarTareaDeSesion, sembrarConsumosPendientes } from "./sesiones.js";
|
|
69
|
+
import { RUTA_ARTEFACTOS, esRutaDeArtefacto } from "../../core/artefactos.js";
|
|
70
|
+
import { arbolDeProyecto, leerFicheroDeProyecto, motivoDeRutaInaceptable, } from "../../agent/arbolDeProyecto.js";
|
|
71
|
+
import { leerArtefactoCrudo, leerArtefactoDeSesion, } from "../../agent/artefactosEnDisco.js";
|
|
72
|
+
import { CatalogoModelos } from "../../agent/catalogoModelos.js";
|
|
73
|
+
import { Modelos } from "../../agent/modelos.js";
|
|
74
|
+
import { crearJuezDeTarea, invocarConModelos } from "../../agent/juezDeTarea.js";
|
|
75
|
+
import { AumentadorGuionizado } from "../../core/ports.js";
|
|
76
|
+
import { arrancarServidor } from "./servidor.js";
|
|
77
|
+
import { consolaParaTarea, crearCorredorDeTareas, revisionConGit, } from "./corredorDeTareas.js";
|
|
78
|
+
import { CONCURRENCIA_POR_OMISION, conEstado, darPorBuenaAMano, tituloDeTarea, } from "../../core/tareas.js";
|
|
79
|
+
import { aplicarFeedback, TOPE_DE_ADJUNTO } from "../../agent/tareasEnDisco.js";
|
|
80
|
+
import { nombreDeAdjuntoAceptable } from "../../core/adjuntos.js";
|
|
81
|
+
import { rutaMemoriaDeProyecto } from "../../agent/memoriaDeProyecto.js";
|
|
82
|
+
import { crearAumentador, invocarParaAumentar } from "../../agent/aumentador.js";
|
|
83
|
+
import { baseDeWorkspacePorOmision, conexionDeVestibulo, crearVestibulo, escribirProyectoEnDisco, esProyectoEnDisco, } from "./vestibulo.js";
|
|
84
|
+
// Valor y no tipo: la traducción de una `Tarea` a lo que viaja vive JUNTO al tipo que
|
|
85
|
+
// produce y no aquí. Estuvo en este cierre, y ahí se cayó `veredicto` sin que nada se
|
|
86
|
+
// pusiera rojo (F1 de la revisión final) — el patrón de fallo que este repo ya tiene
|
|
87
|
+
// documentado cinco veces: una composición de producción dentro de algo que los tests
|
|
88
|
+
// doblan.
|
|
89
|
+
import { filaDeTarea } from "./transporte.js";
|
|
90
|
+
/** Las dos rutas del cable. El cliente las tiene escritas en `apps/web/src/conexion.ts`. */
|
|
91
|
+
/**
|
|
92
|
+
* Cuánto se espera al aviso de «lo que ya había sin commitear» antes de anunciar sin él.
|
|
93
|
+
*
|
|
94
|
+
* Dos segundos porque esto va en el camino de ABRIR una sesión y ahí lo que se nota es el
|
|
95
|
+
* indicador: un anuncio sin ese campo es «no consta» —lo que ya significaba ausente— y el
|
|
96
|
+
* siguiente flanco de turno lo trae. Bloquear la apertura por un `git status` lento es la
|
|
97
|
+
* definición de que el aviso frena, que es justo lo que su propia regla prohíbe.
|
|
98
|
+
*/
|
|
99
|
+
export const MS_DE_TRABAJO_AL_ABRIR = 2000;
|
|
100
|
+
export const RUTA_EVENTOS = "/eventos";
|
|
101
|
+
export const RUTA_ACCION = "/accion";
|
|
102
|
+
/**
|
|
103
|
+
* El documento de un ARTEFACTO, para el iframe y para la descarga.
|
|
104
|
+
*
|
|
105
|
+
* Tiene que ser HTTP y no cable: un iframe pinta un DOCUMENTO, y por `/eventos` viajan
|
|
106
|
+
* mensajes. El nombre va en la query y no en la ruta porque `registrarRuta` casa por
|
|
107
|
+
* coincidencia EXACTA (`servidor.ts`), así que `/artefacto/<nombre>` no encontraría
|
|
108
|
+
* manejador. Consecuencia asumida: las URLs relativas de dentro del HTML no resuelven —
|
|
109
|
+
* el contrato de las skills que los escriben es «autocontenido».
|
|
110
|
+
*/
|
|
111
|
+
export const RUTA_ARTEFACTO = "/artefacto";
|
|
112
|
+
/**
|
|
113
|
+
* `POST /adjunto?tarea=<id>&nombre=<fichero>` — los BYTES de un adjunto.
|
|
114
|
+
*
|
|
115
|
+
* Por HTTP y no por el cable: el SSE lleva JSON y esto son bytes. El nombre va en la QUERY
|
|
116
|
+
* y no en la ruta por lo mismo que el del artefacto: `registrarRuta` casa por coincidencia
|
|
117
|
+
* EXACTA, así que `/adjunto/<nombre>` no encontraría manejador.
|
|
118
|
+
*/
|
|
119
|
+
export const RUTA_ADJUNTO = "/adjunto";
|
|
120
|
+
/** Cuántas líneas del log de una instalación viajan: la COLA, lo último. */
|
|
121
|
+
export const LINEAS_DE_LOG = 12;
|
|
122
|
+
/** Cada cuánto se emite el progreso. Ver `atenderReceta`: cada emisión manda la cola entera. */
|
|
123
|
+
export const MS_ENTRE_PROGRESOS = 250;
|
|
124
|
+
/** Lo que se sirve: el build del cliente. Tres niveles arriba tanto desde `src/web/servidor/`
|
|
125
|
+
* como desde `dist/web/servidor/`, que es la disposición que se publica en npm. */
|
|
126
|
+
export function raizDelClientePorOmision() {
|
|
127
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "apps", "web", "dist");
|
|
128
|
+
}
|
|
129
|
+
export const FALTA_EL_BUILD = "falta el build del cliente: ejecuta «npm run build:web»";
|
|
130
|
+
/** Tope del cuerpo de `POST /accion`. Generoso para una prosa larga, finito porque el
|
|
131
|
+
* cuerpo se acumula en memoria y un cliente roto no puede llenarla. */
|
|
132
|
+
const TOPE_DE_CUERPO = 1_000_000;
|
|
133
|
+
/** El `Content-Type` con el que se sirve un artefacto inline: al texto se le pone el juego
|
|
134
|
+
* de caracteres, porque sin él el navegador lo adivina de los bytes y un HTML en UTF-8 sale
|
|
135
|
+
* con la acentuación rota. A una imagen no se le pone: no lo lleva. */
|
|
136
|
+
function tipoServido(mime) {
|
|
137
|
+
return mime.startsWith("text/") || mime === "image/svg+xml" || mime === "application/json"
|
|
138
|
+
? `${mime}; charset=utf-8`
|
|
139
|
+
: mime;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Monta `/eventos` y `/accion` sobre un servidor ya levantado.
|
|
143
|
+
*
|
|
144
|
+
* Separada de `arrancarConsolaWeb` para poder probar el cable entero —conexión, registro
|
|
145
|
+
* de comandos, alta, cambio de proyecto— sin puerto, sin disco y sin navegador: los
|
|
146
|
+
* manejadores se invocan con dobles de petición y respuesta.
|
|
147
|
+
*
|
|
148
|
+
* Devuelve `emitirTareas`: el corredor de tareas NACE antes que este cable (lo necesita
|
|
149
|
+
* `arrancarConsolaWeb` para pasarle `corredorDeTareas`), así que su `alCambiar` —un cambio
|
|
150
|
+
* de estado que nace DENTRO del lazo, no de una petición del cliente— no tiene manera de
|
|
151
|
+
* alcanzar el `emitir` de aquí salvo que se lo devuelva. Es la misma costura que
|
|
152
|
+
* `vestibulo.alCambiarEstadoDeSesion`, solo que el emisor nace después que quien lo
|
|
153
|
+
* necesita en vez de antes.
|
|
154
|
+
*/
|
|
155
|
+
export function montarRutas(servidor, vestibulo, opciones = {}) {
|
|
156
|
+
const informar = opciones.informar ?? (() => { });
|
|
157
|
+
const hayCredencialDe = opciones.hayCredencial ?? (() => false);
|
|
158
|
+
/** Los personalizados dados de alta AHORA: se relee en cada mensaje, porque esta misma
|
|
159
|
+
* ventana los da de alta y de baja sin reiniciar nada. */
|
|
160
|
+
const personalizados = () => opciones.proveedoresPersonalizados?.() ?? [];
|
|
161
|
+
const idsPersonalizados = () => personalizados().map((d) => idDeProveedorPersonalizado(d.slug));
|
|
162
|
+
const baseUrlDe = (proveedor) => compatibleConOpenAi(proveedor, personalizados())?.baseUrl;
|
|
163
|
+
/**
|
|
164
|
+
* ¿Existe ese proveedor? De serie, o personalizado DADO DE ALTA. Una sola función porque
|
|
165
|
+
* había dos puertas con el mismo criterio y una se quedó atrás: la del catálogo devolvía
|
|
166
|
+
* en silencio para un `custom:…`, así que pulsar un proveedor recién dado de alta en la
|
|
167
|
+
* pastilla se quedaba en «consultando…» para siempre — el botón muerto de siempre, y con
|
|
168
|
+
* el modelo pulsándolo.
|
|
169
|
+
*/
|
|
170
|
+
const proveedorConocido = (id) => PROVEEDORES.includes(id)
|
|
171
|
+
|| personalizados().some((d) => idDeProveedorPersonalizado(d.slug) === id);
|
|
172
|
+
/** Los tres estados que se pueden AFIRMAR de una credencial. Ver `ProveedorDeModelos`. */
|
|
173
|
+
const credencialDe = (proveedor) => SIN_CREDENCIAL.has(proveedor) ? "nativa" : hayCredencialDe(proveedor) ? "puesta" : "falta";
|
|
174
|
+
/**
|
|
175
|
+
* Los clientes vivos. Fue una sola ranura y era un fallo: el último en conectar dejaba
|
|
176
|
+
* mudos a los anteriores sin decírselo —medido con una pestaña local y otra por un túnel—.
|
|
177
|
+
* Emitir es escribirle a todos; el transporte hace lo mismo por su lado.
|
|
178
|
+
*/
|
|
179
|
+
const clientes = new Set();
|
|
180
|
+
/**
|
|
181
|
+
* Los clientes por su IDENTIFICADOR, y qué tareas está mirando cada uno.
|
|
182
|
+
*
|
|
183
|
+
* **Existe porque el SSE y el `POST /accion` son dos peticiones distintas.** El único
|
|
184
|
+
* sitio con un sumidero en la mano es la ruta del SSE, así que sin un identificador que
|
|
185
|
+
* el cliente repita en su `{clase:"mirar"}` el servidor no sabría a qué pestaña
|
|
186
|
+
* engancharle la mirada — y tendría que emitirle el transcript de una tarea de fondo a
|
|
187
|
+
* TODO el mundo, que es justo lo que no puede pasar (los actos de una tarea no aparecen
|
|
188
|
+
* en el chat de nadie). Lo elige el cliente, una vez por conexión, igual que elige el id
|
|
189
|
+
* de una tarea al subirle un adjunto por `POST /adjunto`.
|
|
190
|
+
*
|
|
191
|
+
* El id **nunca es una ruta ni un nombre de fichero**: es solo la clave de este mapa, y
|
|
192
|
+
* las entradas nacen y mueren con la conexión del SSE — no crece con los mensajes.
|
|
193
|
+
*
|
|
194
|
+
* `mirando` guarda el ENVOLTORIO que se enganchó al corredor (no el sumidero pelado),
|
|
195
|
+
* porque es el que hay que pasarle a `dejarDeMirar` para quitar ESE y no los demás.
|
|
196
|
+
*/
|
|
197
|
+
const porIdDeCliente = new Map();
|
|
198
|
+
/**
|
|
199
|
+
* La consola a la que está ENGANCHADO el cable ahora mismo. No se recalcula al cerrar:
|
|
200
|
+
* hay que desconectar la que se conectó, no la que sea la actual en ese momento — entre
|
|
201
|
+
* medias puede haberse abierto un proyecto.
|
|
202
|
+
*/
|
|
203
|
+
let adjunto;
|
|
204
|
+
/** Lo elegido en el wizard hasta ahora. Ninguno se inventa: sin elección, no hay lista. */
|
|
205
|
+
let entornoElegido;
|
|
206
|
+
let proyectoElegido;
|
|
207
|
+
let proyectos = [];
|
|
208
|
+
let ramas = [];
|
|
209
|
+
/**
|
|
210
|
+
* El paso de cuenta ya conducido en ESTE proceso. Hace falta porque `origenDeTrabajo` se
|
|
211
|
+
* congela al construir el vestíbulo: `pasosPendientes()` seguiría diciendo «cuenta»
|
|
212
|
+
* después de haberla dado, y el wizard volvería a pedirla en cada reconexión.
|
|
213
|
+
*
|
|
214
|
+
* Se marca cuando `pasoDeCuenta()` TERMINA, no cuando se lanza — medido: marcarlo antes
|
|
215
|
+
* de esperar significaba que recargar a mitad del selector (o de teclear la clave) daba
|
|
216
|
+
* por «hecho» un paso que en realidad ni se había contestado ni se iba a volver a
|
|
217
|
+
* ofrecer, y el usuario se quedaba con el modelo por omisión sin que nada se lo dijera.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* Si hay turno en vuelo AHORA en la sesión que se está mirando. Hace falta porque quien
|
|
221
|
+
* conecta a mitad de turno tiene que enterarse: el mensaje que lo anunció ya pasó.
|
|
222
|
+
*
|
|
223
|
+
* Se DERIVA de la consola en foco, no se cachea en una variable que un flanco actualiza.
|
|
224
|
+
* Era lo segundo, y desde que cambiar de sesión no mata el turno anterior una copia se
|
|
225
|
+
* queda vieja en el caso que más importa: volver a una sesión que está trabajando no es un
|
|
226
|
+
* flanco de turno —el turno no empezó ni acabó—, así que la variable habría dicho «no hay
|
|
227
|
+
* nada corriendo» y `adjuntar` habría encendido el compositor delante de un agente que
|
|
228
|
+
* escribe.
|
|
229
|
+
*/
|
|
230
|
+
const turnoEnVuelo = () => vestibulo.proyectoAbierto()?.turnoEnVuelo === true;
|
|
231
|
+
let cuentaHecha = false;
|
|
232
|
+
/**
|
|
233
|
+
* El propio `pasoDeCuenta()` en vuelo, compartido entre conexiones. Sin esto, dos
|
|
234
|
+
* conexiones solapadas (dos pestañas, o la reconexión que llega antes de que la vieja
|
|
235
|
+
* se haya desenganchado del todo) verían las dos `cuentaHecha` en `false` y llamarían
|
|
236
|
+
* a `pasoDeCuenta()` cada una la suya — dos `asistenteDeModelo` a la vez apilando DOS
|
|
237
|
+
* resolutores en la MISMA cola FIFO de `consolaWeb.ts#seleccionar`, de forma que la
|
|
238
|
+
* respuesta de una pestaña resolvería la pregunta de la OTRA. Una sola llamada real,
|
|
239
|
+
* y la segunda conexión se limita a esperar la que ya está en marcha.
|
|
240
|
+
*/
|
|
241
|
+
let cuentaEnCurso;
|
|
242
|
+
/**
|
|
243
|
+
* Lo que falló en el último paso del alta. Viaja en el propio mensaje del alta porque el
|
|
244
|
+
* fallo pertenece al paso que lo produjo: el acto de sistema que `informar` deja aterriza
|
|
245
|
+
* en la Trayectoria —la otra pestaña—, y el wizard repintaba el mismo paso sin decir nada.
|
|
246
|
+
*/
|
|
247
|
+
let aviso;
|
|
248
|
+
const destinoActual = () => vestibulo.proyectoAbierto() ?? vestibulo.consola;
|
|
249
|
+
const emitir = (mensaje) => {
|
|
250
|
+
for (const cliente of clientes)
|
|
251
|
+
cliente(mensaje);
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Lo que valga esa promesa, o nada si tarda demasiado. **No la cancela**: sigue viva y su
|
|
255
|
+
* valor se usará en el siguiente anuncio — lo que se acota es la ESPERA, no el trabajo.
|
|
256
|
+
*/
|
|
257
|
+
const conPlazo = async (promesa, ms) => {
|
|
258
|
+
let reloj;
|
|
259
|
+
try {
|
|
260
|
+
return await Promise.race([
|
|
261
|
+
promesa,
|
|
262
|
+
new Promise((resuelto) => {
|
|
263
|
+
reloj = setTimeout(() => resuelto(undefined), ms);
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
// Sin esto el temporizador mantiene vivo el proceso hasta que vence, y son segundos
|
|
269
|
+
// en cada anuncio: el alta se emite dos veces por turno.
|
|
270
|
+
if (reloj !== undefined)
|
|
271
|
+
clearTimeout(reloj);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* El mensaje de alta: qué falta, y con qué elegirlo.
|
|
276
|
+
*
|
|
277
|
+
* Con proyecto YA abierto no falta nada, y se dice con `pasos` vacío SIN mirar
|
|
278
|
+
* `pasosPendientes()` — es el comportamiento de siempre (el atajo de `--guion` sobre un
|
|
279
|
+
* proyecto offline, por ejemplo, abre directo y nunca debería enseñar el alta, tenga o
|
|
280
|
+
* no cuenta/entorno resueltos) y no algo que este cambio deba tocar.
|
|
281
|
+
*
|
|
282
|
+
* Cambio de rumbo del usuario, para cuando NO hay proyecto abierto: el paso de proyecto
|
|
283
|
+
* salió del alta, así que `pasos` sale DIRECTO de `pasosPendientes()` —que ya nunca
|
|
284
|
+
* incluye «proyecto»— en vez de esperar a que se abra uno. Antes de este cambio
|
|
285
|
+
* `pasos: []` solo pasaba con un proyecto abierto, y el cliente usaba esa implicación
|
|
286
|
+
* para pintar la maqueta completa; ahora también pasa sin proyecto (con cuenta y
|
|
287
|
+
* entorno resueltos), así que la implicación ya no basta y `proyectoAbierto` viaja
|
|
288
|
+
* aparte (`transporte.ts` lo documenta) para que el cliente sepa si esperar una
|
|
289
|
+
* elección en la barra o pintar la sesión de verdad.
|
|
290
|
+
*
|
|
291
|
+
* «cuenta» NO se anuncia nunca al wizard: ese paso lo conduce `conducirCuenta` sobre el
|
|
292
|
+
* selector y el secreto, que es lo que mantiene la clave en su único mensaje y lo que
|
|
293
|
+
* hace que se elija TAMBIÉN el modelo. El wizard sigue sabiendo pintarlo por si otra
|
|
294
|
+
* piel se lo manda; esta no.
|
|
295
|
+
*/
|
|
296
|
+
const anunciarAlta = async () => {
|
|
297
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
298
|
+
const proyectoAbierto = abierto !== undefined;
|
|
299
|
+
// Se lee del disco EN CADA anuncio y no se cachea al abrir: `configurarModoInicial`
|
|
300
|
+
// puede escribirlo después de abrir (el alta de un proyecto cloud), y una copia
|
|
301
|
+
// tomada antes se quedaría diciendo lo de antes.
|
|
302
|
+
const modo = abierto === undefined ? undefined : modoDeProyecto(abierto.raiz);
|
|
303
|
+
// CUÁL es el abierto, deducido de su raíz: la que le tocaría a cada proyecto de la
|
|
304
|
+
// lista se calcula con la misma función que la creó. Guardar el id aparte al abrirlo
|
|
305
|
+
// sería una segunda fuente de verdad que se queda vieja el día que alguien abra por
|
|
306
|
+
// otro camino.
|
|
307
|
+
// El `entorno` se copia a una constante porque TypeScript no puede saber que
|
|
308
|
+
// `entornoElegido` —una variable del cierre, que otro mensaje puede cambiar— sigue
|
|
309
|
+
// definida dentro del callback.
|
|
310
|
+
const entorno = entornoElegido;
|
|
311
|
+
const activo = abierto === undefined || entorno === undefined
|
|
312
|
+
? undefined
|
|
313
|
+
: proyectos.find((p) => {
|
|
314
|
+
try {
|
|
315
|
+
return vestibulo.raizDeProyecto(entorno, p.nombre) === abierto.raiz;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
})?.id;
|
|
321
|
+
const pendientes = proyectoAbierto ? [] : await vestibulo.pasosPendientes();
|
|
322
|
+
// Lo que ya había sin commitear al ABRIR. `abierto` se capturó arriba, antes de este
|
|
323
|
+
// `await`: `anunciarAlta` no va en la cola del vestíbulo, así que entre medias puede
|
|
324
|
+
// haberse abierto otro proyecto y el dato tiene que ser del que se anuncia. La promesa
|
|
325
|
+
// está cacheada en la consola y no rechaza nunca.
|
|
326
|
+
/**
|
|
327
|
+
* **Ese aviso AVISA, no FRENA — y esperarlo sin plazo lo convertía en un freno.**
|
|
328
|
+
* Detrás hay un `git status --untracked-files=all` sobre el proyecto del usuario, que en
|
|
329
|
+
* una copia grande con muchos ficheros sin rastrear puede tardar lo que quiera. Y este
|
|
330
|
+
* `await` está en el `finally` de abrir una sesión, justo antes de apagar el indicador:
|
|
331
|
+
* mientras no resolviera, la barra se quedaba en «abriendo…» PARA SIEMPRE y la consola
|
|
332
|
+
* no dejaba abrir nada más (medido en la pantalla del usuario).
|
|
333
|
+
*
|
|
334
|
+
* Con plazo, lo que se pierde es el aviso de esta pasada y nada más: el alta se reanuncia
|
|
335
|
+
* en los DOS flancos de cada turno, así que en cuanto la promesa esté lista el dato sale
|
|
336
|
+
* en el siguiente anuncio. Ausente ya significaba «no consta» en las cuatro capas, que es
|
|
337
|
+
* exactamente lo que hay que decir mientras no se sabe.
|
|
338
|
+
*/
|
|
339
|
+
const trabajo = abierto === undefined ? undefined : await conPlazo(abierto.trabajoAlAbrir, MS_DE_TRABAJO_AL_ABRIR);
|
|
340
|
+
const pasos = pendientes.includes("entorno") ? ["entorno"] : [];
|
|
341
|
+
// Las raíces que trabajan, UNA vez para todos los proyectos del anuncio.
|
|
342
|
+
const trabajandoAqui = raicesTrabajando();
|
|
343
|
+
/** La raíz que le tocaría a ese proyecto, o nada si no se puede calcular (sin entorno
|
|
344
|
+
* elegido, o un nombre que `rutaDeWorkspace` rechaza). No se afirma sobre lo que no se
|
|
345
|
+
* puede nombrar. */
|
|
346
|
+
const raizDeProyectoOSilencio = (nombre) => {
|
|
347
|
+
if (entorno === undefined)
|
|
348
|
+
return undefined;
|
|
349
|
+
try {
|
|
350
|
+
return vestibulo.raizDeProyecto(entorno, nombre);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
emitir({
|
|
357
|
+
clase: "alta",
|
|
358
|
+
pasos,
|
|
359
|
+
proveedores: PROVEEDORES.map((p) => ({ id: p, nombre: nombreDeProveedor(p) })),
|
|
360
|
+
entornos: [...vestibulo.opcionesDeEntorno()],
|
|
361
|
+
// Los registrados de verdad, además de los ofrecidos: la ventana de ajustes los
|
|
362
|
+
// lista, y la barra lateral llevaba enseñando la lista OFRECIDA como si fuera ésta.
|
|
363
|
+
registrados: vestibulo.entornosRegistrados().map((e) => ({
|
|
364
|
+
id: e.id,
|
|
365
|
+
nombre: e.nombre,
|
|
366
|
+
url: e.url,
|
|
367
|
+
// Solo si el entorno lo dice: ausente es «no lo he elegido», y el cliente aplica su
|
|
368
|
+
// omisión. Mandar `[]` en su lugar sería decir «ninguno», que es otra cosa.
|
|
369
|
+
...(e.proyectos === undefined ? {} : { proyectos: [...e.proyectos] }),
|
|
370
|
+
})),
|
|
371
|
+
// Cada proyecto con las sesiones de su copia local. Se recalcula en cada anuncio: una
|
|
372
|
+
// sesión nueva aparece en cuanto se abre, sin que nadie recargue.
|
|
373
|
+
proyectos: proyectos.map((p) => {
|
|
374
|
+
const sesiones = entornoElegido === undefined ? [] : sesionesDelProyecto(p.nombre);
|
|
375
|
+
return {
|
|
376
|
+
...p,
|
|
377
|
+
...(sesiones.length === 0 ? {} : { sesiones }),
|
|
378
|
+
/**
|
|
379
|
+
* Y la marca del PROYECTO solo cuando no hay fila que marcar.
|
|
380
|
+
*
|
|
381
|
+
* La deducción es la misma por raíz que `activo`, con la misma función que la creó
|
|
382
|
+
* —un id guardado aparte se queda viejo el día que alguien abra por otro camino—,
|
|
383
|
+
* pero se calla si alguna de sus sesiones ya lleva la marca: decirlo en los dos
|
|
384
|
+
* niveles a la vez es la duplicación que el usuario señaló, y la que importa es la
|
|
385
|
+
* fila, que es la que se abre. Queda para lo que la fila NO puede decir: una
|
|
386
|
+
* sesión que todavía no está en el índice, o sea la de una TAREA de fondo antes de
|
|
387
|
+
* su primer volcado — las de persona entran ya con el mensaje.
|
|
388
|
+
*/
|
|
389
|
+
...(raizDeProyectoOSilencio(p.nombre) !== undefined &&
|
|
390
|
+
trabajandoAqui.has(raizDeProyectoOSilencio(p.nombre)) &&
|
|
391
|
+
!sesiones.some((se) => se.trabajando === true)
|
|
392
|
+
? { trabajando: true }
|
|
393
|
+
: {}),
|
|
394
|
+
// Si ya está bajado, abrirlo no necesita ni rama ni descarga: es lo que decide
|
|
395
|
+
// qué enseña la ventana de sesión nueva, y decidirlo en el cliente exigiría
|
|
396
|
+
// que supiera dónde vive la copia local.
|
|
397
|
+
...(hayCopiaLocal(p.nombre) ? { local: true } : {}),
|
|
398
|
+
};
|
|
399
|
+
}),
|
|
400
|
+
ramas,
|
|
401
|
+
proyectoAbierto,
|
|
402
|
+
...(modo === undefined ? {} : { modo }),
|
|
403
|
+
...(entornoElegido === undefined ? {} : { entornoActivo: entornoElegido }),
|
|
404
|
+
...(activo === undefined ? {} : { proyectoActivo: activo }),
|
|
405
|
+
...(abierto?.sesion === undefined ? {} : { sesionActiva: abierto.sesion }),
|
|
406
|
+
...(abierto?.dispositivo === undefined ? {} : { dispositivoActivo: abierto.dispositivo }),
|
|
407
|
+
...(abierto?.historica === true ? { historica: true } : {}),
|
|
408
|
+
// Este proyecto escribe sin preguntar. Viaja en el ALTA y no solo en el aviso del
|
|
409
|
+
// turno porque la decisión se tomó una vez, quizá hace meses, y quien se sienta hoy
|
|
410
|
+
// tiene que saberlo ANTES de pedir nada — no después, con los ficheros ya cambiados.
|
|
411
|
+
// Se calcula aquí y no se guarda: las tres condiciones incluyen si hay alguien
|
|
412
|
+
// delante, y eso cambia con la conexión.
|
|
413
|
+
...(abierto !== undefined &&
|
|
414
|
+
seAplicaSinAprobacion({
|
|
415
|
+
raiz: abierto.estadoDeSesion.raiz,
|
|
416
|
+
sinAprobacion: cargarSettings().settings.sinAprobacion,
|
|
417
|
+
cloudstudio: cloudstudioDelProyecto(abierto.estadoDeSesion.raiz),
|
|
418
|
+
// `true` sin más, y hay que decir por qué no es una simplificación: el alta se
|
|
419
|
+
// EMITE, o sea que solo llega a un cliente conectado, y la consola web declara
|
|
420
|
+
// `interactivo: true` por la misma razón (`consolaWeb.ts`). Quien decide de verdad
|
|
421
|
+
// en cada ronda es el ejecutor, que vuelve a preguntar las tres condiciones.
|
|
422
|
+
interactivo: true,
|
|
423
|
+
})
|
|
424
|
+
? { sinAprobacion: true }
|
|
425
|
+
: {}),
|
|
426
|
+
// Solo si SE MIRÓ y había algo. Las otras tres respuestas —limpio, sin git, no se
|
|
427
|
+
// pudo— se callan: un mensaje en cada apertura limpia es ruido en casi todas, y el
|
|
428
|
+
// aviso dejaría de leerse justo el día que importa.
|
|
429
|
+
...(trabajo?.via === "git" && trabajo.ficheros !== undefined && trabajo.ficheros.length > 0
|
|
430
|
+
? {
|
|
431
|
+
trabajoAlAbrir: {
|
|
432
|
+
ficheros: trabajo.ficheros.slice(0, FICHEROS_DEL_AVISO),
|
|
433
|
+
total: trabajo.ficheros.length,
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
: {}),
|
|
437
|
+
...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }),
|
|
438
|
+
...(aviso === undefined ? {} : { aviso }),
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
/**
|
|
442
|
+
* Los catálogos ya consultados en ESTE proceso, por proveedor. Se guardan porque cada
|
|
443
|
+
* uno es una llamada de red: abrir el menú dos veces no la repite. Un fallo también se
|
|
444
|
+
* recuerda —como fallo— hasta que alguien lo vuelva a pedir a propósito.
|
|
445
|
+
*/
|
|
446
|
+
const catalogos = new Map();
|
|
447
|
+
/**
|
|
448
|
+
* El estado de modelos: qué está en vigor y qué se puede elegir.
|
|
449
|
+
*
|
|
450
|
+
* `actual` sale del estado de sesión de la consola ABIERTA y de ningún sitio más. Sin
|
|
451
|
+
* proyecto abierto no hay sesión y por tanto no hay modelo que afirmar: el campo se va
|
|
452
|
+
* y el cliente pinta «Elige modelo» en vez de una fila muerta.
|
|
453
|
+
*/
|
|
454
|
+
const emitirModelos = () => emitir(mensajeDeModelos());
|
|
455
|
+
/**
|
|
456
|
+
* Los subagentes en vigor, compuestos pero sin mandar.
|
|
457
|
+
*
|
|
458
|
+
* `cargarAgentes` cada vez y no una lista cacheada: los `.md` se pueden editar a mano con
|
|
459
|
+
* la consola abierta, y una lista congelada al arrancar haría que el usuario creyera que
|
|
460
|
+
* su cambio no se aplicó — que es exactamente lo que pasa con el agente, que también los
|
|
461
|
+
* relee en cada construcción.
|
|
462
|
+
*/
|
|
463
|
+
const mensajeDeAgentes = () => {
|
|
464
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
465
|
+
const { agentes, problemas } = cargarAgentes(abierto?.raiz);
|
|
466
|
+
return {
|
|
467
|
+
clase: "agentes",
|
|
468
|
+
agentes: agentes.map((a) => ({
|
|
469
|
+
nombre: a.nombre,
|
|
470
|
+
descripcion: a.descripcion,
|
|
471
|
+
motor: a.motor,
|
|
472
|
+
...(a.modelo === undefined ? {} : { modelo: a.modelo }),
|
|
473
|
+
soloLectura: a.soloLectura,
|
|
474
|
+
skills: a.skills,
|
|
475
|
+
instrucciones: a.instrucciones,
|
|
476
|
+
origen: a.origen,
|
|
477
|
+
})),
|
|
478
|
+
problemas,
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
const emitirAgentes = () => emitir(mensajeDeAgentes());
|
|
482
|
+
/**
|
|
483
|
+
* Alta, cambio y borrado de un subagente.
|
|
484
|
+
*
|
|
485
|
+
* El ÁMBITO decide la carpeta, y viene del cliente en vez de deducirse: con un proyecto
|
|
486
|
+
* abierto valen las dos, y adivinar cuál quiere el usuario es cómo un «revisor» pensado
|
|
487
|
+
* para todos los proyectos acaba escondido en uno. Sin proyecto abierto solo cabe el
|
|
488
|
+
* global, y pedir el de proyecto se dice en vez de escribirlo en cualquier sitio.
|
|
489
|
+
*/
|
|
490
|
+
const atenderAgente = (mensaje) => {
|
|
491
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
492
|
+
if (mensaje.ambito === "proyecto" && abierto === undefined) {
|
|
493
|
+
informar("no hay ningún proyecto abierto: ese subagente solo se puede guardar como global");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const base = mensaje.ambito === "proyecto" ? abierto.raiz : homedir();
|
|
497
|
+
try {
|
|
498
|
+
if (mensaje.accion === "borrar") {
|
|
499
|
+
informar(borrarAgente(base, mensaje.agente.nombre)
|
|
500
|
+
? `subagente «${mensaje.agente.nombre}» borrado`
|
|
501
|
+
: `«${mensaje.agente.nombre}» no existía en ${mensaje.ambito}`);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
// Se valida escribiendo Y VOLVIENDO A LEER, no confiando en el formulario: es el
|
|
505
|
+
// mismo fichero que se puede editar a mano, así que el cargador es la única
|
|
506
|
+
// autoridad sobre si vale. Si no pasara, el agente desaparecería al siguiente
|
|
507
|
+
// arranque sin que nadie hubiera hecho nada raro.
|
|
508
|
+
const candidato = {
|
|
509
|
+
...mensaje.agente,
|
|
510
|
+
motor: mensaje.agente.motor,
|
|
511
|
+
origen: mensaje.ambito,
|
|
512
|
+
};
|
|
513
|
+
const comprobado = leerAgente(candidato.nombre, escribirAgente(candidato), mensaje.ambito);
|
|
514
|
+
if ("error" in comprobado) {
|
|
515
|
+
informar(`no se guarda «${candidato.nombre}»: ${comprobado.error}`);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
guardarAgente(base, candidato);
|
|
519
|
+
informar(`subagente «${candidato.nombre}» guardado en ${mensaje.ambito}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
524
|
+
}
|
|
525
|
+
emitirAgentes();
|
|
526
|
+
};
|
|
527
|
+
/** El mensaje de modelos, compuesto pero sin mandar: `adjuntar` se lo da SOLO al cliente
|
|
528
|
+
* que acaba de llegar, y el resto de sitios lo emite a todos. */
|
|
529
|
+
const mensajeDeModelos = () => {
|
|
530
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
531
|
+
const trabajo = abierto === undefined ? undefined : resolver(abierto.estadoDeSesion.fuentes).trabajo;
|
|
532
|
+
// Dos preguntas, dos campos. `actual` es la SESIÓN —y sin sesión no hay nada que
|
|
533
|
+
// afirmar—, y `porDefecto` es lo que usarán las nuevas, que existe SIEMPRE que se
|
|
534
|
+
// pueda leer el config global. Sin el segundo, Ajustes enseñaría «sin elegir» sobre una
|
|
535
|
+
// máquina con un defecto escrito, justo en la pantalla donde se configura.
|
|
536
|
+
const porDefecto = opciones.modeloPorDefecto?.();
|
|
537
|
+
return {
|
|
538
|
+
clase: "modelos",
|
|
539
|
+
...(trabajo === undefined ? {} : { actual: `${trabajo.proveedor}/${trabajo.modelo}` }),
|
|
540
|
+
...(porDefecto === undefined ? {} : { porDefecto }),
|
|
541
|
+
proveedores: [...PROVEEDORES, ...idsPersonalizados()].map((p) => ({
|
|
542
|
+
id: p,
|
|
543
|
+
nombre: nombreDeProveedor(p, personalizados()),
|
|
544
|
+
credencial: credencialDe(p),
|
|
545
|
+
// Solo se marca lo que se puede afirmar: sin puerto para mirarlo, no se dice que
|
|
546
|
+
// esté en el fichero (y la interfaz no ofrecerá borrarla).
|
|
547
|
+
...(opciones.credencialEnFichero?.(p) === true && opciones.borrarCredencial !== undefined
|
|
548
|
+
? { enFichero: true }
|
|
549
|
+
: {}),
|
|
550
|
+
// La URL viaja SOLO en los personalizados, y no es una ruta de la máquina: es lo
|
|
551
|
+
// que tecleó el usuario, y es justo lo que hay que ver al lado de su clave para
|
|
552
|
+
// saber a dónde va. Los de serie no la llevan: su URL está en el repo.
|
|
553
|
+
...(esProveedorPersonalizado(p)
|
|
554
|
+
? { personalizado: true, ...(baseUrlDe(p) === undefined ? {} : { baseUrl: baseUrlDe(p) }) }
|
|
555
|
+
: {}),
|
|
556
|
+
...(catalogos.get(p) ?? {}),
|
|
557
|
+
})),
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
561
|
+
* Engancha el cable a la consola que toque, con el transcript entero por delante.
|
|
562
|
+
*
|
|
563
|
+
* **La invariante es que TODOS los clientes vivos están registrados en `adjunto`**, y por
|
|
564
|
+
* eso hay dos casos y no uno:
|
|
565
|
+
*
|
|
566
|
+
* - Llega un cliente nuevo (`recien`): se registra ÉL en la consola actual y la ráfaga
|
|
567
|
+
* —transcript, comandos, modelos— es suya sola. Mandársela a todos repetiría el
|
|
568
|
+
* transcript en las pestañas que ya lo tienen.
|
|
569
|
+
* - Cambia la consola (se abre un proyecto): se registran TODOS los clientes en la nueva
|
|
570
|
+
* y la ráfaga va a todos, porque todos cambian de transcript.
|
|
571
|
+
*
|
|
572
|
+
* Registrar solo al recién llegado en el primer caso y a nadie en el segundo fue un fallo
|
|
573
|
+
* MEDIDO: al abrir un proyecto, la consola nueva se quedaba sin ningún sumidero, así que
|
|
574
|
+
* el turno corría —el agente trabajaba de verdad— y no salía nada por pantalla. Escribir
|
|
575
|
+
* y que no pasara nada.
|
|
576
|
+
*/
|
|
577
|
+
const adjuntar = (recien) => {
|
|
578
|
+
if (recien !== undefined && informeDeDispositivos === undefined)
|
|
579
|
+
void atenderDispositivos().catch(contar);
|
|
580
|
+
if (recien !== undefined)
|
|
581
|
+
comprobarLosSinClave();
|
|
582
|
+
const destino = destinoActual();
|
|
583
|
+
const cambiaDeConsola = adjunto !== destino;
|
|
584
|
+
/**
|
|
585
|
+
* `soltar` y NO `desconectar`, y es la pieza que hace que cambiar de sesión no rompa la
|
|
586
|
+
* que se deja atrás. `desconectar()` significa «se ha ido el humano»: rechaza la
|
|
587
|
+
* aprobación que hubiera delante y contesta cadena vacía a todo el que esperara. Aquí
|
|
588
|
+
* el humano no se ha ido — está mirando otra sesión, y la de antes puede tener un turno
|
|
589
|
+
* corriendo. Ver `Transporte.soltar`.
|
|
590
|
+
*/
|
|
591
|
+
if (adjunto !== undefined && cambiaDeConsola)
|
|
592
|
+
adjunto.soltar();
|
|
593
|
+
adjunto = destino;
|
|
594
|
+
// A quién hay que registrar y a quién hay que darle la ráfaga: al recién llegado, o a
|
|
595
|
+
// todos si lo que cambió fue la consola.
|
|
596
|
+
const destinatarios = recien !== undefined ? [recien] : cambiaDeConsola ? [...clientes] : [];
|
|
597
|
+
let actos = destino.conectar();
|
|
598
|
+
for (const cliente of destinatarios)
|
|
599
|
+
actos = destino.conectar(cliente);
|
|
600
|
+
const modelos = mensajeDeModelos();
|
|
601
|
+
// Los subagentes van en la ráfaga por lo mismo que los modelos: la ventana de ajustes
|
|
602
|
+
// se puede abrir en cuanto conecta, y sin esto enseñaría una lista vacía hasta que algo
|
|
603
|
+
// los cambiara — que es indistinguible de «no tienes ninguno».
|
|
604
|
+
const agentes = mensajeDeAgentes();
|
|
605
|
+
// La cola de tareas, si esta ejecución las tiene: la misma regla que `agentes`, sin
|
|
606
|
+
// esto la pestaña de tareas se quedaría vacía hasta el primer cambio de la cola.
|
|
607
|
+
const tareas = mensajeDeTareas();
|
|
608
|
+
/**
|
|
609
|
+
* Lo consumido por la sesión que se va a pintar. Va en la ráfaga por lo mismo que los
|
|
610
|
+
* modelos: una pestaña que conecta a mitad de sesión no vio los cambios anteriores, y
|
|
611
|
+
* sin esto su contador se quedaría a cero hasta el siguiente token — que en un turno
|
|
612
|
+
* largo son minutos. `undefined` es «no consta» y entonces no se manda nada.
|
|
613
|
+
*/
|
|
614
|
+
const consumoDeLaSesion = vestibulo.consumoDeSesion();
|
|
615
|
+
for (const cliente of destinatarios) {
|
|
616
|
+
// El orden importa: primero el transcript y luego el estado que pinta cada
|
|
617
|
+
// disparador. Al reconectar se manda entero: el cliente tira sus proyecciones al
|
|
618
|
+
// caerse el SSE (`store.ts#marcarDesconectado`), así que hay que repoblarlas.
|
|
619
|
+
cliente({ clase: "reemision", actos: [...actos] });
|
|
620
|
+
cliente(modelos);
|
|
621
|
+
cliente(agentes);
|
|
622
|
+
if (tareas !== undefined)
|
|
623
|
+
cliente(tareas);
|
|
624
|
+
if (consumoDeLaSesion !== undefined) {
|
|
625
|
+
cliente({
|
|
626
|
+
clase: "consumo",
|
|
627
|
+
modelo: consumoDeLaSesion.modelo,
|
|
628
|
+
externo: consumoDeLaSesion.externo,
|
|
629
|
+
ventana: ventanaDeAhora(consumoDeLaSesion.contexto),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// La foto de la máquina, si ya se tomó. Si no, se dispara abajo UNA vez y llega a
|
|
633
|
+
// todos por el SSE cuando termine: no se espera aquí, que son varios procesos.
|
|
634
|
+
if (informeDeDispositivos !== undefined) {
|
|
635
|
+
cliente({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
|
|
636
|
+
}
|
|
637
|
+
// Y si hay turno corriendo, se dice: quien conecta a mitad no vio el mensaje que lo
|
|
638
|
+
// anunció, y sin esto vería el compositor encendido y sin borde —«no pasa nada»—
|
|
639
|
+
// mientras lo que escribiera se quedaba en la cola.
|
|
640
|
+
cliente({ clase: "turno", activo: turnoEnVuelo() });
|
|
641
|
+
/**
|
|
642
|
+
* Y la aprobación que esta consola tenga EN VUELO. Hace falta desde que volver a una
|
|
643
|
+
* sesión de segundo plano es posible: su turno pudo pararse en un modal mientras
|
|
644
|
+
* nadie miraba, y ese mensaje se emitió una vez —no está en la traza, es el único que
|
|
645
|
+
* lleva contenido de fichero— así que sin reemitirlo el compositor se quedaría
|
|
646
|
+
* apagado delante de un turno que espera una decisión que no se puede dar.
|
|
647
|
+
*/
|
|
648
|
+
for (const pendiente of destino.mensajesDeAprobacion())
|
|
649
|
+
cliente(pendiente);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* El paso de cuenta, conducido por el asistente de siempre sobre esta consola. Se lanza
|
|
654
|
+
* suelto (no se espera) porque el manejador del SSE tiene que devolver para que el
|
|
655
|
+
* navegador reciba las preguntas que este asistente va a emitir.
|
|
656
|
+
*/
|
|
657
|
+
const conducirCuenta = async (porPeticion = false) => {
|
|
658
|
+
const pendientes = await vestibulo.pasosPendientes();
|
|
659
|
+
// Que este alta NO conduzca el modelo es distinto de que ya esté conducido: lo primero
|
|
660
|
+
// pasa cuando el modelo viene de fuera (una bandera, la config global) y entonces no
|
|
661
|
+
// hay nada que volver a preguntar. Callarlo dejaría un botón «Modelo ✓» que al pulsarlo
|
|
662
|
+
// no hace nada — el fallo mudo de siempre.
|
|
663
|
+
const noProcede = !pendientes.includes("cuenta") || vestibulo.proyectoAbierto() !== undefined;
|
|
664
|
+
if (noProcede && porPeticion) {
|
|
665
|
+
aviso =
|
|
666
|
+
"el modelo de esta sesión no lo decide el alta: viene de una bandera o de la configuración. Cámbialo con «/modelo» cuando entres.";
|
|
667
|
+
informar(aviso);
|
|
668
|
+
}
|
|
669
|
+
if (noProcede || cuentaHecha)
|
|
670
|
+
return;
|
|
671
|
+
if (cuentaEnCurso === undefined) {
|
|
672
|
+
cuentaEnCurso = vestibulo
|
|
673
|
+
.pasoDeCuenta()
|
|
674
|
+
.then((resultado) => {
|
|
675
|
+
// «Hecho» solo si de verdad se resolvió. `cancelado` es lo que devuelve el
|
|
676
|
+
// asistente cuando ya no queda nadie a quien preguntar —con `exigirEleccion`
|
|
677
|
+
// no sale por cancelar, solo por `eof()`—, o sea el silencio de una pestaña
|
|
678
|
+
// que se fue a mitad del selector: la SIGUIENTE conexión tiene que poder
|
|
679
|
+
// intentarlo de verdad, no heredar un «ya se preguntó» que nadie contestó.
|
|
680
|
+
//
|
|
681
|
+
// `sin-preguntar` SÍ cuenta como hecho: significa que no había nada que
|
|
682
|
+
// preguntar (la piel no tiene selector, o ya había elección). Tratarlo como
|
|
683
|
+
// pendiente dejaría fuera para siempre a quien no puede contestar.
|
|
684
|
+
if (resultado !== "cancelado")
|
|
685
|
+
cuentaHecha = true;
|
|
686
|
+
})
|
|
687
|
+
.finally(() => {
|
|
688
|
+
cuentaEnCurso = undefined;
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
await cuentaEnCurso;
|
|
692
|
+
};
|
|
693
|
+
const contar = (error) => {
|
|
694
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Quien entra DIRECTO al Dashboard (las tres condiciones ya cumplidas) se salta el paso
|
|
698
|
+
* "entorno" del wizard entero, y con él la única línea que hasta ahora rellenaba
|
|
699
|
+
* `proyectos` (`atenderAlta`, más abajo). Sin esto la barra se quedaba con "Sin
|
|
700
|
+
* proyectos que enseñar aquí todavía" aunque el entorno estuviera registrado de sobra —
|
|
701
|
+
* la puerta que se acaba de abrir dejaba al usuario dentro y sin nada que hacer, peor
|
|
702
|
+
* que el wizard que se quitó. Se resuelve el PRIMERO de `entornosRegistrados()`: la
|
|
703
|
+
* misma asunción que ya hace `App.tsx` del lado cliente para `entornoActivo`, con el
|
|
704
|
+
* mismo motivo — no hay señal de «cuál es el activo» cuando hay más de uno registrado.
|
|
705
|
+
*
|
|
706
|
+
* `entornoElegido` SOLO se fija si `proyectosDe` sale bien: con un token muerto sin
|
|
707
|
+
* refresco o la red caída, la siguiente reconexión tiene que poder reintentarlo, no
|
|
708
|
+
* heredar un «ya se intentó» que se quedó en `proyectos: []` para siempre. Esto puede
|
|
709
|
+
* abrir el navegador de verdad si el token necesita reautenticar —`conectarCloudStudio`
|
|
710
|
+
* ya lo hace así—, y es lo correcto: un token vivo no toca el puerto de callback en
|
|
711
|
+
* absoluto (arreglado en `agent/cloudstudioMcp.ts#abrirCliente`), así que dos conexiones
|
|
712
|
+
* seguidas no chocan por intentarlo cada una.
|
|
713
|
+
*/
|
|
714
|
+
const poblarProyectosSiProcede = async () => {
|
|
715
|
+
if (vestibulo.proyectoAbierto() !== undefined)
|
|
716
|
+
return;
|
|
717
|
+
if (entornoElegido !== undefined)
|
|
718
|
+
return;
|
|
719
|
+
const [primero] = vestibulo.entornosRegistrados();
|
|
720
|
+
if (primero === undefined)
|
|
721
|
+
return;
|
|
722
|
+
try {
|
|
723
|
+
proyectos = await vestibulo.proyectosDe(primero.id);
|
|
724
|
+
entornoElegido = primero.id;
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
aviso = error instanceof Error ? error.message : String(error);
|
|
728
|
+
contar(error);
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
/**
|
|
732
|
+
* «Dime qué sirve este proveedor.» Una llamada de red por proveedor, cacheada, y el
|
|
733
|
+
* fallo de uno se guarda como suyo: el menú lo lista inservible y los demás siguen
|
|
734
|
+
* elegibles. Nunca lanza — quien pide un catálogo no puede tumbar el cable.
|
|
735
|
+
*/
|
|
736
|
+
/**
|
|
737
|
+
* La foto de la máquina y UNA detección en vuelo como mucho. Dos pestañas que conectan a
|
|
738
|
+
* la vez no lanzan dos rondas de adb y xcrun: la segunda se engancha a la promesa de la
|
|
739
|
+
* primera. Y la respuesta va a TODOS —la máquina es la misma para todos—.
|
|
740
|
+
*/
|
|
741
|
+
let informeDeDispositivos;
|
|
742
|
+
/** La ruta de cada herramienta es una ruta del home del usuario: no sale por el cable. */
|
|
743
|
+
const sinRutas = (informe) => ({
|
|
744
|
+
...informe,
|
|
745
|
+
herramientas: informe.herramientas.map(({ ruta: _ruta, ...resto }) => resto),
|
|
746
|
+
});
|
|
747
|
+
/** Los cuatro nombres conocidos y nada más: lo que llega por el cable no elige binario. */
|
|
748
|
+
const esNombreDeHerramienta = (v) => v === "adb" || v === "emulator" || v === "xcrun" || v === "devicectl";
|
|
749
|
+
const mensajeDeTareas = () => {
|
|
750
|
+
if (opciones.colaDeTareas === undefined)
|
|
751
|
+
return undefined;
|
|
752
|
+
const otro = opciones.corredorDeTareas?.ejecutaOtroProceso?.();
|
|
753
|
+
return {
|
|
754
|
+
clase: "tareas",
|
|
755
|
+
lista: opciones.colaDeTareas.listar().map(filaDeTarea),
|
|
756
|
+
concurrencia: opciones.concurrenciaDeTareas?.() ?? CONCURRENCIA_POR_OMISION,
|
|
757
|
+
corriendoAqui: opciones.corredorDeTareas?.corriendoAqui() ?? false,
|
|
758
|
+
// Ausente = no se sabe, y no se sintetiza: con `false` a la mínima, la pantalla diría
|
|
759
|
+
// «nadie las ejecuta» de una máquina donde sí las ejecuta otro proceso.
|
|
760
|
+
...(otro === undefined ? {} : { ejecutaOtroProceso: otro }),
|
|
761
|
+
};
|
|
762
|
+
};
|
|
763
|
+
const emitirTareas = () => {
|
|
764
|
+
const m = mensajeDeTareas();
|
|
765
|
+
if (m !== undefined)
|
|
766
|
+
emitir(m);
|
|
767
|
+
};
|
|
768
|
+
/**
|
|
769
|
+
* Un id de proyecto a su tripleta completa, o nada si no se puede resolver.
|
|
770
|
+
*
|
|
771
|
+
* La MISMA resolución que `atenderSesion` ya hace para abrir un proyecto por id
|
|
772
|
+
* (`proyectos.find` + `vestibulo.raizDeProyecto`): crear una tarea necesita la raíz para
|
|
773
|
+
* poder ejecutarla algún día, y esa raíz es una ruta de la máquina que el cliente nunca
|
|
774
|
+
* ha mandado — solo manda el id. Sin entorno elegido, o con un id que no está en la
|
|
775
|
+
* lista vigente de `proyectos`, no hay tripleta que resolver: falla CERRADO, nunca se
|
|
776
|
+
* inventa una raíz.
|
|
777
|
+
*/
|
|
778
|
+
const proyectoParaTarea = (id) => {
|
|
779
|
+
if (entornoElegido === undefined)
|
|
780
|
+
return undefined;
|
|
781
|
+
const identidad = proyectos.find((p) => p.id === id);
|
|
782
|
+
if (identidad === undefined)
|
|
783
|
+
return undefined;
|
|
784
|
+
try {
|
|
785
|
+
return { id: identidad.id, raiz: vestibulo.raizDeProyecto(entornoElegido, identidad.nombre), nombre: identidad.nombre };
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
// `entornoElegido` dejó de estar registrado entre medias: no se sabe la raíz.
|
|
789
|
+
return undefined;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* Crea una tarea `nueva`. Vive aquí y no en una opción de fuera por lo que documenta
|
|
794
|
+
* `OpcionesDeMontaje.colaDeTareas`: la resolución de `proyecto` necesita el estado de
|
|
795
|
+
* este cierre. Sin proyecto resoluble no se escribe nada, y se DICE — la misma regla que
|
|
796
|
+
* una transición imposible: no se falla en silencio.
|
|
797
|
+
*/
|
|
798
|
+
const atenderCrearTarea = (proyectoId, peticion, encargo,
|
|
799
|
+
/**
|
|
800
|
+
* El id del BORRADOR bajo el que el navegador subió los adjuntos, si subió alguno.
|
|
801
|
+
*
|
|
802
|
+
* Los bytes viajan por `POST /adjunto` ANTES de que la tarea exista —hay que tenerlos en
|
|
803
|
+
* disco antes de encolarla, porque crear dispara `revisarTareas()` y el corredor puede
|
|
804
|
+
* arrancarla en el acto—, así que el cliente elige un id y este es el momento de
|
|
805
|
+
* ADOPTARLO. Lo que hace que eso sea seguro son las dos guardas de abajo, y las mismas
|
|
806
|
+
* que la propia ruta de subida aplica: forma de segmento llano, y que no sea ya una
|
|
807
|
+
* tarea.
|
|
808
|
+
*/
|
|
809
|
+
borrador) => {
|
|
810
|
+
if (opciones.colaDeTareas === undefined)
|
|
811
|
+
return undefined;
|
|
812
|
+
const proyecto = proyectoParaTarea(proyectoId);
|
|
813
|
+
if (proyecto === undefined) {
|
|
814
|
+
informar(`no se pudo crear la tarea: el proyecto «${proyectoId}» no se pudo resolver`);
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
const lista = opciones.colaDeTareas.listar();
|
|
818
|
+
if (borrador !== undefined && !esBorradorLibre(borrador, lista)) {
|
|
819
|
+
// **No se cae a un id nuevo en silencio**, y esa es la decisión: seguir con un uuid
|
|
820
|
+
// dejaría los adjuntos que la persona acaba de subir colgando de una carpeta que
|
|
821
|
+
// ninguna tarea nombra — encolaría el trabajo sin ellos y sin decirlo.
|
|
822
|
+
informar("no se pudo crear la tarea: ese borrador de adjuntos no vale (o ya es una tarea)");
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
const id = borrador ?? randomUUID();
|
|
826
|
+
const nueva = {
|
|
827
|
+
id,
|
|
828
|
+
proyecto,
|
|
829
|
+
titulo: tituloDeTarea(peticion),
|
|
830
|
+
peticion,
|
|
831
|
+
encargo,
|
|
832
|
+
// Del DISCO y no de lo que diga el cliente: es la única fuente que sabe qué llegó de
|
|
833
|
+
// verdad y cuánto pesa. Ver `TareasEnDisco.listarAdjuntos`.
|
|
834
|
+
adjuntos: opciones.colaDeTareas.listarAdjuntos(id),
|
|
835
|
+
estado: "nuevo",
|
|
836
|
+
creada: new Date().toISOString(),
|
|
837
|
+
};
|
|
838
|
+
opciones.colaDeTareas.guardar([...lista, nueva]);
|
|
839
|
+
return { id: nueva.id };
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* ¿Se puede adoptar ese id de borrador? Segmento llano Y que no sea ya una tarea.
|
|
843
|
+
*
|
|
844
|
+
* Las dos mitades son la misma regla que la ruta de subida: el id lo elige el CLIENTE, así
|
|
845
|
+
* que la forma es lo que evita que se salga de la carpeta de la cola, y la segunda evita
|
|
846
|
+
* que un borrador aterrice sobre una tarea viva — una que el corredor puede estar
|
|
847
|
+
* ejecutando ahora mismo con su `/adjuntos/` montada.
|
|
848
|
+
*/
|
|
849
|
+
const esBorradorLibre = (id, lista) => nombreDeAdjuntoAceptable(id) && !lista.some((t) => t.id === id);
|
|
850
|
+
/**
|
|
851
|
+
* La augmentación: una llamada al modelo, bajo demanda, con el proyecto RESUELTO.
|
|
852
|
+
*
|
|
853
|
+
* Vive aquí y no en el despachador de mensajes por lo mismo que `atenderCrearTarea`: la
|
|
854
|
+
* resolución de `{id, raiz, nombre}` necesita el estado de este cierre, y el cliente solo
|
|
855
|
+
* manda el id. Sin proyecto resoluble no se pregunta a nadie: se contesta el error, que es
|
|
856
|
+
* lo que la ventana pinta — nunca se inventa una raíz.
|
|
857
|
+
*/
|
|
858
|
+
const atenderAugmentar = async (augmentar, proyectoId, texto, borrador) => {
|
|
859
|
+
const proyecto = proyectoParaTarea(proyectoId);
|
|
860
|
+
if (proyecto === undefined) {
|
|
861
|
+
emitir({
|
|
862
|
+
clase: "tarea",
|
|
863
|
+
accion: "augmentado",
|
|
864
|
+
error: `el proyecto «${proyectoId}» no se pudo resolver`,
|
|
865
|
+
});
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
// Los adjuntos ya subidos, por NOMBRE y tipo. Del disco, igual que al crear.
|
|
869
|
+
const adjuntos = borrador === undefined || opciones.colaDeTareas === undefined || !nombreDeAdjuntoAceptable(borrador)
|
|
870
|
+
? []
|
|
871
|
+
: opciones.colaDeTareas
|
|
872
|
+
.listarAdjuntos(borrador)
|
|
873
|
+
.map((a) => ({ nombre: a.nombre, ...(a.mime === undefined ? {} : { mime: a.mime }) }));
|
|
874
|
+
try {
|
|
875
|
+
const encargo = await augmentar({ texto, proyecto, adjuntos });
|
|
876
|
+
emitir({ clase: "tarea", accion: "augmentado", encargo });
|
|
877
|
+
}
|
|
878
|
+
catch (error) {
|
|
879
|
+
// **El MENSAJE si lo escribimos nosotros, el CÓDIGO si lo escribió el sistema**, que
|
|
880
|
+
// es la regla de `corredorDeTareas.ts#sinRutas`. Esto era `codigoDe(error)` a secas, y
|
|
881
|
+
// para un `ErrorDelAumentador` eso devuelve su `name`: la ventana enseñaba «No se pudo
|
|
882
|
+
// preparar el encargo (ErrorDelAumentador)», que no dice nada de lo que hay que
|
|
883
|
+
// arreglar. El mensaje de los nuestros está escrito para leerse y no lleva rutas; el de
|
|
884
|
+
// un error de Node sí las lleva, y de ese solo sale el `code`.
|
|
885
|
+
emitir({ clase: "tarea", accion: "augmentado", error: motivoLegible(error) });
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
/**
|
|
889
|
+
* Reintentar, descartar o terminar una tarea existente.
|
|
890
|
+
*
|
|
891
|
+
* **Descartar BORRA** y no comprueba el estado —no hay «cancelada» en `core/tareas.ts`—,
|
|
892
|
+
* ni siquiera si está `en-proceso`. Reintentar y terminar pasan por `conEstado`, que LANZA
|
|
893
|
+
* ante una transición imposible; aquí se atrapa y se DICE con `informar` — nunca se
|
|
894
|
+
* propaga al cliente, y nunca se escribe nada a medias.
|
|
895
|
+
*
|
|
896
|
+
* **Descartar CORTA el turno en vuelo ANTES de borrar (Task 14).** Antes de esto, el
|
|
897
|
+
* comentario de aquí decía que «el corredor ya cuenta con que una tarea corriendo se
|
|
898
|
+
* descarte por debajo» — y era cierto que CONTABA con ello (`corredorDeTareas.ts#revisar`
|
|
899
|
+
* no se rompía), pero contarlo no es lo mismo que MANEJARLO: el turno seguía corriendo de
|
|
900
|
+
* verdad, escribiendo en el proyecto de alguien sin que ninguna pantalla lo dijera, y su
|
|
901
|
+
* carpeta de adjuntos —montada viva como `/adjuntos/` para ese turno— se borraba en el
|
|
902
|
+
* acto por debajo suyo. `Corredor.cortar(id)` (medido y cableado en `corredorDeTareas.ts`,
|
|
903
|
+
* reusando `entrada.cortar`, lo mismo que ya usa `parar()`) se espera ANTES de
|
|
904
|
+
* `borrarTarea`, así que la carpeta de adjuntos solo se toca DESPUÉS de que la consola
|
|
905
|
+
* haya soltado su montaje — nunca antes: quitarle el suelo a un turno vivo es un fallo por
|
|
906
|
+
* sí solo, con independencia de todo lo demás.
|
|
907
|
+
*
|
|
908
|
+
* **Y si no se puede cortar a tiempo, NO se borra.** `Corredor.cortar` devuelve `false`
|
|
909
|
+
* cuando la tarea SÍ corría aquí y no soltó el proyecto dentro del plazo — la misma
|
|
910
|
+
* situación que `parar()` ya sabe contar sin mentir. Borrar de todos modos dejaría el
|
|
911
|
+
* turno huérfano exactamente igual que antes de este arreglo, solo que con menos excusa:
|
|
912
|
+
* se prefiere el aviso honesto («sigue en marcha, reinténtalo») a un corte que promete
|
|
913
|
+
* haber parado algo que no paró.
|
|
914
|
+
*
|
|
915
|
+
* **Y si corre en OTRO proceso, tampoco.** `corredorDeTareas?.corriendoAqui() === false`
|
|
916
|
+
* con la tarea `en-proceso` en disco solo puede significar eso —ESTE proceso solo sirve el
|
|
917
|
+
* dashboard, y el que de verdad la ejecuta no está aquí para preguntarle—; `Corredor.cortar`
|
|
918
|
+
* no tiene con qué alcanzarlo (el límite lo pone el sistema operativo, no esta función), así
|
|
919
|
+
* que forzar el borrado sería la misma orfandad de antes, disfrazada de arreglada. Es la
|
|
920
|
+
* MISMA regla que ya sigue `Vestibulo.borrarSesion` con la sesión de una tarea en curso:
|
|
921
|
+
* declinar con el motivo, no matar un turno ajeno porque alguien limpió una fila.
|
|
922
|
+
*/
|
|
923
|
+
/**
|
|
924
|
+
* El transcript de una tarea, etiquetado con SU id.
|
|
925
|
+
*
|
|
926
|
+
* La etiqueta no es decoración: por este mismo cable llega el transcript de la sesión
|
|
927
|
+
* propia (`acto`, `sustitucion`, `reemision`), así que sin ella los actos de una tarea de
|
|
928
|
+
* fondo se mezclarían con la conversación de quien mira. Con `mirada` delante, el cliente
|
|
929
|
+
* los pinta en su panel y en ningún otro sitio.
|
|
930
|
+
*
|
|
931
|
+
* Y es una lista BLANCA por segunda vez: el transporte solo le manda a un mirón el
|
|
932
|
+
* transcript (`Transporte.mirar`), y aquí solo se traduce ese transcript. Una clase nueva
|
|
933
|
+
* del cable no llega a esta pantalla hasta que alguien la nombre en los dos sitios.
|
|
934
|
+
*/
|
|
935
|
+
const etiquetarComoMirada = (tarea, enviar) => (mensaje) => {
|
|
936
|
+
if (mensaje.clase === "acto") {
|
|
937
|
+
enviar({ clase: "mirada", tarea, via: "alta", actos: [mensaje.acto] });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (mensaje.clase === "sustitucion") {
|
|
941
|
+
enviar({ clase: "mirada", tarea, via: "sustitucion", actos: [mensaje.acto] });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (mensaje.clase === "reemision") {
|
|
945
|
+
enviar({ clase: "mirada", tarea, via: "todos", actos: mensaje.actos });
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
/**
|
|
949
|
+
* Empezar o dejar de mirar en vivo lo que hace una tarea.
|
|
950
|
+
*
|
|
951
|
+
* **Es OPT-IN y de SOLO lectura, y las dos cosas son estructurales aquí.** No se abre
|
|
952
|
+
* ningún proyecto, no se muda el cable y no se le pasa NADA a la consola de la tarea: lo
|
|
953
|
+
* único que ocurre es que un sumidero se engancha a su transporte para recibir el
|
|
954
|
+
* transcript que ya se estaba guardando. El mensaje se ataja en `POST /accion` antes del
|
|
955
|
+
* `recibir` de la consola precisamente por eso — si cayera ahí, `correrConsola` podría
|
|
956
|
+
* acabar corriendo un turno sobre la consola de una tarea, y con un mirón enganchado su
|
|
957
|
+
* `eof()` diría que hay alguien a quien preguntar. Medido: `conectar` volvía ese `eof()`
|
|
958
|
+
* falso; `mirar` (`transporte.ts`) es el conjunto aparte que lo evita.
|
|
959
|
+
*
|
|
960
|
+
* Tres silencios a propósito, y ninguno es un error que contar:
|
|
961
|
+
* - **Un `cliente` que no consta**: es una pestaña que ya se fue, o un id viejo tras una
|
|
962
|
+
* reconexión. Un fallo de lookup.
|
|
963
|
+
* - **Una tarea que no corre AQUÍ** (`mirar` devuelve `undefined`): ya terminó, o la
|
|
964
|
+
* ejecuta el otro proceso. No se emite `{actos: []}`, que diría «corre y no ha hecho
|
|
965
|
+
* nada»; lo que esa tarea sí es ya lo cuenta el mensaje de la cola.
|
|
966
|
+
* - **Mirar dos veces lo mismo**: idempotente. Un doble clic o un efecto que se dispare
|
|
967
|
+
* dos veces no puede dejar dos sumideros del mismo cliente en el mismo turno.
|
|
968
|
+
*/
|
|
969
|
+
const atenderMirar = (mensaje) => {
|
|
970
|
+
const cliente = porIdDeCliente.get(mensaje.cliente);
|
|
971
|
+
if (cliente === undefined)
|
|
972
|
+
return;
|
|
973
|
+
const yaEnganchado = cliente.mirando.get(mensaje.tarea);
|
|
974
|
+
if (!mensaje.ver) {
|
|
975
|
+
if (yaEnganchado === undefined)
|
|
976
|
+
return;
|
|
977
|
+
cliente.mirando.delete(mensaje.tarea);
|
|
978
|
+
// ESE envoltorio y no otro: la otra persona que mire la misma tarea sigue mirándola.
|
|
979
|
+
opciones.corredorDeTareas?.dejarDeMirar?.(mensaje.tarea, yaEnganchado);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (yaEnganchado !== undefined)
|
|
983
|
+
return;
|
|
984
|
+
const envoltorio = etiquetarComoMirada(mensaje.tarea, cliente.enviar);
|
|
985
|
+
const actos = opciones.corredorDeTareas?.mirar?.(mensaje.tarea, envoltorio);
|
|
986
|
+
if (actos === undefined)
|
|
987
|
+
return;
|
|
988
|
+
cliente.mirando.set(mensaje.tarea, envoltorio);
|
|
989
|
+
// El transcript de ese instante, SOLO al que lo pidió — el corredor lo devuelve en vez
|
|
990
|
+
// de emitirlo, igual que `conectar`, para no mandárselo a quien ya lo tenga.
|
|
991
|
+
cliente.enviar({ clase: "mirada", tarea: mensaje.tarea, via: "todos", actos: [...actos] });
|
|
992
|
+
};
|
|
993
|
+
const atenderAccionDeTarea = async (accion, id) => {
|
|
994
|
+
if (opciones.colaDeTareas === undefined)
|
|
995
|
+
return;
|
|
996
|
+
if (accion === "descartar") {
|
|
997
|
+
const actual = opciones.colaDeTareas.listar().find((t) => t.id === id);
|
|
998
|
+
if (actual?.estado === "en-proceso" && opciones.corredorDeTareas?.corriendoAqui() === false) {
|
|
999
|
+
informar(`no se pudo descartar «${actual.titulo}»: su turno lo ejecuta otro proceso, y no se puede cortar desde aquí`);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
// Ausente = no hay corredor cableado en esta ejecución, y entonces no hay ningún
|
|
1003
|
+
// turno que pueda estar corriendo: seguro proceder, la misma lectura que «no había
|
|
1004
|
+
// nada en vuelo» dentro del propio corredor.
|
|
1005
|
+
const cortada = (await opciones.corredorDeTareas?.cortar(id)) ?? true;
|
|
1006
|
+
if (!cortada) {
|
|
1007
|
+
informar(`no se pudo descartar «${actual?.titulo ?? id}»: su turno no soltó el proyecto a tiempo — sigue en marcha, reinténtalo`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
opciones.colaDeTareas.borrarTarea(id);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const lista = opciones.colaDeTareas.listar();
|
|
1014
|
+
const actual = lista.find((t) => t.id === id);
|
|
1015
|
+
if (actual === undefined) {
|
|
1016
|
+
informar(`no se pudo ${accion}: la tarea ya no está en la cola`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
// **Terminar a mano pasa por `darPorBuenaAMano` y no por `conEstado` a secas**, y no
|
|
1021
|
+
// es cosmético: es la CUARTA forma de llegar a «Terminada» —sin verificador y sin
|
|
1022
|
+
// juez—, `conEstado` borra el `motivo`, y sin la marca la tarjeta resultante es
|
|
1023
|
+
// indistinguible de una entrega por la puerta completa. Ver `Tarea.terminadaAMano`.
|
|
1024
|
+
const siguiente = accion === "reintentar" ? conEstado(actual, "nuevo") : darPorBuenaAMano(actual);
|
|
1025
|
+
opciones.colaDeTareas.guardar(lista.map((t) => (t.id === id ? siguiente : t)));
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
// Una transición imposible SE IGNORA Y SE DICE: nunca se lanza hacia el cliente, y
|
|
1029
|
+
// nunca se escribe un estado a medias.
|
|
1030
|
+
informar(`no se pudo ${accion} la tarea «${actual.titulo}»: ${error instanceof Error ? error.message : String(error)}`);
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
/**
|
|
1034
|
+
* «Se edita la tarea y se agrega el feedback del usuario» (§0 del diseño, textual):
|
|
1035
|
+
* añadir un feedback a una tarea «esperando feedback» la devuelve al lazo. Vive detrás de
|
|
1036
|
+
* `aplicarFeedback` (`agent/tareasEnDisco.ts`) y no repite su lógica aquí, por el mismo
|
|
1037
|
+
* motivo que `atenderCrearTarea`/`atenderAccionDeTarea` no reimplementan `conEstado`: la
|
|
1038
|
+
* regla de qué feedback vale y qué transición es legal está en una sola función, probada
|
|
1039
|
+
* sola y sin necesitar un servidor de mentira alrededor.
|
|
1040
|
+
*/
|
|
1041
|
+
const atenderFeedbackDeTarea = (id, texto) => {
|
|
1042
|
+
if (opciones.colaDeTareas === undefined)
|
|
1043
|
+
return;
|
|
1044
|
+
const resultado = aplicarFeedback(opciones.colaDeTareas, id, texto);
|
|
1045
|
+
if (!resultado.hecho) {
|
|
1046
|
+
// Nunca se propaga al cliente, igual que una transición imposible: se DICE, y no se
|
|
1047
|
+
// escribe nada a medias.
|
|
1048
|
+
informar(`no se pudo añadir el feedback: ${resultado.motivo ?? "motivo desconocido"}`);
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
/** Los ajustes de destino, o `{}` —que es «se miran todos»— si esta ejecución no los lee. */
|
|
1052
|
+
const ajustesDeDispositivos = () => opciones.ajustesDeDispositivos?.() ?? {};
|
|
1053
|
+
let deteccionEnVuelo;
|
|
1054
|
+
const atenderDispositivos = () => {
|
|
1055
|
+
if (opciones.detectarDispositivos === undefined)
|
|
1056
|
+
return Promise.resolve();
|
|
1057
|
+
if (deteccionEnVuelo !== undefined)
|
|
1058
|
+
return deteccionEnVuelo;
|
|
1059
|
+
const detectar = opciones.detectarDispositivos;
|
|
1060
|
+
deteccionEnVuelo = (async () => {
|
|
1061
|
+
try {
|
|
1062
|
+
informeDeDispositivos = sinRutas(await detectar());
|
|
1063
|
+
emitir({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
// El detector reparte los fallos por herramienta y no lanza por ninguno; que
|
|
1067
|
+
// reviente entero es un bug suyo. Aquí solo se cuenta en el terminal: el escritorio
|
|
1068
|
+
// se queda en «consultando…» (o el botón en «Mirando…»), que es la verdad —no llegó
|
|
1069
|
+
// ninguna foto—, y no se emite una máquina vacía para disimularlo.
|
|
1070
|
+
informar(`no se pudo mirar qué dispositivos hay: ${error instanceof Error ? error.message : String(error)}`);
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
deteccionEnVuelo = undefined;
|
|
1074
|
+
}
|
|
1075
|
+
})();
|
|
1076
|
+
return deteccionEnVuelo;
|
|
1077
|
+
};
|
|
1078
|
+
/**
|
|
1079
|
+
* Verificar la conexión con UN dispositivo, y volver a emitir la foto con lo que contestó.
|
|
1080
|
+
*
|
|
1081
|
+
* Cuatro reglas:
|
|
1082
|
+
* - **El id se resuelve contra la última MEDIDA**, igual que al elegir dispositivo de la
|
|
1083
|
+
* sesión: lo que se le pasa al verificador es el `Dispositivo` que el host midió, no la
|
|
1084
|
+
* cadena que llegó. Un id que no está se ignora en silencio — es una foto vieja del
|
|
1085
|
+
* cliente (desenchufaron el teléfono entre medias), no un error que contar.
|
|
1086
|
+
* - **NO se vuelve a medir.** La verificación vive dentro del informe, así que una medida
|
|
1087
|
+
* nueva se la llevaría — justo la que se acaba de hacer.
|
|
1088
|
+
* - **Solo se toca ESE dispositivo**: las verificaciones de los demás siguen donde
|
|
1089
|
+
* estaban. Se emite el informe entero porque es el mensaje que hay.
|
|
1090
|
+
* - **Y viaja a todos los clientes**, como la foto: la máquina es la misma para todos.
|
|
1091
|
+
*/
|
|
1092
|
+
const atenderConexion = async (id) => {
|
|
1093
|
+
const verificar = opciones.verificarDispositivo;
|
|
1094
|
+
if (verificar === undefined || informeDeDispositivos === undefined)
|
|
1095
|
+
return;
|
|
1096
|
+
const dispositivo = informeDeDispositivos.dispositivos.find((d) => d.id === id);
|
|
1097
|
+
if (dispositivo === undefined)
|
|
1098
|
+
return;
|
|
1099
|
+
let resultado;
|
|
1100
|
+
try {
|
|
1101
|
+
resultado = await verificar(dispositivo);
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
// El verificador no lanza por diseño; que lo haga es un bug suyo, y aun así la
|
|
1105
|
+
// respuesta tiene que ser una respuesta: se dice que no se pudo, sin la ruta de nada.
|
|
1106
|
+
resultado = { ok: false, detalle: `no se pudo verificar (${codigoDe(error)})` };
|
|
1107
|
+
}
|
|
1108
|
+
const verificado = { ...resultado, medido: new Date().toISOString() };
|
|
1109
|
+
if (informeDeDispositivos === undefined)
|
|
1110
|
+
return;
|
|
1111
|
+
informeDeDispositivos = {
|
|
1112
|
+
...informeDeDispositivos,
|
|
1113
|
+
dispositivos: informeDeDispositivos.dispositivos.map((d) => (d.id === id ? { ...d, verificado } : d)),
|
|
1114
|
+
};
|
|
1115
|
+
emitir({ clase: "dispositivos", informe: informeDeDispositivos, ajustes: ajustesDeDispositivos() });
|
|
1116
|
+
};
|
|
1117
|
+
const atenderCatalogo = async (proveedor) => {
|
|
1118
|
+
if (!proveedorConocido(proveedor))
|
|
1119
|
+
return;
|
|
1120
|
+
const id = proveedor;
|
|
1121
|
+
if (opciones.catalogoDeModelos === undefined) {
|
|
1122
|
+
catalogos.set(id, { error: "esta ejecución no puede consultar catálogos de modelos" });
|
|
1123
|
+
emitirModelos();
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
try {
|
|
1127
|
+
const modelos = await opciones.catalogoDeModelos(id);
|
|
1128
|
+
catalogos.set(id, { modelos: modelos.map((m) => ({ id: m.id, ...(m.nombre === undefined ? {} : { nombre: m.nombre }) })) });
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
// `ErrorCatalogoModelos` es publicable por contrato: nunca lleva la clave ni el
|
|
1132
|
+
// cuerpo remoto (`agent/catalogoModelos.ts`).
|
|
1133
|
+
catalogos.set(id, { error: error instanceof Error ? error.message : String(error) });
|
|
1134
|
+
}
|
|
1135
|
+
emitirModelos();
|
|
1136
|
+
};
|
|
1137
|
+
/**
|
|
1138
|
+
* Prueba, UNA vez por proceso, los proveedores que no llevan clave.
|
|
1139
|
+
*
|
|
1140
|
+
* «¿Puedo usar Ollama?» no la contesta ninguna credencial —no lleva ninguna—: la contesta
|
|
1141
|
+
* si el demonio responde, y eso solo se sabe pidiéndole el catálogo. Sin esto, el
|
|
1142
|
+
* proveedor por OMISIÓN de esta consola no aparecería nunca entre los comprobados, que es
|
|
1143
|
+
* lo que la pastilla enseña.
|
|
1144
|
+
*
|
|
1145
|
+
* **Y solo esos.** La regla de «el catálogo se pide bajo demanda» está escrita porque cada
|
|
1146
|
+
* uno es una llamada de red; aquí la excepción se gana sola: Ollama es `localhost` y un
|
|
1147
|
+
* personalizado sin clave es el endpoint que el usuario levantó en su máquina. A los de
|
|
1148
|
+
* pago no se les pregunta al arrancar — ahí la credencial ya responde.
|
|
1149
|
+
*
|
|
1150
|
+
* Una vez por PROCESO y no por cliente: la respuesta se guarda en `catalogos`, y una
|
|
1151
|
+
* segunda pestaña no vuelve a llamar. Y no se espera: el resultado llega a todos por el
|
|
1152
|
+
* SSE cuando esté, igual que la foto de la máquina.
|
|
1153
|
+
*/
|
|
1154
|
+
let comprobados = false;
|
|
1155
|
+
const comprobarLosSinClave = () => {
|
|
1156
|
+
if (comprobados || opciones.catalogoDeModelos === undefined)
|
|
1157
|
+
return;
|
|
1158
|
+
comprobados = true;
|
|
1159
|
+
for (const p of [...PROVEEDORES, ...idsPersonalizados()]) {
|
|
1160
|
+
if (credencialDe(p) === "puesta" || catalogos.has(p))
|
|
1161
|
+
continue;
|
|
1162
|
+
// Los de serie que no llevan clave (`SIN_CREDENCIAL`) y los personalizados a los que
|
|
1163
|
+
// nadie se la ha puesto: en los dos casos, preguntar es la única forma de saberlo.
|
|
1164
|
+
if (!SIN_CREDENCIAL.has(p) && !esProveedorPersonalizado(p))
|
|
1165
|
+
continue;
|
|
1166
|
+
void atenderCatalogo(p).catch(contar);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
/**
|
|
1170
|
+
* Las sesiones guardadas de un proyecto de este entorno. Sin entorno elegido no hay raíz
|
|
1171
|
+
* que calcular, y sin copia local la lista es vacía — que es la verdad, no un fallo.
|
|
1172
|
+
*/
|
|
1173
|
+
/**
|
|
1174
|
+
* Qué sesiones tienen un turno en marcha AHORA, por su id.
|
|
1175
|
+
*
|
|
1176
|
+
* Se pregunta a las consolas vivas y no se guarda en ninguna parte: es un estado de este
|
|
1177
|
+
* instante, y una copia se quedaría diciendo que una sesión trabaja después de que su
|
|
1178
|
+
* turno acabara. Solo entran las que tienen `sesion` —o sea entrada en el índice—, porque
|
|
1179
|
+
* una fila que la barra no pinta no se puede marcar; la de un primer turno que aún no ha
|
|
1180
|
+
* volcado nada aparecerá en cuanto exista, que es el mismo retraso que ya tiene su fila.
|
|
1181
|
+
*/
|
|
1182
|
+
const sesionesTrabajando = () => {
|
|
1183
|
+
const trabajando = new Set();
|
|
1184
|
+
for (const consola of vestibulo.proyectosAbiertos()) {
|
|
1185
|
+
if (consola.turnoEnVuelo && consola.sesion !== undefined)
|
|
1186
|
+
trabajando.add(consola.sesion);
|
|
1187
|
+
}
|
|
1188
|
+
return trabajando;
|
|
1189
|
+
};
|
|
1190
|
+
/**
|
|
1191
|
+
* Y las RAÍCES con un turno en marcha, que no es la misma pregunta.
|
|
1192
|
+
*
|
|
1193
|
+
* Una sesión nueva no tiene fila en el índice hasta que vuelca su primer acto, así que el
|
|
1194
|
+
* caso más común —abrir, pedir algo, irse a otro proyecto— no tiene ninguna sesión que
|
|
1195
|
+
* marcar y la barra no enseñaría nada. La raíz sí se sabe desde el primer instante.
|
|
1196
|
+
*/
|
|
1197
|
+
const raicesTrabajando = () => new Set(vestibulo.proyectosAbiertos().filter((c) => c.turnoEnVuelo).map((c) => c.raiz));
|
|
1198
|
+
/**
|
|
1199
|
+
* Las raíces cuya siembra de consumos ya se intentó, para no repetirla en cada alta.
|
|
1200
|
+
*
|
|
1201
|
+
* El alta se anuncia DOS veces por turno y recorre todos los proyectos, así que sin esto
|
|
1202
|
+
* cada anuncio releería el índice de cada proyecto y mediría sus `.jsonl` — un trabajo
|
|
1203
|
+
* síncrono en el bucle de eventos, multiplicado por el número de proyectos, dos veces por
|
|
1204
|
+
* turno. Una raíz se intenta UNA vez por proceso, que es lo que basta: lo que la siembra
|
|
1205
|
+
* deja sin rellenar lo rellena `anotarActo` en cuanto esa sesión se usa.
|
|
1206
|
+
*/
|
|
1207
|
+
const raicesSembradas = new Set();
|
|
1208
|
+
const sesionesDelProyecto = (nombre) => {
|
|
1209
|
+
if (entornoElegido === undefined)
|
|
1210
|
+
return [];
|
|
1211
|
+
try {
|
|
1212
|
+
const raiz = vestibulo.raizDeProyecto(entornoElegido, nombre);
|
|
1213
|
+
// ANTES de leer la lista, para que este mismo alta ya lleve las cifras de las sesiones
|
|
1214
|
+
// viejas: la siembra escribe el índice y `sesionesDe` lo relee, así que no hace falta
|
|
1215
|
+
// ningún reanuncio — el anuncio que la dispara es el que las enseña.
|
|
1216
|
+
if (!raicesSembradas.has(raiz)) {
|
|
1217
|
+
raicesSembradas.add(raiz);
|
|
1218
|
+
try {
|
|
1219
|
+
sembrarConsumosPendientes(raiz);
|
|
1220
|
+
}
|
|
1221
|
+
catch {
|
|
1222
|
+
// Sembrar es PRESENTACIÓN: lo peor que puede pasar es que una sesión vieja no
|
|
1223
|
+
// enseñe cifra hasta su próximo turno. Blankear la barra entera por un índice roto
|
|
1224
|
+
// sería convertir un adorno que falta en una lista que no está.
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
// Viaja un BOOLEANO y no el id de la tarea: la fila lleva una marca, no el nombre de
|
|
1228
|
+
// la tarea —en 280 px no cabe—, así que el id se queda en el host por la misma regla
|
|
1229
|
+
// que la ruta de una herramienta o el pid del corredor. `deTarea` ausente es «no
|
|
1230
|
+
// consta» y no «es una conversación»: no la lleva ninguna sesión anterior a la marca.
|
|
1231
|
+
const trabajando = sesionesTrabajando();
|
|
1232
|
+
return vestibulo.sesionesDe(raiz).map((s) => ({
|
|
1233
|
+
id: s.id,
|
|
1234
|
+
titulo: s.titulo,
|
|
1235
|
+
...(s.ultimoTurno === undefined ? {} : { ultimoTurno: s.ultimoTurno }),
|
|
1236
|
+
...(s.tarea === undefined ? {} : { deTarea: true }),
|
|
1237
|
+
...(trabajando.has(s.id) ? { trabajando: true } : {}),
|
|
1238
|
+
...(s.consumo === undefined ? {} : { consumo: s.consumo }),
|
|
1239
|
+
}));
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
return [];
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
/**
|
|
1246
|
+
* ¿Existe ya la copia local de ese proyecto? Es `.xonecode/config.json` en su raíz: lo
|
|
1247
|
+
* que `completarProyecto` escribe ENTERO antes de bajar nada.
|
|
1248
|
+
*
|
|
1249
|
+
* El predicado es el del vestíbulo (`esProyectoEnDisco`) y no una copia: `abrirParaTarea`
|
|
1250
|
+
* decide con él si una tarea puede abrir esa raíz, y dos versiones de «esto es un
|
|
1251
|
+
* proyecto» divergen el día que una se afine — con el alta diciendo que hay copia local
|
|
1252
|
+
* y la puerta de tareas diciendo que no.
|
|
1253
|
+
*/
|
|
1254
|
+
const hayCopiaLocal = (nombre) => {
|
|
1255
|
+
if (entornoElegido === undefined)
|
|
1256
|
+
return false;
|
|
1257
|
+
try {
|
|
1258
|
+
return esProyectoEnDisco(vestibulo.raizDeProyecto(entornoElegido, nombre));
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
/**
|
|
1265
|
+
* Cambiar de entorno activo: el de cuyos proyectos se habla. Trae su listado consigo
|
|
1266
|
+
* —eso es una conexión con CloudStudio— y limpia lo del anterior: dejar los proyectos del
|
|
1267
|
+
* entorno viejo bajo el nombre del nuevo sería la peor mentira posible en esta barra.
|
|
1268
|
+
*
|
|
1269
|
+
* Y por eso mismo ANUNCIA, como abrir una sesión: es la misma espera de red sin nada que
|
|
1270
|
+
* pintar en medio, y con un daño de más — el `<select>` de la barra va controlado por el
|
|
1271
|
+
* `alta`, así que durante la espera se quedaba clavado en el entorno VIEJO. Medido en el
|
|
1272
|
+
* navegador: 1.480 ms con el valor viejo y ni una señal. El usuario lo dijo con esas
|
|
1273
|
+
* palabras: «hay un delay pero no mostramos un loading o busy animation en ningún lado».
|
|
1274
|
+
* El campo `entorno` es lo que el cliente necesita para ENSEÑAR el que se ha pedido.
|
|
1275
|
+
*
|
|
1276
|
+
* El anuncio va antes de la primera espera y el apagado en el `finally`, con el alta
|
|
1277
|
+
* DELANTE del flanco de bajada: al revés hay un hueco en el que ya no hay indicador y
|
|
1278
|
+
* todavía no ha llegado el estado nuevo. En el `finally` porque un cambio que falla
|
|
1279
|
+
* —token muerto, red caída— también tiene que apagarlo, y ahí es donde el `select` vuelve
|
|
1280
|
+
* solo al entorno que sigue siendo el de verdad.
|
|
1281
|
+
*/
|
|
1282
|
+
const atenderEntornoActivo = async (entorno) => {
|
|
1283
|
+
aviso = undefined;
|
|
1284
|
+
// Antes de la primera espera, no después: entre elegir y aquí no hay nada que pintar.
|
|
1285
|
+
anunciarAbriendo({ entorno });
|
|
1286
|
+
try {
|
|
1287
|
+
const nuevos = await vestibulo.proyectosDe(entorno);
|
|
1288
|
+
entornoElegido = entorno;
|
|
1289
|
+
proyectoElegido = undefined;
|
|
1290
|
+
ramas = [];
|
|
1291
|
+
proyectos = nuevos;
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
// `entornoElegido` NO se toca si falla: con un token muerto o la red caída, seguir
|
|
1295
|
+
// enseñando lo del entorno anterior es la verdad, y el aviso dice qué pasó.
|
|
1296
|
+
aviso = error instanceof Error ? error.message : String(error);
|
|
1297
|
+
contar(error);
|
|
1298
|
+
}
|
|
1299
|
+
finally {
|
|
1300
|
+
await anunciarAlta().catch(contar);
|
|
1301
|
+
anunciarAbriendo();
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
/**
|
|
1305
|
+
* Los proyectos de UN entorno, SIN hacerlo activo.
|
|
1306
|
+
*
|
|
1307
|
+
* El hermano de `atenderEntornoActivo`, y lo que los separa es todo lo que este NO hace:
|
|
1308
|
+
* no toca `entornoElegido`, ni `proyectos`, ni `ramas`, ni `proyectoElegido`, ni pone
|
|
1309
|
+
* `aviso`. Es una pregunta para las casillas de una pestaña de Ajustes, y mudar el entorno
|
|
1310
|
+
* activo desde ahí le cambiaría la barra —y los proyectos de los que se habla— a quien
|
|
1311
|
+
* esté trabajando en otro servidor. Por eso la aserción que sostiene el diseño en
|
|
1312
|
+
* `arranque.test.ts` es que `alta.entornoActivo` no cambia.
|
|
1313
|
+
*
|
|
1314
|
+
* No se cachea, a propósito: cada apertura de pestaña pregunta. Con caché, la pestaña
|
|
1315
|
+
* podría contradecir a la barra en cuanto alguien cree un proyecto en Studio, y aquí hay
|
|
1316
|
+
* una sola fuente. Es la diferencia con el catálogo de modelos, que sí se cachea porque
|
|
1317
|
+
* la pastilla se abre constantemente y cada consulta es una API de pago.
|
|
1318
|
+
*
|
|
1319
|
+
* El fallo va EN la respuesta y no en el `aviso` global: es de ese entorno, y los demás
|
|
1320
|
+
* siguen usables.
|
|
1321
|
+
*/
|
|
1322
|
+
const atenderProyectosDeEntorno = async (entorno) => {
|
|
1323
|
+
try {
|
|
1324
|
+
const suyos = await vestibulo.proyectosDe(entorno);
|
|
1325
|
+
emitir({
|
|
1326
|
+
clase: "proyectosDeEntorno",
|
|
1327
|
+
entorno,
|
|
1328
|
+
proyectos: suyos.map((p) => ({
|
|
1329
|
+
id: p.id,
|
|
1330
|
+
nombre: p.nombre,
|
|
1331
|
+
...(p.compartido === undefined ? {} : { compartido: p.compartido }),
|
|
1332
|
+
})),
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
catch (error) {
|
|
1336
|
+
contar(error);
|
|
1337
|
+
emitir({
|
|
1338
|
+
clase: "proyectosDeEntorno",
|
|
1339
|
+
entorno,
|
|
1340
|
+
// Sin `proyectos`: ausente es «no se pudo preguntar», que no es una lista vacía.
|
|
1341
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
/**
|
|
1346
|
+
* Abrir una sesión: la nombrada, o una nueva.
|
|
1347
|
+
*
|
|
1348
|
+
* Con copia local ya bajada no hay nada que dar de alta —ni rama que preguntar—, así que
|
|
1349
|
+
* se abre directamente y el cable se muda a su consola. Sin copia local se cae al camino
|
|
1350
|
+
* del alta, que es el único que sabe bajarla: se contestan las ramas y el cliente elige.
|
|
1351
|
+
*/
|
|
1352
|
+
/**
|
|
1353
|
+
* Borrar una sesión guardada, o ponerle nombre.
|
|
1354
|
+
*
|
|
1355
|
+
* La raíz se CALCULA con `raizDeProyecto`, igual que al abrir, y no se toma del proyecto
|
|
1356
|
+
* abierto: se puede borrar una sesión de un proyecto que no es el que se está mirando, y
|
|
1357
|
+
* darlo por hecho borraría en el sitio equivocado. Sin entorno elegido no hay raíz que
|
|
1358
|
+
* calcular y se dice, en vez de escribir a ciegas.
|
|
1359
|
+
*/
|
|
1360
|
+
const atenderAccionDeSesion = async (peticion) => {
|
|
1361
|
+
aviso = undefined;
|
|
1362
|
+
try {
|
|
1363
|
+
if (entornoElegido === undefined) {
|
|
1364
|
+
aviso = "no sé de qué entorno es ese proyecto";
|
|
1365
|
+
informar(aviso);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const identidad = proyectos.find((p) => p.id === peticion.proyecto);
|
|
1369
|
+
const raiz = vestibulo.raizDeProyecto(entornoElegido, identidad?.nombre ?? peticion.proyecto);
|
|
1370
|
+
if (peticion.accion === "borrar") {
|
|
1371
|
+
const { borrada, cerroLaAbierta, motivo } = await vestibulo.borrarSesion(raiz, peticion.sesion);
|
|
1372
|
+
// Un `motivo` es que el vestíbulo DECLINÓ, y entonces se dice ese motivo y no el
|
|
1373
|
+
// «ya no estaba» de siempre: la sesión sigue ahí, y contar lo contrario dejaría al
|
|
1374
|
+
// usuario creyendo que la fila se va a ir del listado. Va además a `aviso`, como
|
|
1375
|
+
// los demás rechazos de este camino.
|
|
1376
|
+
if (motivo !== undefined) {
|
|
1377
|
+
aviso = motivo;
|
|
1378
|
+
informar(motivo);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
// Se dice lo que pasó, incluido el «no había nada»: un menú que borra y calla deja
|
|
1382
|
+
// dudando de si la fila se fue porque se borró o porque falló el listado.
|
|
1383
|
+
informar(borrada
|
|
1384
|
+
? cerroLaAbierta
|
|
1385
|
+
? "sesión borrada; era la que estabas mirando, así que se ha cerrado"
|
|
1386
|
+
: "sesión borrada"
|
|
1387
|
+
: "esa sesión ya no estaba");
|
|
1388
|
+
// Al cerrar la abierta, el cable se queda enganchado a una consola muerta: se muda
|
|
1389
|
+
// de vuelta al vestíbulo, que es lo que el cliente va a pintar (el escritorio).
|
|
1390
|
+
// Y es el TERCER sitio donde cambia qué raíz está bloqueada para las tareas: aquí no
|
|
1391
|
+
// se abre nada, así que la raíz queda LIBRE, y sin revisar la cola una tarea que
|
|
1392
|
+
// esperaba a esa persona se quedaría esperando al siguiente evento que no tiene nada
|
|
1393
|
+
// que ver — que en pantalla se lee como un cuelgue.
|
|
1394
|
+
if (cerroLaAbierta) {
|
|
1395
|
+
adjuntar();
|
|
1396
|
+
opciones.revisarTareas?.();
|
|
1397
|
+
}
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (!vestibulo.renombrarSesion(raiz, peticion.sesion, peticion.titulo)) {
|
|
1401
|
+
aviso = "no se pudo renombrar esa sesión";
|
|
1402
|
+
informar(aviso);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
catch (error) {
|
|
1406
|
+
aviso = error instanceof Error ? error.message : String(error);
|
|
1407
|
+
contar(error);
|
|
1408
|
+
}
|
|
1409
|
+
finally {
|
|
1410
|
+
await anunciarAlta().catch(contar);
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
/**
|
|
1414
|
+
* Los dos flancos de «se está abriendo algo», emitidos como los del turno y por lo mismo:
|
|
1415
|
+
* el cliente no puede saber cuánto tarda esto —de unos cientos de milisegundos a los
|
|
1416
|
+
* minutos de una descarga— y sin señal la interfaz se queda quieta después de un clic.
|
|
1417
|
+
*/
|
|
1418
|
+
/**
|
|
1419
|
+
* Un aviso en la CONVERSACIÓN que se está mirando, como acto de sistema — y solo si no es
|
|
1420
|
+
* lo último que ya se dijo.
|
|
1421
|
+
*
|
|
1422
|
+
* Las dos mitades vienen de la pantalla del usuario. La primera, porque `alta.aviso` por
|
|
1423
|
+
* este camino no lo pinta nadie (lo leen el wizard y la ventana de sesión nueva, y pulsar
|
|
1424
|
+
* una fila de la barra no abre ninguna de las dos), así que un rechazo sería un clic mudo.
|
|
1425
|
+
* La segunda, porque un botón que rechaza invita a insistir: medido, cinco clics dejaron
|
|
1426
|
+
* CINCO copias del mismo párrafo en el chat, y una pared de texto repetido dice menos que
|
|
1427
|
+
* una línea. Se compara con el último acto y nada más: dos avisos distintos, o el mismo
|
|
1428
|
+
* más tarde en la conversación, sí se dicen — es el mismo criterio que la bitácora de
|
|
1429
|
+
* turno, donde un aviso que salta cuando no ha pasado nada enseña a ignorarlos.
|
|
1430
|
+
*/
|
|
1431
|
+
const decirEnLaConversacion = (texto) => {
|
|
1432
|
+
const abierta = vestibulo.proyectoAbierto();
|
|
1433
|
+
if (abierta === undefined)
|
|
1434
|
+
return;
|
|
1435
|
+
const ultimo = abierta.actos().at(-1);
|
|
1436
|
+
if (ultimo?.tipo === "sistema" && ultimo.texto === texto)
|
|
1437
|
+
return;
|
|
1438
|
+
abierta.consola.consola.escribir(`${texto}\n`);
|
|
1439
|
+
};
|
|
1440
|
+
const anunciarAbriendo = (que) => {
|
|
1441
|
+
emitir(que === undefined ? { clase: "abriendo", activo: false } : { clase: "abriendo", activo: true, ...que });
|
|
1442
|
+
};
|
|
1443
|
+
const atenderSesion = async (peticion) => {
|
|
1444
|
+
aviso = undefined;
|
|
1445
|
+
// Antes de la primera espera, no después: entre el clic y aquí no hay nada que pintar.
|
|
1446
|
+
anunciarAbriendo({
|
|
1447
|
+
proyecto: peticion.proyecto,
|
|
1448
|
+
...(peticion.sesion === undefined ? {} : { sesion: peticion.sesion }),
|
|
1449
|
+
});
|
|
1450
|
+
try {
|
|
1451
|
+
if (entornoElegido === undefined) {
|
|
1452
|
+
aviso = "elige antes el entorno del que sale el proyecto";
|
|
1453
|
+
informar(aviso);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const identidad = proyectos.find((p) => p.id === peticion.proyecto);
|
|
1457
|
+
const nombre = identidad?.nombre ?? peticion.proyecto;
|
|
1458
|
+
const raiz = vestibulo.raizDeProyecto(entornoElegido, nombre);
|
|
1459
|
+
// El MISMO predicado que `hayCopiaLocal` y que la puerta de tareas: era la tercera
|
|
1460
|
+
// copia del literal, y la que decide si aquí se abre o se pregunta la rama.
|
|
1461
|
+
if (!esProyectoEnDisco(raiz)) {
|
|
1462
|
+
// Todavía no está bajado: el alta es quien sabe hacerlo, y necesita la rama.
|
|
1463
|
+
proyectoElegido = peticion.proyecto;
|
|
1464
|
+
// La identidad ENTERA, no el id: el servidor abre por nombre. `identidad` ya está
|
|
1465
|
+
// resuelta unas líneas más arriba contra el listado.
|
|
1466
|
+
ramas = await vestibulo.ramasDe(entornoElegido, identidad ?? peticion.proyecto);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
await vestibulo.abrirProyecto({ raiz, ...(peticion.sesion === undefined ? {} : { sesion: peticion.sesion }) });
|
|
1470
|
+
// El cable se muda a la consola del proyecto, como en el alta: sin esto el usuario
|
|
1471
|
+
// mira un transcript vivo cuyas aprobaciones se rechazan solas al otro lado.
|
|
1472
|
+
adjuntar();
|
|
1473
|
+
// Y la cola se vuelve a mirar: este proyecto queda bloqueado para las tareas —gana la
|
|
1474
|
+
// persona— y el que estuviera abierto antes acaba de quedar libre.
|
|
1475
|
+
opciones.revisarTareas?.();
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
aviso = error instanceof Error ? error.message : String(error);
|
|
1479
|
+
/**
|
|
1480
|
+
* Y se DICE en la conversación que se está mirando, porque `alta.aviso` por este
|
|
1481
|
+
* camino no lo pinta nadie: lo leen el wizard y la ventana de sesión nueva, y un clic
|
|
1482
|
+
* en una fila de la barra no abre ninguna de las dos. Medido leyendo `App.tsx`, y
|
|
1483
|
+
* alcanzable de verdad desde que un proyecto puede declinar por estar trabajando —el
|
|
1484
|
+
* rechazo de una sesión de tarea en curso ya recorría este mismo camino mudo.
|
|
1485
|
+
*
|
|
1486
|
+
* Se escribe como acto de SISTEMA, que es el canal por el que el chat ya enseña la
|
|
1487
|
+
* respuesta a un comando y los avisos de honestidad. Sin ninguna consola en foco no se
|
|
1488
|
+
* pinta en ninguna parte: para llegar ahí hace falta haber borrado la sesión que se
|
|
1489
|
+
* miraba mientras otro proyecto trabajaba, y ese hueco se queda declarado.
|
|
1490
|
+
*/
|
|
1491
|
+
decirEnLaConversacion(aviso);
|
|
1492
|
+
contar(error);
|
|
1493
|
+
}
|
|
1494
|
+
finally {
|
|
1495
|
+
// El alta PRIMERO y el flanco de bajada después: al revés hay un hueco en el que ya
|
|
1496
|
+
// no hay indicador y todavía no ha llegado el estado nuevo. Y en el `finally`, así
|
|
1497
|
+
// que un fallo al abrir también lo apaga.
|
|
1498
|
+
await anunciarAlta().catch(contar);
|
|
1499
|
+
anunciarAbriendo();
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
/**
|
|
1503
|
+
* «Ponme este modelo.»
|
|
1504
|
+
*
|
|
1505
|
+
* Lo que llega del cliente es la intención —`proveedor/modelo`— y no un comando: la
|
|
1506
|
+
* interfaz no habla en la sintaxis de otra piel ni se apunta actos de usuario que nadie
|
|
1507
|
+
* tecleó. Aplicarlo SÍ reusa el manejador de `/modelo` (`COMANDOS`, `cli/consola.ts`),
|
|
1508
|
+
* porque la precedencia entre banderas, ficheros y elecciones en caliente vive ahí y una
|
|
1509
|
+
* segunda implementación divergiría el primer día. Se encola la línea en el lazo, que es
|
|
1510
|
+
* el único que puede adoptar el estado nuevo, y el acuse que escribe el manejador es lo
|
|
1511
|
+
* que el usuario ve.
|
|
1512
|
+
*
|
|
1513
|
+
* **Y son DOS cosas a la vez, que es lo que este manejador arregla.** `/modelo` escribe la
|
|
1514
|
+
* bandera del estado de la SESIÓN: cambia en caliente y no toca el disco, así que
|
|
1515
|
+
* encolarlo y nada más dejaba la elección muriendo con la consola — se elegía, se
|
|
1516
|
+
* reiniciaba, y el modelo era el de antes. Aquí, además, se GUARDA como defecto de los
|
|
1517
|
+
* tres papeles en el `config.json` global, que es lo que sobrevive. Elegir en el
|
|
1518
|
+
* compositor y elegir en Ajustes son la misma frase, así que tienen que hacer lo mismo.
|
|
1519
|
+
*
|
|
1520
|
+
* Por eso también funciona SIN proyecto abierto, que es donde se configura: en el
|
|
1521
|
+
* vestíbulo no hay lazo al que encolar nada, pero sí hay un defecto que escribir, y la
|
|
1522
|
+
* próxima sesión lo recogerá. Antes esto era un rechazo —«no hay ninguna sesión abierta a
|
|
1523
|
+
* la que cambiarle el modelo»— sobre la única pantalla desde la que se puede fijar el
|
|
1524
|
+
* modelo por defecto.
|
|
1525
|
+
*
|
|
1526
|
+
* Si no hay escritor, se DICE que no se ha guardado en vez de callarlo: un ajuste que
|
|
1527
|
+
* parece puesto y no lo está es peor que uno que falta.
|
|
1528
|
+
*/
|
|
1529
|
+
const atenderModelo = (id) => {
|
|
1530
|
+
try {
|
|
1531
|
+
parsear(id);
|
|
1532
|
+
}
|
|
1533
|
+
catch (error) {
|
|
1534
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
1538
|
+
if (opciones.guardarModeloGlobal === undefined) {
|
|
1539
|
+
if (abierto === undefined) {
|
|
1540
|
+
informar("esta ejecución no puede guardar el modelo por defecto");
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
abierto.consola.encolar(`/modelo ${id}`);
|
|
1544
|
+
informar(`modelo de esta sesión: ${id} · esta ejecución no lo guarda como defecto`);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
try {
|
|
1548
|
+
for (const papel of PAPELES)
|
|
1549
|
+
opciones.guardarModeloGlobal(papel, id);
|
|
1550
|
+
}
|
|
1551
|
+
catch (error) {
|
|
1552
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (abierto === undefined) {
|
|
1556
|
+
informar(`modelo por defecto: ${id} · lo usarán las sesiones nuevas`);
|
|
1557
|
+
}
|
|
1558
|
+
else {
|
|
1559
|
+
abierto.consola.encolar(`/modelo ${id}`);
|
|
1560
|
+
informar(`${id} guardado como modelo por defecto, además de aplicarlo a esta sesión`);
|
|
1561
|
+
}
|
|
1562
|
+
// El defecto acaba de cambiar, así que lo que el cliente pinta como «por defecto» se
|
|
1563
|
+
// quedaría viejo hasta el siguiente cambio de estado. Con sesión abierta el `actual`
|
|
1564
|
+
// llega solo —el lazo adopta el modelo y eso vuelve por `alCambiarEstadoDeSesion`—.
|
|
1565
|
+
emitirModelos();
|
|
1566
|
+
};
|
|
1567
|
+
/**
|
|
1568
|
+
* Pide la clave de un proveedor y la guarda, con la MISMA disciplina que el asistente de
|
|
1569
|
+
* cuenta: la criba de balde primero (`motivoDeClaveInaceptable`), y nada se escribe si no
|
|
1570
|
+
* pasa. La pregunta sale por la consola a la que está enganchado el cable —la del
|
|
1571
|
+
* proyecto si hay uno, la del vestíbulo si no—, así que llega como `clase: "secreto"` y
|
|
1572
|
+
* la clave vuelve por ese mismo mensaje y por ninguno más.
|
|
1573
|
+
*
|
|
1574
|
+
* Lo que NO se hace aquí es probarla contra el catálogo antes de escribir, como sí hace
|
|
1575
|
+
* el alta: ahí la elección de modelo obliga a listar de todos modos, y aquí el usuario
|
|
1576
|
+
* puede estar poniendo la clave de un proveedor que no va a usar todavía. El menú del
|
|
1577
|
+
* compositor la probará cuando toque, y su error se enseña donde se elige.
|
|
1578
|
+
*/
|
|
1579
|
+
const pedirCredencial = async (proveedor) => {
|
|
1580
|
+
if (opciones.guardarCredencial === undefined) {
|
|
1581
|
+
informar("esta ejecución no puede guardar credenciales");
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const consola = vestibulo.proyectoAbierto()?.consola.consola ?? vestibulo.consola.consola;
|
|
1585
|
+
const clave = (await consola.leerSecreto(`clave de ${proveedor}: `)).trim();
|
|
1586
|
+
// Cadena vacía es lo que responde una consola sin nadie al otro lado, y también el
|
|
1587
|
+
// usuario que da a Enter sin escribir: en los dos casos no se guarda nada y no se dice
|
|
1588
|
+
// nada más — quien canceló no necesita un sermón.
|
|
1589
|
+
if (clave === "")
|
|
1590
|
+
return;
|
|
1591
|
+
const motivo = motivoDeClaveInaceptable(clave);
|
|
1592
|
+
if (motivo !== undefined) {
|
|
1593
|
+
informar(`no se guardó nada: ${motivo}`);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
try {
|
|
1597
|
+
const { ruta } = opciones.guardarCredencial(proveedor, clave);
|
|
1598
|
+
informar(`credencial de ${proveedor} guardada en ${ruta}`);
|
|
1599
|
+
}
|
|
1600
|
+
catch (error) {
|
|
1601
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
1602
|
+
}
|
|
1603
|
+
emitirModelos();
|
|
1604
|
+
};
|
|
1605
|
+
/**
|
|
1606
|
+
* Dar de alta o retirar un proveedor personalizado.
|
|
1607
|
+
*
|
|
1608
|
+
* Cinco reglas, y ninguna es de formulario:
|
|
1609
|
+
* - **El identificador lo DERIVA el servidor** del nombre, con la función de `core/`.
|
|
1610
|
+
* Derivarlo también en el cliente sería una segunda copia de la regla, y divergiría.
|
|
1611
|
+
* - **Un slug que ya existe se RECHAZA**, no se pisa: dos endpoints distintos con el
|
|
1612
|
+
* mismo nombre acabarían compartiendo entrada en `auth.json`, o sea que la clave del
|
|
1613
|
+
* segundo viajaría al host del primero. Para cambiar una URL hay que dar de baja y
|
|
1614
|
+
* volver a dar de alta, y la baja dice que se lleva la clave.
|
|
1615
|
+
* - **La URL se comprueba con la MISMA regla que un MCP** (`motivoDeEndpointInaceptable`,
|
|
1616
|
+
* hoy en `core/`): https fuera de la máquina, http solo en loopback, sin credenciales
|
|
1617
|
+
* dentro. Loopback es el caso principal, no la excepción: LM Studio, llama.cpp y vLLM
|
|
1618
|
+
* escuchan ahí.
|
|
1619
|
+
* - **La baja se lleva la credencial.** Una clave en `auth.json` bajo un proveedor que ya
|
|
1620
|
+
* no existe no se puede mandar a ninguna parte, pero sigue siendo un secreto en disco y
|
|
1621
|
+
* nadie volvería a verla en la interfaz para borrarla.
|
|
1622
|
+
* - **El motivo vuelve por el cable**, no por el transcript: esta ventana no lo pinta.
|
|
1623
|
+
*/
|
|
1624
|
+
const atenderProveedor = (mensaje) => {
|
|
1625
|
+
const contestar = (hecho, motivo) => emitir({ clase: "proveedor", hecho, ...(motivo === undefined ? {} : { motivo }) });
|
|
1626
|
+
if (mensaje.accion === "alta") {
|
|
1627
|
+
if (opciones.guardarProveedor === undefined) {
|
|
1628
|
+
contestar(false, "esta ejecución no puede dar de alta proveedores");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
const nombre = typeof mensaje.nombre === "string" ? mensaje.nombre.trim() : "";
|
|
1632
|
+
const baseUrl = typeof mensaje.baseUrl === "string" ? mensaje.baseUrl.trim() : "";
|
|
1633
|
+
if (nombre === "") {
|
|
1634
|
+
contestar(false, "ponle un nombre para reconocerlo");
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
const slug = slugDesdeNombre(nombre);
|
|
1638
|
+
const malSlug = motivoDeSlugInaceptable(slug);
|
|
1639
|
+
if (malSlug !== undefined) {
|
|
1640
|
+
contestar(false, `de ese nombre no sale un identificador válido: ${malSlug}`);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const malUrl = motivoDeEndpointInaceptable(baseUrl);
|
|
1644
|
+
if (malUrl !== undefined) {
|
|
1645
|
+
contestar(false, malUrl);
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
if (personalizados().some((d) => d.slug === slug)) {
|
|
1649
|
+
contestar(false, `ya hay un proveedor con el identificador «${slug}»: dale otro nombre, o da de baja el que hay`);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
opciones.guardarProveedor({ slug, nombre, baseUrl });
|
|
1654
|
+
}
|
|
1655
|
+
catch (error) {
|
|
1656
|
+
contestar(false, error instanceof Error ? error.message : String(error));
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
contestar(true);
|
|
1660
|
+
emitirModelos();
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (opciones.borrarProveedor === undefined) {
|
|
1664
|
+
contestar(false, "esta ejecución no puede dar de baja proveedores");
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const slug = typeof mensaje.slug === "string" ? mensaje.slug : "";
|
|
1668
|
+
if (!personalizados().some((d) => d.slug === slug)) {
|
|
1669
|
+
// No está: una vista vieja del cliente, no un error que contar. Se contesta «hecho»
|
|
1670
|
+
// porque el estado que pedía —que no esté— es el que hay.
|
|
1671
|
+
contestar(true);
|
|
1672
|
+
emitirModelos();
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
try {
|
|
1676
|
+
opciones.borrarProveedor(slug);
|
|
1677
|
+
// Y su clave detrás, si esta ejecución puede: ver la regla de arriba.
|
|
1678
|
+
opciones.borrarCredencial?.(idDeProveedorPersonalizado(slug));
|
|
1679
|
+
}
|
|
1680
|
+
catch (error) {
|
|
1681
|
+
contestar(false, error instanceof Error ? error.message : String(error));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
contestar(true);
|
|
1685
|
+
emitirModelos();
|
|
1686
|
+
};
|
|
1687
|
+
/**
|
|
1688
|
+
* Borrar una credencial. Se DICE lo que pasó por el transcript —incluido el caso en que
|
|
1689
|
+
* el fichero ya no la tenía— y se reemite el estado de modelos, que es lo que repinta el
|
|
1690
|
+
* punto. Si la variable de entorno la sigue llevando, eso también se dice: el punto se
|
|
1691
|
+
* quedará verde y callarlo parecería un fallo del botón.
|
|
1692
|
+
*/
|
|
1693
|
+
const atenderCredencial = (mensaje) => {
|
|
1694
|
+
// De serie o personalizado DADO DE ALTA: un slug que no consta se ignora en silencio,
|
|
1695
|
+
// como un id de dispositivo que ya no está — es una vista vieja del cliente, no un
|
|
1696
|
+
// error que contar.
|
|
1697
|
+
if (!proveedorConocido(mensaje.proveedor))
|
|
1698
|
+
return;
|
|
1699
|
+
const proveedor = mensaje.proveedor;
|
|
1700
|
+
if (mensaje.accion === "pedir") {
|
|
1701
|
+
void pedirCredencial(proveedor).catch(contar);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (opciones.borrarCredencial === undefined) {
|
|
1705
|
+
informar("esta ejecución no puede borrar credenciales");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const { ruta, borrada, quedaEnEntorno } = opciones.borrarCredencial(proveedor);
|
|
1710
|
+
informar(borrada
|
|
1711
|
+
? `credencial de ${proveedor} borrada de ${ruta}`
|
|
1712
|
+
: `${proveedor} no tenía credencial en ${ruta}`);
|
|
1713
|
+
if (quedaEnEntorno) {
|
|
1714
|
+
informar(`ojo: ${proveedor} sigue con credencial puesta por una variable de entorno`);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
catch (error) {
|
|
1718
|
+
informar(error instanceof Error ? error.message : String(error));
|
|
1719
|
+
}
|
|
1720
|
+
emitirModelos();
|
|
1721
|
+
};
|
|
1722
|
+
/**
|
|
1723
|
+
* Los ficheros de la sesión abierta, o el parche de uno.
|
|
1724
|
+
*
|
|
1725
|
+
* Tres respuestas y no dos, porque son tres situaciones distintas y una sola lista vacía
|
|
1726
|
+
* las haría indistinguibles:
|
|
1727
|
+
*
|
|
1728
|
+
* - **`sin-empezar`**: hay proyecto abierto pero la sesión todavía no tiene id (el id
|
|
1729
|
+
* nace al volcar el primer acto, ver `vestibulo.ts`). No ha tocado nada, y eso SE SABE.
|
|
1730
|
+
* Decir «sin-marca» aquí diagnosticaría mal: la vista mandaría a comprobar si el
|
|
1731
|
+
* proyecto es un repo de git cuando lo único que pasa es que acabas de sentarte.
|
|
1732
|
+
* - **`sin-marca`**: no hay con qué comparar —sin proyecto abierto, o sin el puerto que
|
|
1733
|
+
* sabe mirar el repo—. No se sabe.
|
|
1734
|
+
* - **`git`**: comparado, y esto es lo que hay.
|
|
1735
|
+
*/
|
|
1736
|
+
const atenderRevision = async (ruta) => {
|
|
1737
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
1738
|
+
const sesion = abierto?.sesion;
|
|
1739
|
+
if (abierto === undefined || opciones.cambiosDeSesion === undefined) {
|
|
1740
|
+
emitir({ clase: "revision", via: "sin-marca", ficheros: [] });
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (sesion === undefined) {
|
|
1744
|
+
emitir({ clase: "revision", via: "sin-empezar", ficheros: [] });
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (ruta !== undefined) {
|
|
1748
|
+
const parche = await opciones.parcheDeSesion?.(abierto.raiz, sesion, ruta);
|
|
1749
|
+
// Sin parche que dar se dice con el texto vacío y no callando: el cliente tiene una
|
|
1750
|
+
// fila abierta esperando, y el silencio la deja cargando para siempre.
|
|
1751
|
+
emitir({
|
|
1752
|
+
clase: "parche",
|
|
1753
|
+
ruta,
|
|
1754
|
+
texto: parche?.texto ?? "",
|
|
1755
|
+
recortado: parche?.recortado ?? false,
|
|
1756
|
+
});
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const { via, ficheros, mezclados } = await opciones.cambiosDeSesion(abierto.raiz, sesion);
|
|
1760
|
+
emitir({ clase: "revision", via, ficheros, ...(mezclados === undefined ? {} : { mezclados }) });
|
|
1761
|
+
};
|
|
1762
|
+
/**
|
|
1763
|
+
* La sincronización con CloudStudio del proyecto abierto, pedida desde un control
|
|
1764
|
+
* (la banda de arriba de Revisión, y «Volver a mirar»).
|
|
1765
|
+
*
|
|
1766
|
+
* **`estado` se mide aquí; `subir` y `bajar` se ENCOLAN**, y la asimetría es deliberada.
|
|
1767
|
+
* Medir es leer una ref de git que ya está en local (`lecturaDeSync`), así que encolarla
|
|
1768
|
+
* por el lazo sería abrir una sesión MCP —OAuth, red, credenciales— para contar lo que ya
|
|
1769
|
+
* se sabe. Subir y bajar, en cambio, son las MISMAS dos acciones del terminal, y por el
|
|
1770
|
+
* lazo salen con el MISMO plan, la MISMA guarda de árbol sucio y la MISMA aprobación
|
|
1771
|
+
* (`Pregunta.tsx`, que es lo que autoriza la escritura). Escribirlas otra vez aquí sería
|
|
1772
|
+
* un segundo camino de subida, que es justo donde el hueco de política que cierra
|
|
1773
|
+
* `core/cloudstudio.ts#PoliticaDeAprobacion` podría volver a abrirse.
|
|
1774
|
+
*
|
|
1775
|
+
* El lazo además las SERIALIZA: una línea encolada corre DESPUÉS del turno en vuelo, así
|
|
1776
|
+
* que un `/sync subir` no puede subir un fichero que el agente está escribiendo a medias.
|
|
1777
|
+
*
|
|
1778
|
+
* Y lo que NO se hace es volver a emitir la lectura después de encolar: el servidor no
|
|
1779
|
+
* sabe cuándo termina una línea de la cola, y un «3 ficheros por subir» recién emitido
|
|
1780
|
+
* tras pulsar Subir sería una cifra que nadie ha vuelto a medir. La pestaña se refresca
|
|
1781
|
+
* al volver a ella y al pulsar «Volver a mirar», que es cuando de verdad se mira.
|
|
1782
|
+
*/
|
|
1783
|
+
const atenderSync = (accion) => {
|
|
1784
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
1785
|
+
if (abierto === undefined) {
|
|
1786
|
+
emitir({ clase: "sync", error: "no hay ningún proyecto abierto que sincronizar" });
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (accion !== "estado") {
|
|
1790
|
+
abierto.consola.encolar(`/sync ${accion}`);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
void lecturaDeSync(abierto.raiz, abierto.sesion).then(emitir).catch(contar);
|
|
1794
|
+
};
|
|
1795
|
+
/**
|
|
1796
|
+
* El lanzamiento en curso, si lo hay.
|
|
1797
|
+
*
|
|
1798
|
+
* **Uno a la vez, y para toda la máquina**, como `trabajo` con las recetas y por un motivo
|
|
1799
|
+
* más fuerte: dos lanzamientos al mismo aparato se pisarían en el MISMO directorio del
|
|
1800
|
+
* dispositivo, y el ZIP de la subida no limpia el destino —lo que quede de uno se lanzaría
|
|
1801
|
+
* como parte del otro—. Un segundo «Ejecutar» mientras corre no lanza nada: se reenvía el
|
|
1802
|
+
* estado, que es lo que la otra pestaña necesita para pintar el recorrido que ya va.
|
|
1803
|
+
*/
|
|
1804
|
+
let lanzamiento;
|
|
1805
|
+
/** Cuándo se emitió el último progreso del lanzamiento. Ver `alFase`: no es por línea. */
|
|
1806
|
+
let ultimoLanzamiento = 0;
|
|
1807
|
+
/**
|
|
1808
|
+
* Emite el recorrido tal y como va. `ms` es el reloj del CABLE —desde que se dijo
|
|
1809
|
+
* `corriendo`— y no el `ResultadoDeLanzamiento.ms` de la máquina, que mide lo mismo pero
|
|
1810
|
+
* desde dentro: lo que la pestaña enseña es cuánto lleva el recorrido que ELLA está viendo, y
|
|
1811
|
+
* ese empezó en el primer mensaje. Los dos números coinciden salvo por el viaje, así que
|
|
1812
|
+
* enseñar uno mientras el otro avanza sería el desajuste más tonto posible.
|
|
1813
|
+
*/
|
|
1814
|
+
const emitirLanzamiento = (estado, motivo) => {
|
|
1815
|
+
if (lanzamiento === undefined)
|
|
1816
|
+
return;
|
|
1817
|
+
emitir({
|
|
1818
|
+
clase: "lanzamiento",
|
|
1819
|
+
...(lanzamiento.proyecto === undefined ? {} : { proyecto: lanzamiento.proyecto }),
|
|
1820
|
+
...(lanzamiento.dispositivo === undefined ? {} : { dispositivo: lanzamiento.dispositivo }),
|
|
1821
|
+
fase: lanzamiento.fase,
|
|
1822
|
+
estado,
|
|
1823
|
+
// La COLA del recorrido y no todo: subir un ZIP suelta decenas de líneas de `adb`, y el
|
|
1824
|
+
// cable no es un sitio donde guardarlas. Lo que hace falta es saber que avanza.
|
|
1825
|
+
lineas: lanzamiento.lineas.slice(-LINEAS_DE_LOG),
|
|
1826
|
+
ms: Date.now() - lanzamiento.t0,
|
|
1827
|
+
...(motivo === undefined ? {} : { motivo }),
|
|
1828
|
+
});
|
|
1829
|
+
ultimoLanzamiento = Date.now();
|
|
1830
|
+
};
|
|
1831
|
+
/**
|
|
1832
|
+
* El framework de XOne en ESE dispositivo, o `undefined` = «no se sabe».
|
|
1833
|
+
*
|
|
1834
|
+
* **Se mide en cada veredicto y no se cachea**, y es una decisión: cachearlo ahorraría un
|
|
1835
|
+
* adb por consulta, pero diría «listo» sobre un framework que alguien acaba de desinstalar —
|
|
1836
|
+
* y el síntoma sería el peor de todos, un lanzamiento que se acepta y una app que no
|
|
1837
|
+
* arranca. Un medidor que reviente es un bug suyo (el real no lanza: contesta
|
|
1838
|
+
* `{instalado: false, detalle}` para lo que no sabe), así que se cuenta y el veredicto sale
|
|
1839
|
+
* como «no se sabe», que es la verdad.
|
|
1840
|
+
*/
|
|
1841
|
+
const medirFramework = async (dispositivo) => {
|
|
1842
|
+
const medir = opciones.frameworkEnDispositivo;
|
|
1843
|
+
if (medir === undefined)
|
|
1844
|
+
return undefined;
|
|
1845
|
+
try {
|
|
1846
|
+
return await medir(dispositivo);
|
|
1847
|
+
}
|
|
1848
|
+
catch (error) {
|
|
1849
|
+
informar(`no se pudo mirar si hay framework de XOne en «${dispositivo.nombre}» (${codigoDe(error)})`);
|
|
1850
|
+
return undefined;
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
/**
|
|
1854
|
+
* El texto de un descriptor del proyecto (`app.xml`, `app.ini`), o el motivo por el que no
|
|
1855
|
+
* se pudo leer.
|
|
1856
|
+
*
|
|
1857
|
+
* Reusa `leerFichero` en vez de abrir un segundo lector, y no es comodidad: ese lector ya
|
|
1858
|
+
* lleva la barrera de rutas y la regla de las VISTAS APLANADAS (`X.xml` con un `X.xne` al
|
|
1859
|
+
* lado), así que un lector propio sería un segundo sitio donde esa regla puede dejar de
|
|
1860
|
+
* estar. El `error` que devuelve se dice tal cual: es una línea escrita por él.
|
|
1861
|
+
*/
|
|
1862
|
+
const leerDescriptor = async (raiz, ruta) => {
|
|
1863
|
+
const leer = opciones.leerFichero;
|
|
1864
|
+
if (leer === undefined)
|
|
1865
|
+
return { motivo: "esta ejecución no puede leer ficheros del proyecto" };
|
|
1866
|
+
try {
|
|
1867
|
+
const fichero = await leer(raiz, ruta);
|
|
1868
|
+
if (fichero.texto !== undefined)
|
|
1869
|
+
return { texto: fichero.texto };
|
|
1870
|
+
return { motivo: fichero.error ?? "no es un fichero de texto" };
|
|
1871
|
+
}
|
|
1872
|
+
catch (error) {
|
|
1873
|
+
return { motivo: motivoLegible(error) };
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
/**
|
|
1877
|
+
* Mide el proyecto abierto y compone el veredicto. `undefined` = no hay proyecto abierto.
|
|
1878
|
+
*
|
|
1879
|
+
* Es la composición que junta las cuatro fuentes: la ELECCIÓN de la sesión cruzada con la
|
|
1880
|
+
* última medida (`dispositivoDeLaSesion`), el framework medido ahora, y los descriptores del
|
|
1881
|
+
* proyecto leídos ahora. Lo caro —adb, el disco— está detrás de opciones, así que un test del
|
|
1882
|
+
* cable la ejercita entera con dobles.
|
|
1883
|
+
*/
|
|
1884
|
+
const medirLanzamiento = async () => {
|
|
1885
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
1886
|
+
if (abierto === undefined)
|
|
1887
|
+
return undefined;
|
|
1888
|
+
const raiz = abierto.raiz;
|
|
1889
|
+
const { dispositivo, elegido } = dispositivoDeLaSesion(abierto.dispositivo, informeDeDispositivos);
|
|
1890
|
+
const framework = dispositivo === undefined ? undefined : await medirFramework(dispositivo);
|
|
1891
|
+
const [xml, ini] = await Promise.all([leerDescriptor(raiz, "app.xml"), leerDescriptor(raiz, "app.ini")]);
|
|
1892
|
+
// ¿El fichero NO está, o está y no se deja leer? No es la misma cosa ni se arregla en el
|
|
1893
|
+
// mismo sitio (`sin-app-xml` manda a mirar la carpeta, `app-xml-ilegible` a mirar el
|
|
1894
|
+
// fichero), así que la existencia se pregunta aparte. Sin con qué comprobarlo no se
|
|
1895
|
+
// contesta «no está»: se cuenta como ilegible, con el motivo que dio el lector.
|
|
1896
|
+
const falta = opciones.existeEnProyecto?.(raiz, "app.xml") === false;
|
|
1897
|
+
const veredicto = puedeLanzarse({
|
|
1898
|
+
dispositivo,
|
|
1899
|
+
...(elegido === undefined ? {} : { elegido }),
|
|
1900
|
+
framework,
|
|
1901
|
+
xml: xml.texto,
|
|
1902
|
+
...(xml.texto !== undefined || falta ? {} : { motivoDeLectura: xml.motivo ?? "no se pudo leer" }),
|
|
1903
|
+
ini: ini.texto,
|
|
1904
|
+
// Un `existe` sin con qué comprobarlo CONTESTA QUE SÍ, que es «no se bloquea por esto»:
|
|
1905
|
+
// lo que no se puede comprobar no se afirma, y el falso bloqueo manda a buscar un
|
|
1906
|
+
// fichero que sí está.
|
|
1907
|
+
existe: (rutaRelativa) => opciones.existeEnProyecto === undefined || opciones.existeEnProyecto(raiz, rutaRelativa),
|
|
1908
|
+
});
|
|
1909
|
+
return {
|
|
1910
|
+
abierto,
|
|
1911
|
+
// El nombre del proyecto, que es un SEGMENTO de su carpeta —el mismo que enseña la
|
|
1912
|
+
// barra— y no una ruta: `sinRutas` no deja cruzar una ruta de la máquina por el cable.
|
|
1913
|
+
proyecto: basename(raiz),
|
|
1914
|
+
dispositivo,
|
|
1915
|
+
...(veredicto.app === undefined ? {} : { app: veredicto.app }),
|
|
1916
|
+
veredicto,
|
|
1917
|
+
};
|
|
1918
|
+
};
|
|
1919
|
+
/**
|
|
1920
|
+
* Contesta el veredicto de «¿se puede lanzar?», pedido desde la pestaña.
|
|
1921
|
+
*
|
|
1922
|
+
* **Sin proyecto abierto no se contesta nada**, y es deliberado: el mensaje lleva `proyecto`,
|
|
1923
|
+
* y rellenarlo con una cadena vacía —o con el nombre de un proyecto que ya no está— sería
|
|
1924
|
+
* afirmar un proyecto que no existe. Quien pinta la pestaña ya sabe que no hay ninguno por
|
|
1925
|
+
* `alta.proyectoAbierto`, y esa es la pantalla que le toca («abre un proyecto»).
|
|
1926
|
+
*/
|
|
1927
|
+
const atenderRevisarLanzamiento = async () => {
|
|
1928
|
+
const medida = await medirLanzamiento();
|
|
1929
|
+
if (medida === undefined)
|
|
1930
|
+
return;
|
|
1931
|
+
const foto = medida.dispositivo === undefined ? undefined : fotoDeDispositivo(medida.dispositivo);
|
|
1932
|
+
emitir({
|
|
1933
|
+
clase: "lanzable",
|
|
1934
|
+
proyecto: medida.proyecto,
|
|
1935
|
+
listo: medida.veredicto.listo,
|
|
1936
|
+
// Las FRASES ya escritas, no las causas: quien compone el texto es
|
|
1937
|
+
// `core/puedeLanzarse.ts#motivoDeBloqueo`, que es donde vive la regla y donde se
|
|
1938
|
+
// arregla. Aquí solo se recorren.
|
|
1939
|
+
faltas: medida.veredicto.causas.map(motivoDeBloqueo),
|
|
1940
|
+
...(medida.app === undefined ? {} : { app: medida.app }),
|
|
1941
|
+
...(foto === undefined ? {} : { dispositivo: foto }),
|
|
1942
|
+
// La fecha de la FOTO del equipo, no la de ahora: la medida del framework y la elección
|
|
1943
|
+
// de la sesión son de este instante, pero el `estado` del dispositivo —lo que puede
|
|
1944
|
+
// quedarse viejo— sale de esa foto, y es la que se enseña. Un veredicto sin fecha es una
|
|
1945
|
+
// promesa sin fecha.
|
|
1946
|
+
medido: informeDeDispositivos?.medido ?? new Date().toISOString(),
|
|
1947
|
+
});
|
|
1948
|
+
};
|
|
1949
|
+
/**
|
|
1950
|
+
* Lanza la app del proyecto abierto en el dispositivo de la sesión.
|
|
1951
|
+
*
|
|
1952
|
+
* **Revalida antes de empezar**, y no es prudencia: entre el veredicto y este clic puede
|
|
1953
|
+
* pasar cualquier cosa —desenchufar el teléfono, mover el fichero de la conexión, cerrar el
|
|
1954
|
+
* proyecto—, así que lo que se lanza es lo que se acaba de medir, no lo que se midió cuando
|
|
1955
|
+
* se pintó el botón. Una revalidación que no pasa se contesta con un `lanzamiento` en `fallo`
|
|
1956
|
+
* y NO se llama al lanzador.
|
|
1957
|
+
*/
|
|
1958
|
+
const atenderLanzarApp = async () => {
|
|
1959
|
+
// Ya hay uno: se reenvía su estado en vez de lanzar otro, como `atenderReceta`.
|
|
1960
|
+
if (lanzamiento !== undefined) {
|
|
1961
|
+
emitirLanzamiento("corriendo");
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const medida = await medirLanzamiento();
|
|
1965
|
+
if (medida === undefined) {
|
|
1966
|
+
informar("no hay ningún proyecto abierto que lanzar");
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
const foto = medida.dispositivo === undefined ? undefined : fotoDeDispositivo(medida.dispositivo);
|
|
1970
|
+
const fallar = (motivo) => {
|
|
1971
|
+
emitir({
|
|
1972
|
+
clase: "lanzamiento",
|
|
1973
|
+
proyecto: medida.proyecto,
|
|
1974
|
+
...(foto === undefined ? {} : { dispositivo: foto }),
|
|
1975
|
+
fase: "comprobando",
|
|
1976
|
+
estado: "fallo",
|
|
1977
|
+
lineas: [],
|
|
1978
|
+
ms: 0,
|
|
1979
|
+
motivo,
|
|
1980
|
+
});
|
|
1981
|
+
};
|
|
1982
|
+
const lanzar = opciones.lanzarEnDispositivo;
|
|
1983
|
+
const primeraFalta = medida.veredicto.causas[0];
|
|
1984
|
+
if (primeraFalta !== undefined) {
|
|
1985
|
+
// La PRIMERA, que es la que hay que arreglar antes de que nada más importe: el orden de
|
|
1986
|
+
// `puedeLanzarse` es el de la lectura, no el alfabético.
|
|
1987
|
+
fallar(motivoDeBloqueo(primeraFalta));
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (medida.dispositivo === undefined) {
|
|
1991
|
+
// Inalcanzable con el veredicto vacío —sin dispositivo siempre hay causa—, pero el tipo
|
|
1992
|
+
// no lo sabe y lanzar sin dispositivo no se puede ni componer.
|
|
1993
|
+
fallar(SIN_NOMBRE_DE_APP);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (medida.app === undefined) {
|
|
1997
|
+
fallar(SIN_NOMBRE_DE_APP);
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
if (lanzar === undefined) {
|
|
2001
|
+
fallar(SIN_CAMINO_DE_LANZAMIENTO);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* El recorrido, tal y como lo cuenta la máquina.
|
|
2006
|
+
*
|
|
2007
|
+
* **La fase que CAMBIA siempre se emite**, y es una decisión: son SEIS en todo el recorrido
|
|
2008
|
+
* —no un flujo—, y cada una es lo que hace que la pestaña diga en cuál va. Lo que se acota a
|
|
2009
|
+
* `MS_ENTRE_PROGRESOS` son las líneas DENTRO de una misma fase, que es donde está el flujo:
|
|
2010
|
+
* subir un ZIP suelta decenas de líneas de `adb` y cada emisión manda la cola entera.
|
|
2011
|
+
*/
|
|
2012
|
+
const alFase = (fase, linea) => {
|
|
2013
|
+
if (lanzamiento === undefined)
|
|
2014
|
+
return;
|
|
2015
|
+
lanzamiento.lineas.push(linea);
|
|
2016
|
+
const cambioDeFase = fase !== lanzamiento.fase;
|
|
2017
|
+
lanzamiento.fase = fase;
|
|
2018
|
+
if (cambioDeFase || Date.now() - ultimoLanzamiento >= MS_ENTRE_PROGRESOS) {
|
|
2019
|
+
emitirLanzamiento("corriendo");
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
/**
|
|
2023
|
+
* El estado se compone ANTES de llamar, porque `lanzarEnDispositivo` puede llamar a
|
|
2024
|
+
* `alFase` de forma SÍNCRONA —su cuerpo corre hasta el primer `await`— y el primer
|
|
2025
|
+
* `corriendo` tiene que encontrar el trabajo ya puesto.
|
|
2026
|
+
*/
|
|
2027
|
+
const trabajo = {
|
|
2028
|
+
proyecto: medida.proyecto,
|
|
2029
|
+
...(foto === undefined ? {} : { dispositivo: foto }),
|
|
2030
|
+
fase: "comprobando",
|
|
2031
|
+
lineas: [],
|
|
2032
|
+
cancelar: () => { },
|
|
2033
|
+
t0: Date.now(),
|
|
2034
|
+
};
|
|
2035
|
+
lanzamiento = trabajo;
|
|
2036
|
+
// El primer mensaje dice `corriendo`, y su fase es `comprobando`: el recorrido EMPIEZA
|
|
2037
|
+
// ahí, así que la primera `alFase("comprobando", …)` no cambia nada y no vuelve a emitir.
|
|
2038
|
+
// Los seis tramos se ven igual —el primero ya viene en este mensaje— y el cliente no
|
|
2039
|
+
// recibe un mensaje de más para decir lo que ya decía.
|
|
2040
|
+
emitirLanzamiento("corriendo");
|
|
2041
|
+
try {
|
|
2042
|
+
// **Dentro del `try` y no fuera**: que el lanzador reviente al CONSTRUIR el trabajo —un
|
|
2043
|
+
// doble mal hecho, o un bug suyo en el camino síncrono— dejaría el recorrido en
|
|
2044
|
+
// `corriendo` para siempre y el botón muerto, que es justo lo que este `try` existe para
|
|
2045
|
+
// evitar. Su contrato es no lanzar nunca; esto es por si deja de cumplirlo.
|
|
2046
|
+
const enCurso = lanzar({ dispositivo: medida.dispositivo, raiz: medida.abierto.raiz, app: medida.app }, { alFase });
|
|
2047
|
+
trabajo.cancelar = enCurso.cancelar;
|
|
2048
|
+
const resultado = await enCurso.terminado;
|
|
2049
|
+
// El último SIEMPRE se emite, aunque no haya pasado el plazo: es el que dice cómo acabó.
|
|
2050
|
+
// Y la fase es la que dice el RESULTADO —no la última que se oyó— por si el trabajo
|
|
2051
|
+
// acabó en una que no llegó a contarse.
|
|
2052
|
+
ultimoLanzamiento = 0;
|
|
2053
|
+
trabajo.fase = resultado.fase;
|
|
2054
|
+
emitirLanzamiento(resultado.estado, resultado.motivo);
|
|
2055
|
+
}
|
|
2056
|
+
catch (error) {
|
|
2057
|
+
// El contrato del lanzador es no lanzar nunca, así que esto es un bug suyo; aun así hay
|
|
2058
|
+
// que CERRAR el recorrido, o la pestaña se queda en `corriendo` para siempre con el botón
|
|
2059
|
+
// muerto y sin una palabra.
|
|
2060
|
+
ultimoLanzamiento = 0;
|
|
2061
|
+
emitirLanzamiento("fallo", motivoLegible(error));
|
|
2062
|
+
}
|
|
2063
|
+
finally {
|
|
2064
|
+
lanzamiento = undefined;
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
/** Parar el lanzamiento en curso. Un lanzamiento a la vez, así que no lleva id que elegir. */
|
|
2068
|
+
const atenderCancelarLanzamiento = () => {
|
|
2069
|
+
if (lanzamiento === undefined) {
|
|
2070
|
+
informar("no hay ningún lanzamiento en curso que cancelar");
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
lanzamiento.cancelar();
|
|
2074
|
+
};
|
|
2075
|
+
/**
|
|
2076
|
+
* Solo el CÓDIGO de un fallo de sistema de ficheros (`EACCES`, `EMFILE`…), nunca su
|
|
2077
|
+
* mensaje: el de Node lleva la ruta absoluta del disco, y aquí `informar` acaba en el
|
|
2078
|
+
* transcript. Sin código, el nombre del error; sin error, la palabra.
|
|
2079
|
+
*/
|
|
2080
|
+
const codigoDe = (error) => {
|
|
2081
|
+
if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string")
|
|
2082
|
+
return error.code;
|
|
2083
|
+
return error instanceof Error ? error.name : "error";
|
|
2084
|
+
};
|
|
2085
|
+
/**
|
|
2086
|
+
* El MENSAJE si lo escribimos nosotros, y el CÓDIGO si lo escribió el sistema.
|
|
2087
|
+
*
|
|
2088
|
+
* La misma función que `corredorDeTareas.ts#sinRutas`, y por el mismo motivo: un error de
|
|
2089
|
+
* Node trae `code` y su mensaje lleva la ruta absoluta, mientras que uno escrito a mano en
|
|
2090
|
+
* este repo no trae `code` y su mensaje es justo lo que hay que leer. `codigoDe` a secas
|
|
2091
|
+
* convierte «falta la credencial para openai; usa /provider openai» en
|
|
2092
|
+
* «ErrorDelAumentador», que es un nombre de clase enseñado a una persona.
|
|
2093
|
+
*/
|
|
2094
|
+
const motivoLegible = (error) => typeof error === "object" && error !== null && "code" in error
|
|
2095
|
+
? codigoDe(error)
|
|
2096
|
+
: (error instanceof Error ? error.message : String(error)).split(/\r?\n/)[0].slice(0, 200);
|
|
2097
|
+
/**
|
|
2098
|
+
* El árbol del proyecto abierto. Sin proyecto no hay pestaña que lo pida, así que no se
|
|
2099
|
+
* contesta nada; sin PUERTO sí se contesta, con error: un árbol que nunca llega deja al
|
|
2100
|
+
* cliente en «consultando…» para siempre, y un cargando eterno es un fallo mudo.
|
|
2101
|
+
*/
|
|
2102
|
+
const atenderArbol = async () => {
|
|
2103
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2104
|
+
if (abierto === undefined)
|
|
2105
|
+
return;
|
|
2106
|
+
if (opciones.arbolDelProyecto === undefined) {
|
|
2107
|
+
emitir({ clase: "arbol", rutas: [], recortado: false, error: "esta ejecución no puede listar el proyecto" });
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
try {
|
|
2111
|
+
const { rutas, recortado } = await opciones.arbolDelProyecto(abierto.raiz);
|
|
2112
|
+
emitir({ clase: "arbol", rutas, recortado });
|
|
2113
|
+
}
|
|
2114
|
+
catch (error) {
|
|
2115
|
+
// El mensaje de Node lleva la ruta absoluta del disco, y por el cable no viaja
|
|
2116
|
+
// ninguna ruta de la máquina. Eso vale TAMBIÉN para `informar`: en producción escribe
|
|
2117
|
+
// un acto de sistema en el transcript, así que a él solo le llega el código (EACCES…).
|
|
2118
|
+
informar(`no se pudo listar el proyecto (${codigoDe(error)})`);
|
|
2119
|
+
emitir({ clase: "arbol", rutas: [], recortado: false, error: "no se pudo listar el proyecto" });
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
/**
|
|
2123
|
+
* El contenido de una ruta del proyecto abierto. El lector decide si se puede enseñar.
|
|
2124
|
+
*
|
|
2125
|
+
* El `try` es por lo mismo que el del árbol: `leerFicheroDeProyecto` devuelve los rechazos
|
|
2126
|
+
* como `error` en el resultado, pero después del `realpath` todavía puede LANZAR —un
|
|
2127
|
+
* EACCES al hacer `stat` o al abrir—, y esa excepción llegaba al `contar` genérico sin
|
|
2128
|
+
* contestar nada, dejando al cliente en «Trayendo…» para siempre. La respuesta lleva la
|
|
2129
|
+
* `ruta` porque el cliente indexa los contenidos por ella: sin eso no sabría cuál falló.
|
|
2130
|
+
*/
|
|
2131
|
+
const atenderFichero = async (ruta) => {
|
|
2132
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2133
|
+
if (abierto === undefined)
|
|
2134
|
+
return;
|
|
2135
|
+
if (opciones.leerFichero === undefined) {
|
|
2136
|
+
emitir({ clase: "fichero", ruta, recortado: false, binario: false, bytes: 0, error: "esta ejecución no puede leer el proyecto" });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
try {
|
|
2140
|
+
emitir({ clase: "fichero", ...(await opciones.leerFichero(abierto.raiz, ruta)) });
|
|
2141
|
+
}
|
|
2142
|
+
catch (error) {
|
|
2143
|
+
// Como en el árbol: ni por el cable ni por `informar` viaja el mensaje de Node, que
|
|
2144
|
+
// lleva la ruta absoluta; la `ruta` relativa es la que mandó el cliente.
|
|
2145
|
+
informar(`no se pudo leer «${ruta}» (${codigoDe(error)})`);
|
|
2146
|
+
emitir({
|
|
2147
|
+
clase: "fichero",
|
|
2148
|
+
ruta,
|
|
2149
|
+
recortado: false,
|
|
2150
|
+
binario: false,
|
|
2151
|
+
bytes: 0,
|
|
2152
|
+
error: "no se pudo leer el fichero",
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
/**
|
|
2157
|
+
* El contenido de un ARTEFACTO de la sesión abierta, por su nombre.
|
|
2158
|
+
*
|
|
2159
|
+
* El id que se usa es `idDeHilo` y no `sesion`, y la diferencia es la que hace que esto
|
|
2160
|
+
* funcione: `sesion` espera a que haya entrada en el índice, o sea al final del turno,
|
|
2161
|
+
* mientras que el acto que anuncia un artefacto se emite a MITAD de turno. Con el otro id
|
|
2162
|
+
* el visor habría contestado «no existe» justo cuando se acaba de dibujar.
|
|
2163
|
+
*
|
|
2164
|
+
* El `try` es el de `atenderFichero`, palabra por palabra y por lo mismo: el lector
|
|
2165
|
+
* devuelve sus rechazos como `error`, pero después del `realpath` todavía puede LANZAR un
|
|
2166
|
+
* EACCES, y esa excepción dejaba al cliente esperando para siempre. Ni por el cable ni por
|
|
2167
|
+
* `informar` viaja el mensaje de Node, que lleva la ruta absoluta.
|
|
2168
|
+
*/
|
|
2169
|
+
const atenderArtefacto = async (nombre) => {
|
|
2170
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2171
|
+
if (abierto === undefined)
|
|
2172
|
+
return;
|
|
2173
|
+
const ruta = `${RUTA_ARTEFACTOS}${nombre}`;
|
|
2174
|
+
const fallo = (error) => emitir({ clase: "artefacto", ruta, recortado: false, binario: false, bytes: 0, error });
|
|
2175
|
+
if (opciones.leerArtefacto === undefined) {
|
|
2176
|
+
fallo("esta ejecución no puede leer los artefactos");
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
try {
|
|
2180
|
+
emitir({ clase: "artefacto", ...(await opciones.leerArtefacto(abierto.raiz, abierto.idDeHilo, nombre)) });
|
|
2181
|
+
}
|
|
2182
|
+
catch (error) {
|
|
2183
|
+
informar(`no se pudo leer el artefacto «${nombre}» (${codigoDe(error)})`);
|
|
2184
|
+
fallo("no se pudo leer el artefacto");
|
|
2185
|
+
}
|
|
2186
|
+
};
|
|
2187
|
+
/**
|
|
2188
|
+
* El paso de receta que se está ejecutando, si hay alguno.
|
|
2189
|
+
*
|
|
2190
|
+
* **Uno a la vez, y para toda la máquina.** Dos `sdkmanager` a la vez sobre el mismo SDK
|
|
2191
|
+
* es una carrera con la instalación de por medio, y la máquina es UNA aunque haya dos
|
|
2192
|
+
* pestañas — el mismo motivo por el que la detección comparte una sola medida en vuelo. Un
|
|
2193
|
+
* segundo «ejecutar» mientras corre no lanza nada: se reenvía el estado, que es lo que la
|
|
2194
|
+
* otra pestaña necesita para pintar el log que ya va por dentro.
|
|
2195
|
+
*/
|
|
2196
|
+
let trabajo;
|
|
2197
|
+
/** Cuándo se emitió el último progreso: el log se emite a ritmo, no por línea. */
|
|
2198
|
+
let ultimoProgreso = 0;
|
|
2199
|
+
const emitirProgreso = (estado, motivo) => {
|
|
2200
|
+
if (trabajo === undefined)
|
|
2201
|
+
return;
|
|
2202
|
+
emitir({
|
|
2203
|
+
clase: "instalacion",
|
|
2204
|
+
receta: trabajo.receta,
|
|
2205
|
+
paso: trabajo.paso,
|
|
2206
|
+
titulo: trabajo.titulo,
|
|
2207
|
+
estado,
|
|
2208
|
+
// La COLA del log y no todo: `sdkmanager` son miles de líneas y el cable no es un sitio
|
|
2209
|
+
// donde guardarlas. Lo que hace falta es saber que avanza y en qué va.
|
|
2210
|
+
lineas: trabajo.lineas.slice(-LINEAS_DE_LOG),
|
|
2211
|
+
ms: Date.now() - trabajo.t0,
|
|
2212
|
+
...(motivo === undefined ? {} : { motivo }),
|
|
2213
|
+
});
|
|
2214
|
+
ultimoProgreso = Date.now();
|
|
2215
|
+
};
|
|
2216
|
+
/**
|
|
2217
|
+
* Lanza un paso, o cancela el que corre.
|
|
2218
|
+
*
|
|
2219
|
+
* Al terminar se vuelve a MEDIR, y es lo que decide si el paso queda hecho: lo que diga el
|
|
2220
|
+
* instalador es lo que él cree, y la foto es lo que hay. Es la misma regla que ya sigue
|
|
2221
|
+
* `instalar`.
|
|
2222
|
+
*/
|
|
2223
|
+
const atenderReceta = (mensaje) => {
|
|
2224
|
+
if (mensaje.accion === "cancelar") {
|
|
2225
|
+
trabajo?.cancelar();
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
if (trabajo !== undefined) {
|
|
2229
|
+
// Ya hay uno: se reenvía su estado en vez de lanzar otro.
|
|
2230
|
+
emitirProgreso("corriendo");
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
const correr = opciones.correrPasoDeReceta;
|
|
2234
|
+
if (correr === undefined) {
|
|
2235
|
+
informar("esta ejecución no puede ejecutar pasos de instalación");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
const enMarcha = correr(mensaje.id, mensaje.paso, (linea) => {
|
|
2239
|
+
if (trabajo === undefined)
|
|
2240
|
+
return;
|
|
2241
|
+
trabajo.lineas.push(linea);
|
|
2242
|
+
// A ritmo: cada emisión manda la cola entera, y por línea sería cuadrático en bytes
|
|
2243
|
+
// con un `sdkmanager` que habla cada pocos milisegundos. Es la misma razón que
|
|
2244
|
+
// `MS_ENTRE_PARCIALES` en la piel web.
|
|
2245
|
+
if (Date.now() - ultimoProgreso >= MS_ENTRE_PROGRESOS)
|
|
2246
|
+
emitirProgreso("corriendo");
|
|
2247
|
+
});
|
|
2248
|
+
trabajo = {
|
|
2249
|
+
receta: mensaje.id,
|
|
2250
|
+
paso: mensaje.paso,
|
|
2251
|
+
titulo: enMarcha.titulo,
|
|
2252
|
+
lineas: [],
|
|
2253
|
+
cancelar: enMarcha.cancelar,
|
|
2254
|
+
t0: Date.now(),
|
|
2255
|
+
};
|
|
2256
|
+
emitirProgreso("corriendo");
|
|
2257
|
+
void (async () => {
|
|
2258
|
+
const resultado = await enMarcha.terminado;
|
|
2259
|
+
// El último progreso SIEMPRE se emite, aunque no haya pasado el plazo: es el que
|
|
2260
|
+
// completa el log y el que dice cómo acabó.
|
|
2261
|
+
ultimoProgreso = 0;
|
|
2262
|
+
emitirProgreso(resultado.estado, resultado.motivo);
|
|
2263
|
+
trabajo = undefined;
|
|
2264
|
+
// Y la foto nueva es la que dice si el paso quedó hecho, no el código de salida.
|
|
2265
|
+
informeDeDispositivos = undefined;
|
|
2266
|
+
await atenderDispositivos().catch(contar);
|
|
2267
|
+
})();
|
|
2268
|
+
};
|
|
2269
|
+
/**
|
|
2270
|
+
* Los modelos de un motor externo, para el desplegable de un subagente.
|
|
2271
|
+
*
|
|
2272
|
+
* Bajo demanda y cacheado por proceso: el de Claude Code es una tabla, pero el de Codex se
|
|
2273
|
+
* le PREGUNTA a él —`model/list` sobre su `app-server`—, y eso arranca un proceso. Pedirlo
|
|
2274
|
+
* al conectar lo lanzaría en cada arranque para una ventana que casi nadie abre.
|
|
2275
|
+
*/
|
|
2276
|
+
const modelosPorMotor = new Map();
|
|
2277
|
+
const atenderModelosDeMotor = async (motor) => {
|
|
2278
|
+
const emitirlos = (r) => emitir({ clase: "modelosDeMotor", motor, modelos: r.modelos, ...(r.error === undefined ? {} : { error: r.error }) });
|
|
2279
|
+
const guardado = modelosPorMotor.get(motor);
|
|
2280
|
+
if (guardado !== undefined) {
|
|
2281
|
+
emitirlos(guardado);
|
|
2282
|
+
return;
|
|
2283
|
+
}
|
|
2284
|
+
if (opciones.modelosDeMotor === undefined) {
|
|
2285
|
+
emitirlos({ modelos: [], error: "esta ejecución no puede consultar los modelos de ese motor" });
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const r = await opciones.modelosDeMotor(motor);
|
|
2289
|
+
// Un fallo NO se cachea: es lo que pasa cuando Codex no estaba instalado todavía, y
|
|
2290
|
+
// cachearlo dejaría el desplegable vacío hasta reiniciar la consola aunque lo instale.
|
|
2291
|
+
if (r.error === undefined)
|
|
2292
|
+
modelosPorMotor.set(motor, r);
|
|
2293
|
+
emitirlos(r);
|
|
2294
|
+
};
|
|
2295
|
+
/** Un paso del alta resuelto en el navegador. Cada rama termina volviendo a anunciar. */
|
|
2296
|
+
const atenderAlta = async (mensaje) => {
|
|
2297
|
+
// Se limpia al empezar: un aviso viejo pegado a un paso que ya salió bien mentiría.
|
|
2298
|
+
aviso = undefined;
|
|
2299
|
+
try {
|
|
2300
|
+
if (mensaje.paso === "cuenta") {
|
|
2301
|
+
// Volver al paso de modelo desde la progresión del alta. Se re-arma `cuentaHecha`
|
|
2302
|
+
// —lo que impide preguntar dos veces es justo esa marca— y se relanza el asistente
|
|
2303
|
+
// SUELTO: pintarlo es cosa suya (`selector` y `secreto`), y el `POST` no puede
|
|
2304
|
+
// quedarse abierto mientras un humano elige. Cuando termine, `anunciarAlta` cuenta
|
|
2305
|
+
// cómo quedó todo.
|
|
2306
|
+
cuentaHecha = false;
|
|
2307
|
+
void conducirCuenta(true)
|
|
2308
|
+
.catch(contar)
|
|
2309
|
+
.finally(() => void anunciarAlta().catch(contar));
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
if (mensaje.paso === "entorno") {
|
|
2313
|
+
const elegido = mensaje.entorno;
|
|
2314
|
+
if (elegido === undefined || elegido.url.trim() === "") {
|
|
2315
|
+
aviso = "el entorno necesita una URL";
|
|
2316
|
+
informar(aviso);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
// SIEMPRE se registra, aunque el entorno ya esté en la lista. La versión anterior
|
|
2320
|
+
// se lo saltaba comparando contra `opcionesDeEntorno()`, que es la lista OFRECIDA
|
|
2321
|
+
// (los dos oficiales más «otro») y no la registrada: con un `settings.json` recién
|
|
2322
|
+
// nacido, elegir WebStudio casaba con el oficial, no se registraba nada, y el
|
|
2323
|
+
// `proyectosDe` siguiente moría con «el entorno no está registrado». Registrar dos
|
|
2324
|
+
// veces no cuesta nada: en disco `guardarEntorno` sustituye por id y en memoria el
|
|
2325
|
+
// vestíbulo hace lo mismo.
|
|
2326
|
+
// El id con el que quedó REGISTRADO, no el que llegó: el formulario solo pide la
|
|
2327
|
+
// URL, y de un «otro» el vestíbulo deduce id y nombre del host
|
|
2328
|
+
// (`identidadDeEntorno`). Con el id de la lista, el `proyectosDe` de la línea
|
|
2329
|
+
// siguiente moriría con «el entorno «otro» no está registrado».
|
|
2330
|
+
const { entorno: registrado } = await vestibulo.registrarEntorno({
|
|
2331
|
+
id: elegido.id,
|
|
2332
|
+
nombre: elegido.nombre,
|
|
2333
|
+
url: elegido.url,
|
|
2334
|
+
});
|
|
2335
|
+
entornoElegido = registrado.id;
|
|
2336
|
+
proyectoElegido = undefined;
|
|
2337
|
+
ramas = [];
|
|
2338
|
+
proyectos = await vestibulo.proyectosDe(registrado.id);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
if (entornoElegido === undefined) {
|
|
2342
|
+
aviso = "elige antes el entorno del que sale el proyecto";
|
|
2343
|
+
informar(aviso);
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
const proyecto = mensaje.proyecto;
|
|
2347
|
+
if (proyecto === undefined || proyecto === "") {
|
|
2348
|
+
aviso = "el paso de proyecto necesita un proyecto";
|
|
2349
|
+
informar(aviso);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
if (mensaje.rama === undefined || mensaje.rama === "") {
|
|
2353
|
+
// Sin rama todavía no se abre nada: se contestan las ramas de ese proyecto. Es la
|
|
2354
|
+
// alternativa a inventarse las del primero de la lista antes de que nadie elija.
|
|
2355
|
+
proyectoElegido = proyecto;
|
|
2356
|
+
// Igual que arriba: el servidor abre por NOMBRE, y el cable trae el id.
|
|
2357
|
+
ramas = await vestibulo.ramasDe(entornoElegido, proyectos.find((p) => p.id === proyecto) ?? proyecto);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
// El proyecto que viene en ESTE mensaje, no el cacheado: lo enviado es la verdad y
|
|
2361
|
+
// `proyectoElegido` puede haberse quedado atrás.
|
|
2362
|
+
const identidad = proyectos.find((p) => p.id === proyecto) ?? proyecto;
|
|
2363
|
+
// La espera LARGA: aquí se baja el proyecto entero. Se dice como tal (`descargando`)
|
|
2364
|
+
// porque un indicador que solo gire no distingue medio segundo de tres minutos.
|
|
2365
|
+
anunciarAbriendo({ proyecto, descargando: true });
|
|
2366
|
+
const { raiz } = await vestibulo.completarProyecto({
|
|
2367
|
+
entorno: entornoElegido,
|
|
2368
|
+
proyecto: identidad,
|
|
2369
|
+
rama: mensaje.rama,
|
|
2370
|
+
});
|
|
2371
|
+
await vestibulo.abrirProyecto({ raiz });
|
|
2372
|
+
// El cable se muda a la consola del proyecto. Sin esto el usuario mira un transcript
|
|
2373
|
+
// vivo cuyas aprobaciones se rechazan solas al otro lado. `adjuntar` reemite también
|
|
2374
|
+
// el estado de modelos, que hasta ahora no tenía `actual` que dar: sin sesión abierta
|
|
2375
|
+
// no hay modelo en vigor.
|
|
2376
|
+
adjuntar();
|
|
2377
|
+
// Como en `atenderSesion`: abrir bloquea esta raíz para las tareas y libera la que
|
|
2378
|
+
// estuviera abierta antes.
|
|
2379
|
+
opciones.revisarTareas?.();
|
|
2380
|
+
}
|
|
2381
|
+
catch (error) {
|
|
2382
|
+
// El aviso se fija ANTES de anunciar: el `finally` de abajo es quien lo lleva al paso.
|
|
2383
|
+
aviso = error instanceof Error ? error.message : String(error);
|
|
2384
|
+
contar(error);
|
|
2385
|
+
}
|
|
2386
|
+
finally {
|
|
2387
|
+
await anunciarAlta().catch(contar);
|
|
2388
|
+
// Solo este paso del alta enciende el indicador, pero apagarlo aquí es correcto para
|
|
2389
|
+
// todos: apagar lo que ya está apagado no se ve.
|
|
2390
|
+
anunciarAbriendo();
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
// El modelo en vigor cambia DENTRO del lazo de la consola (`/modelo` y `/modelos` no
|
|
2394
|
+
// tocan disco), así que la única forma de enterarse es que el vestíbulo lo diga. Sin
|
|
2395
|
+
// esto, el disparador del compositor seguiría enseñando el modelo con el que se abrió la
|
|
2396
|
+
// sesión después de haberlo cambiado — una cifra con forma de verdad.
|
|
2397
|
+
vestibulo.alCambiarEstadoDeSesion(() => emitirModelos());
|
|
2398
|
+
// El turno se emite solo (`consolaWeb.turno`), pero además hay que RECORDARLO: una pestaña
|
|
2399
|
+
// que conecta a mitad no vio ese mensaje, y necesita saberlo para apagar su compositor.
|
|
2400
|
+
/**
|
|
2401
|
+
* Lo consumido por la sesión en foco. Se emite en cada cambio y también al conectar: una
|
|
2402
|
+
* pestaña que llega a mitad de sesión no vio los anteriores, y sin esto su contador se
|
|
2403
|
+
* quedaría a cero hasta el siguiente token — que en un turno largo son minutos.
|
|
2404
|
+
*/
|
|
2405
|
+
const emitirConsumo = () => {
|
|
2406
|
+
const c = vestibulo.consumoDeSesion();
|
|
2407
|
+
// Ausente es «no consta», y entonces no se manda nada: el cliente prefiere no pintar el
|
|
2408
|
+
// contador a pintar un cero que nadie ha medido.
|
|
2409
|
+
if (c === undefined)
|
|
2410
|
+
return;
|
|
2411
|
+
emitir({ clase: "consumo", modelo: c.modelo, externo: c.externo, ventana: ventanaDeAhora(c.contexto) });
|
|
2412
|
+
};
|
|
2413
|
+
/**
|
|
2414
|
+
* Cuánto ocupa la ventana y cuál es su tope, si se sabe.
|
|
2415
|
+
*
|
|
2416
|
+
* El tope se resuelve con **la misma función que la barra del terminal**
|
|
2417
|
+
* (`cli/main.ts#crearTopeDelModelo`, que entra por OPCIÓN para no importar `cli/` desde
|
|
2418
|
+
* aquí — el mismo motivo por el que `crearEjecutor` entra así). Dos resoluciones serían
|
|
2419
|
+
* dos topes que divergen, y de ahí sale un porcentaje que miente en una de las dos
|
|
2420
|
+
* pieles. Esa función ya respeta la precedencia de `/config`: lo del proyecto gana a lo
|
|
2421
|
+
* global y la tabla es el último recurso.
|
|
2422
|
+
*
|
|
2423
|
+
* Se re-resuelve en cada emisión y no se cachea, por lo mismo que en el terminal:
|
|
2424
|
+
* `/modelo` cambia el modelo en caliente, y el tope del modelo equivocado es la misma
|
|
2425
|
+
* mentira. Sin tope no se manda el campo: con Ollama no hay A PROPÓSITO.
|
|
2426
|
+
*/
|
|
2427
|
+
const ventanaDeAhora = (usado) => {
|
|
2428
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2429
|
+
if (abierto === undefined)
|
|
2430
|
+
return { usado };
|
|
2431
|
+
const trabajo = resolver(abierto.estadoDeSesion.fuentes).trabajo;
|
|
2432
|
+
const tope = opciones.topeDeContexto?.(abierto.raiz, `${trabajo.proveedor}/${trabajo.modelo}`);
|
|
2433
|
+
return tope === undefined ? { usado } : { usado, tope };
|
|
2434
|
+
};
|
|
2435
|
+
vestibulo.alCambiarConsumo(() => emitirConsumo());
|
|
2436
|
+
vestibulo.alCambiarTurno(() => {
|
|
2437
|
+
// La cola se vuelve a mirar: por aquí pasa también el CIERRE de una consola de segundo
|
|
2438
|
+
// plano cuyo turno acabó, y eso libera su raíz para las tareas («gana la persona» deja
|
|
2439
|
+
// de aplicar ahí). Sin este empujón la tarea que esperaba ese proyecto se quedaría
|
|
2440
|
+
// quieta hasta que alguien tocara la cola por su cuenta: no hay temporizador.
|
|
2441
|
+
opciones.revisarTareas?.();
|
|
2442
|
+
// El alta se reanuncia en los DOS flancos, y DIFERIDO. Medido: una sesión nueva no
|
|
2443
|
+
// aparecía en la barra hasta recargar la página, porque su id nace en `volcar()` —al
|
|
2444
|
+
// final del turno— y nadie volvía a anunciar. Y `historica` deja de ser cierto al
|
|
2445
|
+
// EMPEZAR el primer turno nuevo, que es el otro flanco. Diferido a una microtarea porque
|
|
2446
|
+
// `vestibulo.ts` llama a esta escucha ANTES de `volcar()`, en el mismo `finally`
|
|
2447
|
+
// síncrono: anunciar en el acto leería la sesión sin id todavía. Una microtarea corre
|
|
2448
|
+
// cuando ese bloque ha terminado, o sea con el `.jsonl` ya escrito.
|
|
2449
|
+
void Promise.resolve()
|
|
2450
|
+
.then(() => anunciarAlta())
|
|
2451
|
+
.catch(contar);
|
|
2452
|
+
});
|
|
2453
|
+
servidor.registrarRuta("GET", RUTA_EVENTOS, (peticion, respuesta) => {
|
|
2454
|
+
respuesta.writeHead(200, {
|
|
2455
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2456
|
+
"Cache-Control": "no-cache",
|
|
2457
|
+
Connection: "keep-alive",
|
|
2458
|
+
// Sin esto un proxy intermedio bufferiza el stream y el transcript llega a tirones.
|
|
2459
|
+
"X-Accel-Buffering": "no",
|
|
2460
|
+
});
|
|
2461
|
+
const sumidero = (mensaje) => {
|
|
2462
|
+
// El socket se puede haber ido entre el último acto y este: escribir en él lanza, y
|
|
2463
|
+
// ese lanzamiento subiría por el emisor del acto hasta el motor de turno.
|
|
2464
|
+
try {
|
|
2465
|
+
respuesta.write(`data: ${JSON.stringify(mensaje)}\n\n`);
|
|
2466
|
+
}
|
|
2467
|
+
catch {
|
|
2468
|
+
/* el cliente se fue; el `close` de abajo ya desconecta */
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
2471
|
+
clientes.add(sumidero);
|
|
2472
|
+
/**
|
|
2473
|
+
* El identificador que el navegador eligió para ESTA conexión, si lo mandó. Es lo que
|
|
2474
|
+
* después le permite decir «engánchame a la tarea t1» por `POST /accion`, que es otra
|
|
2475
|
+
* petición y no trae sumidero ninguno. Ausente = un cliente que no va a mirar nada, y
|
|
2476
|
+
* entonces no se apunta: nada que limpiar al cerrarse.
|
|
2477
|
+
*
|
|
2478
|
+
* Se lee de la query a mano y no con `new URL()` por la misma razón que
|
|
2479
|
+
* `servidor.ts#manejarPeticion`, aunque aquí no haya segmentos que preservar: es el
|
|
2480
|
+
* patrón del fichero. Y no es una ruta ni un nombre de fichero: solo la clave de
|
|
2481
|
+
* `porIdDeCliente`.
|
|
2482
|
+
*/
|
|
2483
|
+
const idDeCliente = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "").get("cliente") ?? undefined;
|
|
2484
|
+
if (idDeCliente !== undefined && idDeCliente !== "") {
|
|
2485
|
+
/**
|
|
2486
|
+
* Si el id ya estaba, es la MISMA pestaña reconectando, y sus envoltorios viejos hay
|
|
2487
|
+
* que desengancharlos AQUÍ: el `close` de la conexión anterior puede llegar después
|
|
2488
|
+
* —es la carrera que documenta el `close` de abajo— y entonces se salta la limpieza por
|
|
2489
|
+
* la guarda de `enviar === sumidero`, que está bien puesta porque si no se llevaría por
|
|
2490
|
+
* delante las miradas del recién llegado. Sin esto, esos envoltorios se quedaban en el
|
|
2491
|
+
* `mirones` del transporte de la tarea hasta que su consola cerrara, escribiendo en un
|
|
2492
|
+
* socket que ya no está (el `try/catch` del sumidero se lo traga: ni se veía). Reclamar
|
|
2493
|
+
* el id es el único momento en que consta que la conexión anterior murió, y el cliente
|
|
2494
|
+
* vuelve a pedir lo que mirara al reconectar.
|
|
2495
|
+
*/
|
|
2496
|
+
const anterior = porIdDeCliente.get(idDeCliente);
|
|
2497
|
+
if (anterior !== undefined) {
|
|
2498
|
+
for (const [tarea, envoltorio] of anterior.mirando) {
|
|
2499
|
+
opciones.corredorDeTareas?.dejarDeMirar?.(tarea, envoltorio);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
porIdDeCliente.set(idDeCliente, { enviar: sumidero, mirando: new Map() });
|
|
2503
|
+
}
|
|
2504
|
+
// Un comentario SSE abre el stream de verdad: sin nada escrito, algunos navegadores no
|
|
2505
|
+
// disparan `onopen` hasta el primer dato.
|
|
2506
|
+
respuesta.write(": xonecode\n\n");
|
|
2507
|
+
adjuntar(sumidero);
|
|
2508
|
+
// ANTES de `conducirCuenta()`, no después: el nombre ya está resuelto (es local, no
|
|
2509
|
+
// depende de ninguna cuenta) y el paso de cuenta puede tardar lo que tarde un humano
|
|
2510
|
+
// en elegir modelo y teclear una clave. Mandarlo solo dentro de `alta` —al final de
|
|
2511
|
+
// TODO esto— dejaba el saludo en «Hola» a secas mientras tanto (`transporte.ts`
|
|
2512
|
+
// documenta la medida).
|
|
2513
|
+
// Solo al recién llegado: los demás ya recibieron su saludo al conectar.
|
|
2514
|
+
sumidero({ clase: "bienvenida", ...(vestibulo.nombre === undefined ? {} : { nombre: vestibulo.nombre }) });
|
|
2515
|
+
void conducirCuenta()
|
|
2516
|
+
.catch(contar)
|
|
2517
|
+
.then(() => poblarProyectosSiProcede())
|
|
2518
|
+
.finally(() => void anunciarAlta().catch(contar));
|
|
2519
|
+
peticion.on("close", () => {
|
|
2520
|
+
// Se va ESTE cliente, no «el cliente». La guarda de antes (`enviar !== sumidero`)
|
|
2521
|
+
// existía porque el `close` de una pestaña recargada puede llegar DESPUÉS de que el
|
|
2522
|
+
// SSE nuevo se enganche, y con una sola ranura eso desconectaba al recién llegado;
|
|
2523
|
+
// con un conjunto, quitar el suyo es exacto y esa carrera desaparece.
|
|
2524
|
+
clientes.delete(sumidero);
|
|
2525
|
+
/**
|
|
2526
|
+
* Y se desenganchan sus MIRADAS. Sin esto, el envoltorio de una pestaña cerrada se
|
|
2527
|
+
* queda enganchado al turno de la tarea para siempre, escribiendo en un socket que ya
|
|
2528
|
+
* no está. La guarda de `enviar === sumidero` es la misma carrera que documenta el
|
|
2529
|
+
* `close` de aquí arriba: una pestaña recargada puede cerrar DESPUÉS de que su
|
|
2530
|
+
* reconexión haya reclamado el mismo id, y sin comparar el sumidero el cierre viejo se
|
|
2531
|
+
* llevaría por delante las miradas del recién llegado.
|
|
2532
|
+
*/
|
|
2533
|
+
if (idDeCliente !== undefined) {
|
|
2534
|
+
const entrada = porIdDeCliente.get(idDeCliente);
|
|
2535
|
+
if (entrada !== undefined && entrada.enviar === sumidero) {
|
|
2536
|
+
for (const [tarea, envoltorio] of entrada.mirando) {
|
|
2537
|
+
opciones.corredorDeTareas?.dejarDeMirar?.(tarea, envoltorio);
|
|
2538
|
+
}
|
|
2539
|
+
porIdDeCliente.delete(idDeCliente);
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
// Y la consola solo se da por sola cuando se va el ÚLTIMO: el transporte lo decide
|
|
2543
|
+
// mirando sus sumideros. Cortar a la primera baja rechazaría la aprobación que otra
|
|
2544
|
+
// pestaña todavía tiene delante.
|
|
2545
|
+
adjunto?.desconectar(sumidero);
|
|
2546
|
+
if (clientes.size === 0) {
|
|
2547
|
+
/**
|
|
2548
|
+
* Y las de SEGUNDO PLANO también, que es lo que `soltar` deja pendiente a
|
|
2549
|
+
* propósito: mudarse de consola no da por ido al humano, pero cerrarse el último
|
|
2550
|
+
* SSE sí. Sin esto, una consola que se quedó detrás con un turno en marcha seguiría
|
|
2551
|
+
* creyendo que hay alguien a quien preguntar y su aprobación esperaría el plazo
|
|
2552
|
+
* entero antes de rechazarse. Se les dice a todas menos a la que ya se acaba de
|
|
2553
|
+
* cortar arriba.
|
|
2554
|
+
*/
|
|
2555
|
+
for (const consola of vestibulo.proyectosAbiertos()) {
|
|
2556
|
+
if (consola !== adjunto)
|
|
2557
|
+
consola.desconectar();
|
|
2558
|
+
}
|
|
2559
|
+
adjunto = undefined;
|
|
2560
|
+
}
|
|
2561
|
+
});
|
|
2562
|
+
});
|
|
2563
|
+
/**
|
|
2564
|
+
* `GET /artefacto?n=<nombre>` — el documento que pinta el iframe, y la descarga.
|
|
2565
|
+
*
|
|
2566
|
+
* **Aquí vive la decisión de sandbox que tenía parada esta pantalla.** Lo que se sirve lo
|
|
2567
|
+
* escribió un MODELO, y servirlo en el mismo origen que tiene la cookie del token sería
|
|
2568
|
+
* darle a ese HTML la consola entera: un `fetch("/accion")` desde dentro podría abrir un
|
|
2569
|
+
* proyecto, cambiar el modelo o pedir una clave. Se cierra con dos capas, y las dos hacen
|
|
2570
|
+
* falta:
|
|
2571
|
+
* - El iframe va `sandbox="allow-scripts"` SIN `allow-same-origin` (`Artefactos.tsx`),
|
|
2572
|
+
* así que el documento tiene un origen OPACO: no ve la cookie, no ve el padre, y sus
|
|
2573
|
+
* peticiones salen con `Origin: null`, que la comprobación de `servidor.ts` ya contesta
|
|
2574
|
+
* con 403.
|
|
2575
|
+
* - Y la respuesta lleva `Content-Security-Policy: sandbox allow-scripts`, que cubre lo
|
|
2576
|
+
* que el atributo no puede: abrir esta URL en una pestaña del navegador es una
|
|
2577
|
+
* navegación de PRIMER nivel en el origen real, con la cookie puesta. La cabecera hace
|
|
2578
|
+
* que ahí también sea un origen opaco.
|
|
2579
|
+
*
|
|
2580
|
+
* Lo que NO se pone es una CSP de red. Estos artefactos cargan tipografías y Mermaid de un
|
|
2581
|
+
* CDN (es lo que sus skills mandan), y con origen opaco no hay nada que exfiltrar: cerrar
|
|
2582
|
+
* la red solo los dejaría sin estilo. La contrapartida se DICE en la pantalla: no se ven
|
|
2583
|
+
* sin conexión.
|
|
2584
|
+
*
|
|
2585
|
+
* Y un mime que no conocemos no se sirve inline. Adivinarlo es cómo un `.txt` acaba
|
|
2586
|
+
* ejecutándose como HTML; sin `nosniff` lo adivinaría el navegador.
|
|
2587
|
+
*/
|
|
2588
|
+
servidor.registrarRuta("GET", RUTA_ARTEFACTO, async (peticion, respuesta) => {
|
|
2589
|
+
const responder = (codigo, texto) => {
|
|
2590
|
+
respuesta.writeHead(codigo, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2591
|
+
respuesta.end(texto);
|
|
2592
|
+
};
|
|
2593
|
+
// El nombre sale de la QUERY. `searchParams` decodifica una vez, así que un `%2e%2e`
|
|
2594
|
+
// llega ya como `..` y lo caza `esRutaDeArtefacto`; un `%252e%252e` llega con el `%`
|
|
2595
|
+
// dentro, que tampoco es texto llano. La ruta no se parsea con `new URL` para nada más:
|
|
2596
|
+
// aquí no hay ningún camino que normalizar.
|
|
2597
|
+
const query = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "");
|
|
2598
|
+
const nombre = query.get("n");
|
|
2599
|
+
if (nombre === null || nombre === "") {
|
|
2600
|
+
responder(400, "falta el artefacto");
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
// La MISMA función que decide que escribir ahí no pide aprobación humana. Se comprueba
|
|
2604
|
+
// también aquí, antes de llamar al lector —que tiene su propia barrera— porque una ruta
|
|
2605
|
+
// que se apoya solo en la barrera de su lector se queda abierta el día que alguien
|
|
2606
|
+
// cambie el lector.
|
|
2607
|
+
if (!esRutaDeArtefacto(`${RUTA_ARTEFACTOS}${nombre}`)) {
|
|
2608
|
+
responder(403, "ese nombre no es de un artefacto");
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2612
|
+
if (abierto === undefined || opciones.leerArtefactoCrudo === undefined) {
|
|
2613
|
+
responder(404, "no hay artefacto");
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
let leido;
|
|
2617
|
+
try {
|
|
2618
|
+
leido = await opciones.leerArtefactoCrudo(abierto.raiz, abierto.idDeHilo, nombre);
|
|
2619
|
+
}
|
|
2620
|
+
catch (error) {
|
|
2621
|
+
// El mensaje de Node lleva la ruta absoluta: al cliente solo le llega el código, y a
|
|
2622
|
+
// `informar` tampoco más, que escribe en el transcript y por tanto viaja.
|
|
2623
|
+
informar(`no se pudo servir el artefacto «${nombre}» (${codigoDe(error)})`);
|
|
2624
|
+
responder(500, "no se pudo leer");
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if (!leido.ok) {
|
|
2628
|
+
// Un código por motivo: con un 404 para todo no habría forma de distinguir «ese
|
|
2629
|
+
// nombre no vale» de «ya no está» ni de «no cabe».
|
|
2630
|
+
const codigos = { rechazado: 403, "no-existe": 404, "demasiado-grande": 413 };
|
|
2631
|
+
const textos = {
|
|
2632
|
+
rechazado: "ese nombre no es de un artefacto",
|
|
2633
|
+
"no-existe": "no hay artefacto",
|
|
2634
|
+
"demasiado-grande": "el artefacto es demasiado grande",
|
|
2635
|
+
};
|
|
2636
|
+
responder(codigos[leido.motivo], textos[leido.motivo]);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const descargar = query.get("descargar") !== null;
|
|
2640
|
+
// Sin mime conocido se descarga en vez de adivinar. El nombre ya pasó la barrera de
|
|
2641
|
+
// segmento llano, así que entre comillas no puede romper la cabecera.
|
|
2642
|
+
const inline = !descargar && leido.mime !== undefined;
|
|
2643
|
+
respuesta.writeHead(200, {
|
|
2644
|
+
"Content-Type": inline ? tipoServido(leido.mime) : "application/octet-stream",
|
|
2645
|
+
"Content-Length": leido.datos.length,
|
|
2646
|
+
"Content-Security-Policy": "sandbox allow-scripts",
|
|
2647
|
+
"X-Content-Type-Options": "nosniff",
|
|
2648
|
+
// Un artefacto se sobrescribe con el mismo nombre en el turno siguiente, y una
|
|
2649
|
+
// respuesta cacheada enseñaría el dibujo de antes sin decirlo.
|
|
2650
|
+
"Cache-Control": "no-store",
|
|
2651
|
+
...(inline ? {} : { "Content-Disposition": `attachment; filename="${leido.nombre}"` }),
|
|
2652
|
+
});
|
|
2653
|
+
respuesta.end(leido.datos);
|
|
2654
|
+
});
|
|
2655
|
+
/**
|
|
2656
|
+
* `POST /adjunto?tarea=<id>&nombre=<fichero>` — los BYTES de un adjunto de tarea.
|
|
2657
|
+
*
|
|
2658
|
+
* Por HTTP y no por el cable, que lleva JSON. Las comprobaciones de `Host`, `Origin` y
|
|
2659
|
+
* token las hace `servidor.ts` antes de llegar aquí, igual que a todas las rutas. Lo
|
|
2660
|
+
* propio de esta son cuatro cosas:
|
|
2661
|
+
*
|
|
2662
|
+
* - **El nombre pasa la MISMA barrera de segmento llano que un artefacto**
|
|
2663
|
+
* (`nombreDeAdjuntoAceptable`, `core/adjuntos.ts`), y el id de la tarea también: de ellos
|
|
2664
|
+
* se compone una ruta de disco. `searchParams` decodifica una vez, así que un `%2e%2e`
|
|
2665
|
+
* llega ya como `..` y lo caza la lista blanca; un `%252e%252e` llega con el `%` dentro,
|
|
2666
|
+
* que tampoco es texto llano. La barrera se aplica además OTRA vez dentro del puerto
|
|
2667
|
+
* —sobre el texto y sobre el camino real—, que es donde se caza un enlace simbólico.
|
|
2668
|
+
* - **Una subida no puede caer en la carpeta de una tarea que ya existe** (409). El id lo
|
|
2669
|
+
* elige el cliente, porque los bytes tienen que estar en disco ANTES de crear la tarea
|
|
2670
|
+
* (crear dispara `revisarTareas()` y el corredor puede arrancarla en el acto). Sin esta
|
|
2671
|
+
* guarda, un adjunto podría aterrizar en la carpeta de una tarea viva que el agente está
|
|
2672
|
+
* leyendo por `/adjuntos/` ahora mismo.
|
|
2673
|
+
* - **El cuerpo se lee con el tope del ADJUNTO, no con el del cable** (1 MB): 413 en
|
|
2674
|
+
* cuanto se pasa, y se corta ahí — no se acumulan 20 MB para después rechazarlos.
|
|
2675
|
+
* - **Ninguna respuesta lleva una ruta de la máquina** ni nada de lo recibido, que es la
|
|
2676
|
+
* regla de `POST /accion`.
|
|
2677
|
+
*/
|
|
2678
|
+
servidor.registrarRuta("POST", RUTA_ADJUNTO, async (peticion, respuesta) => {
|
|
2679
|
+
const responder = (codigo, texto) => {
|
|
2680
|
+
respuesta.writeHead(codigo, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2681
|
+
respuesta.end(texto);
|
|
2682
|
+
};
|
|
2683
|
+
const query = new URLSearchParams((peticion.url ?? "").split("?")[1] ?? "");
|
|
2684
|
+
const tarea = query.get("tarea");
|
|
2685
|
+
const nombre = query.get("nombre");
|
|
2686
|
+
if (tarea === null || tarea === "" || nombre === null || nombre === "") {
|
|
2687
|
+
responder(400, "faltan «tarea» o «nombre»");
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (!nombreDeAdjuntoAceptable(tarea) || !nombreDeAdjuntoAceptable(nombre)) {
|
|
2691
|
+
responder(403, "ese nombre no vale para un adjunto");
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
if (opciones.colaDeTareas === undefined) {
|
|
2695
|
+
responder(404, "esta consola no ejecuta tareas");
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
if (opciones.colaDeTareas.listar().some((t) => t.id === tarea)) {
|
|
2699
|
+
responder(409, "ese identificador ya es de una tarea: los adjuntos se suben antes de crearla");
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
let datos;
|
|
2703
|
+
try {
|
|
2704
|
+
datos = await leerCuerpoCrudo(peticion, TOPE_DE_ADJUNTO);
|
|
2705
|
+
}
|
|
2706
|
+
catch {
|
|
2707
|
+
// Se pasó del tope, o el flujo se cortó. NO se devuelve nada de lo recibido: es la
|
|
2708
|
+
// misma regla que `POST /accion`, y aquí lo recibido son los bytes de un documento
|
|
2709
|
+
// de una persona.
|
|
2710
|
+
responder(413, "el adjunto es demasiado grande");
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
const guardado = opciones.colaDeTareas.guardarAdjunto(tarea, nombre, datos);
|
|
2714
|
+
if (!guardado.ok) {
|
|
2715
|
+
// El motivo lo escribe el puerto y no lleva ninguna ruta (su test lo vigila). 413 para
|
|
2716
|
+
// los dos topes —por fichero y por tarea— porque los dos son «no cabe».
|
|
2717
|
+
responder(413, guardado.motivo ?? "no se pudo guardar el adjunto");
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2720
|
+
respuesta.writeHead(204);
|
|
2721
|
+
respuesta.end();
|
|
2722
|
+
});
|
|
2723
|
+
servidor.registrarRuta("POST", RUTA_ACCION, async (peticion, respuesta) => {
|
|
2724
|
+
let mensaje;
|
|
2725
|
+
try {
|
|
2726
|
+
mensaje = JSON.parse(await leerCuerpo(peticion));
|
|
2727
|
+
}
|
|
2728
|
+
catch {
|
|
2729
|
+
// Cuerpo ilegible o demasiado grande. NO se devuelve nada de lo recibido: por aquí
|
|
2730
|
+
// pasa la clave de API del paso de cuenta, y un eco la dejaría en el log del cliente.
|
|
2731
|
+
respuesta.writeHead(400);
|
|
2732
|
+
respuesta.end();
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
if (typeof mensaje === "object" &&
|
|
2736
|
+
mensaje !== null &&
|
|
2737
|
+
mensaje.clase === "entorno" &&
|
|
2738
|
+
mensaje.accion === "activo") {
|
|
2739
|
+
void atenderEntornoActivo(mensaje.entorno);
|
|
2740
|
+
respuesta.writeHead(204);
|
|
2741
|
+
respuesta.end();
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
if (typeof mensaje === "object" &&
|
|
2745
|
+
mensaje !== null &&
|
|
2746
|
+
mensaje.clase === "entorno" &&
|
|
2747
|
+
mensaje.accion === "proyectos") {
|
|
2748
|
+
void atenderProyectosDeEntorno(mensaje.entorno);
|
|
2749
|
+
respuesta.writeHead(204);
|
|
2750
|
+
respuesta.end();
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
if (typeof mensaje === "object" &&
|
|
2754
|
+
mensaje !== null &&
|
|
2755
|
+
mensaje.clase === "entorno" &&
|
|
2756
|
+
mensaje.accion === "visibles") {
|
|
2757
|
+
void vestibulo
|
|
2758
|
+
.guardarProyectosVisibles(mensaje.entorno, mensaje.proyectos)
|
|
2759
|
+
.catch(contar)
|
|
2760
|
+
.finally(() => void anunciarAlta().catch(contar));
|
|
2761
|
+
respuesta.writeHead(204);
|
|
2762
|
+
respuesta.end();
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "revision") {
|
|
2766
|
+
void atenderRevision(mensaje.ruta).catch(contar);
|
|
2767
|
+
respuesta.writeHead(204);
|
|
2768
|
+
respuesta.end();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "arbol") {
|
|
2772
|
+
void atenderArbol().catch(contar);
|
|
2773
|
+
respuesta.writeHead(204);
|
|
2774
|
+
respuesta.end();
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (typeof mensaje === "object" &&
|
|
2778
|
+
mensaje !== null &&
|
|
2779
|
+
mensaje.clase === "sync" &&
|
|
2780
|
+
(mensaje.accion === "estado" || mensaje.accion === "subir" || mensaje.accion === "bajar")) {
|
|
2781
|
+
// Una acción que no se entiende NO cae en `estado`: en esta casa lo que no se
|
|
2782
|
+
// entiende se rechaza, y `estado` solo se alcanza nombrándolo.
|
|
2783
|
+
atenderSync(mensaje.accion);
|
|
2784
|
+
respuesta.writeHead(204);
|
|
2785
|
+
respuesta.end();
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "fichero" && typeof mensaje.ruta === "string") {
|
|
2789
|
+
void atenderFichero(mensaje.ruta).catch(contar);
|
|
2790
|
+
respuesta.writeHead(204);
|
|
2791
|
+
respuesta.end();
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "artefacto" && typeof mensaje.nombre === "string") {
|
|
2795
|
+
void atenderArtefacto(mensaje.nombre).catch(contar);
|
|
2796
|
+
respuesta.writeHead(204);
|
|
2797
|
+
respuesta.end();
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
if (typeof mensaje === "object" &&
|
|
2801
|
+
mensaje !== null &&
|
|
2802
|
+
mensaje.clase === "receta" &&
|
|
2803
|
+
typeof mensaje.id === "string" &&
|
|
2804
|
+
typeof mensaje.paso === "number" &&
|
|
2805
|
+
(mensaje.accion === "ejecutar" || mensaje.accion === "cancelar")) {
|
|
2806
|
+
atenderReceta(mensaje);
|
|
2807
|
+
respuesta.writeHead(204);
|
|
2808
|
+
respuesta.end();
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* ANTES del `recibir` de la consola, y también antes que las demás clases de tarea: por
|
|
2813
|
+
* aquí no entra nada hacia ningún turno. Ver `atenderMirar`.
|
|
2814
|
+
*/
|
|
2815
|
+
if (typeof mensaje === "object" &&
|
|
2816
|
+
mensaje !== null &&
|
|
2817
|
+
mensaje.clase === "mirar" &&
|
|
2818
|
+
typeof mensaje.tarea === "string" &&
|
|
2819
|
+
typeof mensaje.cliente === "string" &&
|
|
2820
|
+
typeof mensaje.ver === "boolean") {
|
|
2821
|
+
atenderMirar(mensaje);
|
|
2822
|
+
respuesta.writeHead(204);
|
|
2823
|
+
respuesta.end();
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "tarea") {
|
|
2827
|
+
if (mensaje.accion === "crear" && opciones.colaDeTareas !== undefined) {
|
|
2828
|
+
const creada = atenderCrearTarea(mensaje.proyecto, mensaje.peticion, mensaje.encargo, mensaje.borrador);
|
|
2829
|
+
if (creada !== undefined) {
|
|
2830
|
+
opciones.revisarTareas?.();
|
|
2831
|
+
emitirTareas();
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
else if (mensaje.accion === "augmentar" && opciones.augmentar !== undefined) {
|
|
2835
|
+
void atenderAugmentar(opciones.augmentar, mensaje.proyecto, mensaje.peticion, mensaje.borrador);
|
|
2836
|
+
}
|
|
2837
|
+
else if (mensaje.accion === "feedback" && opciones.colaDeTareas !== undefined) {
|
|
2838
|
+
atenderFeedbackDeTarea(mensaje.id, mensaje.texto);
|
|
2839
|
+
opciones.revisarTareas?.();
|
|
2840
|
+
emitirTareas();
|
|
2841
|
+
}
|
|
2842
|
+
else if (mensaje.accion !== "crear" &&
|
|
2843
|
+
mensaje.accion !== "augmentar" &&
|
|
2844
|
+
mensaje.accion !== "feedback" &&
|
|
2845
|
+
opciones.colaDeTareas !== undefined) {
|
|
2846
|
+
/**
|
|
2847
|
+
* Ya no es `void x(); revisarTareas(); emitirTareas();` seguidas — reintentar y
|
|
2848
|
+
* terminar siguen siendo instantáneos, pero descartar puede esperar a que el
|
|
2849
|
+
* corredor corte un turno en vuelo (Task 14), y emitir la cola ANTES de que eso
|
|
2850
|
+
* termine enseñaría la tarea todavía «en proceso» un instante antes de que
|
|
2851
|
+
* `borrarTarea` la quite de verdad. Se secuencia con `.then()`, el mismo patrón que
|
|
2852
|
+
* el resto de acciones asíncronas de este manejador (`atenderRevision`,
|
|
2853
|
+
* `atenderArbol`…), y el fallo se cuenta y no se propaga: la respuesta HTTP ya se
|
|
2854
|
+
* mandó en el acto.
|
|
2855
|
+
*/
|
|
2856
|
+
void atenderAccionDeTarea(mensaje.accion, mensaje.id)
|
|
2857
|
+
.then(() => {
|
|
2858
|
+
opciones.revisarTareas?.();
|
|
2859
|
+
emitirTareas();
|
|
2860
|
+
})
|
|
2861
|
+
.catch(contar);
|
|
2862
|
+
}
|
|
2863
|
+
respuesta.writeHead(204);
|
|
2864
|
+
respuesta.end();
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "tareas" && typeof mensaje.concurrencia === "number") {
|
|
2868
|
+
// Cambiar el tope no vale nada sin volver a revisar: con la cola llena y hueco nuevo,
|
|
2869
|
+
// sin este empujón se quedaría esperando al siguiente evento que no tiene nada que
|
|
2870
|
+
// ver — la misma razón por la que crear y accionar revisan tras escribir.
|
|
2871
|
+
opciones.guardarConcurrencia?.(mensaje.concurrencia);
|
|
2872
|
+
opciones.revisarTareas?.();
|
|
2873
|
+
emitirTareas();
|
|
2874
|
+
respuesta.writeHead(204);
|
|
2875
|
+
respuesta.end();
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "modelosDeMotor" && typeof mensaje.motor === "string") {
|
|
2879
|
+
void atenderModelosDeMotor(mensaje.motor).catch(contar);
|
|
2880
|
+
respuesta.writeHead(204);
|
|
2881
|
+
respuesta.end();
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelar") {
|
|
2885
|
+
// Parar ESTE turno, no cerrar la conversación. Sin proyecto abierto no hay turno que
|
|
2886
|
+
// parar y se dice: un botón que no puede cumplir no puede callar.
|
|
2887
|
+
const abierto = vestibulo.proyectoAbierto();
|
|
2888
|
+
if (abierto === undefined || !abierto.cancelarTurno()) {
|
|
2889
|
+
informar("no hay ningún turno en vuelo que parar");
|
|
2890
|
+
}
|
|
2891
|
+
respuesta.writeHead(204);
|
|
2892
|
+
respuesta.end();
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "sesion") {
|
|
2896
|
+
// Suelto: abrir un proyecto arranca una consola entera y el `POST` no se queda
|
|
2897
|
+
// esperando. Lo que pase se cuenta por el cable.
|
|
2898
|
+
void atenderSesion(mensaje);
|
|
2899
|
+
respuesta.writeHead(204);
|
|
2900
|
+
respuesta.end();
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "sesionAccion") {
|
|
2904
|
+
void atenderAccionDeSesion(mensaje).catch(contar);
|
|
2905
|
+
respuesta.writeHead(204);
|
|
2906
|
+
respuesta.end();
|
|
2907
|
+
return;
|
|
2908
|
+
}
|
|
2909
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "modelo") {
|
|
2910
|
+
atenderModelo(mensaje.id);
|
|
2911
|
+
respuesta.writeHead(204);
|
|
2912
|
+
respuesta.end();
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "agente") {
|
|
2916
|
+
atenderAgente(mensaje);
|
|
2917
|
+
respuesta.writeHead(204);
|
|
2918
|
+
respuesta.end();
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "proveedor") {
|
|
2922
|
+
atenderProveedor(mensaje);
|
|
2923
|
+
respuesta.writeHead(204);
|
|
2924
|
+
respuesta.end();
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "credencial") {
|
|
2928
|
+
atenderCredencial(mensaje);
|
|
2929
|
+
respuesta.writeHead(204);
|
|
2930
|
+
respuesta.end();
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "dispositivo") {
|
|
2934
|
+
// **El cliente manda el ID y nada más.** El nombre, la plataforma y la clase salen de
|
|
2935
|
+
// la última MEDIDA: son datos sobre la máquina, y el navegador no es fuente sobre la
|
|
2936
|
+
// máquina — aceptar su versión dejaría entrar un «iPhone 16» que nadie ha visto. Un id
|
|
2937
|
+
// que no esté en la medida se ignora en silencio: es una foto vieja del cliente
|
|
2938
|
+
// (desenchufaron el teléfono entre medias), no un error que contar.
|
|
2939
|
+
const abierta = vestibulo.proyectoAbierto();
|
|
2940
|
+
const pedido = mensaje.id;
|
|
2941
|
+
if (abierta !== undefined) {
|
|
2942
|
+
if (pedido === undefined)
|
|
2943
|
+
abierta.elegirDispositivo(undefined);
|
|
2944
|
+
else if (typeof pedido === "string") {
|
|
2945
|
+
const d = informeDeDispositivos?.dispositivos.find((x) => x.id === pedido);
|
|
2946
|
+
if (d !== undefined) {
|
|
2947
|
+
abierta.elegirDispositivo({ id: d.id, nombre: d.nombre, plataforma: d.plataforma, clase: d.clase });
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
// Suelto: `anunciarAlta` consulta CloudStudio y el `POST` no puede quedarse
|
|
2951
|
+
// esperando por eso. El alta nueva llega por el SSE, como siempre.
|
|
2952
|
+
void anunciarAlta().catch(contar);
|
|
2953
|
+
}
|
|
2954
|
+
respuesta.writeHead(204);
|
|
2955
|
+
respuesta.end();
|
|
2956
|
+
return;
|
|
2957
|
+
}
|
|
2958
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "conexion") {
|
|
2959
|
+
// Suelto: hablarle a un dispositivo tarda segundos (adb en frío arranca su demonio) y
|
|
2960
|
+
// la respuesta va por el SSE, como la foto.
|
|
2961
|
+
const id = mensaje.id;
|
|
2962
|
+
if (typeof id === "string")
|
|
2963
|
+
void atenderConexion(id).catch(contar);
|
|
2964
|
+
respuesta.writeHead(204);
|
|
2965
|
+
respuesta.end();
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "revisarLanzamiento") {
|
|
2969
|
+
// Suelto, como `conexion`: medir el framework es un adb, y la respuesta va por el SSE
|
|
2970
|
+
// en forma de `lanzable`.
|
|
2971
|
+
void atenderRevisarLanzamiento().catch(contar);
|
|
2972
|
+
respuesta.writeHead(204);
|
|
2973
|
+
respuesta.end();
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "lanzarApp") {
|
|
2977
|
+
// Suelto: revalida (otro adb), y el lanzamiento entero tarda minutos. La respuesta va
|
|
2978
|
+
// por el SSE, en forma de `lanzamiento`, y no se espera aquí ni de lejos.
|
|
2979
|
+
void atenderLanzarApp().catch(contar);
|
|
2980
|
+
respuesta.writeHead(204);
|
|
2981
|
+
respuesta.end();
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "cancelarLanzamiento") {
|
|
2985
|
+
// Este SÍ es síncrono: `cancelar` solo mata el grupo del proceso en curso.
|
|
2986
|
+
atenderCancelarLanzamiento();
|
|
2987
|
+
respuesta.writeHead(204);
|
|
2988
|
+
respuesta.end();
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "dispositivos") {
|
|
2992
|
+
// Suelto, como el catálogo: lanza procesos que tardan segundos y la respuesta va por
|
|
2993
|
+
// el SSE. Es la ÚNICA forma de volver a medir: no hay sondeo.
|
|
2994
|
+
//
|
|
2995
|
+
// Con `ajustes` se GUARDAN primero y después se mide, en ese orden y no al revés: el
|
|
2996
|
+
// detector los lee de disco en cada medida, así que medir antes de guardar daría la
|
|
2997
|
+
// foto de la configuración anterior justo cuando se acaba de cambiar. Un fallo al
|
|
2998
|
+
// escribir NO impide medir —se cuenta y se sigue—, porque la foto sigue siendo
|
|
2999
|
+
// verdad; lo que no se puede es callarlo.
|
|
3000
|
+
const pedidos = mensaje.ajustes;
|
|
3001
|
+
if (typeof pedidos === "object" && pedidos !== null && opciones.guardarAjustesDeDispositivos !== undefined) {
|
|
3002
|
+
const limpios = {};
|
|
3003
|
+
for (const plataforma of PLATAFORMAS_DE_DISPOSITIVO) {
|
|
3004
|
+
const valor = pedidos[plataforma];
|
|
3005
|
+
if (typeof valor === "boolean")
|
|
3006
|
+
limpios[plataforma] = valor;
|
|
3007
|
+
}
|
|
3008
|
+
try {
|
|
3009
|
+
opciones.guardarAjustesDeDispositivos(limpios);
|
|
3010
|
+
}
|
|
3011
|
+
catch (error) {
|
|
3012
|
+
informar(`no se pudieron guardar los destinos de prueba (${codigoDe(error)})`);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
// Instalar va ANTES de medir y por el nombre, nunca por un comando del cliente. Se
|
|
3016
|
+
// espera a que termine para que la foto de después cuente la verdad: medir mientras
|
|
3017
|
+
// el instalador corre diría que la herramienta sigue sin estar.
|
|
3018
|
+
const aInstalar = mensaje.instalar;
|
|
3019
|
+
const instalador = opciones.instalarHerramienta;
|
|
3020
|
+
if (typeof aInstalar === "string" && instalador !== undefined && esNombreDeHerramienta(aInstalar)) {
|
|
3021
|
+
void (async () => {
|
|
3022
|
+
try {
|
|
3023
|
+
await instalador(aInstalar);
|
|
3024
|
+
}
|
|
3025
|
+
catch (error) {
|
|
3026
|
+
// Un instalador que falla no puede tumbar nada: se cuenta y se mide igual, que
|
|
3027
|
+
// es lo que dirá si la herramienta apareció o no.
|
|
3028
|
+
informar(`no se pudo instalar ${aInstalar} (${codigoDe(error)})`);
|
|
3029
|
+
}
|
|
3030
|
+
informeDeDispositivos = undefined;
|
|
3031
|
+
await atenderDispositivos().catch(contar);
|
|
3032
|
+
})();
|
|
3033
|
+
respuesta.writeHead(204);
|
|
3034
|
+
respuesta.end();
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
informeDeDispositivos = undefined;
|
|
3038
|
+
void atenderDispositivos().catch(contar);
|
|
3039
|
+
respuesta.writeHead(204);
|
|
3040
|
+
respuesta.end();
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "catalogo") {
|
|
3044
|
+
// Suelto, como el alta: consultar un catálogo es una petición de red y el `POST` no
|
|
3045
|
+
// se queda abierto esperándola. La respuesta viaja por el SSE.
|
|
3046
|
+
void atenderCatalogo(mensaje.proveedor).catch(contar);
|
|
3047
|
+
respuesta.writeHead(204);
|
|
3048
|
+
respuesta.end();
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
if (typeof mensaje === "object" && mensaje !== null && mensaje.clase === "alta") {
|
|
3052
|
+
// Suelto y sin esperar: el alta hace dos viajes a CloudStudio y una descarga entera,
|
|
3053
|
+
// y el `POST` no puede quedarse abierto minutos. Lo que pase se cuenta por el cable.
|
|
3054
|
+
void atenderAlta(mensaje);
|
|
3055
|
+
}
|
|
3056
|
+
else {
|
|
3057
|
+
destinoActual().recibir(mensaje);
|
|
3058
|
+
/**
|
|
3059
|
+
* Y si fue una PROSA, se reanuncia el alta: la sesión acaba de darse de alta en el
|
|
3060
|
+
* índice (`ConsolaDeProyecto.recibir`), así que hay una fila nueva que la barra tiene
|
|
3061
|
+
* que enseñar YA — con su título y marcada como la activa.
|
|
3062
|
+
*
|
|
3063
|
+
* Diferido por lo mismo que el reanuncio de los flancos: el lazo coge la línea en una
|
|
3064
|
+
* microtarea, y anunciar en el acto contaría el turno como no empezado. No sustituye a
|
|
3065
|
+
* los flancos —de ellos sale la marca de «trabajando»—, cubre el caso en que no hay
|
|
3066
|
+
* flanco ninguno: un `/comando`, que no corre turno.
|
|
3067
|
+
*/
|
|
3068
|
+
if (mensaje.clase === "prosa") {
|
|
3069
|
+
void Promise.resolve()
|
|
3070
|
+
.then(() => anunciarAlta())
|
|
3071
|
+
.catch(contar);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
respuesta.writeHead(204);
|
|
3075
|
+
respuesta.end();
|
|
3076
|
+
});
|
|
3077
|
+
return { emitirTareas };
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Construye el corredor de tareas y la costura que lo conecta al cable.
|
|
3081
|
+
*
|
|
3082
|
+
* Extraído de `arrancarConsolaWeb` a su propia función por la MISMA razón que
|
|
3083
|
+
* `backendDeAgente` (`agent/proyecto.ts`): la composición vivía inline dentro de una
|
|
3084
|
+
* función que todos sus tests DOBLAN —`vestibulo`, `crearServidor`, `crearEjecutor`—, así
|
|
3085
|
+
* que esta costura concreta podía dejar de estar montada con el resto en verde. Aquí
|
|
3086
|
+
* tiene su propio test que la compone de VERDAD: construye un corredor real
|
|
3087
|
+
* (`crearCorredorDeTareas`) y comprueba que un cambio que el corredor mueve por su
|
|
3088
|
+
* cuenta —nace DENTRO del lazo, no de una petición del cliente— llega a
|
|
3089
|
+
* `emitirTareas`, y que si `emitirTareas` revienta el lazo de tareas no se entera.
|
|
3090
|
+
*/
|
|
3091
|
+
/**
|
|
3092
|
+
* Las fuentes con que se resuelve el papel del JUEZ para una tarea, por la raíz de SU
|
|
3093
|
+
* proyecto.
|
|
3094
|
+
*
|
|
3095
|
+
* Está extraída y exportada por el mismo motivo que `revisionConGit` y que `backendDeAgente`:
|
|
3096
|
+
* vivía dentro del cierre de `arrancarConsolaWeb`, que todos sus tests doblan, así que
|
|
3097
|
+
* quitarle la capa de proyecto no ponía ni un test en rojo — comprobado por mutación. Es la
|
|
3098
|
+
* cuarta vez en esta tanda que una composición de producción escondida en un cierre deja una
|
|
3099
|
+
* regla sin montar con todo en verde.
|
|
3100
|
+
*
|
|
3101
|
+
* Las dos capas salen de `cargar(raiz)`, que trae el `config.json` del proyecto y el global:
|
|
3102
|
+
* en la consola web `FuentesDeEleccion.proyecto` no se rellena nunca —el vestíbulo sirve
|
|
3103
|
+
* muchos proyectos y las fuentes se construyen una vez al arrancar—, así que sin preguntarle
|
|
3104
|
+
* al disco por la raíz de la tarea, un proyecto que apuntara `afilado` a otro modelo se
|
|
3105
|
+
* ignoraba en silencio. La misma trampa que `cloudstudioDelProyecto` ya resolvió así.
|
|
3106
|
+
*
|
|
3107
|
+
* `proyecto` se omite en vez de ponerse a `undefined`: ausente es «este proyecto no dice
|
|
3108
|
+
* nada», y la precedencia de `core/modelos.ts` cuenta con eso.
|
|
3109
|
+
*/
|
|
3110
|
+
export function fuentesDelJuez(raiz) {
|
|
3111
|
+
const { config } = cargar(raiz);
|
|
3112
|
+
return {
|
|
3113
|
+
...(config.proyecto === undefined ? {} : { proyecto: config.proyecto }),
|
|
3114
|
+
global: config.global,
|
|
3115
|
+
entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
/**
|
|
3119
|
+
* Las fuentes del modelo con que arranca CADA consola de proyecto del vestíbulo.
|
|
3120
|
+
*
|
|
3121
|
+
* Es una función y no una constante por lo mismo que `fuentesDelJuez` está extraída: vivía
|
|
3122
|
+
* dentro del cierre de `vestibuloReal` y se construía UNA vez, al arrancar. El global es del
|
|
3123
|
+
* usuario —esta misma consola lo reescribe desde Ajustes—, así que la instantánea dejaba a
|
|
3124
|
+
* las sesiones NUEVAS resolviendo contra el valor viejo mientras Ajustes enseñaba ya el
|
|
3125
|
+
* elegido. El síntoma, medido: el proceso arrancó con `gemini` en el fichero, el fichero pasó
|
|
3126
|
+
* a `ollama`, y una sesión nueva seguía diciendo `gemini`.
|
|
3127
|
+
*
|
|
3128
|
+
* La capa de PROYECTO no se rellena —el vestíbulo sirve muchos proyectos a la vez y aquí no
|
|
3129
|
+
* hay uno del que hablar—, así que se llama a `cargar` por el cwd SOLO para llegar al fichero
|
|
3130
|
+
* global, que es el único que este lector tiene que ver. Mismo camino que `modeloPorDefecto`.
|
|
3131
|
+
*/
|
|
3132
|
+
export function fuentesDeLaConsolaWeb(cwd) {
|
|
3133
|
+
return {
|
|
3134
|
+
global: cargar(cwd).config.global,
|
|
3135
|
+
entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Cuánta memoria de proyecto se le da al aumentador.
|
|
3140
|
+
*
|
|
3141
|
+
* Es CONTEXTO de una llamada, no un fichero que servir: `.xonecode/memoria.md` lo escribe el
|
|
3142
|
+
* agente turno tras turno y puede crecer sin tope. Y el árbol del proyecto ya se descartó por
|
|
3143
|
+
* lo mismo (§7 del diseño: «el árbol entero sería contexto por gastar»); dejar la memoria sin
|
|
3144
|
+
* acotar reintroduciría el problema por la puerta de al lado.
|
|
3145
|
+
*/
|
|
3146
|
+
export const TOPE_DE_MEMORIA = 4_000;
|
|
3147
|
+
/**
|
|
3148
|
+
* Lo que se le añade a la petición del aumentador leyendo el DISCO por la raíz: la rama del
|
|
3149
|
+
* proyecto y su memoria.
|
|
3150
|
+
*
|
|
3151
|
+
* **Por la raíz y no por las fuentes**, la trampa que `cloudstudioDelProyecto` ya resolvió:
|
|
3152
|
+
* en la consola web `FuentesDeEleccion.proyecto` no se rellena nunca. Y lo que el disco no
|
|
3153
|
+
* dice NO se pone —ausente es «no hay»—: un `rama: undefined` o un `memoria: ""` en el prompt
|
|
3154
|
+
* harían que el modelo hablara de una rama sin nombre como si fuera un dato.
|
|
3155
|
+
*
|
|
3156
|
+
* Nada de esto lanza. Que no haya memoria, o que el `config.json` no se pueda leer, no puede
|
|
3157
|
+
* impedir redactar un encargo.
|
|
3158
|
+
*/
|
|
3159
|
+
export function contextoDelProyecto(raiz) {
|
|
3160
|
+
const rama = cloudstudioDelProyecto(raiz)?.rama;
|
|
3161
|
+
let memoria;
|
|
3162
|
+
try {
|
|
3163
|
+
const leido = readFileSync(rutaMemoriaDeProyecto(raiz), "utf8").trim();
|
|
3164
|
+
if (leido !== "")
|
|
3165
|
+
memoria = leido.slice(0, TOPE_DE_MEMORIA);
|
|
3166
|
+
}
|
|
3167
|
+
catch {
|
|
3168
|
+
// No hay memoria, o no se puede leer: no se afirma ninguna.
|
|
3169
|
+
}
|
|
3170
|
+
return {
|
|
3171
|
+
...(rama === undefined || rama === "" ? {} : { rama }),
|
|
3172
|
+
...(memoria === undefined ? {} : { memoria }),
|
|
3173
|
+
};
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* Cruza la elección GUARDADA de la sesión con la última MEDIDA del equipo.
|
|
3177
|
+
*
|
|
3178
|
+
* **Esto es la costura entera de `dispositivo-desconocido`, y sin ella esa causa no tiene
|
|
3179
|
+
* emisor.** Las dos situaciones —«no has elegido ninguno» y «elegiste uno que ya no está»—
|
|
3180
|
+
* llegan a `puedeLanzarse` con `dispositivo: undefined`, porque la diferencia no cabe en el
|
|
3181
|
+
* dato del dispositivo: no hay ninguno. Lo único que las separa es el `elegido`, y quien lo
|
|
3182
|
+
* sabe es la SESIÓN, que guarda la foto (`ConsolaDeProyecto.dispositivo`) mientras el informe
|
|
3183
|
+
* la resuelve.
|
|
3184
|
+
*
|
|
3185
|
+
* **Se resuelve por lo que NO se encontró**: con un id guardado que no está en la medida, la
|
|
3186
|
+
* causa es `dispositivo-desconocido`; sin id guardado no hay nada que buscar y ése es
|
|
3187
|
+
* `sin-dispositivo-elegido`. Y cuando el id SÍ resuelve, `elegido` no se pone — manda
|
|
3188
|
+
* `dispositivo`, que es el dato bueno.
|
|
3189
|
+
*
|
|
3190
|
+
* La rama `dispositivo` del cable (`POST /accion`) ya tolera en silencio un id que no esté en
|
|
3191
|
+
* la medida —es una foto vieja del cliente, no un error que contar—; aquí ese mismo caso se
|
|
3192
|
+
* CUENTA, porque es justo lo que decide la frase que se le enseña a quien va a pulsar.
|
|
3193
|
+
*
|
|
3194
|
+
* Extraída y exportada por la razón de siempre: es una regla de producción, y compuesta dentro
|
|
3195
|
+
* de un cierre que los tests doblan no estaría probada — estaría escrita.
|
|
3196
|
+
*/
|
|
3197
|
+
export function dispositivoDeLaSesion(elegido, informe) {
|
|
3198
|
+
if (elegido === undefined)
|
|
3199
|
+
return { dispositivo: undefined };
|
|
3200
|
+
const fila = informe?.dispositivos.find((d) => d.id === elegido.id);
|
|
3201
|
+
if (fila === undefined)
|
|
3202
|
+
return { dispositivo: undefined, elegido: elegido.id };
|
|
3203
|
+
return { dispositivo: fila };
|
|
3204
|
+
}
|
|
3205
|
+
/**
|
|
3206
|
+
* La foto de un dispositivo tal y como viaja: las CUATRO cosas que la pestaña pinta, y nada
|
|
3207
|
+
* más. `estado` y `detalle` se quedan fuera a propósito — son de la medida, y la medida ya
|
|
3208
|
+
* viaja entera por el mensaje `dispositivos`; repetirla en cada veredicto sería una segunda
|
|
3209
|
+
* copia del mismo dato, que es la que se queda vieja.
|
|
3210
|
+
*/
|
|
3211
|
+
function fotoDeDispositivo(dispositivo) {
|
|
3212
|
+
return {
|
|
3213
|
+
id: dispositivo.id,
|
|
3214
|
+
nombre: dispositivo.nombre,
|
|
3215
|
+
plataforma: dispositivo.plataforma,
|
|
3216
|
+
clase: dispositivo.clase,
|
|
3217
|
+
};
|
|
3218
|
+
}
|
|
3219
|
+
/**
|
|
3220
|
+
* Por qué no se lanza aunque el veredicto diga que sí, en los dos casos que el veredicto no
|
|
3221
|
+
* cubre a propósito.
|
|
3222
|
+
*
|
|
3223
|
+
* No son causas de `CausaDeBloqueo`: el catálogo de `core/puedeLanzarse.ts` dice qué le falta
|
|
3224
|
+
* al DISPOSITIVO o al PROYECTO, y estos dos dicen qué le falta a ESTA ejecución para poder
|
|
3225
|
+
* hacer el trabajo. Meterlos en la unión del veredicto mezclaría «tu proyecto no está listo»
|
|
3226
|
+
* con «yo no puedo», que se arreglan en sitios distintos.
|
|
3227
|
+
*/
|
|
3228
|
+
const SIN_NOMBRE_DE_APP = "No se puede lanzar sin saber cómo se llama la app: el nombre sale del `name=` de app.ini, y ese fichero no está o no lo dice. Ábrelo en XOne Studio y vuelve a guardarlo.";
|
|
3229
|
+
const SIN_CAMINO_DE_LANZAMIENTO = "Esta ejecución de xonecode no puede lanzar apps —no tiene montado el lanzamiento—, así que el veredicto se puede mirar pero no ejecutar.";
|
|
3230
|
+
/**
|
|
3231
|
+
* Lo que la pestaña CloudStudio enseña: de qué rama es este proyecto y cuántos ficheros
|
|
3232
|
+
* quedan por subir.
|
|
3233
|
+
*
|
|
3234
|
+
* **Extraída y exportada, no inline en el manejador del cable**, por la razón de siempre en
|
|
3235
|
+
* esta casa: una regla de producción compuesta dentro de un cierre que los tests doblan no
|
|
3236
|
+
* está probada, está escrita. Aquí lo que se caería sin síntoma es la MEDIDA —una lectura
|
|
3237
|
+
* que devolviera cero pendientes siempre se vería como un proyecto al día, que es
|
|
3238
|
+
* exactamente lo que nadie iría a comprobar—.
|
|
3239
|
+
*
|
|
3240
|
+
* Se mide contra la ref de seguimiento (`refs/remotes/cloudstudio/<rama>`), que es la MISMA
|
|
3241
|
+
* cuenta que da `/sync estado` y la que decide qué sube el plan, y **sin abrir sesión MCP**:
|
|
3242
|
+
* el número no está arriba, está en esa ref, y abrir OAuth para contar lo que ya se sabe en
|
|
3243
|
+
* local sería pedir red y credenciales para pintar un contador. El comando del terminal sí
|
|
3244
|
+
* la abre —es genérico y no se toca—; la diferencia se declara aquí.
|
|
3245
|
+
*
|
|
3246
|
+
* Un mensaje SIN `proyecto` ni `rama` no es un fallo: es que este proyecto no está dado de
|
|
3247
|
+
* alta en CloudStudio, y quien lo pinta tiene que poder decir eso en vez de un cero.
|
|
3248
|
+
*
|
|
3249
|
+
* `sesion` entra para poder decir DE QUIÉN son los pendientes (`deLaSesion`), que es lo que
|
|
3250
|
+
* evita que las dos cifras de la pestaña —la de la banda y la de la lista— parezcan
|
|
3251
|
+
* contradecirse cuando en realidad se miden contra referencias distintas.
|
|
3252
|
+
*/
|
|
3253
|
+
export async function lecturaDeSync(raiz, sesion) {
|
|
3254
|
+
const cloudstudio = cloudstudioDelProyecto(raiz);
|
|
3255
|
+
const proyecto = cloudstudio?.proyecto?.nombre;
|
|
3256
|
+
const rama = cloudstudio?.rama;
|
|
3257
|
+
if (proyecto === undefined || rama === undefined || rama === "")
|
|
3258
|
+
return { clase: "sync" };
|
|
3259
|
+
try {
|
|
3260
|
+
const pendientes = await cambiosPendientes(raiz, rama);
|
|
3261
|
+
const deLaSesion = await cuantosDeLaSesion(raiz, sesion, pendientes);
|
|
3262
|
+
return {
|
|
3263
|
+
clase: "sync",
|
|
3264
|
+
proyecto,
|
|
3265
|
+
rama,
|
|
3266
|
+
pendientes: pendientes.length,
|
|
3267
|
+
...(deLaSesion === undefined ? {} : { deLaSesion }),
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
catch {
|
|
3271
|
+
// Ni el `code` de Node ni el stderr de git: lo que aquí falla es que la copia no se
|
|
3272
|
+
// puede comparar con la rama (no es un repo, o no tiene la ref de la descarga), y eso
|
|
3273
|
+
// se dice con palabras. El mensaje de git trae rutas absolutas del disco.
|
|
3274
|
+
return {
|
|
3275
|
+
clase: "sync",
|
|
3276
|
+
proyecto,
|
|
3277
|
+
rama,
|
|
3278
|
+
error: "no se pudo medir lo que falta por subir: la copia local no se puede comparar con la rama",
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* De los pendientes, cuántos tocó ESTA sesión — o `undefined` cuando no se puede atribuir.
|
|
3284
|
+
*
|
|
3285
|
+
* Existe porque las dos cifras que la pestaña enseña juntas se miden contra referencias
|
|
3286
|
+
* distintas: `pendientes` contra la rama (todo lo que difiere de lo que consta arriba, sea de
|
|
3287
|
+
* quien sea) y la lista de Revisión contra el sello de la sesión. Sin esto, la banda dice
|
|
3288
|
+
* «3 ficheros por subir» encima de una sesión cuyos cambios no están entre esos 3, y las dos
|
|
3289
|
+
* cifras parecen contradecirse cuando lo que pasa es que hablan de trabajos distintos.
|
|
3290
|
+
*
|
|
3291
|
+
* **`via !== "git"` se trata como no atribuible, y es la decisión que importa.** Las otras
|
|
3292
|
+
* dos vías no son atribución: `desde-apertura` es «todo lo que cambió en esta copia desde que
|
|
3293
|
+
* te sentaste», que incluye lo que escribieran una tarea de fondo o el propio usuario, y
|
|
3294
|
+
* `sin-marca` es no saber. Devolver un número con esas dos afirmaría «esta sesión tocó N de
|
|
3295
|
+
* estos» sobre algo que la sesión no tocó — la misma mentira que `Revision` evita rotulando
|
|
3296
|
+
* esas filas «Desde que abriste» en vez de «Sesión».
|
|
3297
|
+
*
|
|
3298
|
+
* Y **un fallo aquí no puede llevarse por delante la medida que sí se hizo**: arriba hay un
|
|
3299
|
+
* `git diff` que ya contestó, y convertir eso en «no se pudo medir lo que falta por subir»
|
|
3300
|
+
* porque la atribución falló sería tirar el dato bueno por el malo. Se devuelve `undefined`,
|
|
3301
|
+
* que es exactamente «no consta» y lo que la banda sabe no pintar.
|
|
3302
|
+
*/
|
|
3303
|
+
async function cuantosDeLaSesion(raiz, sesion, pendientes) {
|
|
3304
|
+
if (sesion === undefined)
|
|
3305
|
+
return undefined;
|
|
3306
|
+
try {
|
|
3307
|
+
const { via, ficheros } = await cambiosDeSesion(raiz, sesion);
|
|
3308
|
+
if (via !== "git")
|
|
3309
|
+
return undefined;
|
|
3310
|
+
const suyos = new Set(ficheros.map((fichero) => fichero.ruta));
|
|
3311
|
+
return pendientes.filter((cambio) => suyos.has(cambio.ruta)).length;
|
|
3312
|
+
}
|
|
3313
|
+
catch {
|
|
3314
|
+
return undefined;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* La costura entre el cable y el aumentador: convierte lo que `montarRutas` resuelve
|
|
3319
|
+
* —proyecto y adjuntos— en la `PeticionDeTarea` del puerto, añadiéndole lo que solo se sabe
|
|
3320
|
+
* mirando el disco.
|
|
3321
|
+
*
|
|
3322
|
+
* **Extraída y exportada, no inline en `arrancarConsolaWeb`**, por la misma razón que
|
|
3323
|
+
* `revisionConGit` y `fuentesDelJuez`: en esta tanda cuatro veces una composición de
|
|
3324
|
+
* producción vivía en un cierre que todos sus tests doblan, y una regla se quedó sin montar
|
|
3325
|
+
* con todo en verde. Lo que aquí se caería sin dar ni un síntoma es la mitad del contexto: un
|
|
3326
|
+
* encargo redactado sin la rama ni la memoria del proyecto se ve perfectamente normal.
|
|
3327
|
+
*
|
|
3328
|
+
* El error se PROPAGA: quien lo convierte en palabras para la ventana es `atenderAugmentar`,
|
|
3329
|
+
* que aplica la regla del mensaje-o-código. Tragárselo aquí dejaría a la ventana con un
|
|
3330
|
+
* encargo vacío y sin motivo.
|
|
3331
|
+
*/
|
|
3332
|
+
export function augmentacionCableada(opciones) {
|
|
3333
|
+
return async ({ texto, proyecto, adjuntos }) => {
|
|
3334
|
+
const extra = opciones.contexto?.(proyecto.raiz) ?? {};
|
|
3335
|
+
return opciones.aumentador.augmentar({
|
|
3336
|
+
texto,
|
|
3337
|
+
// El `id` del proyecto NO viaja al modelo: es un identificador de CloudStudio y para
|
|
3338
|
+
// redactar no aporta nada. Sí la raíz, que es con lo que se resuelve el papel.
|
|
3339
|
+
proyecto: { nombre: proyecto.nombre, raiz: proyecto.raiz, ...(extra.rama === undefined ? {} : { rama: extra.rama }) },
|
|
3340
|
+
adjuntos,
|
|
3341
|
+
...(extra.memoria === undefined ? {} : { memoria: extra.memoria }),
|
|
3342
|
+
});
|
|
3343
|
+
};
|
|
3344
|
+
}
|
|
3345
|
+
/**
|
|
3346
|
+
* El commit de cada turno, cableado — y extraído por el motivo documentado en CLAUDE.md:
|
|
3347
|
+
* esta composición vivía en un cierre de `arrancarConsolaWeb` que todos sus tests doblan, y
|
|
3348
|
+
* es LA MISMA forma de fallo que ya se ha medido tres veces en `abrirParaTarea` (una lambda
|
|
3349
|
+
* escrita a mano que se deja un argumento, con TypeScript callado porque una función que
|
|
3350
|
+
* ignora parámetros es asignable). Aquí el argumento que se puede caer es el que sostiene la
|
|
3351
|
+
* atribución entera: sin `sesion`, el commit sale sin sello y la pestaña Revisión se cae al
|
|
3352
|
+
* respaldo para siempre, sin un solo síntoma.
|
|
3353
|
+
*
|
|
3354
|
+
* Dos decisiones que se quedan aquí y no en el vestíbulo:
|
|
3355
|
+
* - **DÓNDE se commitea**: solo en la copia que creó xonecode. En la carpeta que abrió una
|
|
3356
|
+
* persona —offline, o `./bin/xonecode` dentro de su repo— un commit por turno sería
|
|
3357
|
+
* ensuciarle el historial cada vez que habla con el agente.
|
|
3358
|
+
* - **Qué se DICE**: solo el fallo. Que no haya nada que commitear, que la carpeta no sea un
|
|
3359
|
+
* repo (todo proyecto offline) o que no sea del workspace no son avisos: son el caso
|
|
3360
|
+
* normal, y uno por turno enseñaría a ignorarlos.
|
|
3361
|
+
*/
|
|
3362
|
+
export function commitDeTurnoCableado(opciones) {
|
|
3363
|
+
const commitear = opciones.commitear ?? commitDeTurno;
|
|
3364
|
+
return async (raiz, mensaje, sesion) => {
|
|
3365
|
+
if (!dentroDelWorkspace(raiz, opciones.base))
|
|
3366
|
+
return undefined;
|
|
3367
|
+
const hecho = await commitear(raiz, mensaje, sesion);
|
|
3368
|
+
return hecho.via === "fallo" ? `no se pudo commitear el turno: ${hecho.motivo}` : undefined;
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
export function construirCorredorDeTareasCableado(opciones) {
|
|
3372
|
+
const disco = opciones.tareasFabrica === undefined ? undefined : opciones.tareasFabrica(opciones.informar);
|
|
3373
|
+
/**
|
|
3374
|
+
* El tope de concurrencia se LEE de `settings.json` en cada llamada, nunca se cachea —la
|
|
3375
|
+
* misma disciplina que `ajustesDeDispositivos` (más abajo) y que `sinAprobacion`: la
|
|
3376
|
+
* sección «Tareas» de Ajustes escribe con `guardarConcurrenciaDeTareas` mientras el
|
|
3377
|
+
* proceso vive, y una copia capturada al construir este corredor no la vería. Ausente en
|
|
3378
|
+
* disco = `CONCURRENCIA_POR_OMISION` (2), la misma omisión que ya usaba la variable en
|
|
3379
|
+
* memoria que esto sustituye.
|
|
3380
|
+
*/
|
|
3381
|
+
const concurrenciaDeTareas = () => cargarSettings().settings.concurrenciaDeTareas ?? CONCURRENCIA_POR_OMISION;
|
|
3382
|
+
/**
|
|
3383
|
+
* El puente hacia `emitirTareas`. El corredor nace ANTES que el cable —`montarRutas`
|
|
3384
|
+
* necesita poder pedirle `revisar()` y `corriendoAqui()`—, pero su `alCambiar` (una
|
|
3385
|
+
* tarea que el CORREDOR mueve por su cuenta: empieza, aparca, termina) solo puede
|
|
3386
|
+
* alcanzar el cable una vez que existe. `arrancarConectado` es quien la rellena, antes
|
|
3387
|
+
* de arrancar. Sin este puente, el kanban solo se movería cuando alguien tocara algo
|
|
3388
|
+
* desde el navegador — la peor forma de estar roto: parece que funciona.
|
|
3389
|
+
*/
|
|
3390
|
+
let emitirCambioDeTareas;
|
|
3391
|
+
const corredor = disco === undefined
|
|
3392
|
+
? undefined
|
|
3393
|
+
: crearCorredorDeTareas({
|
|
3394
|
+
disco,
|
|
3395
|
+
// La costura de las tres piezas: la segunda puerta del vestíbulo (que no mueve el
|
|
3396
|
+
// cable), la consola que APARCA en vez de contestar por nadie, y el ejecutor de
|
|
3397
|
+
// siempre con las mismas barreras que el de una persona.
|
|
3398
|
+
// `sesion` reenviada: es lo que hace que reanudar (un reintento, o un feedback)
|
|
3399
|
+
// reabra el MISMO hilo en vez de uno en blanco. Ver `corredorDeTareas.ts`.
|
|
3400
|
+
// `sesion`, `adjuntos` y `tarea` reenviadas TAL CUAL, y las tres por el mismo
|
|
3401
|
+
// motivo: quien sabe de qué tarea es esta apertura es el corredor. La primera hace
|
|
3402
|
+
// que reanudar siga la MISMA conversación; la segunda es lo que monta `/adjuntos/`
|
|
3403
|
+
// en el backend del agente (`core/adjuntos.ts`); la tercera es lo que marca su
|
|
3404
|
+
// sesión en el índice del proyecto (`EntradaIndice.tarea`). Dejarse cualquiera no
|
|
3405
|
+
// da ningún síntoma —esto es una lambda que reenvía a mano, y TypeScript no se
|
|
3406
|
+
// queja de una función que ignora argumentos—: un hilo en blanco, unos adjuntos
|
|
3407
|
+
// que el agente no puede abrir, o una fila de la barra que miente para siempre.
|
|
3408
|
+
abrirParaTarea: async (raiz, sesion, adjuntos, tarea) => consolaParaTarea(await opciones.vestibulo.abrirParaTarea(raiz, sesion, adjuntos, tarea)),
|
|
3409
|
+
// La SIEMBRA de la marca en las sesiones que ya existían. Ver
|
|
3410
|
+
// `sesiones.ts#marcarTareaDeSesion`: solo añade, así que correrla en cada arranque
|
|
3411
|
+
// no puede convertir una sesión de tarea en una conversación.
|
|
3412
|
+
marcarSesionDeTarea: (raiz, sesion, tarea) => void marcarTareaDeSesion(raiz, sesion, tarea),
|
|
3413
|
+
// Se lee de disco en cada pasada: cambiar el tope en Ajustes se nota sin
|
|
3414
|
+
// reiniciar nada, en la siguiente ronda de planificación.
|
|
3415
|
+
concurrencia: concurrenciaDeTareas,
|
|
3416
|
+
/**
|
|
3417
|
+
* GANA LA PERSONA: en el proyecto que alguien tiene abierto no arranca ninguna
|
|
3418
|
+
* tarea. Una tarea y una persona sobre el mismo árbol no tienen aislamiento de
|
|
3419
|
+
* ninguna clase, y el cerrojo del corredor no protege de eso — protege de dos
|
|
3420
|
+
* corredores. Se pregunta en cada pasada, no al arrancar: abrir un proyecto no
|
|
3421
|
+
* reinicia nada.
|
|
3422
|
+
*
|
|
3423
|
+
* **TODAS las abiertas, no la que está en foco.** Desde que cambiar de sesión no
|
|
3424
|
+
* mata el turno anterior, una consola humana puede seguir viva en segundo plano
|
|
3425
|
+
* —con el agente escribiendo— mientras el navegador mira otro proyecto. Con
|
|
3426
|
+
* `proyectoAbierto()` esa raíz se habría contado como libre y una tarea habría
|
|
3427
|
+
* arrancado a escribir en la misma copia: el fallo ABIERTO de ese cambio, y el
|
|
3428
|
+
* síntoma no es un error sino un diff corrompido que nadie atribuye.
|
|
3429
|
+
*/
|
|
3430
|
+
bloqueados: () => opciones.vestibulo.proyectosAbiertos().map((c) => c.raiz),
|
|
3431
|
+
/**
|
|
3432
|
+
* `sesion` sobrevive si y solo si hay algo que una persona pueda ABRIR, y esto es
|
|
3433
|
+
* lo que lo decide: la sesión está en el índice del proyecto —o sea que su
|
|
3434
|
+
* transcript se volcó y se lee desde la barra lateral— o no está. Es exactamente
|
|
3435
|
+
* la lista que la barra pinta, no una segunda fuente que pueda contradecirla.
|
|
3436
|
+
*/
|
|
3437
|
+
sesionAbrible: (raiz, sesion) => opciones.vestibulo.sesionesDe(raiz).some((s) => s.id === sesion),
|
|
3438
|
+
// Y el hilo del agente de una sesión que no nombra nada abrible se olvida, igual
|
|
3439
|
+
// que al borrar una conversación: un checkpoint es la lista de mensajes entera y
|
|
3440
|
+
// crece, y ahí seguiría vivo e invisible desde la interfaz para siempre.
|
|
3441
|
+
olvidarHilo: opciones.olvidarHiloDeSesion,
|
|
3442
|
+
/**
|
|
3443
|
+
* La puerta de la ENTREGA: «terminada» ya no significa «el turno acabó». Las tres
|
|
3444
|
+
* condiciones las comprueba el código (`core/entrega.ts`) y el juez de QA opina, y
|
|
3445
|
+
* hacen falta las dos — es la regla que este repo ya tenía escrita para la subida
|
|
3446
|
+
* autónoma, y el mismo motivo por el que los avisos de honestidad son código y no
|
|
3447
|
+
* prompt.
|
|
3448
|
+
*/
|
|
3449
|
+
juez: opciones.juez,
|
|
3450
|
+
revisable: opciones.revisable,
|
|
3451
|
+
/**
|
|
3452
|
+
* El puente hacia el cable, y NUNCA puede tumbar el lazo de tareas: un sumidero
|
|
3453
|
+
* muerto —o cualquier otra cosa que `emitirTareas` haga mal— es un problema del
|
|
3454
|
+
* cable, no de la tarea que el corredor acaba de escribir.
|
|
3455
|
+
* `escribirSiSigueSiendoNuestra` llama a este `alCambiar` SIN su propio `try`,
|
|
3456
|
+
* así que una excepción de aquí subiría hasta `aparcar`/`correr` y podría hacer
|
|
3457
|
+
* que una escritura que SÍ funcionó se cuente como fallida. Se atrapa aquí, en
|
|
3458
|
+
* el único sitio que sabe que lo que sigue es «avisar», no «guardar».
|
|
3459
|
+
*
|
|
3460
|
+
* El mensaje usa `error.message` y no `codigoDe` (la regla de `montarRutas` y de
|
|
3461
|
+
* `corredorDeTareas.ts`): esa función existe para tapar la ruta absoluta de un
|
|
3462
|
+
* fallo de FICHERO, y lo que puede fallar aquí es `emitirTareas()` — un recorrido
|
|
3463
|
+
* de sumideros SSE que ya se protege cada uno por su cuenta (`sumidero` en la
|
|
3464
|
+
* ruta `/eventos`), así que en producción esto no lanza nada con un disco detrás.
|
|
3465
|
+
* Lo único que llega hasta aquí es un sumidero de mentira que revienta a propósito.
|
|
3466
|
+
*/
|
|
3467
|
+
alCambiar: () => {
|
|
3468
|
+
try {
|
|
3469
|
+
emitirCambioDeTareas?.();
|
|
3470
|
+
}
|
|
3471
|
+
catch (error) {
|
|
3472
|
+
opciones.informar(`no se pudo avisar del cambio en la cola de tareas (${error instanceof Error ? error.message : String(error)})`);
|
|
3473
|
+
}
|
|
3474
|
+
},
|
|
3475
|
+
informar: opciones.informar,
|
|
3476
|
+
});
|
|
3477
|
+
return {
|
|
3478
|
+
corredor,
|
|
3479
|
+
opcionesDeMontaje: {
|
|
3480
|
+
...(disco === undefined ? {} : { colaDeTareas: disco }),
|
|
3481
|
+
...(corredor === undefined ? {} : { corredorDeTareas: corredor }),
|
|
3482
|
+
concurrenciaDeTareas,
|
|
3483
|
+
// Persiste en `settings.json` — `guardarConcurrenciaDeTareas` acota a
|
|
3484
|
+
// `0..TOPE_DE_CONCURRENCIA_DE_TAREAS` por su cuenta, así que un valor fuera de rango
|
|
3485
|
+
// que llegara por el cable no deja basura en el fichero.
|
|
3486
|
+
guardarConcurrencia: (c) => {
|
|
3487
|
+
guardarConcurrenciaDeTareas(undefined, c);
|
|
3488
|
+
},
|
|
3489
|
+
},
|
|
3490
|
+
arrancarConectado: async (cable) => {
|
|
3491
|
+
emitirCambioDeTareas = cable.emitirTareas;
|
|
3492
|
+
await corredor?.arrancar();
|
|
3493
|
+
},
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* El cuerpo en BYTES, con su propio tope.
|
|
3498
|
+
*
|
|
3499
|
+
* No se reutiliza `leerCuerpo`: ese acota a `TOPE_DE_CUERPO` (1 MB) y devuelve `utf8`, o sea
|
|
3500
|
+
* las dos cosas equivocadas para un adjunto — un PNG de 3 MB se rechazaría, y pasarlo por
|
|
3501
|
+
* utf8 lo destrozaría. El tope se corta EN CUANTO se pasa y no al final: acumular 20 MB para
|
|
3502
|
+
* después decir que no caben sería pagar la memoria del rechazo.
|
|
3503
|
+
*/
|
|
3504
|
+
async function leerCuerpoCrudo(peticion, tope) {
|
|
3505
|
+
const trozos = [];
|
|
3506
|
+
let total = 0;
|
|
3507
|
+
for await (const trozo of peticion) {
|
|
3508
|
+
const buffer = Buffer.from(trozo);
|
|
3509
|
+
total += buffer.length;
|
|
3510
|
+
if (total > tope)
|
|
3511
|
+
throw new Error("cuerpo demasiado grande");
|
|
3512
|
+
trozos.push(buffer);
|
|
3513
|
+
}
|
|
3514
|
+
return Buffer.concat(trozos);
|
|
3515
|
+
}
|
|
3516
|
+
async function leerCuerpo(peticion) {
|
|
3517
|
+
const trozos = [];
|
|
3518
|
+
let total = 0;
|
|
3519
|
+
for await (const trozo of peticion) {
|
|
3520
|
+
const buffer = Buffer.from(trozo);
|
|
3521
|
+
total += buffer.length;
|
|
3522
|
+
if (total > TOPE_DE_CUERPO)
|
|
3523
|
+
throw new Error("cuerpo demasiado grande");
|
|
3524
|
+
trozos.push(buffer);
|
|
3525
|
+
}
|
|
3526
|
+
return Buffer.concat(trozos).toString("utf8");
|
|
3527
|
+
}
|
|
3528
|
+
/**
|
|
3529
|
+
* Levanta la consola web y se queda. Devuelve el código de salida del proceso.
|
|
3530
|
+
*
|
|
3531
|
+
* El orden de las comprobaciones no es casual: primero lo que hace IMPOSIBLE arrancar
|
|
3532
|
+
* (falta el build del cliente → 70, fallo del entorno y no del proyecto), y después lo que
|
|
3533
|
+
* solo hay que DECIR (un proyecto offline en el cwd). Lo segundo informa y sigue, porque no
|
|
3534
|
+
* es un error: quien abrió aquí un proyecto offline puede querer la web para otro.
|
|
3535
|
+
*
|
|
3536
|
+
* **`--guion` sobre un proyecto offline lo abre solo, sin pasar por el alta.** Es la única
|
|
3537
|
+
* vía para ver la maqueta completa —barra con datos, transcript, compositor— sin
|
|
3538
|
+
* credenciales de CloudStudio: el alta de la web solo sabe de entornos y proyectos
|
|
3539
|
+
* REMOTOS (`vestibulo.ts`), así que un proyecto offline nunca llega a `proyectoAbierto()`
|
|
3540
|
+
* por ese camino, con o sin `--guion`. `vestibulo.abrirProyecto` (`vestibulo.ts#abrirDeVerdad`)
|
|
3541
|
+
* no toca red —es local, el mismo turno que corre `--cli`—, así que abrirlo aquí no es un
|
|
3542
|
+
* doble de nada: es la operación real, disparada por una bandera que ya existe y ya
|
|
3543
|
+
* significa «sin gastar ni conectar». Sin `--guion` esto NO se abre solo —sería magia, no
|
|
3544
|
+
* un modo declarado—, y el aviso de abajo sigue mandando a `--cli`.
|
|
3545
|
+
*/
|
|
3546
|
+
/**
|
|
3547
|
+
* Cuántos nombres de fichero se mandan en el aviso de trabajo sin commitear. El alta se
|
|
3548
|
+
* reemite en los DOS flancos de cada turno, así que la lista viaja muchas veces; y la frase
|
|
3549
|
+
* que la pinta no puede llevar trescientos nombres de todas formas. Lo que no cabe se
|
|
3550
|
+
* cuenta: `total` va siempre entero.
|
|
3551
|
+
*/
|
|
3552
|
+
export const FICHEROS_DEL_AVISO = 20;
|
|
3553
|
+
export async function arrancarConsolaWeb(opciones) {
|
|
3554
|
+
const escribir = opciones.escribir ?? ((texto) => void process.stdout.write(texto));
|
|
3555
|
+
const raizDelCliente = opciones.raizDelCliente ?? raizDelClientePorOmision();
|
|
3556
|
+
if (!existsSync(join(raizDelCliente, "index.html"))) {
|
|
3557
|
+
process.stderr.write(`${FALTA_EL_BUILD}\n`);
|
|
3558
|
+
return 70; // EX_SOFTWARE: falta una pieza del entorno, el proyecto no tiene la culpa
|
|
3559
|
+
}
|
|
3560
|
+
const offline = esProyectoOffline(opciones.cwd);
|
|
3561
|
+
if (offline && opciones.guion !== true) {
|
|
3562
|
+
escribir("este directorio es un proyecto offline: ábrelo con «xonecode --cli»\n");
|
|
3563
|
+
}
|
|
3564
|
+
const arrancar = opciones.crearServidor ?? arrancarServidor;
|
|
3565
|
+
const servidor = await arrancar({
|
|
3566
|
+
puerto: opciones.puerto,
|
|
3567
|
+
raizEstaticos: raizDelCliente,
|
|
3568
|
+
...(opciones.anfitrion === undefined ? {} : { anfitrion: opciones.anfitrion }),
|
|
3569
|
+
});
|
|
3570
|
+
/**
|
|
3571
|
+
* El aviso que se ve por los DOS sitios, y es el mismo para el vestíbulo y para las rutas.
|
|
3572
|
+
*
|
|
3573
|
+
* El vestíbulo se captura perezosamente porque esta función se construye antes que él.
|
|
3574
|
+
* Medido antes de este arreglo: `montarRutas` recibía un `informar` que solo escribía en
|
|
3575
|
+
* el terminal, así que una URL rechazada o un `fetch failed` durante el alta salían por la
|
|
3576
|
+
* consola del proceso y NO llegaban al navegador — el `finally` re-anunciaba el alta, el
|
|
3577
|
+
* wizard repintaba el mismo paso, y el usuario no leía ni una palabra. En la piel que
|
|
3578
|
+
* ahora es la de omisión, y que vive en un navegador donde el terminal puede ni verse, eso
|
|
3579
|
+
* es un fallo mudo. Al revés también hace falta: en cuanto hay proyecto abierto, la consola
|
|
3580
|
+
* del vestíbulo ya no la mira nadie, y ahí caería «la consola del proyecto terminó con un
|
|
3581
|
+
* error».
|
|
3582
|
+
*/
|
|
3583
|
+
let vestibulo;
|
|
3584
|
+
const informar = (texto) => {
|
|
3585
|
+
escribir(`${texto}\n`);
|
|
3586
|
+
vestibulo?.consola.consola.escribir(`${texto}\n`);
|
|
3587
|
+
};
|
|
3588
|
+
vestibulo = opciones.vestibulo ?? vestibuloReal(opciones, servidor, escribir, informar);
|
|
3589
|
+
const conVestibulo = vestibulo;
|
|
3590
|
+
/**
|
|
3591
|
+
* El corredor de tareas y su costura con el cable — `construirCorredorDeTareasCableado`
|
|
3592
|
+
* y no inline: es lo que la hace comprobable de verdad (ver su comentario). Se construye
|
|
3593
|
+
* ANTES de `montarRutas` porque las rutas necesitan poder pedirle una revisión —abrir o
|
|
3594
|
+
* cerrar un proyecto cambia qué se puede arrancar— y solo necesita el vestíbulo, que ya
|
|
3595
|
+
* está. Y ejecutar solo se ejecuta si hay cola: ver `OpcionesDeArranque.tareas`.
|
|
3596
|
+
*/
|
|
3597
|
+
const { corredor, opcionesDeMontaje: opcionesDeTareas, arrancarConectado: arrancarCorredorConectado, } = construirCorredorDeTareasCableado({
|
|
3598
|
+
vestibulo: conVestibulo,
|
|
3599
|
+
tareasFabrica: opciones.tareas,
|
|
3600
|
+
informar,
|
|
3601
|
+
olvidarHiloDeSesion: async (raiz, hilo) => olvidarHilo(crearCheckpointerDeProyecto(raiz), hilo),
|
|
3602
|
+
/**
|
|
3603
|
+
* El juez de QA de las tareas, con el papel `afilado` — el que `core/modelos.ts` le
|
|
3604
|
+
* reserva.
|
|
3605
|
+
*
|
|
3606
|
+
* **Los `Modelos` se construyen en CADA consulta**, no una vez al arrancar, y es la
|
|
3607
|
+
* misma razón por la que `sinAprobacion` relee los settings en cada ronda: el asistente
|
|
3608
|
+
* de cuenta y `/provider` escriben la credencial y la elección de modelo mientras el
|
|
3609
|
+
* proceso vive, y un `Modelos` capturado al arrancar dejaría al juez con el reparto de
|
|
3610
|
+
* antes. Una consulta por tarea, así que leer el `config.json` ahí no cuesta nada.
|
|
3611
|
+
*
|
|
3612
|
+
* **Y se lee el del PROYECTO además del global, por la raíz de la tarea.** En la consola
|
|
3613
|
+
* web `FuentesDeEleccion.proyecto` no se rellena nunca —el vestíbulo sirve muchos
|
|
3614
|
+
* proyectos y las fuentes se construyen una vez al arrancar—, así que sin preguntarle al
|
|
3615
|
+
* disco por la raíz, un `config.json` de proyecto que apuntara el papel `afilado` a otro
|
|
3616
|
+
* modelo se ignoraba en silencio. Es la misma trampa que `cloudstudioDelProyecto` ya
|
|
3617
|
+
* resolvió así, y el mismo motivo: un ajuste escrito que no hace nada es peor que no
|
|
3618
|
+
* poder ponerlo, porque quien lo puso se cree servido.
|
|
3619
|
+
*/
|
|
3620
|
+
juez: crearJuezDeTarea({
|
|
3621
|
+
invocar: (papel, prompt, raiz) => invocarConModelos(new Modelos(fuentesDelJuez(raiz), proveedoresPersonalizados))(papel, prompt, raiz),
|
|
3622
|
+
}),
|
|
3623
|
+
/**
|
|
3624
|
+
* Que lo escrito se pueda REVISAR es `cambiosDeSesion(...).via === "git"`: la MISMA
|
|
3625
|
+
* función que pinta la pestaña Revisión, no una parecida, así que la condición significa
|
|
3626
|
+
* literalmente «Revisión lo enseña». Sin aprobación previa ese diff es el único momento
|
|
3627
|
+
* en que una persona puede mirar lo que hizo una tarea, y entregar trabajo que nadie
|
|
3628
|
+
* puede ver sería dejar vacío el sitio del modal.
|
|
3629
|
+
*
|
|
3630
|
+
* Y NO es «el árbol de git está limpio»: eso está medido y sería falso siempre — una
|
|
3631
|
+
* tarea que escribe un fichero deja el árbol sucio por definición.
|
|
3632
|
+
*
|
|
3633
|
+
* De la MISMA respuesta sale si la sesión cambió algo, y solo se afirma cuando hay
|
|
3634
|
+
* marca: sin ella no es que no escribiera, es que no hay con qué mirarlo. Eso es lo que
|
|
3635
|
+
* permite entregar una tarea de solo lectura sin abrir un camino para entregar sin
|
|
3636
|
+
* verificar, y lo que impide decidirlo con `autorizadas` — que es la intención del
|
|
3637
|
+
* agente y no el hecho.
|
|
3638
|
+
*/
|
|
3639
|
+
revisable: revisionConGit(cambiosDeSesion),
|
|
3640
|
+
});
|
|
3641
|
+
if (offline && opciones.guion === true) {
|
|
3642
|
+
const abierto = await vestibulo.abrirProyecto({ raiz: opciones.cwd });
|
|
3643
|
+
// Por los DOS sitios, como el resto de `informar` un poco más abajo: el terminal
|
|
3644
|
+
// (quien lanzó el proceso) Y el transcript del proyecto (la Trayectoria, que es
|
|
3645
|
+
// donde aterriza un acto de sistema — `anunciarAlta` más arriba documenta el mismo
|
|
3646
|
+
// reparto). Es la evidencia de que esto es de pega, por el mismo motivo por el que
|
|
3647
|
+
// la marca de doble (`core/ports.ts#ES_DOBLE`) es un símbolo que no se puede omitir
|
|
3648
|
+
// por descuido: un aviso que solo viviera en un sitio que nadie mira no avisa nada.
|
|
3649
|
+
const aviso = "proyecto offline abierto solo, por --guion: el agente que responde es de pega " +
|
|
3650
|
+
"(`ejecutarTurnoGuionizado`) y no hubo CloudStudio de por medio — nadie eligió " +
|
|
3651
|
+
"este proyecto en un alta.";
|
|
3652
|
+
escribir(`${aviso}\n`);
|
|
3653
|
+
abierto.consola.consola.escribir(`${aviso}\n`);
|
|
3654
|
+
}
|
|
3655
|
+
const cable = montarRutas(servidor, vestibulo, {
|
|
3656
|
+
informar,
|
|
3657
|
+
// El tope de ventana, tal cual llega: es la misma función que la barra del terminal, y
|
|
3658
|
+
// dos resoluciones serían dos porcentajes distintos para el mismo modelo.
|
|
3659
|
+
...(opciones.topeDeContexto === undefined ? {} : { topeDeContexto: opciones.topeDeContexto }),
|
|
3660
|
+
...(corredor === undefined ? {} : { revisarTareas: () => corredor.revisar() }),
|
|
3661
|
+
// El PUERTO de disco y el corredor, no operaciones sueltas: `montarRutas` resuelve
|
|
3662
|
+
// `crearTarea`/`accionDeTarea` con SU propio estado (`proyectos`, `entornoElegido`,
|
|
3663
|
+
// `vestibulo.raizDeProyecto`), que es la única forma de convertir un id de proyecto en
|
|
3664
|
+
// la raíz que la tarea necesita para poder correr. Ver el comentario de
|
|
3665
|
+
// `OpcionesDeMontaje.colaDeTareas`.
|
|
3666
|
+
...opcionesDeTareas,
|
|
3667
|
+
// Los dos puertos del selector de modelos, con las piezas reales: quién tiene
|
|
3668
|
+
// credencial (`auth.json` o el entorno, leído desde el cwd) y el catálogo VIVO.
|
|
3669
|
+
hayCredencial: (proveedor) => hayCredencial(proveedor, opciones.cwd),
|
|
3670
|
+
// «Está en auth.json» es una pregunta distinta de «¿puedo usarlo?»: se lee el fichero,
|
|
3671
|
+
// sin mirar el entorno, porque es lo único que un botón de borrar puede cumplir.
|
|
3672
|
+
credencialEnFichero: (proveedor) => cargar(opciones.cwd).auth[proveedor] !== undefined,
|
|
3673
|
+
borrarCredencial,
|
|
3674
|
+
guardarCredencial,
|
|
3675
|
+
/**
|
|
3676
|
+
* El defecto se GUARDA en el `config.json` global (`agent/configEnDisco.ts#guardarModeloGlobal`),
|
|
3677
|
+
* que es el último escalón de la precedencia y el único que sobrevive al proceso. Se
|
|
3678
|
+
* escribe para los TRES papeles porque elegir un modelo en la interfaz es una frase
|
|
3679
|
+
* sobre el producto y no sobre un papel — el mismo criterio que `/modelo`, que fija los
|
|
3680
|
+
* tres en caliente.
|
|
3681
|
+
*/
|
|
3682
|
+
guardarModeloGlobal: (papel, id) => guardarModeloGlobal(papel, id),
|
|
3683
|
+
/**
|
|
3684
|
+
* El que usarán las sesiones nuevas, resuelto contra el config GLOBAL (más el entorno,
|
|
3685
|
+
* que es de donde lo tomaría también una sesión).
|
|
3686
|
+
*
|
|
3687
|
+
* Viaja SIEMPRE, también con sesión abierta, porque son dos preguntas distintas: una
|
|
3688
|
+
* sesión puede llevar encima un `/modelo` en caliente —eso es `actual`— y el defecto
|
|
3689
|
+
* seguir siendo otro. Se relee en cada emisión y no se cachea: el fichero es del
|
|
3690
|
+
* usuario, se puede editar por fuera, y esta misma ventana lo acaba de reescribir.
|
|
3691
|
+
*/
|
|
3692
|
+
modeloPorDefecto: () => {
|
|
3693
|
+
const { proveedor, modelo } = resolver({
|
|
3694
|
+
global: cargar(opciones.cwd).config.global,
|
|
3695
|
+
entorno: { XONECODE_MODELO: process.env.XONECODE_MODELO },
|
|
3696
|
+
}).trabajo;
|
|
3697
|
+
return `${proveedor}/${modelo}`;
|
|
3698
|
+
},
|
|
3699
|
+
cambiosDeSesion,
|
|
3700
|
+
parcheDeSesion,
|
|
3701
|
+
// El proyecto tal como lo ve el agente, para la pestaña Ficheros: mismo filtro, misma
|
|
3702
|
+
// barrera de rutas (`agent/arbolDeProyecto.ts`).
|
|
3703
|
+
arbolDelProyecto: async (raiz) => arbolDeProyecto(raiz),
|
|
3704
|
+
leerFichero: leerFicheroDeProyecto,
|
|
3705
|
+
leerArtefacto: leerArtefactoDeSesion,
|
|
3706
|
+
leerArtefactoCrudo,
|
|
3707
|
+
correrPasoDeReceta: (receta, paso, alSalirLinea) => correrPasoDeReceta(receta, paso, { alSalirLinea }),
|
|
3708
|
+
modelosDeMotor,
|
|
3709
|
+
// La máquina de verdad: adb/emulator del PATH o del SDK, xcrun solo en macOS y solo con
|
|
3710
|
+
// herramientas de desarrollo. Cada proceso con su tope.
|
|
3711
|
+
detectarDispositivos: () => detectarDispositivos({}, cargarSettings().settings.dispositivos ?? {}),
|
|
3712
|
+
// Se lee de disco en cada consulta, no se cachea: `settings.json` es del usuario.
|
|
3713
|
+
ajustesDeDispositivos: () => cargarSettings().settings.dispositivos ?? {},
|
|
3714
|
+
guardarAjustesDeDispositivos: (ajustes) => void guardarDispositivos(undefined, ajustes),
|
|
3715
|
+
instalarHerramienta: instalarHerramientaDeDispositivos,
|
|
3716
|
+
verificarDispositivo,
|
|
3717
|
+
/**
|
|
3718
|
+
* Las cuatro de «¿se puede lanzar y lánzalo?», compuestas con las funciones REALES.
|
|
3719
|
+
*
|
|
3720
|
+
* Es la trampa que este repo ha pagado nueve veces —`backendDeAgente`, el corredor sin
|
|
3721
|
+
* cablear, el `escribio` a fuego, la capa de proyecto del juez, `/adjuntos/`, `filaDeTarea`,
|
|
3722
|
+
* el prop de las pestañas por entorno, `opcionesDeSubagenteExterno` y
|
|
3723
|
+
* `ConsolaDeProyecto.consumo`—: una regla de producción que vive dentro de un cierre que
|
|
3724
|
+
* los tests doblan **no está probada, está escrita**. Aquí las cuatro van con las de
|
|
3725
|
+
* verdad y junto a `detectarDispositivos`, que es donde se lee qué es real y qué es un
|
|
3726
|
+
* doble en esta ejecución.
|
|
3727
|
+
*/
|
|
3728
|
+
frameworkEnDispositivo: (dispositivo) => frameworkEnDispositivo(dispositivo),
|
|
3729
|
+
// La criba de balde primero (relativa, sin `.`/`..`/segmentos vacíos) y el disco después.
|
|
3730
|
+
// Con `join` sobre una ruta ya cribada: sin la criba, un `../../.env` de `app.xml`
|
|
3731
|
+
// preguntaría por un fichero de fuera del proyecto.
|
|
3732
|
+
existeEnProyecto: (raiz, rutaRelativa) => motivoDeRutaInaceptable(rutaRelativa) !== undefined || existsSync(join(raiz, rutaRelativa)),
|
|
3733
|
+
/**
|
|
3734
|
+
* La de verdad, y **sin construir sus dependencias aquí**: `lanzarEnDispositivo` trae su
|
|
3735
|
+
* propio `DependenciasDeLanzamiento` por omisión —los `spawn` reales, el empaquetado real,
|
|
3736
|
+
* el `hotswap` real— y lo único que se le pasa es `alFase`, que es el gancho por el que el
|
|
3737
|
+
* recorrido llega al cable. Montarlo entero desde aquí sería (a) una segunda lista de
|
|
3738
|
+
* dependencias que se queda vieja en cuanto el módulo añada una, y (b) el modo de fallo de
|
|
3739
|
+
* arriba: una composición de producción que solo existe en el cableado nadie la prueba.
|
|
3740
|
+
*/
|
|
3741
|
+
lanzarEnDispositivo,
|
|
3742
|
+
catalogoDeModelos: async (proveedor) => {
|
|
3743
|
+
const modelos = await new CatalogoModelos(undefined, undefined, proveedoresPersonalizados).listar(proveedor);
|
|
3744
|
+
return modelos.map((m) => ({ id: m.id, ...(m.nombre === undefined ? {} : { nombre: m.nombre }) }));
|
|
3745
|
+
},
|
|
3746
|
+
// Se releen de disco en cada consulta, igual que los ajustes de dispositivos: esta
|
|
3747
|
+
// misma ventana los da de alta, y una lista capturada al montar las rutas se quedaría
|
|
3748
|
+
// vieja hasta reiniciar el proceso.
|
|
3749
|
+
proveedoresPersonalizados: proveedoresPersonalizados,
|
|
3750
|
+
guardarProveedor: (declarado) => guardarProveedorPersonalizado(declarado),
|
|
3751
|
+
borrarProveedor: (slug) => borrarProveedorPersonalizado(slug),
|
|
3752
|
+
/**
|
|
3753
|
+
* El AUMENTADOR de la ventana de crear una tarea (`agent/aumentador.ts`), con el papel
|
|
3754
|
+
* `trabajo` — es una tarea de redacción, no una clasificación.
|
|
3755
|
+
*
|
|
3756
|
+
* Tres cosas que se deciden aquí:
|
|
3757
|
+
* - **La composición vive en `augmentacionCableada` y no inline**, por lo mismo que
|
|
3758
|
+
* `revisionConGit` y `fuentesDelJuez`: es la quinta vez en esta tanda que hace falta
|
|
3759
|
+
* decirlo, y lo que se caería sin síntoma es la mitad del contexto del encargo.
|
|
3760
|
+
* - **Los `Modelos` se construyen en CADA llamada**, igual que los del juez: el
|
|
3761
|
+
* asistente de cuenta y `/provider` escriben la credencial mientras el proceso vive, y
|
|
3762
|
+
* uno capturado al arrancar se quedaría con el reparto de antes. Y con
|
|
3763
|
+
* `fuentesDelJuez(raiz)`, que es «las fuentes de ESE proyecto»: sin la capa de
|
|
3764
|
+
* proyecto, un `config.json` que apunte `trabajo` a otro modelo se ignoraría.
|
|
3765
|
+
* - **Con `--guion` se monta el DOBLE**, no el real: esa bandera significa «sin gastar
|
|
3766
|
+
* ni conectar», y el doble dice en el propio encargo que es de pega (`[DOBLE]`).
|
|
3767
|
+
*/
|
|
3768
|
+
augmentar: augmentacionCableada({
|
|
3769
|
+
aumentador: opciones.guion === true
|
|
3770
|
+
? new AumentadorGuionizado()
|
|
3771
|
+
: crearAumentador({
|
|
3772
|
+
invocar: (papel, prompt, raiz) => invocarParaAumentar(new Modelos(fuentesDelJuez(raiz), proveedoresPersonalizados))(papel, prompt, raiz),
|
|
3773
|
+
}),
|
|
3774
|
+
contexto: contextoDelProyecto,
|
|
3775
|
+
}),
|
|
3776
|
+
});
|
|
3777
|
+
// Con las rutas ya montadas: la reconciliación y el primer despacho cambian la cola, y
|
|
3778
|
+
// quien conecte después la recibe entera en su ráfaga de bienvenida.
|
|
3779
|
+
// `arrancarConectado` es la ÚNICA forma de arrancar el corredor: conecta el puente hacia
|
|
3780
|
+
// `cable.emitirTareas` y arranca en el orden correcto, así que no hay manera de llamarlo
|
|
3781
|
+
// mal desde aquí.
|
|
3782
|
+
await arrancarCorredorConectado(cable);
|
|
3783
|
+
/**
|
|
3784
|
+
* Con qué código corre ESTE proceso, antes que la URL.
|
|
3785
|
+
*
|
|
3786
|
+
* Existe por tres rondas perdidas: un cambio del servidor no se ve hasta parar y arrancar
|
|
3787
|
+
* el proceso, mientras que el cliente se lee del DISCO en cada petición, así que una
|
|
3788
|
+
* consola puede enseñar a la vez lo nuevo del cliente y lo viejo del servidor — y nadie
|
|
3789
|
+
* tenía forma de saber qué había vivo dentro. Va por opción para no atarlo a git en los
|
|
3790
|
+
* tests; ausente = no se dice nada, que es lo que hacía siempre.
|
|
3791
|
+
*/
|
|
3792
|
+
const version = opciones.version?.();
|
|
3793
|
+
if (version !== undefined)
|
|
3794
|
+
escribir(`${lineaDeVersion(version)}\n`);
|
|
3795
|
+
escribir(`consola web en ${servidor.url}\n`);
|
|
3796
|
+
if (opciones.anfitrion !== undefined) {
|
|
3797
|
+
// Se dice SIEMPRE y con lo que hay detrás nombrado. Una puerta abierta que solo consta
|
|
3798
|
+
// en la línea de comandos que alguien tecleó hace media hora no consta.
|
|
3799
|
+
escribir(`ATENCIÓN: también se acepta «${opciones.anfitrion}» como Host, así que esta consola es alcanzable por ese túnel.\n` +
|
|
3800
|
+
`Detrás hay un agente que escribe ficheros en este equipo y las credenciales de ~/.xonecode. ` +
|
|
3801
|
+
`Lo único que lo separa de quien tenga la URL es el token.\n`);
|
|
3802
|
+
}
|
|
3803
|
+
if (opciones.abrir) {
|
|
3804
|
+
// Lo accesorio: la URL ya está impresa, así que un fallo aquí no puede tumbar nada.
|
|
3805
|
+
// `abrirEnSistema` escucha el `error` del spawn justo por esto.
|
|
3806
|
+
try {
|
|
3807
|
+
(opciones.abrirNavegador ?? abrirEnSistema)(new URL(servidor.url));
|
|
3808
|
+
}
|
|
3809
|
+
catch (error) {
|
|
3810
|
+
escribir(`no se pudo abrir el navegador (${error instanceof Error ? error.message : String(error)}); abre la URL a mano\n`);
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
await (opciones.esperarCierre ?? esperarInterrupcion)();
|
|
3814
|
+
/**
|
|
3815
|
+
* El corredor PRIMERO, y el orden es load-bearing: `parar()` corta los turnos en vuelo y
|
|
3816
|
+
* los deja aparcados diciendo que la consola se cerró a mitad. Al revés, el
|
|
3817
|
+
* `cerrarLasDeTareas` del vestíbulo abortaría esos mismos turnos por debajo del corredor,
|
|
3818
|
+
* que lo contaría como un fallo del turno —«el turno falló (AbortError)»— cuando lo que
|
|
3819
|
+
* pasó es que alguien paró el proceso. El motivo se lee en el kanban, así que la
|
|
3820
|
+
* diferencia no es interna.
|
|
3821
|
+
*/
|
|
3822
|
+
await corredor?.parar();
|
|
3823
|
+
await vestibulo.cerrar();
|
|
3824
|
+
await servidor.cerrar();
|
|
3825
|
+
return 0;
|
|
3826
|
+
}
|
|
3827
|
+
/**
|
|
3828
|
+
* El `modo` que declara el `.xonecode/config.json` de una raíz, o `undefined`.
|
|
3829
|
+
*
|
|
3830
|
+
* `undefined` es «no se sabe», no «offline»: cubre el fichero que no está, el JSON roto y
|
|
3831
|
+
* el valor que no es ninguno de los dos. Quien pregunta decide qué hacer con no saber —
|
|
3832
|
+
* `esProyectoOffline` lo trata como «no es offline» y la cabecera de la consola web no
|
|
3833
|
+
* pinta pastilla—, y ninguno de los dos afirma sobre lo que no ha leído.
|
|
3834
|
+
*
|
|
3835
|
+
* Del config no sale nada más: ni la URL del entorno, ni el proyecto, ni la rama. Es lo
|
|
3836
|
+
* mismo que ya hace `proyectosDeResultado` con la respuesta de CloudStudio (CLAUDE.md),
|
|
3837
|
+
* quedarse SOLO con lo que hace falta enseñar.
|
|
3838
|
+
*/
|
|
3839
|
+
export function modoDeProyecto(raiz) {
|
|
3840
|
+
try {
|
|
3841
|
+
const crudo = JSON.parse(readFileSync(join(raiz, ".xonecode", "config.json"), "utf8"));
|
|
3842
|
+
if (typeof crudo !== "object" || crudo === null)
|
|
3843
|
+
return undefined;
|
|
3844
|
+
const modo = crudo.modo;
|
|
3845
|
+
return modo === "offline" || modo === "cloud" ? modo : undefined;
|
|
3846
|
+
}
|
|
3847
|
+
catch {
|
|
3848
|
+
return undefined;
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
/** Un `.xonecode/config.json` con `modo: "offline"` en el cwd. Lo que no se pueda leer no
|
|
3852
|
+
* es un proyecto offline: no se afirma sobre lo que no se sabe. */
|
|
3853
|
+
function esProyectoOffline(cwd) {
|
|
3854
|
+
return modoDeProyecto(cwd) === "offline";
|
|
3855
|
+
}
|
|
3856
|
+
/**
|
|
3857
|
+
* La espera por omisión: hasta que alguien interrumpa. Sin ella `arrancarConsolaWeb`
|
|
3858
|
+
* devolvería en cuanto el servidor está en pie, y `bin.ts` hace `process.exit(codigo)` —
|
|
3859
|
+
* o sea que el servidor recién levantado moriría antes de servir una sola petición.
|
|
3860
|
+
*/
|
|
3861
|
+
function esperarInterrupcion() {
|
|
3862
|
+
return new Promise((resolver) => {
|
|
3863
|
+
process.once("SIGINT", () => resolver());
|
|
3864
|
+
process.once("SIGTERM", () => resolver());
|
|
3865
|
+
});
|
|
3866
|
+
}
|
|
3867
|
+
/** El vestíbulo con todas sus piezas reales. Se construye DESPUÉS del servidor porque el
|
|
3868
|
+
* callback de OAuth necesita saber a qué URL devolver al navegador. */
|
|
3869
|
+
function vestibuloReal(opciones, servidor, escribir, informar) {
|
|
3870
|
+
// Lo PRIMERO, antes de leer nada de disco: si esto va a fallar, que falle sin haber
|
|
3871
|
+
// aplicado credenciales al proceso ni construido medio vestíbulo.
|
|
3872
|
+
const ejecutor = banderaDeEjecutor(opciones);
|
|
3873
|
+
const cargado = cargar(opciones.cwd);
|
|
3874
|
+
aplicarAuth(cargado.auth);
|
|
3875
|
+
// La lectura NO se hace aquí: se hace al abrir cada consola, que es lo que hace que una
|
|
3876
|
+
// sesión nueva vea el `config.json` de ahora y no el del arranque. Ver
|
|
3877
|
+
// `fuentesDeLaConsolaWeb`.
|
|
3878
|
+
const fuentes = () => fuentesDeLaConsolaWeb(opciones.cwd);
|
|
3879
|
+
const settings = cargarSettings().settings;
|
|
3880
|
+
const dependenciasDeProyecto = opciones.dependenciasDeProyecto;
|
|
3881
|
+
return crearVestibulo({
|
|
3882
|
+
informar,
|
|
3883
|
+
origenDeTrabajo: resolver(fuentes()).trabajo.origen,
|
|
3884
|
+
fuentes,
|
|
3885
|
+
// Se resuelve UNA vez, aquí, y no en cada `anunciarAlta`: `git config`/`os.userInfo`
|
|
3886
|
+
// no cambian a media conexión, y repetir el subproceso en cada anuncio del alta sería
|
|
3887
|
+
// gastar sin motivo. Nunca viaja hacia CloudStudio ni hacia ningún acto —
|
|
3888
|
+
// `agent/persona.ts` lo documenta—.
|
|
3889
|
+
nombre: nombreDePersona(opciones.cwd),
|
|
3890
|
+
catalogoModelos: new CatalogoModelos(),
|
|
3891
|
+
guardarCredencial,
|
|
3892
|
+
aplicarCredencial: aplicarCredencialAlProceso,
|
|
3893
|
+
// Los proveedores que YA tienen clave no se vuelven a pedir. Sin esto la omisión del
|
|
3894
|
+
// vestíbulo es `false` para todos —la dirección segura, pero molesta— y el asistente
|
|
3895
|
+
// pediría de nuevo una credencial que está escrita.
|
|
3896
|
+
hayCredencial: (proveedor) => hayCredencial(proveedor, opciones.cwd),
|
|
3897
|
+
guardarEntorno: (entorno) => guardarEntornoEnDisco(undefined, entorno),
|
|
3898
|
+
guardarModeloGlobal,
|
|
3899
|
+
guardarConfigDeProyecto: escribirProyectoEnDisco,
|
|
3900
|
+
// El «antes» de cada sesión: se fotografía al abrir el proyecto y se nombra cuando la
|
|
3901
|
+
// sesión tiene id. Ver `agent/sesionGit.ts` para por qué es una ref y no un tag.
|
|
3902
|
+
marcarSesion: fotoDeApertura,
|
|
3903
|
+
// Lo que ya había sin commitear al abrir, para poder decirlo. Comparte el hueco
|
|
3904
|
+
// declarado de `marcarSesion`: esta composición vive en un cierre que `vestibuloReal`
|
|
3905
|
+
// no expone y que ningún test construye —lee el `settings.json` REAL del usuario—, así
|
|
3906
|
+
// que lo que está probado es que el vestíbulo la usa por las dos puertas, no que aquí
|
|
3907
|
+
// siga puesta.
|
|
3908
|
+
sinCommitear: trabajoSinCommitear,
|
|
3909
|
+
// El commit del turno, con el sello de la sesión. La composición está EXTRAÍDA y probada
|
|
3910
|
+
// (`commitDeTurnoCableado`): el argumento que se cae en una lambda escrita a mano es
|
|
3911
|
+
// justo el que sostiene la atribución de Revisión.
|
|
3912
|
+
commitearTurno: commitDeTurnoCableado({ base: settings.workspace ?? baseDeWorkspacePorOmision() }),
|
|
3913
|
+
olvidarMarcaDeSesion: olvidarSesion,
|
|
3914
|
+
// La memoria del agente por hilo. `historica` deja de ser «se reabrió» para ser «no hay
|
|
3915
|
+
// checkpoint que cargar», y borrar una sesión se lleva también su checkpoint.
|
|
3916
|
+
hayMemoriaDeHilo: async (raiz, hilo) => hayCheckpoint(crearCheckpointerDeProyecto(raiz), hilo),
|
|
3917
|
+
olvidarMemoriaDeHilo: async (raiz, hilo) => olvidarHilo(crearCheckpointerDeProyecto(raiz), hilo),
|
|
3918
|
+
entornos: settings.entornos,
|
|
3919
|
+
...(settings.workspace === undefined ? {} : { baseDeWorkspace: settings.workspace }),
|
|
3920
|
+
// La URL de la web para que la página del callback devuelva AQUÍ y no diga «vuelve a
|
|
3921
|
+
// la terminal», que en un navegador es falso.
|
|
3922
|
+
...conexionDeVestibulo(servidor.url),
|
|
3923
|
+
// La descarga es la de siempre, la del `/sync bajar` del proyecto ya dado de alta:
|
|
3924
|
+
// `completarProyecto` escribe el `config.json` ENTERO antes de llamar aquí, así que el
|
|
3925
|
+
// sincronizador lee del disco exactamente lo que leería después. Nada de la
|
|
3926
|
+
// sincronización se toca ni se duplica.
|
|
3927
|
+
descargar: async ({ raiz }) => {
|
|
3928
|
+
const sincronizar = dependenciasDeProyecto?.(raiz).sincronizar;
|
|
3929
|
+
// Un `return` a secas aquí sería el no-op silencioso que este repo evita en todas
|
|
3930
|
+
// partes: el alta quedaría escrita y el usuario creyendo que su proyecto está bajado.
|
|
3931
|
+
if (sincronizar === undefined) {
|
|
3932
|
+
throw new Error("esta consola web se montó sin `dependenciasDeProyecto`: no hay con qué descargar");
|
|
3933
|
+
}
|
|
3934
|
+
const bajada = await sincronizar("bajar", raiz, undefined, escribir);
|
|
3935
|
+
if (bajada.tipo === "texto") {
|
|
3936
|
+
escribir(bajada.texto);
|
|
3937
|
+
return;
|
|
3938
|
+
}
|
|
3939
|
+
// El árbol sucio para la copia local. Bajar SOBRESCRIBE el disco, así que se dice
|
|
3940
|
+
// qué hay y no se baja nada — igual que en el alta de terminal.
|
|
3941
|
+
throw new Error(`hay trabajo local sin commitear (${bajada.pendientes.join(", ")}); la descarga sobrescribe el disco`);
|
|
3942
|
+
},
|
|
3943
|
+
// Sin `crearEjecutor` el vestíbulo cae en `ejecutarTurnoGuionizado` — el agente de
|
|
3944
|
+
// PEGA— y no habría forma de notarlo desde el navegador: los turnos correrían, las
|
|
3945
|
+
// fases se pintarían y nada de lo que dijera sería de un modelo. Se exige, igual que
|
|
3946
|
+
// `descargar` exige su sincronizador. `--guion` es la única forma de pedir el de pega,
|
|
3947
|
+
// y ahí se pide a propósito.
|
|
3948
|
+
...ejecutor,
|
|
3949
|
+
...(dependenciasDeProyecto === undefined ? {} : { dependenciasDeProyecto }),
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
/**
|
|
3953
|
+
* O el ejecutor real, o el de pega PEDIDO con `--guion`, o un error. Lo que no hay es una
|
|
3954
|
+
* tercera opción muda.
|
|
3955
|
+
*/
|
|
3956
|
+
function banderaDeEjecutor(opciones) {
|
|
3957
|
+
if (opciones.guion === true)
|
|
3958
|
+
return {};
|
|
3959
|
+
if (opciones.crearEjecutor === undefined) {
|
|
3960
|
+
throw new Error("esta consola web se montó sin `crearEjecutor`: correría el agente de pega sin decirlo (usa --guion si es lo que quieres)");
|
|
3961
|
+
}
|
|
3962
|
+
return { crearEjecutor: opciones.crearEjecutor };
|
|
3963
|
+
}
|
|
3964
|
+
//# sourceMappingURL=arranque.js.map
|