@milpa/comal 0.2.2 → 0.2.4
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 +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +261 -62
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +283 -15
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { readFile, stat, rm, mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { t, registerMessages } from '../i18n/index.mjs';
|
|
6
|
+
import { HubClient, hubRoot } from './hub-client.mjs';
|
|
7
|
+
import { relayGit } from './git.mjs';
|
|
8
|
+
import { proposalCard, trustCard, objectiveInput, startCard, planCard, questionCard, finishCard, statusCard, taskCounts, eyesText, eyesState, designText, blockedCard, criteriaCard, criteriaConfirmCard, criteriaWarnings, criteriaWaiting, escalationCard, line } from './cards.mjs';
|
|
9
|
+
import { RelayClient, RelayConnection, needsHuman, RECOMMENDED_CAPS } from '../plugins/open-relay/client.mjs';
|
|
10
|
+
import { approve, reject, decide, reply, ack, acceptCriteria, rejectCriteria, reopenCriteria, overrideReview, retryReview } from '../plugins/open-relay/actions.mjs';
|
|
11
|
+
import { decisionBlock } from '../plugins/open-relay/decision.mjs';
|
|
12
|
+
import { normalizeSwarmView, blockLabel } from '../tui/swarm-panel.mjs';
|
|
13
|
+
import { swarmFinished, blockedForOperator, blockedReason } from './follow.mjs';
|
|
14
|
+
import { pendingDelivery, pendingOperatorDeliveries, operatorDelivery } from '../plugins/open-relay/lookup.mjs';
|
|
15
|
+
import { projectPlanCard, relayOptionLabel } from '../remote-relay.mjs';
|
|
16
|
+
import { RelayDecisionBroker } from './broker.mjs';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
// /relay: comando del HOST (no un plugin: los plugins no registran comandos de barra) que
|
|
20
|
+
// lleva de un repo a un enjambre funcionando con tres confirmaciones humanas: la propuesta
|
|
21
|
+
// del proyecto (crear y arrancar), la confianza (plugin de esta versión de Comal + delegación
|
|
22
|
+
// de relayd) y el arranque (objetivo, líder, presupuesto). Después, el plan del líder, las
|
|
23
|
+
// preguntas y el resultado llegan como tarjetas (follow.mjs).
|
|
24
|
+
// Principio de Milpa: la autoridad es del host. Todo esto son controles humanos invisibles
|
|
25
|
+
// para el modelo: no hay herramienta que cree proyectos, arranque enjambres ni apruebe. El
|
|
26
|
+
// modelo sólo puede dejar un BORRADOR de objetivo (RelayDraft) que el humano ve en la
|
|
27
|
+
// tarjeta de arranque.
|
|
28
|
+
registerMessages({
|
|
29
|
+
'Use /relay [status|new [NAME]|start|stop|unlink|hub ROOT].': 'Usa /relay [status|new [NOMBRE]|start|stop|unlink|hub RAÍZ].',
|
|
30
|
+
'/relay needs a git repository (run Comal inside one).': '/relay necesita un repositorio git (abre Comal dentro de uno).',
|
|
31
|
+
'A /relay card is already open.': 'Ya hay una tarjeta de /relay abierta.',
|
|
32
|
+
'Starting relay-hub': 'Arrancando relay-hub',
|
|
33
|
+
'The local hub API (loopback {listen}) was not running; Comal starts it as the user unit relay-hub.': 'La API local del hub (loopback {listen}) no estaba en marcha; Comal la arranca como la unidad de usuario relay-hub.',
|
|
34
|
+
'relay-hub started ({how}) on {listen}.': 'relay-hub arrancado ({how}) en {listen}.',
|
|
35
|
+
'Reading the repo': 'Leyendo el repo',
|
|
36
|
+
'Criteria of {swarm} are already {status}.': 'Los criterios de {swarm} ya están {status}.',
|
|
37
|
+
'Success criteria v{version} accepted ({status}): the lead can plan.': 'Criterios de éxito v{version} aceptados ({status}): el líder ya puede planear.',
|
|
38
|
+
'Success criteria rejected ({status}); the reviewer got your reason.': 'Criterios de éxito rechazados ({status}); el revisor recibió tu motivo.',
|
|
39
|
+
'The review of {task} no longer waits for you.': 'La revisión de {task} ya no te espera.',
|
|
40
|
+
'{task} accepted over the reviewer ({status}).': '{task} aceptada sobre el revisor ({status}).',
|
|
41
|
+
'Why another review': 'Por qué otra revisión',
|
|
42
|
+
'Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.': 'Queda en relayd como tu decisión (review.retry) con exactamente este texto; el revisor recibe una tarea de revisión nueva.',
|
|
43
|
+
'{task} goes back to the reviewer ({status}).': '{task} vuelve al revisor ({status}).',
|
|
44
|
+
'Decide the success criteria of {swarm}': 'Decidir los criterios de éxito de {swarm}',
|
|
45
|
+
'Escalated review of {task}': 'Revisión escalada de {task}',
|
|
46
|
+
'See the result of {swarm}': 'Ver el resultado de {swarm}',
|
|
47
|
+
'project.detect: branch, excludes, checks and budget for this repo. Nothing is written.': 'project.detect: rama, exclusiones, checks y presupuesto de este repo. No se escribe nada.',
|
|
48
|
+
'Nothing was created.': 'No se creó nada.',
|
|
49
|
+
'Creating {name}': 'Creando {name}',
|
|
50
|
+
'relay-hub writes the project, adds the branch {branch} and starts its relayd (up to 2 min).': 'relay-hub escribe el proyecto, añade la rama {branch} y arranca su relayd (hasta 2 min).',
|
|
51
|
+
'Project {name} created and started: relayd ready, UI {web}, branch {branch}.': 'Proyecto {name} creado y arrancado: relayd listo, UI {web}, rama {branch}.',
|
|
52
|
+
'Field to edit': 'Campo a editar',
|
|
53
|
+
'Name': 'Nombre', 'Base (branch or commit)': 'Base (rama o commit)', 'Model of the agents': 'Modelo de los agentes', 'Checks': 'Checks', 'Budget per swarm': 'Presupuesto por enjambre', 'Excludes': 'Exclusiones', 'Back to the proposal': 'Volver a la propuesta',
|
|
54
|
+
'Lowercase letters, digits and dashes (up to 40). Branch relay/NAME.': 'Minúsculas, dígitos y guiones (hasta 40). Rama relay/NOMBRE.',
|
|
55
|
+
'Any ref of the repo: HEAD, origin/main, a commit…': 'Cualquier ref del repo: HEAD, origin/main, un commit…',
|
|
56
|
+
'Ollama model for comal (lead and agents).': 'Modelo de Ollama para comal (líder y agentes).',
|
|
57
|
+
'Calls, tokens and wall time, e.g. 300 30M 4h.': 'Llamadas, tokens y tiempo de pared, p. ej. 300 30M 4h.',
|
|
58
|
+
'Comma-separated globs, relative to the repo.': 'Globs separados por comas, relativos al repo.',
|
|
59
|
+
'Check {id}': 'Check {id}',
|
|
60
|
+
'Command run in the candidate (sh, from its root). Empty removes the check.': 'Comando que se ejecuta en el candidato (sh, desde su raíz). Vacío quita el check.',
|
|
61
|
+
'Add a check': 'Añadir un check',
|
|
62
|
+
'New check: id (c-…) and command, e.g. c-lint npm run lint': 'Check nuevo: id (c-…) y comando, p. ej. c-lint npm run lint',
|
|
63
|
+
'Use lowercase, digits and dashes.': 'Usa minúsculas, dígitos y guiones.',
|
|
64
|
+
'Use three values: calls tokens time (300 30M 4h).': 'Usa tres valores: llamadas tokens tiempo (300 30M 4h).',
|
|
65
|
+
'Use c-ID followed by the command.': 'Usa c-ID seguido del comando.',
|
|
66
|
+
'Connecting Comal': 'Conectando Comal',
|
|
67
|
+
'Issuing the delegate session and enabling the open-relay plugin for this repo.': 'Emitiendo la sesión delegada y activando el plugin open-relay para este repo.',
|
|
68
|
+
'Comal connected to {project}: delegation {caps} in {file}; the plugin watches the swarm (/swarm).': 'Comal conectado a {project}: delegación {caps} en {file}; el plugin vigila el enjambre (/swarm).',
|
|
69
|
+
'Project {project} is ready; Comal is not connected (trust card declined). /relay connects it later.': 'El proyecto {project} está listo; Comal no está conectado (se declinó la tarjeta de confianza). /relay lo conecta después.',
|
|
70
|
+
'The open-relay plugin did not become active: {error}': 'El plugin open-relay no quedó activo: {error}',
|
|
71
|
+
'No swarm started.': 'No se arrancó ningún enjambre.',
|
|
72
|
+
'Starting the swarm': 'Arrancando el enjambre',
|
|
73
|
+
'swarm.start in {project}: account, root grant, lead and root task.': 'swarm.start en {project}: cuenta, grant raíz, líder y tarea raíz.',
|
|
74
|
+
'Swarm {swarm} started in {project} (lead {lead}). The lead is reading the repo; its plan will show up here as a card. /swarm shows the live board.': 'Enjambre {swarm} arrancado en {project} (líder {lead}). El líder está leyendo el repo; su plan aparecerá aquí como tarjeta. /swarm muestra el tablero en vivo.',
|
|
75
|
+
'Project {project} stopped (unit {unit}: {state}). Its swarms and the branch stay; /relay starts it again.': 'Proyecto {project} parado (unidad {unit}: {state}). Sus enjambres y la rama se quedan; /relay lo vuelve a arrancar.',
|
|
76
|
+
'Starting {project}': 'Arrancando {project}',
|
|
77
|
+
'relay-hub starts its relayd and waits until it is ready.': 'relay-hub arranca su relayd y espera a que esté listo.',
|
|
78
|
+
'Project {project} started: relayd {state}, UI {web}.': 'Proyecto {project} arrancado: relayd {state}, UI {web}.',
|
|
79
|
+
'This repo is not linked to a relay-hub project. /relay proposes one.': 'Este repo no está ligado a un proyecto de relay-hub. /relay propone uno.',
|
|
80
|
+
'Unlinked {project} from this repo (the project stays in relay-hub).': 'Desligado {project} de este repo (el proyecto sigue en relay-hub).',
|
|
81
|
+
'relay-hub root saved: {root} (RELAY_HUB_ROOT still wins).': 'Raíz de relay-hub guardada: {root} (RELAY_HUB_ROOT sigue ganando).',
|
|
82
|
+
'Use /relay hub /absolute/root.': 'Usa /relay hub /raíz/absoluta.',
|
|
83
|
+
'Plan {id} is already {status}.': 'El plan {id} ya está {status}.',
|
|
84
|
+
'Reason for the lead': 'Motivo para el líder',
|
|
85
|
+
'The lead is woken up with exactly this text and proposes again.': 'El líder despierta con exactamente este texto y vuelve a proponer.',
|
|
86
|
+
'Plan {id} approved ({status}): relayd applied {steps} steps; the swarm is working.': 'Plan {id} aprobado ({status}): relayd aplicó {steps} pasos; el enjambre está trabajando.',
|
|
87
|
+
'Plan {id} rejected ({status}); the lead got your reason.': 'Plan {id} rechazado ({status}); el líder recibió tu motivo.',
|
|
88
|
+
'Note for the ADR (optional)': 'Nota para el ADR (opcional)',
|
|
89
|
+
'Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.': 'Por qué eliges «{option}». Enter vacío la omite; va a la decisión del ADR.',
|
|
90
|
+
'Chosen option: «{option}».': 'Opción elegida: «{option}».',
|
|
91
|
+
'Decision recorded as {adr} and sent to {to}.': 'Decisión registrada como {adr} y enviada a {to}.',
|
|
92
|
+
'Your answer': 'Tu respuesta',
|
|
93
|
+
'Exact text sent to {to} (msg.reply).': 'Texto exacto que recibe {to} (msg.reply).',
|
|
94
|
+
'Answer sent to {to} ({status}).': 'Respuesta enviada a {to} ({status}).',
|
|
95
|
+
'Message marked as read.': 'Mensaje dado por leído.',
|
|
96
|
+
'Diff of {branch} ({count} more lines not shown; git diff {base}..{branch})': 'Diff de {branch} ({count} líneas más sin mostrar; git diff {base}..{branch})',
|
|
97
|
+
'The model drafted a swarm objective; /relay shows it on the start card (nothing was started).': 'El modelo redactó un objetivo de enjambre; /relay lo muestra en la tarjeta de arranque (no se arrancó nada).',
|
|
98
|
+
'not linked': 'sin ligar',
|
|
99
|
+
'relayd is stopped': 'relayd está parado',
|
|
100
|
+
'no delegation yet': 'todavía sin delegación',
|
|
101
|
+
'the open-relay plugin watches another project': 'el plugin open-relay vigila otro proyecto',
|
|
102
|
+
'the open-relay plugin is {state}': 'el plugin open-relay está {state}',
|
|
103
|
+
'relayd rejected the delegation ({code})': 'relayd rechazó la delegación ({code})',
|
|
104
|
+
'Comal version changed: the trust is asked again for this version': 'Cambió la versión de Comal: la confianza se pide de nuevo para esta versión',
|
|
105
|
+
'Design file': 'Archivo del diseño',
|
|
106
|
+
'Path of a local image (PNG or JPEG). relay-hub copies it to design/ and the agents read it in `_relay/design/`. Nothing is started.': 'Ruta de una imagen local (PNG o JPEG). relay-hub la copia a design/ y los agentes la leen en `_relay/design/`. No se arranca nada.',
|
|
107
|
+
'Design attached: {file} ({size}) · the lead and the agents read it in `{mirror}`.': 'Diseño adjuntado: {file} ({size}) · el líder y los agentes lo leen en `{mirror}`.',
|
|
108
|
+
'Design: {file} ({size}) · the lead and the agents read it in `{mirror}`': 'Diseño: {file} ({size}) · el líder y los agentes lo leen en `{mirror}`',
|
|
109
|
+
'The hub could not attach the design: {error}': 'El hub no pudo adjuntar el diseño: {error}',
|
|
110
|
+
'Browser check (c-web)': 'Check de navegador (c-web)',
|
|
111
|
+
'Add a browser check (c-web)': 'Añadir un check de navegador (c-web)',
|
|
112
|
+
'Page of the browser check (c-web)': 'Página del check de navegador (c-web)',
|
|
113
|
+
'Path of the page in the project, e.g. / or /pricing.': 'Ruta de la página en el proyecto, p. ej. / o /precios.',
|
|
114
|
+
'Selector of the browser check (c-web)': 'Selector del check de navegador (c-web)',
|
|
115
|
+
'CSS selector the page must show, e.g. #greeting': 'Selector CSS que la página debe mostrar, p. ej. #greeting',
|
|
116
|
+
'Use a path that starts with / (e.g. / or /pricing).': 'Usa una ruta que empiece por / (p. ej. / o /precios).',
|
|
117
|
+
'Use a selector without spaces (e.g. #greeting).': 'Usa un selector sin espacios (p. ej. #greeting).',
|
|
118
|
+
'browser check (chrome-lab over ssh)': 'check de navegador (chrome-lab por ssh)',
|
|
119
|
+
'Plan {id} changed since it was shown (another digest); review it again.': 'El plan {id} cambió desde que se mostró (otro digest); revísalo de nuevo.',
|
|
120
|
+
'The question {id} is no longer waiting for an answer.': 'La pregunta {id} ya no espera respuesta.',
|
|
121
|
+
'The success criteria of {swarm} changed since they were shown; review them again.': 'Los criterios de éxito de {swarm} cambiaron desde que se mostraron; revísalos de nuevo.',
|
|
122
|
+
'This card is being answered from the browser right now; /relay shows it again.': 'Esta tarjeta se está respondiendo ahora mismo desde el navegador; /relay la vuelve a mostrar.',
|
|
123
|
+
'Blocked task {task}': 'Tarea bloqueada {task}',
|
|
124
|
+
'Retry it yourself in your terminal, exactly: {command}': 'Reinténtala tú en tu terminal, exactamente: {command}',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const NAME = /^[a-z][a-z0-9-]{0,39}$/;
|
|
128
|
+
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
|
129
|
+
const shellWord = value => /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
130
|
+
// Resultado de apply* que es una decisión aplicada en relayd (el resto: obsoleta o ya decidida).
|
|
131
|
+
const APPLIED = new Set(['approved', 'rejected', 'decided', 'answered', 'acked']);
|
|
132
|
+
/** Campos de la fila del libro cuando decide el navegador (/rc): origen, sesión, request_id y resultado. */
|
|
133
|
+
const remoteRow = (remote, outcome) => remote?.source === 'remote_browser' ? { source: remote.source, remote_session: remote.remote_session, remote_request_id: remote.remote_request_id, card_id: remote.card_id, remote_state: 'applied', remote_outcome: outcome } : {};
|
|
134
|
+
const CHECK = /^c-[a-z0-9][a-z0-9-]{0,30}$/;
|
|
135
|
+
/** Parte del presupuesto del swarm para el revisor (open-relay gate 6: el 30 % no daba para
|
|
136
|
+
* dos rondas de corrección y la revisión final). */
|
|
137
|
+
export const REVIEW_SHARE_PCT = 40;
|
|
138
|
+
const webUrl = value => value ? (/^https?:\/\//.test(value) ? value : `http://${value}`) : null;
|
|
139
|
+
const exists = async file => { try { await stat(file); return true; } catch { return false; } };
|
|
140
|
+
const samePath = (a, b) => path.resolve(String(a || '')) === path.resolve(String(b || ''));
|
|
141
|
+
const amount = text => { const match = /^(\d+(?:[.,]\d+)?)\s*([kKmMgG]?)$/.exec(text); if (!match) return null; const value = Number(match[1].replace(',', '.')) * ({ k: 1e3, m: 1e6, g: 1e9 }[match[2].toLowerCase()] || 1); return Number.isFinite(value) && value > 0 ? Math.round(value) : null; };
|
|
142
|
+
const duration = text => { const match = /^(\d+(?:[.,]\d+)?)\s*(h|m|min|s)?$/i.exec(text); if (!match) return null; const value = Number(match[1].replace(',', '.')) * ({ h: 3600e3, m: 60e3, min: 60e3, s: 1e3 }[String(match[2] || 'h').toLowerCase()]); return Number.isFinite(value) && value > 0 ? Math.round(value) : null; };
|
|
143
|
+
/** «300 30M 4h» → presupuesto del hub. */
|
|
144
|
+
export function parseBudget(text) {
|
|
145
|
+
const parts = String(text || '').trim().split(/[\s,·/]+/).filter(Boolean).filter(part => !/^(calls?|llamadas?|tokens?)$/i.test(part));
|
|
146
|
+
if (parts.length !== 3) return null;
|
|
147
|
+
const calls = amount(parts[0]), tokens = amount(parts[1]), wall = duration(parts[2]);
|
|
148
|
+
return calls && tokens && wall ? { calls, tokens, wall_time_ms: wall } : null;
|
|
149
|
+
}
|
|
150
|
+
/** Script de un check a partir de una línea de comando (mismo encabezado que los del hub). */
|
|
151
|
+
export function checkScript(id, command) {
|
|
152
|
+
return `#!/bin/sh\n# Editado en Comal (/relay). El candidato (solo lectura) está en $RELAY_CANDIDATE;\n# las rutas de la tarea, una por línea, en $RELAY_TASK_PATHS. exit 0 = PASS.\nset -eu\nC="\${RELAY_CANDIDATE:?}"\n# ${id}: ${command.replace(/\n/g, ' ')}\ncd "$C"\n${command}\necho "PASS: ${id}"\n`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check de navegador del proyecto (`c-web`): su `browser` lleva la página y el selector y lo
|
|
156
|
+
// corre el runner de navegador del hub (chrome-lab por ssh), no la shell. El script que se
|
|
157
|
+
// escribe falla en cerrado para que nadie lo confunda con una comprobación sin navegador.
|
|
158
|
+
const WEB_CHECK = 'c-web';
|
|
159
|
+
const isBrowserCheck = check => check?.id === WEB_CHECK || Boolean(check?.browser);
|
|
160
|
+
/** Página y selector de un check de navegador: `browser.page/selector` o la descripción «/ #greeting». */
|
|
161
|
+
const browserTarget = check => {
|
|
162
|
+
const browser = check?.browser && typeof check.browser === 'object' ? check.browser : {};
|
|
163
|
+
const match = /(\/\S*)\s+(#\S+)/.exec(String(check?.description || ''));
|
|
164
|
+
return { page: browser.page || match?.[1] || '/', selector: browser.selector || match?.[2] || '' };
|
|
165
|
+
};
|
|
166
|
+
const browserCheck = ({ page, selector }, current) => {
|
|
167
|
+
const id = current?.id || WEB_CHECK;
|
|
168
|
+
return {
|
|
169
|
+
...current, id, browser: { page, selector },
|
|
170
|
+
description: `${page} ${selector} · ${t('browser check (chrome-lab over ssh)')}`,
|
|
171
|
+
script: current?.script || checkScript(id, `echo '${id}: lo comprueba el runner de navegador del hub (chrome-lab por ssh); mira browser.page y browser.selector' >&2; exit 1`),
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
/** ¿Hay ojos? Mismo criterio que cards.eyesText: sin estado o con un estado de «ninguno», no. */
|
|
175
|
+
const eyesAvailable = eyes => Boolean(eyes) && !['', 'none', 'absent', 'missing', 'off', 'disabled'].includes(eyesState(eyes));
|
|
176
|
+
|
|
177
|
+
export class RelayFlow {
|
|
178
|
+
constructor({ cwd, ui, state, plugins, env = process.env, version, pluginCode, hubFactory = options => new HubClient(options), delegateFactory = options => new RelayClient(options), connect = options => new RelayConnection(options), git = relayGit, record = async () => {}, execFileFn = execFile, signal, broker = new RelayDecisionBroker() }) {
|
|
179
|
+
Object.assign(this, { cwd, ui, state, plugins, env, version, pluginCode, hubFactory, delegateFactory, connect, git, record, execFileFn, signal, broker });
|
|
180
|
+
this.busy = false; this.draft = null; this.current = null; this.clients = new Map(); this.view = null;
|
|
181
|
+
}
|
|
182
|
+
/** Última foto del panel /swarm (la del plugin open-relay); la tarjeta de estado la usa. */
|
|
183
|
+
setView(view) { this.view = normalizeSwarmView(view); }
|
|
184
|
+
/** Borrador del modelo (RelayDraft): sólo texto para la tarjeta de arranque, nunca arranca nada. */
|
|
185
|
+
setDraft(objective) { this.draft = { objective: String(objective).slice(0, 4000), at: Date.now() }; this.ui?.notice?.(t('The model drafted a swarm objective; /relay shows it on the start card (nothing was started).')); }
|
|
186
|
+
async repo() { try { return await this.git.toplevel(this.cwd); } catch { throw Object.assign(new Error(t('/relay needs a git repository (run Comal inside one).')), { code: 'RELAY_NO_REPO' }); } }
|
|
187
|
+
async hub() { return this.hubFactory({ root: hubRoot({ env: this.env, saved: await this.state.hubRoot() }).root, env: this.env }); }
|
|
188
|
+
/** Proyecto ligado a este repo (para el seguimiento por tarjetas), sin tocar el hub. */
|
|
189
|
+
async init() { try { const repo = await this.repo(), link = await this.state.link(repo); this.current = link ? { repo, ...link } : null; } catch { this.current = null; } return this.current; }
|
|
190
|
+
controlDir(hub, project) { return path.join(hub.root, 'projects', project, 'ctl'); }
|
|
191
|
+
/** Cliente delegado del HOST para las decisiones de las tarjetas (mismo token que el plugin). */
|
|
192
|
+
client(project, root) {
|
|
193
|
+
const key = `${root}/${project}`;
|
|
194
|
+
if (!this.clients.has(key)) { const controlDir = path.join(root, 'projects', project, 'ctl'); this.clients.set(key, this.delegateFactory({ controlDir, tokenFile: path.join(controlDir, 'comal.token') })); }
|
|
195
|
+
return this.clients.get(key);
|
|
196
|
+
}
|
|
197
|
+
close() { for (const client of this.clients.values()) client.close?.(); this.clients.clear(); }
|
|
198
|
+
|
|
199
|
+
/** /relay en la TUI. Devuelve el texto que queda en la conversación (o ''). */
|
|
200
|
+
async run(arg = '') {
|
|
201
|
+
if (this.busy) throw new Error(t('A /relay card is already open.'));
|
|
202
|
+
this.busy = true;
|
|
203
|
+
try { return await this.#dispatch(String(arg).trim()); }
|
|
204
|
+
finally { this.busy = false; if (this.ui.setup?.kind === 'status') this.ui.clearSetup(); }
|
|
205
|
+
}
|
|
206
|
+
async #dispatch(arg) {
|
|
207
|
+
const [sub = '', ...rest] = arg.split(/\s+/).filter(Boolean);
|
|
208
|
+
if (sub === 'hub') {
|
|
209
|
+
const root = rest.join(' ').trim();
|
|
210
|
+
if (!root || !path.isAbsolute(root.replace(/^~(?=\/|$)/, os.homedir()))) throw new Error(t('Use /relay hub /absolute/root.'));
|
|
211
|
+
const resolved = path.resolve(root.replace(/^~(?=\/|$)/, os.homedir()));
|
|
212
|
+
await this.state.setHubRoot(resolved); return t('relay-hub root saved: {root} (RELAY_HUB_ROOT still wins).', { root: resolved });
|
|
213
|
+
}
|
|
214
|
+
if (!['', 'status', 'new', 'start', 'stop', 'unlink'].includes(sub) || (sub !== 'new' && rest.length) || rest.length > 1) throw new Error(t('Use /relay [status|new [NAME]|start|stop|unlink|hub ROOT].'));
|
|
215
|
+
const repo = await this.repo();
|
|
216
|
+
if (sub === 'unlink') { const link = await this.state.link(repo); if (!link) return t('This repo is not linked to a relay-hub project. /relay proposes one.'); await this.state.unlink(repo); this.current = null; return t('Unlinked {project} from this repo (the project stays in relay-hub).', { project: link.project }); }
|
|
217
|
+
const hub = await this.#ensureHub();
|
|
218
|
+
if (sub === 'new') return this.#propose(repo, hub, { name: rest[0] });
|
|
219
|
+
const link = await this.#link(repo, hub);
|
|
220
|
+
if (!link) { if (sub === 'stop' || sub === 'start') return t('This repo is not linked to a relay-hub project. /relay proposes one.'); return this.#propose(repo, hub, {}); }
|
|
221
|
+
if (sub === 'stop') return this.#stop(hub, link.project);
|
|
222
|
+
if (sub === 'start') return this.#start(hub, link.project);
|
|
223
|
+
return this.#status(repo, hub, link);
|
|
224
|
+
}
|
|
225
|
+
/** -p: estado como JSON (sin tarjetas: arrancar o decidir es de la TUI). */
|
|
226
|
+
async text() {
|
|
227
|
+
const repo = await this.repo(), hub = await this.hub(), link = await this.state.link(repo), health = await hub.ready();
|
|
228
|
+
const out = { repo, hub: { root: hub.root, running: Boolean(health), version: health?.version || null }, link: link || null };
|
|
229
|
+
if (health && link) out.project = await hub.call('project.status', { project: link.project }).catch(error => ({ error: { code: error.code, message: error.message } }));
|
|
230
|
+
return JSON.stringify(out, null, 2);
|
|
231
|
+
}
|
|
232
|
+
async #ensureHub() {
|
|
233
|
+
const hub = await this.hub();
|
|
234
|
+
if (await hub.ready({ signal: this.signal })) return hub;
|
|
235
|
+
const listen = await hub.listen();
|
|
236
|
+
this.ui.setupStatus({ title: t('Starting relay-hub'), description: t('The local hub API (loopback {listen}) was not running; Comal starts it as the user unit relay-hub.', { listen }) });
|
|
237
|
+
const started = await hub.ensure({ signal: this.signal });
|
|
238
|
+
this.ui.clearSetup();
|
|
239
|
+
if (started.started) { this.ui.notice(t('relay-hub started ({how}) on {listen}.', { how: started.unit ? `systemd --user ${started.unit}` : `pid ${started.pid}`, listen })); await this.record({ action: 'hub.start', root: hub.root, listen, unit: started.unit || null }); }
|
|
240
|
+
return hub;
|
|
241
|
+
}
|
|
242
|
+
/** Proyecto ligado y todavía presente en el hub (si no, el vínculo se olvida). */
|
|
243
|
+
async #link(repo, hub) {
|
|
244
|
+
const link = await this.state.link(repo);
|
|
245
|
+
if (!link) { this.current = null; return null; }
|
|
246
|
+
const { projects = [] } = await hub.call('project.list', {}, { signal: this.signal });
|
|
247
|
+
if (!projects.some(item => item.name === link.project)) { await this.state.unlink(repo); this.current = null; return null; }
|
|
248
|
+
this.current = { repo, ...link }; return this.current;
|
|
249
|
+
}
|
|
250
|
+
async #projectFile(hub, project) { try { return JSON.parse(await readFile(path.join(hub.root, 'projects', project, 'project.json'), 'utf8'))?.proposal || null; } catch { return null; } }
|
|
251
|
+
|
|
252
|
+
// ── 1. Propuesta ──────────────────────────────────────────────────────────
|
|
253
|
+
async #detect(hub, repo, edits) {
|
|
254
|
+
this.ui.setupStatus({ title: t('Reading the repo'), description: t('project.detect: branch, excludes, checks and budget for this repo. Nothing is written.') });
|
|
255
|
+
try {
|
|
256
|
+
const proposal = await hub.call('project.detect', { repo, ...(edits.name ? { name: edits.name } : {}), ...(edits.base ? { base: edits.base } : {}) }, { signal: this.signal });
|
|
257
|
+
if (edits.model) proposal.runtimes = (proposal.runtimes || []).map(item => ({ ...item, model: edits.model }));
|
|
258
|
+
if (edits.budget) proposal.budget = edits.budget;
|
|
259
|
+
if (edits.excludes) proposal.excludes = edits.excludes;
|
|
260
|
+
if (edits.checks) proposal.checks = edits.checks;
|
|
261
|
+
return proposal;
|
|
262
|
+
} finally { this.ui.clearSetup(); }
|
|
263
|
+
}
|
|
264
|
+
async #propose(repo, hub, { name }) {
|
|
265
|
+
const edits = { name: name || undefined };
|
|
266
|
+
let { projects = [] } = await hub.call('project.list', {}, { signal: this.signal });
|
|
267
|
+
let proposal = await this.#detect(hub, repo, edits);
|
|
268
|
+
// Nombre libre por omisión: el del directorio, y si ya existe, NOMBRE-2, NOMBRE-3…
|
|
269
|
+
if (!name && projects.some(item => item.name === proposal.name)) {
|
|
270
|
+
for (let n = 2; n < 100; n++) { const candidate = `${proposal.name}`.slice(0, 36) + `-${n}`; if (!projects.some(item => item.name === candidate)) { edits.name = candidate; break; } }
|
|
271
|
+
proposal = await this.#detect(hub, repo, edits);
|
|
272
|
+
}
|
|
273
|
+
const existing = projects.filter(item => item.repo && samePath(item.repo, repo));
|
|
274
|
+
for (;;) {
|
|
275
|
+
const choice = await this.ui.setupChoice({ ...proposalCard({ proposal, existing, repoLabel: path.basename(repo) }), signal: this.signal });
|
|
276
|
+
if (!choice || choice === 'cancel') return t('Nothing was created.');
|
|
277
|
+
if (choice === 'edit') { proposal = await this.#edit(hub, repo, proposal, edits); continue; }
|
|
278
|
+
if (choice.startsWith('existing:')) {
|
|
279
|
+
const project = choice.slice('existing:'.length), info = await this.#projectFile(hub, project);
|
|
280
|
+
await this.state.setLink(repo, { project, hub_root: hub.root, branch: info?.branch || `relay/${project}`, base_commit: info?.base_commit || null });
|
|
281
|
+
this.current = { repo, ...(await this.state.link(repo)) };
|
|
282
|
+
await this.record({ action: 'project.link', project });
|
|
283
|
+
const status = await hub.call('project.status', { project }, { signal: this.signal });
|
|
284
|
+
if (status.unit?.active !== 'active') await this.#start(hub, project, { quiet: true });
|
|
285
|
+
return this.#connectAndStart(hub, project, info);
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
this.ui.setupStatus({ title: t('Creating {name}', { name: proposal.name }), description: t('relay-hub writes the project, adds the branch {branch} and starts its relayd (up to 2 min).', { branch: proposal.branch }) });
|
|
290
|
+
let started;
|
|
291
|
+
try {
|
|
292
|
+
const created = await hub.call('project.create', { proposal }, { signal: this.signal });
|
|
293
|
+
await this.record({ action: 'project.create', project: proposal.name, repo, branch: proposal.branch, base: proposal.base || 'HEAD', base_commit: proposal.base_commit, checks: (proposal.checks || []).map(check => check.id), branch_created: created?.branch_created ?? null });
|
|
294
|
+
await this.state.setLink(repo, { project: proposal.name, hub_root: hub.root, branch: proposal.branch, base_commit: proposal.base_commit });
|
|
295
|
+
this.current = { repo, ...(await this.state.link(repo)) };
|
|
296
|
+
started = await hub.call('project.start', { project: proposal.name }, { signal: this.signal, timeoutMs: 180000 });
|
|
297
|
+
await this.record({ action: 'project.start', project: proposal.name, active: started?.active ?? null });
|
|
298
|
+
} finally { this.ui.clearSetup(); }
|
|
299
|
+
this.ui.notice(t('Project {name} created and started: relayd ready, UI {web}, branch {branch}.', { name: proposal.name, web: webUrl(started?.web) || `http://127.0.0.1:${proposal.port}`, branch: proposal.branch }));
|
|
300
|
+
return this.#connectAndStart(hub, proposal.name, proposal);
|
|
301
|
+
}
|
|
302
|
+
async #edit(hub, repo, proposal, edits) {
|
|
303
|
+
// Check de navegador del proyecto: si viene (c-web) se edita su página y su selector; con
|
|
304
|
+
// ojos disponibles y sin él, la misma entrada lo añade a proposal.checks.
|
|
305
|
+
const web = (proposal.checks || []).find(isBrowserCheck), target = browserTarget(web), eyes = eyesAvailable(proposal.eyes);
|
|
306
|
+
const field = await this.ui.setupChoice({ title: t('Field to edit'), items: [
|
|
307
|
+
{ id: 'name', label: t('Name'), description: proposal.name }, { id: 'base', label: t('Base (branch or commit)'), description: `${proposal.base || 'HEAD'} (${String(proposal.base_commit || '').slice(0, 8)})` },
|
|
308
|
+
{ id: 'model', label: t('Model of the agents'), description: proposal.runtimes?.[0]?.model || '' }, { id: 'checks', label: t('Checks'), description: (proposal.checks || []).map(check => check.id).join(', ') },
|
|
309
|
+
{ id: 'budget', label: t('Budget per swarm'), description: `${proposal.budget?.calls} ${proposal.budget?.tokens} ${Math.round((proposal.budget?.wall_time_ms || 0) / 60000)}min` },
|
|
310
|
+
{ id: 'excludes', label: t('Excludes'), description: (proposal.excludes || []).slice(0, 4).join(', ') },
|
|
311
|
+
...(web || eyes ? [{ id: 'web', label: t(web ? 'Browser check (c-web)' : 'Add a browser check (c-web)'), description: web ? `${target.page} ${target.selector}`.trim() : eyesText(proposal.eyes) }] : []),
|
|
312
|
+
{ id: 'back', label: t('Back to the proposal') },
|
|
313
|
+
], signal: this.signal });
|
|
314
|
+
const ask = (title, description, value, validate) => this.ui.setupInput({ title, description, value, validate, signal: this.signal });
|
|
315
|
+
if (field === 'name') { const value = await ask(t('Name'), t('Lowercase letters, digits and dashes (up to 40). Branch relay/NAME.'), proposal.name, text => NAME.test(text.trim()) || t('Use lowercase, digits and dashes.')); if (value) { edits.name = value.trim(); return this.#detect(hub, repo, edits); } }
|
|
316
|
+
if (field === 'base') { const value = await ask(t('Base (branch or commit)'), t('Any ref of the repo: HEAD, origin/main, a commit…'), proposal.base || 'HEAD'); if (value?.trim()) { edits.base = value.trim(); return this.#detect(hub, repo, edits); } }
|
|
317
|
+
if (field === 'model') { const value = await ask(t('Model of the agents'), t('Ollama model for comal (lead and agents).'), proposal.runtimes?.[0]?.model || ''); if (value?.trim()) { edits.model = value.trim(); proposal.runtimes = (proposal.runtimes || []).map(item => ({ ...item, model: edits.model })); } }
|
|
318
|
+
if (field === 'budget') { const value = await ask(t('Budget per swarm'), t('Calls, tokens and wall time, e.g. 300 30M 4h.'), `${proposal.budget?.calls} ${proposal.budget?.tokens} ${Math.round((proposal.budget?.wall_time_ms || 0) / 3600000) || 4}h`, text => Boolean(parseBudget(text)) || t('Use three values: calls tokens time (300 30M 4h).')); if (value) { edits.budget = parseBudget(value); proposal.budget = edits.budget; } }
|
|
319
|
+
if (field === 'excludes') { const value = await ask(t('Excludes'), t('Comma-separated globs, relative to the repo.'), (proposal.excludes || []).join(', ')); if (value !== null && value !== undefined) { edits.excludes = value.split(',').map(item => item.trim()).filter(Boolean); proposal.excludes = edits.excludes; } }
|
|
320
|
+
if (field === 'checks') {
|
|
321
|
+
const checks = [...(proposal.checks || [])];
|
|
322
|
+
const pick = await this.ui.setupChoice({ title: t('Checks'), items: [...checks.map(check => ({ id: `check:${check.id}`, label: check.id, description: line(String(check.description || '').replace(/`/g, ''), 120) })), { id: 'add', label: t('Add a check') }, { id: 'back', label: t('Back to the proposal') }], signal: this.signal });
|
|
323
|
+
if (pick?.startsWith('check:')) {
|
|
324
|
+
const id = pick.slice(6), value = await ask(t('Check {id}', { id }), t('Command run in the candidate (sh, from its root). Empty removes the check.'), '');
|
|
325
|
+
if (value !== null && value !== undefined) { edits.checks = value.trim() ? checks.map(check => check.id === id ? { ...check, description: `\`${value.trim()}\` (${t('Checks').toLowerCase()} /relay)`, script: checkScript(id, value.trim()) } : check) : checks.filter(check => check.id !== id); proposal.checks = edits.checks; }
|
|
326
|
+
} else if (pick === 'add') {
|
|
327
|
+
const value = await ask(t('Add a check'), t('New check: id (c-…) and command, e.g. c-lint npm run lint'), 'c-', text => { const [id, ...command] = text.trim().split(/\s+/); return (CHECK.test(id) && command.length > 0 && !checks.some(check => check.id === id)) || t('Use c-ID followed by the command.'); });
|
|
328
|
+
if (value) { const [id, ...command] = value.trim().split(/\s+/); edits.checks = [...checks, { id, description: `\`${command.join(' ')}\` (/relay)`, script: checkScript(id, command.join(' ')), timeout_ms: 600000 }]; proposal.checks = edits.checks; }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (field === 'web') {
|
|
332
|
+
const page = await ask(t('Page of the browser check (c-web)'), t('Path of the page in the project, e.g. / or /pricing.'), target.page || '/', text => /^\/[^\s'"]*$/.test(text.trim()) || t('Use a path that starts with / (e.g. / or /pricing).'));
|
|
333
|
+
if (page?.trim()) {
|
|
334
|
+
const selector = await ask(t('Selector of the browser check (c-web)'), t('CSS selector the page must show, e.g. #greeting'), target.selector || '', text => /^[^\s'"]+$/.test(text.trim()) || t('Use a selector without spaces (e.g. #greeting).'));
|
|
335
|
+
if (selector?.trim()) {
|
|
336
|
+
const check = browserCheck({ page: page.trim(), selector: selector.trim() }, web);
|
|
337
|
+
edits.checks = web ? (proposal.checks || []).map(item => item === web ? check : item) : [...(proposal.checks || []), check];
|
|
338
|
+
proposal.checks = edits.checks;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return proposal;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── 2. Confianza ──────────────────────────────────────────────────────────
|
|
346
|
+
/** ¿Comal ya opera este proyecto? {ok, principal, caps} o {ok:false, reason, reissue}. */
|
|
347
|
+
async connection(hub, project) {
|
|
348
|
+
const controlDir = this.controlDir(hub, project), tokenFile = path.join(controlDir, 'comal.token');
|
|
349
|
+
const desc = await this.plugins.describe('open-relay');
|
|
350
|
+
const watching = desc.configured && desc.config?.controlDir === controlDir;
|
|
351
|
+
if (!await exists(tokenFile)) return { ok: false, reason: t('no delegation yet') };
|
|
352
|
+
let hello = null, reissue = null;
|
|
353
|
+
const connection = this.connect({ controlDir, tokenFile });
|
|
354
|
+
try { hello = (await connection.open(this.signal))?.delegate || null; }
|
|
355
|
+
catch (error) { if (needsHuman(error)) reissue = t('relayd rejected the delegation ({code})', { code: error.code }); }
|
|
356
|
+
finally { connection.close?.(); }
|
|
357
|
+
if (reissue) return { ok: false, reason: reissue, reissue };
|
|
358
|
+
if (!watching) return { ok: false, reason: desc.configured ? t('the open-relay plugin watches another project') : t('not linked'), ...hello };
|
|
359
|
+
if (desc.trust?.status === 'stale' && desc.trust.reasons?.some(reason => /package|fingerprint/.test(reason))) return { ok: false, reason: t('Comal version changed: the trust is asked again for this version'), ...hello };
|
|
360
|
+
if (desc.state !== 'active' || desc.trust?.status !== 'trusted' || desc.config?.notify !== 'cards') return { ok: false, reason: t('the open-relay plugin is {state}', { state: desc.state || desc.trust?.status || '?' }), ...hello };
|
|
361
|
+
return { ok: true, principal: hello?.principal, caps: hello?.caps || [] };
|
|
362
|
+
}
|
|
363
|
+
async #trust(hub, project) {
|
|
364
|
+
const state = await this.connection(hub, project);
|
|
365
|
+
if (state.ok) return true;
|
|
366
|
+
const controlDir = this.controlDir(hub, project), tokenFile = path.join(controlDir, 'comal.token'), code = await this.pluginCode();
|
|
367
|
+
const choice = await this.ui.setupChoice({ ...trustCard({ project, controlDir, tokenFile, version: this.version, pkg: code?.package || null, fingerprint: code?.code_fingerprint, reissue: state.reissue }), signal: this.signal });
|
|
368
|
+
if (choice !== 'trust') return false;
|
|
369
|
+
this.ui.setupStatus({ title: t('Connecting Comal'), description: t('Issuing the delegate session and enabling the open-relay plugin for this repo.') });
|
|
370
|
+
try {
|
|
371
|
+
const issued = await this.#delegate(hub, project, { replace: Boolean(state.reissue) });
|
|
372
|
+
await this.plugins.link('open-relay', { controlDir, tokenFile, notify: 'cards' }, { signal: this.signal });
|
|
373
|
+
const desc = await this.plugins.describe('open-relay');
|
|
374
|
+
if (desc.state !== 'active') throw new Error(t('The open-relay plugin did not become active: {error}', { error: desc.error || desc.state || '?' }));
|
|
375
|
+
await this.record({ action: 'relay.trust', project, control_dir: controlDir, token_file: tokenFile, caps: RECOMMENDED_CAPS.split(','), issued: issued.issued !== false, comal_version: this.version, package_integrity: code?.package?.integrity || null, code_fingerprint: code?.code_fingerprint || null });
|
|
376
|
+
this.clients.get(`${hub.root}/${project}`)?.close?.(); this.clients.delete(`${hub.root}/${project}`);
|
|
377
|
+
this.ui.notice(t('Comal connected to {project}: delegation {caps} in {file}; the plugin watches the swarm (/swarm).', { project, caps: RECOMMENDED_CAPS, file: tokenFile }));
|
|
378
|
+
return true;
|
|
379
|
+
} finally { this.ui.clearSetup(); }
|
|
380
|
+
}
|
|
381
|
+
/** Delegación por el hub (project.delegate); con un hub sin ese método, la CLI relay. */
|
|
382
|
+
async #delegate(hub, project, { replace }) {
|
|
383
|
+
try { return await hub.call('project.delegate', { project, client: 'comal', caps: RECOMMENDED_CAPS.split(','), ...(replace ? { replace: true } : {}) }, { signal: this.signal }); }
|
|
384
|
+
catch (error) { if (error.code !== 'method_not_found') throw error; }
|
|
385
|
+
const config = await hub.config(), controlDir = this.controlDir(hub, project), tokenFile = path.join(controlDir, 'comal.token');
|
|
386
|
+
const bin = path.join(config.bin_dir || path.join(config.open_relay || '', 'target', 'release'), 'relay');
|
|
387
|
+
if (replace) await rm(tokenFile, { force: true });
|
|
388
|
+
else if (await exists(tokenFile)) return { token_file: tokenFile, issued: false };
|
|
389
|
+
const principal = (() => { try { return os.userInfo().username || 'operator'; } catch { return 'operator'; } })();
|
|
390
|
+
await new Promise((resolve, reject) => this.execFileFn(bin, ['--control-dir', controlDir, 'session', 'issue-delegate', '--principal', principal, '--client', 'comal', '--caps', RECOMMENDED_CAPS, '--ttl', '30d', '--out', tokenFile], { timeout: 30000 }, (error, _out, stderr) => error ? reject(new Error(`relay session issue-delegate: ${String(stderr || error.message).trim().slice(0, 400)}`)) : resolve()));
|
|
391
|
+
return { token_file: tokenFile, issued: true };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── 3. Arranque ───────────────────────────────────────────────────────────
|
|
395
|
+
async #connectAndStart(hub, project, info) {
|
|
396
|
+
if (!await this.#trust(hub, project)) return t('Project {project} is ready; Comal is not connected (trust card declined). /relay connects it later.', { project });
|
|
397
|
+
return this.#newSwarm(hub, project, info);
|
|
398
|
+
}
|
|
399
|
+
/** ¿El hub de este proyecto admite project.attach? Es de open-relay 2.12. Sin sondas: manda lo
|
|
400
|
+
* que declara project.detect (`attach` o 'project.attach' en sus métodos/capacidades) y, si
|
|
401
|
+
* no dice nada, la versión que publica /v1/health (hub.ready().version). Sin señal: no. */
|
|
402
|
+
async #canAttachDesign(hub, info) {
|
|
403
|
+
const declared = info?.attach ?? info?.project?.attach;
|
|
404
|
+
if (typeof declared === 'boolean') return declared;
|
|
405
|
+
const methods = [...(info?.methods || []), ...(info?.capabilities || [])].map(String);
|
|
406
|
+
if (methods.length) return methods.includes('project.attach');
|
|
407
|
+
try {
|
|
408
|
+
const health = await hub.ready({ signal: this.signal });
|
|
409
|
+
const [major, minor] = String(health?.version || '').split('.').map(part => Number.parseInt(part, 10));
|
|
410
|
+
return Number.isInteger(major) && (major > 2 || (major === 2 && minor >= 12));
|
|
411
|
+
} catch { return false; }
|
|
412
|
+
}
|
|
413
|
+
/** Adjunta el diseño al proyecto ANTES de swarm.start: relay-hub copia la imagen al repo y la
|
|
414
|
+
* espeja en _relay/design/ para que el líder y los agentes la lean. Queda fila en el libro. */
|
|
415
|
+
async #attachDesign(hub, project, file) {
|
|
416
|
+
try {
|
|
417
|
+
const result = await hub.call('project.attach', { project, file }, { signal: this.signal, timeoutMs: 120000 });
|
|
418
|
+
const shown = result?.design || result || {}, name = path.basename(String(shown.name || shown.file || file));
|
|
419
|
+
const design = { name, width: shown.width ?? null, height: shown.height ?? null, bytes: shown.bytes ?? shown.size ?? null };
|
|
420
|
+
await this.record({ action: 'project.attach', project, file, design: `design/${name}`, mirror: `_relay/design/${name}`, width: design.width, height: design.height, bytes: design.bytes });
|
|
421
|
+
this.ui.notice(t('Design attached: {file} ({size}) · the lead and the agents read it in `{mirror}`.', designText(design)));
|
|
422
|
+
return design;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
this.ui.notice(t('The hub could not attach the design: {error}', { error: line(error.message, 200) }));
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async #newSwarm(hub, project, info) {
|
|
429
|
+
info ||= await this.#projectFile(hub, project) || {};
|
|
430
|
+
const lead = { runtime: info.runtimes?.[0]?.runtime || 'comal', model: info.runtimes?.[0]?.model || '?' }, branch = info.branch || `relay/${project}`;
|
|
431
|
+
const budget = info.budget || { calls: 300, tokens: 30000000, wall_time_ms: 14400000 };
|
|
432
|
+
let objective = this.draft?.objective || '', drafted = Boolean(objective), design = info.design || null, withReview = false;
|
|
433
|
+
// project.attach llega con open-relay 2.12: se pregunta al hub por su capacidad, nunca se
|
|
434
|
+
// prueba el método a ciegas (un method_not_found dejaría ruido en el log del hub).
|
|
435
|
+
const canAttachDesign = await this.#canAttachDesign(hub, info);
|
|
436
|
+
const write = value => this.ui.setupInput({ ...objectiveInput({ project, lead: `${lead.runtime} · ${lead.model}` }), value, signal: this.signal });
|
|
437
|
+
for (;;) {
|
|
438
|
+
if (!objective.trim()) { const value = await write(''); if (!value?.trim()) return t('No swarm started.'); objective = value.trim(); drafted = false; }
|
|
439
|
+
// Revisor (open-relay F8): con ojos en el proyecto se ofrece arrancar con revisor.
|
|
440
|
+
const eyes = info.eyes?.host || info.remote_hosts?.[0] || null;
|
|
441
|
+
// El reparto que muestra la tarjeta es el que se pide (share_pct): no depende del valor
|
|
442
|
+
// por omisión del hub (open-relay v2.29: 40 %, antes 30 %).
|
|
443
|
+
const reviewer = eyes ? { runtime: lead.runtime, model: lead.model, eyes, share: REVIEW_SHARE_PCT } : null;
|
|
444
|
+
const choice = await this.ui.setupChoice({ ...startCard({ project, objective, lead, budget, branch, drafted, design, canAttachDesign, reviewer }), signal: this.signal });
|
|
445
|
+
if (choice === 'design') {
|
|
446
|
+
const file = await this.ui.setupInput({ title: t('Design file'), description: t('Path of a local image (PNG or JPEG). relay-hub copies it to design/ and the agents read it in `_relay/design/`. Nothing is started.'), signal: this.signal });
|
|
447
|
+
if (file?.trim()) { const attached = await this.#attachDesign(hub, project, file.trim()); if (attached) design = attached; }
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (choice === 'edit') { const value = await write(objective); if (value?.trim()) { objective = value.trim(); drafted = false; } continue; }
|
|
451
|
+
if (choice !== 'start' && choice !== 'start-review') return t('No swarm started.');
|
|
452
|
+
withReview = choice === 'start-review';
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
// El objetivo que ve el líder cita el diseño: es su referencia visual (nadie la compara solo).
|
|
456
|
+
const designLine = design ? t('Design: {file} ({size}) · the lead and the agents read it in `{mirror}`', designText(design)) : null;
|
|
457
|
+
const brief = designLine ? `${objective}\n\n${designLine}` : objective;
|
|
458
|
+
this.ui.setupStatus({ title: t('Starting the swarm'), description: t('swarm.start in {project}: account, root grant, lead and root task.', { project }) });
|
|
459
|
+
let started;
|
|
460
|
+
try { started = await hub.call('swarm.start', { project, objective: brief, ...(withReview ? { review: { share_pct: REVIEW_SHARE_PCT } } : {}) }, { signal: this.signal }); }
|
|
461
|
+
finally { this.ui.clearSetup(); }
|
|
462
|
+
const swarm = started?.swarm?.swarm_id || started?.swarm?.swarm || '?';
|
|
463
|
+
this.draft = null;
|
|
464
|
+
const link = this.current?.project === project ? this.current : null;
|
|
465
|
+
await this.state.setSwarm(project, swarm, { objective: brief, started_at: new Date().toISOString(), base_commit: link?.base_commit || info.base_commit || null, branch });
|
|
466
|
+
await this.record({ action: 'swarm.start', project, swarm, review: withReview, objective: brief, lead: `${lead.runtime}:${started?.swarm?.lead_model || lead.model}`, budget: started?.swarm?.budget || budget, outcome: started?.swarm?.outcome || null, drafted_by_model: drafted, design: design ? `design/${design.name}` : null });
|
|
467
|
+
return t('Swarm {swarm} started in {project} (lead {lead}). The lead is reading the repo; its plan will show up here as a card. /swarm shows the live board.', { swarm, project, lead: `${lead.runtime} · ${started?.swarm?.lead_model || lead.model}` });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Estado, parar, arrancar ───────────────────────────────────────────────
|
|
471
|
+
async #status(repo, hub, link) {
|
|
472
|
+
const project = link.project, status = await hub.call('project.status', { project }, { signal: this.signal });
|
|
473
|
+
const connected = status.unit?.active === 'active' ? await this.connection(hub, project) : { ok: false, reason: t('relayd is stopped') };
|
|
474
|
+
const watching = connected.ok && this.view?.available;
|
|
475
|
+
const swarms = watching ? this.view.swarms : (status.swarms || []).map(item => ({ id: item.id, objective: item.objective, tasks: [], pending: [], plans: [] }));
|
|
476
|
+
const pending = [];
|
|
477
|
+
if (watching) for (const swarm of this.view.swarms) {
|
|
478
|
+
if (criteriaWaiting(swarm.criteria) && swarm.criteria.digest) pending.push({ id: `criteria|${swarm.id}|${swarm.criteria.digest}`, label: t('Decide the success criteria of {swarm}', { swarm: swarm.id }), description: `v${swarm.criteria.version} · ${swarm.criteria.status}` });
|
|
479
|
+
// open-relay v2.31: criterios aceptados se pueden reabrir a medio swarm (criteria.reopen).
|
|
480
|
+
if (swarm.criteria?.status === 'accepted' && swarm.criteria.digest) pending.push({ id: `reopen|${swarm.id}|${swarm.criteria.digest}`, label: t('Reopen the success criteria of {swarm}', { swarm: swarm.id }), description: `v${swarm.criteria.version} · ${swarm.criteria.status}` });
|
|
481
|
+
for (const task of swarm.tasks || []) if (task.state === 'needs_attention' && task.review_kind) pending.push({ id: `escalation|${swarm.id}|${task.id}`, label: t('Escalated review of {task}', { task: task.id }), description: line(task.title || '', 120) });
|
|
482
|
+
for (const plan of swarm.plans || []) if (plan.proposal_id) pending.push({ id: `plan|${swarm.id}|${plan.proposal_id}`, label: t('Review the plan {id}', { id: plan.proposal_id }), description: line(plan.summary || '', 120) });
|
|
483
|
+
for (const item of swarm.pending || []) if (item.kind === 'question' && item.message_id) pending.push({ id: `question|${swarm.id}|${item.message_id}`, label: t('Answer {from}', { from: item.from }), description: line(item.decision?.question || item.excerpt || '', 120) });
|
|
484
|
+
for (const task of swarm.tasks || []) if (blockedForOperator(task)) pending.push({ id: `blocked|${swarm.id}|${task.id}`, label: t('Blocked task {task}', { task: task.id }), description: line([blockLabel(task.state), task.title].filter(Boolean).join(' · '), 120) });
|
|
485
|
+
// El final que el seguimiento aún no pudo enseñar (pantalla ocupada) también se ofrece aquí.
|
|
486
|
+
if (swarmFinished(swarm)) { const tracked = await this.state.swarm(project, swarm.id); if (tracked && !tracked.finished_at) pending.push({ id: `finish|${swarm.id}|`, label: t('See the result of {swarm}', { swarm: swarm.id }), description: line(tracked.objective || swarm.objective || '', 120) }); }
|
|
487
|
+
}
|
|
488
|
+
const choice = await this.ui.setupChoice({ ...statusCard({ project, status, connected, swarms, pending }), signal: this.signal });
|
|
489
|
+
if (!choice || choice === 'close') return '';
|
|
490
|
+
if (choice === 'swarm') return this.#newSwarm(hub, project, null);
|
|
491
|
+
if (choice === 'board') { this.ui.toggleSwarm?.(); return ''; }
|
|
492
|
+
if (choice === 'trust') return await this.#trust(hub, project) ? this.#newSwarm(hub, project, null) : t('Project {project} is ready; Comal is not connected (trust card declined). /relay connects it later.', { project });
|
|
493
|
+
if (choice === 'stop') return this.#stop(hub, project);
|
|
494
|
+
if (choice === 'start') return this.#start(hub, project);
|
|
495
|
+
if (choice === 'new') return this.#propose(repo, hub, {});
|
|
496
|
+
const [kind, swarm, id] = choice.split('|');
|
|
497
|
+
if (kind === 'plan') await this.planDecision({ project, root: hub.root, swarm, proposal_id: id });
|
|
498
|
+
if (kind === 'question') await this.questionDecision({ project, root: hub.root, swarm, message_id: id });
|
|
499
|
+
if (kind === 'criteria') await this.criteriaDecision({ project, root: hub.root, swarm, digest: id });
|
|
500
|
+
if (kind === 'reopen') await this.reopenCriteria({ project, root: hub.root, swarm, digest: id });
|
|
501
|
+
if (kind === 'escalation') await this.escalationDecision({ project, root: hub.root, swarm, task: { id } });
|
|
502
|
+
if (kind === 'blocked') { const task = this.view?.swarms.find(item => item.id === swarm)?.tasks.find(item => item.id === id); if (task) await this.blockedDecision({ project, root: hub.root, swarm, task }); }
|
|
503
|
+
if (kind === 'finish') { const view = this.view.swarms.find(item => item.id === swarm); if (view) await this.finishDecision({ project, root: hub.root, swarm, view, repo }); }
|
|
504
|
+
return '';
|
|
505
|
+
}
|
|
506
|
+
async #stop(hub, project) {
|
|
507
|
+
const result = await hub.call('project.stop', { project }, { signal: this.signal });
|
|
508
|
+
await this.record({ action: 'project.stop', project, active: result?.active ?? null });
|
|
509
|
+
return t('Project {project} stopped (unit {unit}: {state}). Its swarms and the branch stay; /relay starts it again.', { project, unit: result?.unit || `open-relay-${project}`, state: result?.active || '?' });
|
|
510
|
+
}
|
|
511
|
+
async #start(hub, project, { quiet = false } = {}) {
|
|
512
|
+
this.ui.setupStatus({ title: t('Starting {project}', { project }), description: t('relay-hub starts its relayd and waits until it is ready.') });
|
|
513
|
+
let result; try { result = await hub.call('project.start', { project }, { signal: this.signal, timeoutMs: 180000 }); } finally { this.ui.clearSetup(); }
|
|
514
|
+
await this.record({ action: 'project.start', project, active: result?.active ?? null });
|
|
515
|
+
const text = t('Project {project} started: relayd {state}, UI {web}.', { project, state: result?.daemon?.state || result?.active || '?', web: webUrl(result?.web) || '?' });
|
|
516
|
+
if (quiet) this.ui.notice(text);
|
|
517
|
+
return text;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Tarjetas del seguimiento (follow.mjs) ─────────────────────────────────
|
|
521
|
+
#calls(project, root) {
|
|
522
|
+
const client = this.client(project, root);
|
|
523
|
+
return { client, call: (method, params, signal) => client.call(method, params, { signal }), mutate: (method, params, signal) => client.mutate(method, params, { signal }) };
|
|
524
|
+
}
|
|
525
|
+
// ── Una tarjeta, dos superficies (broker.mjs) ─────────────────────────────
|
|
526
|
+
// Presentar (TUI) y aplicar (TUI o navegador) van separados: apply* vuelve a leer relayd justo
|
|
527
|
+
// antes de mutar, muta con las mismas funciones y claves, y deja la fila del libro. La TUI toma
|
|
528
|
+
// el candado del broker al CONFIRMAR; si el navegador decide antes, su tarjeta se cierra sola.
|
|
529
|
+
/** ¿Ya se decidió desde el navegador? Lo dice y no vuelve a enseñar la tarjeta. */
|
|
530
|
+
#elsewhere(key) {
|
|
531
|
+
const decided = this.broker.outcome(key);
|
|
532
|
+
if (decided?.by !== 'browser') return false;
|
|
533
|
+
this.ui.notice(t('Already decided from the browser ({outcome}); nothing was sent.', { outcome: t(decided.outcome) }));
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
/** Confirmación en la TUI: candado → apply → commit (o suelta el candado si no se aplicó). */
|
|
537
|
+
async #confirm(key, apply, done) {
|
|
538
|
+
const lock = this.broker.begin(key, 'terminal');
|
|
539
|
+
if (!lock.ok) {
|
|
540
|
+
if (lock.decided) { this.#elsewhere(key); return 'decided'; }
|
|
541
|
+
this.ui.notice(t('This card is being answered from the browser right now; /relay shows it again.')); return 'later';
|
|
542
|
+
}
|
|
543
|
+
let result;
|
|
544
|
+
try { result = await apply(); } catch (error) { this.broker.release(key); throw error; }
|
|
545
|
+
if (APPLIED.has(result?.outcome)) { this.broker.commit(key, { outcome: result.outcome, by: 'terminal' }); done(result); return 'decided'; }
|
|
546
|
+
this.broker.release(key);
|
|
547
|
+
return done(result) || 'later';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Tarjeta del plan: datos de plan.get justo antes de mostrarla; se aprueba ese digest. */
|
|
551
|
+
async planDecision({ project, root, swarm, proposal_id }) {
|
|
552
|
+
const key = `plan:${swarm}:${proposal_id}`;
|
|
553
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
554
|
+
const card = this.broker.open(key, this.signal);
|
|
555
|
+
try {
|
|
556
|
+
const { call } = this.#calls(project, root);
|
|
557
|
+
const got = await call('plan.get', { proposal_id }, card.signal);
|
|
558
|
+
if (got?.proposal?.status && got.proposal.status !== 'pending') { this.ui.notice(t('Plan {id} is already {status}.', { id: proposal_id, status: got.proposal.status })); return 'decided'; }
|
|
559
|
+
// Cómo se comprueba cada tarea: la descripción de los checks del proyecto (project.json del hub).
|
|
560
|
+
const checks = (await this.#projectFile({ root }, project))?.checks || [];
|
|
561
|
+
const choice = await this.ui.setupChoice({ ...planCard({ got, checks }), signal: card.signal });
|
|
562
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
563
|
+
const digest = got.proposal.digest, apply = extra => () => this.applyPlan({ project, root, swarm, proposal_id, digest, ...extra });
|
|
564
|
+
const done = result => {
|
|
565
|
+
if (result.outcome === 'approved') this.ui.notice(t('Plan {id} approved ({status}): relayd applied {steps} steps; the swarm is working.', { id: proposal_id, status: result.status, steps: result.steps ?? 0 }));
|
|
566
|
+
else if (result.outcome === 'rejected') this.ui.notice(t('Plan {id} rejected ({status}); the lead got your reason.', { id: proposal_id, status: result.status }));
|
|
567
|
+
else if (result.outcome === 'already_decided') { this.ui.notice(t('Plan {id} is already {status}.', { id: proposal_id, status: result.status || '?' })); return 'decided'; }
|
|
568
|
+
else this.ui.notice(t('Plan {id} changed since it was shown (another digest); review it again.', { id: proposal_id }));
|
|
569
|
+
};
|
|
570
|
+
if (choice === 'approve') return await this.#confirm(key, apply({ choice: 'approve' }), done);
|
|
571
|
+
if (choice === 'reject') {
|
|
572
|
+
const reason = await this.ui.setupInput({ title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
573
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
574
|
+
if (!reason?.trim()) return 'later';
|
|
575
|
+
return await this.#confirm(key, apply({ choice: 'reject', reason: reason.trim() }), done);
|
|
576
|
+
}
|
|
577
|
+
return 'later';
|
|
578
|
+
} catch (error) { if (this.#elsewhere(key)) return 'decided'; throw error; }
|
|
579
|
+
finally { card.close(); }
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Aplica una decisión sobre un plan (TUI o navegador). Re-lee plan.get justo antes de mutar:
|
|
583
|
+
* sólo con status pending y el MISMO digest (relayd admite rechazar un plan ya aprobado y aún
|
|
584
|
+
* no aplicado). Desde el navegador además el sha256 de la proyección que vio debe coincidir.
|
|
585
|
+
* → { outcome: approved | rejected | already_decided | not_pending | digest | projection }.
|
|
586
|
+
*/
|
|
587
|
+
async applyPlan({ project, root, swarm, proposal_id, digest, choice, reason = '', projection = null, signal = this.signal, ...remote }) {
|
|
588
|
+
const { call, mutate } = this.#calls(project, root);
|
|
589
|
+
const got = await call('plan.get', { proposal_id }, signal), status = got?.proposal?.status;
|
|
590
|
+
if (status !== 'pending') return { outcome: ['approved', 'applied', 'rejected'].includes(status) ? 'already_decided' : 'not_pending', status };
|
|
591
|
+
if (got.proposal.digest !== digest) return { outcome: 'digest', status };
|
|
592
|
+
if (projection !== null) {
|
|
593
|
+
const checks = (await this.#projectFile({ root }, project))?.checks || [];
|
|
594
|
+
if (projectPlanCard({ project, swarm, got, checks }).card.body.projection_sha256 !== projection) return { outcome: 'projection', status };
|
|
595
|
+
}
|
|
596
|
+
if (choice === 'approve') {
|
|
597
|
+
const result = await approve(mutate, { proposal_id, digest }, signal);
|
|
598
|
+
await this.record({ action: 'plan.approve', project, swarm, proposal_id, digest, status: result.status, outcome: result.outcome, idempotency_key: result.idempotency_key, ...remoteRow(remote, 'approved') });
|
|
599
|
+
await this.#settlePlanNotices(call, mutate, swarm, proposal_id);
|
|
600
|
+
return { outcome: 'approved', status: result.status, steps: result.steps?.length ?? 0 };
|
|
601
|
+
}
|
|
602
|
+
if (choice !== 'reject' || !String(reason).trim()) return { outcome: 'invalid' };
|
|
603
|
+
// D10: relayd (v2.31) rechaza solo si la propuesta sigue `pending`; si otro operador la aprobó
|
|
604
|
+
// entre la re-lectura de arriba y aquí, es un conflicto sin efectos.
|
|
605
|
+
const expected_status = 'pending';
|
|
606
|
+
const result = await reject(mutate, { proposal_id, reason: String(reason).trim(), expected_status }, signal);
|
|
607
|
+
await this.record({ action: 'plan.reject', project, swarm, proposal_id, reason: String(reason).trim(), status: result.status, idempotency_key: result.idempotency_key, ...remoteRow(remote, 'rejected') });
|
|
608
|
+
await this.#settlePlanNotices(call, mutate, swarm, proposal_id);
|
|
609
|
+
return { outcome: 'rejected', status: result.status };
|
|
610
|
+
}
|
|
611
|
+
/** Revisor (open-relay F7): criterios de éxito que esperan al humano (criteria.get justo antes
|
|
612
|
+
* de mostrarlos). Se acepta el digest que se ve; rechazar despierta al revisor con el motivo. */
|
|
613
|
+
async criteriaDecision({ project, root, swarm }) {
|
|
614
|
+
const { call } = this.#calls(project, root);
|
|
615
|
+
const got = await call('criteria.get', { swarm_id: swarm }, this.signal);
|
|
616
|
+
if (!criteriaWaiting(got) || !got?.current?.digest) { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: got?.status || '?' })); return 'decided'; }
|
|
617
|
+
const digest = got.current.digest, key = `criteria:${swarm}:${digest}`;
|
|
618
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
619
|
+
const card = this.broker.open(key, this.signal);
|
|
620
|
+
try {
|
|
621
|
+
const choice = await this.ui.setupChoice({ ...criteriaCard({ got, swarm }), signal: card.signal });
|
|
622
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
623
|
+
const done = result => {
|
|
624
|
+
if (result.outcome === 'approved') this.ui.notice(t('Success criteria v{version} accepted ({status}): the lead can plan.', { version: got.current.version, status: result.status }));
|
|
625
|
+
else if (result.outcome === 'rejected') this.ui.notice(t('Success criteria rejected ({status}); the reviewer got your reason.', { status: result.status }));
|
|
626
|
+
else if (result.outcome === 'already_decided') { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: result.status || '?' })); return 'decided'; }
|
|
627
|
+
else this.ui.notice(t('The success criteria of {swarm} changed since they were shown; review them again.', { swarm }));
|
|
628
|
+
};
|
|
629
|
+
if (choice === 'accept') {
|
|
630
|
+
// open-relay v2.33: con avisos, aceptar se confirma aparte (por omisión, volver).
|
|
631
|
+
const warnings = criteriaWarnings(got.current.items, { checked: got.basis_checked === true });
|
|
632
|
+
if (warnings.size) {
|
|
633
|
+
const sure = await this.ui.setupChoice({ ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
|
|
634
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
635
|
+
if (sure !== 'accept') return 'later';
|
|
636
|
+
}
|
|
637
|
+
return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'accept' }), done);
|
|
638
|
+
}
|
|
639
|
+
if (choice === 'reject') {
|
|
640
|
+
const reason = await this.ui.setupInput({ title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
|
|
641
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
642
|
+
if (!reason?.trim()) return 'later';
|
|
643
|
+
return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'reject', reason: reason.trim() }), done);
|
|
644
|
+
}
|
|
645
|
+
return 'later';
|
|
646
|
+
} catch (error) { if (this.#elsewhere(key)) return 'decided'; throw error; }
|
|
647
|
+
finally { card.close(); }
|
|
648
|
+
}
|
|
649
|
+
/** open-relay v2.31: reabrir los criterios aceptados a medio swarm (criteria.reopen, solo desde
|
|
650
|
+
* la terminal). La versión aceptada sigue vigente hasta que el humano acepte la que proponga el
|
|
651
|
+
* revisor; el motivo le llega tal cual. */
|
|
652
|
+
async reopenCriteria({ project, root, swarm, digest }) {
|
|
653
|
+
const { call, mutate } = this.#calls(project, root);
|
|
654
|
+
const got = await call('criteria.get', { swarm_id: swarm }, this.signal);
|
|
655
|
+
if (got?.status !== 'accepted' || got?.current?.digest !== digest) { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: got?.status || '?' })); return 'decided'; }
|
|
656
|
+
const reason = await this.ui.setupInput({ title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
|
|
657
|
+
if (!reason?.trim()) return 'later';
|
|
658
|
+
const result = await reopenCriteria(mutate, { swarm_id: swarm, digest, reason: reason.trim() }, this.signal);
|
|
659
|
+
await this.record({ action: 'criteria.reopen', project, swarm, digest, version: got.current.version, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
|
|
660
|
+
this.ui.notice(t('Success criteria of {swarm} reopened ({status}); the reviewer got your reason.', { swarm, status: result.status }));
|
|
661
|
+
return 'decided';
|
|
662
|
+
}
|
|
663
|
+
/** Aplica una decisión sobre los criterios (TUI o navegador): criteria.get justo antes, mismo digest. */
|
|
664
|
+
async applyCriteria({ project, root, swarm, digest, choice, reason = '', signal = this.signal, ...remote }) {
|
|
665
|
+
const { call, mutate } = this.#calls(project, root);
|
|
666
|
+
const got = await call('criteria.get', { swarm_id: swarm }, signal);
|
|
667
|
+
if (!criteriaWaiting(got)) return { outcome: ['accepted', 'rejected'].includes(got?.status) ? 'already_decided' : 'not_pending', status: got?.status };
|
|
668
|
+
if (got?.current?.digest !== digest) return { outcome: 'digest', status: got?.status };
|
|
669
|
+
if (choice === 'accept') {
|
|
670
|
+
const result = await acceptCriteria(mutate, { swarm_id: swarm, digest }, signal);
|
|
671
|
+
await this.record({ action: 'criteria.accept', project, swarm, digest, version: got.current.version, status: result.status, idempotency_key: result.idempotency_key, ...remoteRow(remote, 'approved') });
|
|
672
|
+
return { outcome: 'approved', status: result.status, version: got.current.version };
|
|
673
|
+
}
|
|
674
|
+
if (choice !== 'reject' || !String(reason).trim()) return { outcome: 'invalid' };
|
|
675
|
+
const result = await rejectCriteria(mutate, { swarm_id: swarm, digest, reason: String(reason).trim() }, signal);
|
|
676
|
+
await this.record({ action: 'criteria.reject', project, swarm, digest, reason: String(reason).trim(), status: result.status, idempotency_key: result.idempotency_key, ...remoteRow(remote, 'rejected') });
|
|
677
|
+
return { outcome: 'rejected', status: result.status };
|
|
678
|
+
}
|
|
679
|
+
/** Revisor (open-relay F7): revisión escalada (rondas agotadas o sin veredicto). Aceptar sobre
|
|
680
|
+
* el revisor es review.override y otra revisión del mismo candidato review.retry (gate 3),
|
|
681
|
+
* ambos con el motivo del humano; cancelar o revisar sigue en su CLI. */
|
|
682
|
+
async escalationDecision({ project, root, swarm, task: brief }) {
|
|
683
|
+
const { call, mutate } = this.#calls(project, root);
|
|
684
|
+
const got = await call('task.get', { task_id: brief?.id }, this.signal), task = got?.task || got;
|
|
685
|
+
if (task?.state !== 'needs_attention' || !task?.review?.pending) { this.ui.notice(t('The review of {task} no longer waits for you.', { task: line(brief?.id || '?', 60) })); return 'decided'; }
|
|
686
|
+
const choice = await this.ui.setupChoice({ ...escalationCard({ task }), signal: this.signal });
|
|
687
|
+
if (choice === 'retry') {
|
|
688
|
+
const why = await this.ui.setupInput({ title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
|
|
689
|
+
if (!why?.trim()) return 'later';
|
|
690
|
+
const retried = await retryReview(mutate, { task_id: task.id, reason: why.trim() }, this.signal);
|
|
691
|
+
await this.record({ action: 'review.retry', project, swarm, task: task.id, reason: why.trim(), status: retried.status, idempotency_key: retried.idempotency_key });
|
|
692
|
+
this.ui.notice(t('{task} goes back to the reviewer ({status}).', { task: line(task.id, 60), status: retried.status }));
|
|
693
|
+
return 'decided';
|
|
694
|
+
}
|
|
695
|
+
if (choice !== 'override') return 'later';
|
|
696
|
+
const reason = await this.ui.setupInput({ title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
|
|
697
|
+
if (!reason?.trim()) return 'later';
|
|
698
|
+
const result = await overrideReview(mutate, { task_id: task.id, reason: reason.trim() }, this.signal);
|
|
699
|
+
await this.record({ action: 'review.override', project, swarm, task: task.id, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
|
|
700
|
+
this.ui.notice(t('{task} accepted over the reviewer ({status}).', { task: line(task.id, 60), status: result.status }));
|
|
701
|
+
return 'decided';
|
|
702
|
+
}
|
|
703
|
+
// relayd avisa al operador de cada plan con informes (`msg-<propuesta>-proposed` y, con
|
|
704
|
+
// revisor, sólo `msg-<propuesta>-reviewed`). Decidido el plan ya están atendidos. relayd
|
|
705
|
+
// (gate 5) los da por leídos él mismo al aprobar o rechazar; uno más viejo no. Sólo se
|
|
706
|
+
// confirman las entregas del operador que el buzón aún muestra pendientes, por su
|
|
707
|
+
// delivery_id: nunca un id de mensaje ni uno que no existe (relayd respondería
|
|
708
|
+
// UNAUTHORIZED, «entrega no encontrada o sin acceso»). Mejor esfuerzo: un fallo no se
|
|
709
|
+
// muestra como error de la decisión, que ya se aplicó.
|
|
710
|
+
async #settlePlanNotices(call, mutate, swarm, proposal_id) {
|
|
711
|
+
try {
|
|
712
|
+
const wanted = ['proposed', 'reviewed'].map(suffix => operatorDelivery(`msg-${proposal_id}-${suffix}`));
|
|
713
|
+
for (const delivery_id of await pendingOperatorDeliveries(call, swarm, wanted, this.signal)) {
|
|
714
|
+
try { await ack(mutate, { delivery_id }, this.signal); } catch {}
|
|
715
|
+
}
|
|
716
|
+
} catch {}
|
|
717
|
+
}
|
|
718
|
+
/** Tarjeta de una pregunta: opciones (→ ADR con Decide) o texto libre (Reply). */
|
|
719
|
+
async questionDecision({ project, root, swarm, message_id, task }) {
|
|
720
|
+
const key = `question:${swarm}:${message_id}`;
|
|
721
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
722
|
+
const card = this.broker.open(key, this.signal);
|
|
723
|
+
try {
|
|
724
|
+
const { call } = this.#calls(project, root);
|
|
725
|
+
const message = await call('msg.get', { message_id }, card.signal), block = decisionBlock(message);
|
|
726
|
+
const choice = await this.ui.setupChoice({ ...questionCard({ message, block, task }), signal: card.signal });
|
|
727
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
728
|
+
const done = result => {
|
|
729
|
+
if (result.outcome === 'decided') this.ui.notice(t('Decision recorded as {adr} and sent to {to}.', { adr: result.adr || '?', to: result.to }));
|
|
730
|
+
else if (result.outcome === 'answered') this.ui.notice(t('Answer sent to {to} ({status}).', { to: result.to, status: result.status }));
|
|
731
|
+
else if (result.outcome === 'acked') this.ui.notice(t('Message marked as read.'));
|
|
732
|
+
else this.ui.notice(t('The question {id} is no longer waiting for an answer.', { id: message_id }));
|
|
733
|
+
};
|
|
734
|
+
if (choice?.startsWith('option:') && block) {
|
|
735
|
+
const index = Number(choice.slice(7)), option = block.options[index]?.label; if (!option) return 'later';
|
|
736
|
+
const note = await this.ui.setupInput({ title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
|
|
737
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
738
|
+
if (note === null || note === undefined) return 'later';
|
|
739
|
+
return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'option', option_index: index, text: note }), done);
|
|
740
|
+
}
|
|
741
|
+
if (choice === 'reply') {
|
|
742
|
+
const to = message?.from?.role ? `role ${line(message.from.role, 60)}` : line(message?.from?.kind || '?', 40);
|
|
743
|
+
const body = await this.ui.setupInput({ title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
|
|
744
|
+
if (this.#elsewhere(key)) return 'decided';
|
|
745
|
+
if (!body?.trim()) return 'later';
|
|
746
|
+
return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'reply', text: body }), done);
|
|
747
|
+
}
|
|
748
|
+
if (choice === 'ack') return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'ack' }), done);
|
|
749
|
+
return 'later';
|
|
750
|
+
} catch (error) { if (this.#elsewhere(key)) return 'decided'; throw error; }
|
|
751
|
+
finally { card.close(); }
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Aplica una respuesta a una pregunta (TUI o navegador) con las funciones de la tarjeta: opción
|
|
755
|
+
* → ADR (decide: propose → record → reply → ack), texto → msg.reply, o msg.ack. Desde el
|
|
756
|
+
* navegador además la entrega debe seguir pendiente en el buzón del operador y la opción debe
|
|
757
|
+
* ser la que vio (sha256 de su etiqueta proyectada). El ADR lo redacta el host, nunca el navegador.
|
|
758
|
+
*/
|
|
759
|
+
async applyQuestion({ project, root, swarm, message_id, mode, option_index = null, option_label_sha256 = null, text = '', signal = this.signal, ...remote }) {
|
|
760
|
+
const { call, mutate } = this.#calls(project, root);
|
|
761
|
+
const message = await call('msg.get', { message_id }, signal), block = decisionBlock(message);
|
|
762
|
+
if (remote.source === 'remote_browser' && !(await pendingDelivery(call, message?.swarm || swarm, message_id, signal))) return { outcome: 'not_pending' };
|
|
763
|
+
const to = message?.from?.role ? `role ${line(message.from.role, 60)}` : line(message?.from?.kind || '?', 40);
|
|
764
|
+
if (mode === 'option') {
|
|
765
|
+
const chosen = block?.options?.[option_index];
|
|
766
|
+
if (!chosen || (option_label_sha256 !== null && sha256(relayOptionLabel(chosen.label)) !== option_label_sha256)) return { outcome: 'option' };
|
|
767
|
+
const option = chosen.label, note = String(text || '').trim();
|
|
768
|
+
const body = t('Chosen option: «{option}».', { option }) + (note ? `\n${note}` : '');
|
|
769
|
+
const result = await decide(call, mutate, { message_id, option, body, ...(note ? { decision_note: note } : {}), ack: true }, signal);
|
|
770
|
+
await this.record({ action: 'decision.record', project, swarm, message_id, option, decision_id: result.decision?.id, adr_digest: result.decision?.adr_digest, status: result.status, ...remoteRow(remote, 'decided') });
|
|
771
|
+
return { outcome: 'decided', adr: result.decision?.id, to, status: result.status };
|
|
772
|
+
}
|
|
773
|
+
if (mode === 'reply') {
|
|
774
|
+
const body = String(text || '').trim(); if (!body) return { outcome: 'invalid' };
|
|
775
|
+
const result = await reply(call, mutate, { message_id, body, ack: true }, signal);
|
|
776
|
+
await this.record({ action: 'msg.reply', project, swarm, message_id, status: result.status, body_sha256: result.sent?.body_sha256, ...remoteRow(remote, 'answered') });
|
|
777
|
+
return { outcome: 'answered', to, status: result.status };
|
|
778
|
+
}
|
|
779
|
+
if (mode === 'ack') {
|
|
780
|
+
await ack(mutate, { message_id }, signal);
|
|
781
|
+
await this.record({ action: 'msg.ack', project, swarm, message_id, ...remoteRow(remote, 'acked') });
|
|
782
|
+
return { outcome: 'acked', to };
|
|
783
|
+
}
|
|
784
|
+
return { outcome: 'invalid' };
|
|
785
|
+
}
|
|
786
|
+
/** Evidencia del verificador para las tareas `done` con un criterio que es un check de navegador
|
|
787
|
+
* del proyecto (`browser` en project.json). Lee `attestation.list` por cada operación de la tarea
|
|
788
|
+
* con la sesión delegada (capacidad read) y guarda la captura 0600 en la caché del humano para
|
|
789
|
+
* que la compare con el diseño. Sólo metadatos: la tarjeta nunca lleva píxeles. */
|
|
790
|
+
async #evidence({ root, project, swarm, tasks = [] }) {
|
|
791
|
+
if (!root) return [];
|
|
792
|
+
const checks = (await this.#projectFile({ root }, project))?.checks || [];
|
|
793
|
+
const web = new Set(checks.filter(check => isBrowserCheck(check)).map(check => String(check?.id || '')).filter(Boolean));
|
|
794
|
+
if (!web.size) return [];
|
|
795
|
+
const { call } = this.#calls(project, root), out = [];
|
|
796
|
+
const idsOf = value => (Array.isArray(value) ? value : []).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean);
|
|
797
|
+
for (const task of tasks.filter(item => item.state === 'done' && item.id)) {
|
|
798
|
+
let got = null;
|
|
799
|
+
try { got = await call('task.get', { task_id: task.id }, this.signal); } catch { continue; }
|
|
800
|
+
const criteria = idsOf(got?.task?.criteria || task.criteria).filter(id => web.has(id));
|
|
801
|
+
if (!criteria.length) continue;
|
|
802
|
+
const operations = got?.task?.operations || got?.operations || [];
|
|
803
|
+
for (const operation of operations) {
|
|
804
|
+
const operationId = operation?.id || operation?.operation_id;
|
|
805
|
+
if (!operationId) continue;
|
|
806
|
+
let list; try { list = await call('attestation.list', { operation_id: operationId }, this.signal); } catch { continue; }
|
|
807
|
+
for (const item of (Array.isArray(list) ? list : list?.attestations || list?.items || [])) {
|
|
808
|
+
const check = String(item?.check || item?.criterion || item?.criterion_id || criteria[0]);
|
|
809
|
+
if (!web.has(check)) continue;
|
|
810
|
+
const outcome = String(item?.outcome || item?.result || 'PASS');
|
|
811
|
+
const capture = item?.capture && typeof item.capture === 'object' ? item.capture : {};
|
|
812
|
+
const saved = await this.#saveCapture({ project, swarm, task: task.id, check, capture, name: capture.name || item?.capture_name || `${task.id}.png` });
|
|
813
|
+
out.push({ task: task.id, check, outcome, ...(saved ? { capture: saved } : {}) });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
/** `review` de la tarea raíz del líder (task.get) si pasó por la revisión final; si no, null. */
|
|
820
|
+
async #finalReview({ root, project, tasks = [] }) {
|
|
821
|
+
const rootTask = tasks.find(task => /^t-root/.test(task.id || '') && task.state === 'done');
|
|
822
|
+
if (!rootTask || !root) return null;
|
|
823
|
+
try { const got = await this.#calls(project, root).call('task.get', { task_id: rootTask.id }, this.signal); return (got?.task || got)?.review || null; } catch { return null; }
|
|
824
|
+
}
|
|
825
|
+
/** Guarda la captura del verificador en $XDG_CACHE_HOME/comal/relay-evidence/<proyecto>/<enjambre>/
|
|
826
|
+
* con permisos 0600 (fuera del candidato: es del humano). Devuelve {bytes, path} o null. */
|
|
827
|
+
async #saveCapture({ project, swarm, task, check, capture, name }) {
|
|
828
|
+
const file = path.basename(String(name || `${task}.png`)).replace(/[^\w.-]/g, '_') || `${task}.png`;
|
|
829
|
+
const data = capture?.data ?? capture?.base64 ?? capture?.content_base64 ?? capture?.content ?? null;
|
|
830
|
+
let bytes = null;
|
|
831
|
+
try {
|
|
832
|
+
if (typeof data === 'string' && data) bytes = Buffer.from(data.replace(/^data:[^,]*,/, ''), 'base64');
|
|
833
|
+
else if (data instanceof Uint8Array) bytes = Buffer.from(data);
|
|
834
|
+
else if (capture?.path || capture?.file) bytes = await readFile(String(capture.path || capture.file));
|
|
835
|
+
} catch { return null; }
|
|
836
|
+
if (!bytes?.length) return null;
|
|
837
|
+
const cache = this.env?.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
838
|
+
const dir = path.join(cache, 'comal', 'relay-evidence', project, swarm);
|
|
839
|
+
try {
|
|
840
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
841
|
+
const target = path.join(dir, file);
|
|
842
|
+
await writeFile(target, bytes, { mode: 0o600 }); await chmod(target, 0o600);
|
|
843
|
+
await this.record({ action: 'evidence.save', project, swarm, task, check, bytes: bytes.length, path: target });
|
|
844
|
+
return { bytes: bytes.length, path: target };
|
|
845
|
+
} catch { return null; }
|
|
846
|
+
}
|
|
847
|
+
/** Tarjeta de una tarea bloqueada que espera al operador (el verificador no pudo correr el check,
|
|
848
|
+
* configuración, entorno, presupuesto, reintentos de infraestructura agotados). task.resume
|
|
849
|
+
* NUNCA es delegable en open-relay (methods.rs: NEVER) y no está en la lista blanca del cliente
|
|
850
|
+
* delegado: «Reintentar» enseña el comando exacto para la terminal del humano y no llama a
|
|
851
|
+
* nada. Sin motivo, se lee de task.get (infra.last_cause / last_reason). */
|
|
852
|
+
async blockedDecision({ project, root, swarm, task, reason, check, at }) {
|
|
853
|
+
const hubRoot = root || this.current?.hub_root, { call } = this.#calls(project, hubRoot);
|
|
854
|
+
let detail = task;
|
|
855
|
+
if (!reason && task?.id) { try { const got = await call('task.get', { task_id: task.id }, this.signal); detail = { ...task, ...(got?.task || got || {}), state: task.state || got?.task?.state }; reason = blockedReason(detail, task.state); } catch { if (task.wake_cap === true) reason = blockedReason(task, task.state); } }
|
|
856
|
+
const id = line(task?.id || '?', 60), controlDir = path.join(hubRoot || '.', 'projects', project, 'ctl');
|
|
857
|
+
const command = `relay --control-dir ${shellWord(controlDir)} task resume ${shellWord(String(task?.id || '?'))}`;
|
|
858
|
+
const why = String(detail?.state || '').startsWith('blocked') || detail?.state === 'needs_attention' ? blockLabel(detail.state) : null;
|
|
859
|
+
const choice = await this.ui.setupChoice({ ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
|
|
860
|
+
if (choice !== 'retry') return 'later';
|
|
861
|
+
this.ui.notice(t('Retry it yourself in your terminal, exactly: {command}', { command }));
|
|
862
|
+
await this.record({ action: 'blocked.shown', project, swarm, task: task?.id || null, check: check || null, reason: line(reason || '', 200) });
|
|
863
|
+
return 'decided';
|
|
864
|
+
}
|
|
865
|
+
/** Tarjeta final: resumen, cambios de la rama (git, sólo lectura) y la fusión como decisión humana. */
|
|
866
|
+
async finishDecision({ project, swarm, view, repo, root }) {
|
|
867
|
+
const info = await this.state.swarm(project, swarm) || {}, link = this.current?.project === project ? this.current : {};
|
|
868
|
+
const branch = info.branch || link.branch || `relay/${project}`;
|
|
869
|
+
let base = info.base_commit || link.base_commit;
|
|
870
|
+
try { if (!base) base = await this.git.mergeBase(repo, 'HEAD', branch); } catch {}
|
|
871
|
+
let stat = ''; try { stat = base ? await this.git.diffStat(repo, base, branch) : ''; } catch (error) { stat = error.message; }
|
|
872
|
+
const tasks = view?.tasks || [], counts = taskCounts(tasks), ok = tasks.some(task => task.state === 'done' && /^role-int/.test(task.role || '')) || tasks.some(task => /^t-root/.test(task.id) && task.state === 'done');
|
|
873
|
+
// Ojos del verificador: sólo metadatos (tarea, check, PASS, KiB y dónde quedó la captura).
|
|
874
|
+
const evidence = await this.#evidence({ root: root || this.current?.hub_root, project, swarm, tasks });
|
|
875
|
+
// Revisor (open-relay F5/F7): veredicto final de la raíz y su evidencia (metadatos).
|
|
876
|
+
const review = await this.#finalReview({ root: root || this.current?.hub_root, project, tasks });
|
|
877
|
+
await this.state.setSwarm(project, swarm, { finished_at: new Date().toISOString() });
|
|
878
|
+
await this.record({ action: 'swarm.finished', project, swarm, branch, base_commit: base || null, counts, ok, evidence: evidence.length });
|
|
879
|
+
const choice = await this.ui.setupChoice({ ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
|
|
880
|
+
if (choice === 'diff' && base) {
|
|
881
|
+
const diff = await this.git.diff(repo, base, branch);
|
|
882
|
+
this.ui.commandResult?.('relay', 'diff', (diff.truncated ? t('Diff of {branch} ({count} more lines not shown; git diff {base}..{branch})', { branch, count: diff.truncated, base: base.slice(0, 8) }) + '\n\n' : '') + diff.text);
|
|
883
|
+
}
|
|
884
|
+
return 'decided';
|
|
885
|
+
}
|
|
886
|
+
}
|