@milpa/comal 0.2.1 → 0.2.3
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-relay-answers-v1.json +209 -0
- package/package.json +3 -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 +232 -63
- 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 +133 -2
- 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 +99 -6
- package/src/plugin-catalog.mjs +19 -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/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- 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/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -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 +115 -12
- package/src/remote-relay.mjs +468 -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/swarm-view.mjs +53 -0
- 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/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- 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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { digest } from '../../operations.mjs';
|
|
3
3
|
import { clean, excerpt } from './bound.mjs';
|
|
4
|
-
import { messageOrNull, messageOfDelivery, taskForMessage, pendingDelivery, planOrNull } from './lookup.mjs';
|
|
4
|
+
import { messageOrNull, messageOfDelivery, operatorDelivery, taskForMessage, pendingDelivery, planOrNull } from './lookup.mjs';
|
|
5
|
+
import { METHOD_CAPS } from './client.mjs';
|
|
6
|
+
import { decisionBlock, chosenOption, adrScope, draftAdr, adrDigest } from './decision.mjs';
|
|
5
7
|
import { t, registerMessages } from '../../i18n/index.mjs';
|
|
6
8
|
|
|
7
9
|
// Fase 2: cuatro acciones sobre el enjambre. Cada una lleva una tarjeta de
|
|
@@ -30,7 +32,38 @@ registerMessages({
|
|
|
30
32
|
'Confirm delivery (msg.ack): {v0}': 'Confirmar la entrega (msg.ack): {v0}',
|
|
31
33
|
'yes, the message is marked read': 'sí, el mensaje queda leído',
|
|
32
34
|
'no': 'no',
|
|
33
|
-
'Decision note (only in the Comal receipt;
|
|
35
|
+
'Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):': 'Nota de decisión (sólo en el recibo de Comal; Decide la registra como ADR en relayd):',
|
|
36
|
+
'This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.': 'Es una pregunta estructurada: Reply sólo envía texto. Para registrar la opción elegida como un ADR que el enjambre respete, usa Decide.',
|
|
37
|
+
"Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.": 'La delegación de Comal no tiene la capacidad {v0}: relayd rechazará {v1} (NOT_DELEGATED). El humano debe emitir una delegación con ella.',
|
|
38
|
+
'Structured question (agent text, untrusted): {v0}': 'Pregunta estructurada (texto del agente, no confiable): {v0}',
|
|
39
|
+
'Options:': 'Opciones:',
|
|
40
|
+
' {v0}. {v1}{v2}': ' {v0}. {v1}{v2}',
|
|
41
|
+
' ← chosen': ' ← elegida',
|
|
42
|
+
' · recommended by the agent': ' · la recomienda el agente',
|
|
43
|
+
' consequences: {v0}': ' consecuencias: {v0}',
|
|
44
|
+
'Agent recommendation: {v0} — {v1}': 'Recomendación del agente: {v0} — {v1}',
|
|
45
|
+
'Evidence cited (references, not verified): {v0}': 'Evidencia citada (referencias, sin verificar): {v0}',
|
|
46
|
+
'Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}': 'Alcance: {v0} · Reversible: {v1} · Coste de esperar: {v2}',
|
|
47
|
+
'yes': 'sí',
|
|
48
|
+
'not stated': 'no indicado',
|
|
49
|
+
'Record a decision in open-relay · decision.propose + decision.record + msg.reply': 'Registrar una decisión en open-relay · decision.propose + decision.record + msg.reply',
|
|
50
|
+
'Question {v0} from {v1} · swarm {v2}': 'Pregunta {v0} de {v1} · swarm {v2}',
|
|
51
|
+
'The message is not a structured question (no decision block): Decide will fail. Use Reply.': 'El mensaje no es una pregunta estructurada (sin bloque decision): Decide fallará. Usa Reply.',
|
|
52
|
+
'Option «{v0}» is not one of the question\'s options: Decide will fail.': 'La opción «{v0}» no es una de las de la pregunta: Decide fallará.',
|
|
53
|
+
'ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):': 'ADR que redactó Comal y registrará relayd (aceptado, firmado «operador vía comal»):',
|
|
54
|
+
' Title: {v0}': ' Título: {v0}',
|
|
55
|
+
' Scope: {v0}': ' Alcance: {v0}',
|
|
56
|
+
' Answers: {v0}': ' Responde a: {v0}',
|
|
57
|
+
' Context:': ' Contexto:',
|
|
58
|
+
' Decision:': ' Decisión:',
|
|
59
|
+
' Discarded alternatives:': ' Alternativas descartadas:',
|
|
60
|
+
' Consequences:': ' Consecuencias:',
|
|
61
|
+
' ADR digest: {v0}': ' Digest del ADR: {v0}',
|
|
62
|
+
'Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):': 'Respuesta que lleva el ADR (msg.reply con decision_id; {v0} caracteres · sha256 {v1}…):',
|
|
63
|
+
'Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}': 'Pasos: 1. decision.propose (propuesto) · 2. decision.record (aceptado, vigente) · 3. msg.reply con decision_id{v0}',
|
|
64
|
+
' · 4. msg.ack': ' · 4. msg.ack',
|
|
65
|
+
'Already recorded for this question: {v0}': 'Ya registrado para esta pregunta: {v0}',
|
|
66
|
+
'Idempotency keys: {v0}': 'Claves de idempotencia: {v0}',
|
|
34
67
|
'Idempotency key: {v0}': 'Clave de idempotencia: {v0}',
|
|
35
68
|
'Reply in open-relay · {v0}': 'Responder en open-relay · {v0}',
|
|
36
69
|
'relayd does not know message {v0}.': 'relayd no conoce el mensaje {v0}.',
|
|
@@ -52,6 +85,9 @@ registerMessages({
|
|
|
52
85
|
' · seat {v0}': ' · asiento {v0}',
|
|
53
86
|
' · model {v0}': ' · modelo {v0}',
|
|
54
87
|
' … and {v0} more': ' … y {v0} más',
|
|
88
|
+
'Retires (cancels and stops requiring) {v0}: {v1}': 'Retira (cancela y deja de exigir) {v0}: {v1}',
|
|
89
|
+
' … and {v0} more': ' … y {v0} más',
|
|
90
|
+
'On approval, relayd cancels these tasks and stops requiring them: {v0}.': 'Al aprobar, relayd cancela estas tareas y deja de exigirlas: {v0}.',
|
|
55
91
|
'Steps relayd would apply ({v0}): {v1}': 'Pasos que aplicaría relayd ({v0}): {v1}',
|
|
56
92
|
'The proposal is already {v0}: relayd will change nothing.': 'La propuesta ya está {v0}: relayd no cambiará nada.',
|
|
57
93
|
'Applies immediately: on approval, relayd creates the roles, grants and tasks of the plan with no further step.': 'Se aplica en el acto: al aprobar, relayd crea los roles, grants y tareas del plan sin otro paso.',
|
|
@@ -76,15 +112,20 @@ const block = text => String(text).split('\n').map(line => ` │ ${line}`).join
|
|
|
76
112
|
const fromLabel = message => message?.from?.role ? t('role {v0}', { v0: one(message.from.role) }) : message?.from?.kind ? one(message.from.kind) : t('operator');
|
|
77
113
|
const bodyOf = message => message.pruned ? t('(body pruned by retention)') : quoted(message.body, 300);
|
|
78
114
|
|
|
79
|
-
/** Un error de relayd queda con su código (INVALID_INPUT,
|
|
80
|
-
function relayFailure(method, error) {
|
|
115
|
+
/** Un error de relayd queda con su código (INVALID_INPUT, NOT_DELEGATED…) en la conversación y en el recibo. */
|
|
116
|
+
function relayFailure(method, error, extra = '') {
|
|
81
117
|
if (!error?.rpc) return error;
|
|
82
|
-
return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}`), { code: error.code, retryable: error.retryable === true });
|
|
118
|
+
return Object.assign(new Error(`relayd rejected ${method} (${error.code}): ${error.message}${extra}`), { code: error.code, retryable: error.retryable === true, ...(error.capability ? { capability: error.capability } : {}) });
|
|
119
|
+
}
|
|
120
|
+
/** Aviso en la tarjeta si la delegación vista en el hello no trae la capacidad que el método exige. */
|
|
121
|
+
function capabilityWarnings(caps, methods, warnings) {
|
|
122
|
+
if (!Array.isArray(caps)) return;
|
|
123
|
+
for (const method of methods) { const need = METHOD_CAPS[method]; if (need && !caps.includes(need)) warnings.push(t("Comal's delegation lacks the {v0} capability: relayd will reject {v1} (NOT_DELEGATED). The human must issue a delegation with it.", { v0: need, v1: method })); }
|
|
83
124
|
}
|
|
84
125
|
|
|
85
126
|
|
|
86
127
|
// ── Reply ────────────────────────────────────────────────────────────────────
|
|
87
|
-
export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal) {
|
|
128
|
+
export async function replySummary(call, { message_id, body, ack = false, decision_note }, signal, { caps = () => null } = {}) {
|
|
88
129
|
const warnings = [], lines = [], message = await messageOrNull(call, message_id, signal);
|
|
89
130
|
if (!message) warnings.push(t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) }));
|
|
90
131
|
const swarm = message?.swarm;
|
|
@@ -93,6 +134,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
|
|
|
93
134
|
if (message) {
|
|
94
135
|
lines.push(t('Original message {v0} ({v1}), agent text, untrusted:', { v0: one(message_id), v1: one(message.kind) }), ` ${bodyOf(message)}`);
|
|
95
136
|
if (message.from?.kind === 'operator' || !message.from?.role) warnings.push(t('The original message does not come from a role: the reply unblocks no task.'));
|
|
137
|
+
const structured = decisionBlock(message);
|
|
138
|
+
if (structured) { lines.push(...decisionLines(structured)); warnings.push(t('This is a structured question: Reply only sends text. To record the chosen option as an ADR the swarm respects, use Decide.')); }
|
|
96
139
|
}
|
|
97
140
|
if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
|
|
98
141
|
else if (linked) { lines.push(t('Task: {v0} ({v1}) already woke up from this message.', { v0: one(linked.task.id), v1: one(linked.task.state) })); warnings.push(t('Task {v0} is no longer blocked by this question.', { v0: one(linked.task.id) })); }
|
|
@@ -100,7 +143,8 @@ export async function replySummary(call, { message_id, body, ack = false, decisi
|
|
|
100
143
|
if (message && !delivery) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
|
|
101
144
|
lines.push(t('Exact text to send ({v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
|
|
102
145
|
lines.push(t('Confirm delivery (msg.ack): {v0}', { v0: ack ? t('yes, the message is marked read') : t('no') }));
|
|
103
|
-
if (decision_note) lines.push(t('Decision note (only in the Comal receipt;
|
|
146
|
+
if (decision_note) lines.push(t('Decision note (only in the Comal receipt; Decide records it as an ADR in relayd):'), block(decision_note));
|
|
147
|
+
capabilityWarnings(caps(), ack ? ['msg.reply', 'msg.ack'] : ['msg.reply'], warnings);
|
|
104
148
|
lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('reply', { in_reply_to: message_id, body }) }));
|
|
105
149
|
return { title: t('Reply in open-relay · {v0}', { v0: `msg.reply${ack ? ' + msg.ack' : ''}` }), lines, warnings, editable: 'body' };
|
|
106
150
|
}
|
|
@@ -113,7 +157,7 @@ export async function reply(call, mutate, { message_id, body, ack = false, decis
|
|
|
113
157
|
let acked = null;
|
|
114
158
|
if (ack) {
|
|
115
159
|
// La respuesta ya salió: un ack fallido se informa, no borra el envío.
|
|
116
|
-
try { const result = await mutate('msg.ack', { idempotency_key: idempotencyKey('ack', { delivery_id
|
|
160
|
+
try { const delivery_id = operatorDelivery(message_id); const result = await mutate('msg.ack', { idempotency_key: idempotencyKey('ack', { delivery_id }), delivery_id }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
|
|
117
161
|
catch (error) { acked = { status: 'failed', code: error.code || 'ERROR', message: String(error.message).slice(0, 300) }; }
|
|
118
162
|
}
|
|
119
163
|
let task = null;
|
|
@@ -123,18 +167,20 @@ export async function reply(call, mutate, { message_id, body, ack = false, decis
|
|
|
123
167
|
sent: { in_reply_to: message_id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, idempotency_key: key, body, body_chars: body.length, body_sha256: sha256(body) },
|
|
124
168
|
ack: acked, task,
|
|
125
169
|
decision_note: decision_note ?? null,
|
|
126
|
-
...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt
|
|
170
|
+
...(decision_note ? { decision_note_status: 'Stored only in the Comal receipt. To record a structured decision as an ADR in relayd, use Decide.' } : {}),
|
|
127
171
|
...(sent.outcome === 'idempotent_replay' ? { note: 'relayd recognized the key: this reply had already been sent and was not duplicated.' } : {}),
|
|
128
172
|
};
|
|
129
173
|
}
|
|
130
174
|
|
|
131
175
|
// ── Ack ──────────────────────────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
|
|
176
|
+
// Siempre el id de la ENTREGA: relayd confirma entregas; con message_id, la del buzón del operador.
|
|
177
|
+
const ackTarget = ({ delivery_id, message_id }) => delivery_id || operatorDelivery(message_id);
|
|
178
|
+
export async function ackSummary(call, input, signal, { caps = () => null } = {}) {
|
|
134
179
|
const target = ackTarget(input), message_id = input.message_id || messageOfDelivery(input.delivery_id), warnings = [];
|
|
135
180
|
const message = await messageOrNull(call, message_id, signal);
|
|
136
181
|
if (!message) warnings.push(t('relayd does not know message {v0}.', { v0: one(message_id) }));
|
|
137
182
|
else if (!(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t('The delivery is no longer pending: acknowledging it changes nothing.'));
|
|
183
|
+
capabilityWarnings(caps(), ['msg.ack'], warnings);
|
|
138
184
|
return { title: t('Mark as read in open-relay · msg.ack'), warnings, lines: [
|
|
139
185
|
t('Delivery: {v0}', { v0: one(target) }),
|
|
140
186
|
...(message ? [t('Message {v0} ({v1}) from {v2} · swarm {v3}, agent text, untrusted:', { v0: one(message_id), v1: one(message.kind), v2: fromLabel(message), v3: one(message.swarm) }), ` ${bodyOf(message)}`] : []),
|
|
@@ -149,12 +195,18 @@ export async function ack(mutate, input, signal) {
|
|
|
149
195
|
}
|
|
150
196
|
|
|
151
197
|
// ── ApprovePlan / RejectPlan ────────────────────────────────────────────────
|
|
198
|
+
// open-relay `plan.retire`: hijas de la tarea descompuesta que el plan cancela y deja de exigir al aprobarse.
|
|
199
|
+
const retireOf = plan => (Array.isArray(plan?.retire) ? plan.retire : []).filter(id => typeof id === 'string');
|
|
200
|
+
const retireText = retire => `${retire.slice(0, 20).map(one).join(', ')}${retire.length > 20 ? t(' … and {v0} more', { v0: retire.length - 20 }) : ''}`;
|
|
152
201
|
function planLines(got, warnings) {
|
|
153
202
|
const proposal = got.proposal || {}, plan = proposal.plan || {}, lines = [];
|
|
154
203
|
lines.push(t('Proposal {v0} · swarm {v1} · team {v2} · parent task {v3}', { v0: one(proposal.proposal_id), v1: one(proposal.swarm_id), v2: one(proposal.team_id), v3: one(proposal.parent_task) }));
|
|
155
204
|
lines.push(t('Proposed by: role {v0} · status {v1}', { v0: one(proposal.proposer_role), v1: one(proposal.status) }));
|
|
156
205
|
if (proposal.summary || plan.summary) lines.push(t('Summary (agent text, untrusted): {v0}', { v0: quoted(proposal.summary || plan.summary, 300) }));
|
|
157
206
|
const tasks = plan.tasks || [], roles = plan.roles || [];
|
|
207
|
+
// open-relay `plan.retire`, antes de la lista de tareas para que se vea sin desplazarse.
|
|
208
|
+
const retire = retireOf(plan);
|
|
209
|
+
if (retire.length) lines.push(t('Retires (cancels and stops requiring) {v0}: {v1}', { v0: retire.length, v1: retireText(retire) }));
|
|
158
210
|
lines.push(t('New tasks ({v0}):', { v0: tasks.length }));
|
|
159
211
|
for (const task of tasks.slice(0, 20)) lines.push(` · ${one(task.id)}${task.title ? ` «${one(task.title)}»` : ''}${t(task.max_attempts === 1 ? ' → role {v0} · max. {v1} attempt' : ' → role {v0} · max. {v1} attempts', { v0: one(task.role), v1: task.max_attempts })}${task.deps?.length ? t(' · after {v0}', { v0: task.deps.map(one).join(', ') }) : ''}${task.criteria?.length ? t(' · criteria {v0}', { v0: task.criteria.map(item => one(item.id)).join(', ') }) : ''}`);
|
|
160
212
|
if (tasks.length > 20) lines.push(t(' … and {v0} more (plugin__open-relay__Plan lists them)', { v0: tasks.length - 20 }));
|
|
@@ -168,11 +220,14 @@ function planLines(got, warnings) {
|
|
|
168
220
|
return lines;
|
|
169
221
|
}
|
|
170
222
|
|
|
171
|
-
export async function approveSummary(call, { proposal_id, digest: wanted }, signal) {
|
|
223
|
+
export async function approveSummary(call, { proposal_id, digest: wanted }, signal, { caps = () => null } = {}) {
|
|
172
224
|
const warnings = [t('Applies immediately: on approval, relayd creates the roles, grants and tasks of the plan with no further step.')];
|
|
173
225
|
const got = await planOrNull(call, proposal_id, signal);
|
|
226
|
+
capabilityWarnings(caps(), ['plan.approve'], warnings);
|
|
174
227
|
if (!got) return { title: t('Approve plan in open-relay · plan.approve'), lines: [t('Proposal {v0}', { v0: one(proposal_id) }), t('Digest: {v0}', { v0: one(wanted) })], warnings: [...warnings, t('relayd does not know proposal {v0}: the approval will fail.', { v0: one(proposal_id) })] };
|
|
175
|
-
const lines = planLines(got, warnings), current = got.proposal?.digest;
|
|
228
|
+
const lines = planLines(got, warnings), current = got.proposal?.digest, retire = retireOf(got.proposal?.plan);
|
|
229
|
+
// Sólo al aprobar: rechazar no cancela nada.
|
|
230
|
+
if (retire.length) warnings.push(t('On approval, relayd cancels these tasks and stops requiring them: {v0}.', { v0: retireText(retire) }));
|
|
176
231
|
lines.push(t('Approved digest: {v0}', { v0: one(wanted) }) + (current === wanted ? t(' (matches the current one)') : ''));
|
|
177
232
|
if (current !== wanted) warnings.push(t('The digest does not match the current one ({v0}): relayd will reject the approval. Review the plan again.', { v0: one(current) }));
|
|
178
233
|
lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('approve', { proposal_id, digest: wanted }) }));
|
|
@@ -184,17 +239,19 @@ export async function approve(mutate, { proposal_id, digest: wanted }, signal) {
|
|
|
184
239
|
catch (error) { throw relayFailure('plan.approve', error); }
|
|
185
240
|
}
|
|
186
241
|
|
|
187
|
-
export async function rejectSummary(call, { proposal_id, reason }, signal) {
|
|
242
|
+
export async function rejectSummary(call, { proposal_id, reason }, signal, { caps = () => null } = {}) {
|
|
188
243
|
const warnings = [], got = await planOrNull(call, proposal_id, signal);
|
|
244
|
+
capabilityWarnings(caps(), ['plan.reject'], warnings);
|
|
189
245
|
const lines = got ? planLines(got, warnings) : [t('Proposal {v0}', { v0: one(proposal_id) })];
|
|
190
246
|
if (!got) warnings.push(t('relayd does not know proposal {v0}: the rejection will fail.', { v0: one(proposal_id) }));
|
|
191
247
|
lines.push(t('Exact reason the lead receives (it is woken up with it):'), block(reason));
|
|
192
248
|
lines.push(t('Idempotency key: {v0}', { v0: idempotencyKey('reject', { proposal_id, reason }) }));
|
|
193
249
|
return { title: t('Reject plan in open-relay · plan.reject'), lines, warnings, editable: 'reason' };
|
|
194
250
|
}
|
|
195
|
-
export async function reject(mutate, { proposal_id, reason }, signal) {
|
|
251
|
+
export async function reject(mutate, { proposal_id, reason, expected_status }, signal) {
|
|
196
252
|
const key = idempotencyKey('reject', { proposal_id, reason });
|
|
197
|
-
|
|
253
|
+
// `expected_status` (D10, relayd v2.31): sólo viaja si quien llama lo pide; no cambia la clave.
|
|
254
|
+
try { return decision(await mutate('plan.reject', { idempotency_key: key, proposal_id, reason, ...(expected_status ? { expected_status } : {}) }, signal), key); }
|
|
198
255
|
catch (error) { throw relayFailure('plan.reject', error); }
|
|
199
256
|
}
|
|
200
257
|
function decision(result, key) {
|
|
@@ -203,3 +260,142 @@ function decision(result, key) {
|
|
|
203
260
|
proposal: { proposal_id: proposal.proposal_id, status: proposal.status, approver: proposal.approver ?? null, digest: proposal.digest, reason: proposal.reason ? excerpt(proposal.reason, 500) : null, tasks: proposal.tasks, roles: proposal.roles },
|
|
204
261
|
steps: (result.steps || []).slice(0, 40).map(step => ({ kind: step.kind, id: step.id, method: step.method, action: step.action, result: step.result ?? null })) };
|
|
205
262
|
}
|
|
263
|
+
|
|
264
|
+
// ── Decide (fase 3) ─────────────────────────────────────────────────────────
|
|
265
|
+
// Respuesta a una pregunta estructurada que queda como ADR en relayd. El HOST redacta
|
|
266
|
+
// el ADR (decision.mjs) con la pregunta tal como la guarda relayd, la opción elegida y
|
|
267
|
+
// la nota; la tarjeta lo muestra entero con su digest y sólo el humano la aprueba
|
|
268
|
+
// (confirm:'always'). Tres mutaciones con claves estables derivadas de la misma
|
|
269
|
+
// entrada: un reintento de la misma tarjeta retoma donde quedó sin duplicar nada.
|
|
270
|
+
const yesNo = value => value ? t('yes') : t('no');
|
|
271
|
+
/** Líneas legibles del bloque `decision` (texto del agente, ya limpio y acotado). */
|
|
272
|
+
export function decisionLines(block, chosen = null) {
|
|
273
|
+
const lines = [t('Structured question (agent text, untrusted): {v0}', { v0: `«${block.question}»` }), t('Options:')];
|
|
274
|
+
block.options.forEach((item, index) => {
|
|
275
|
+
lines.push(t(' {v0}. {v1}{v2}', { v0: index + 1, v1: `«${item.label}»`, v2: `${item.label === chosen?.label ? t(' ← chosen') : ''}${item.label === block.recommendation?.option ? t(' · recommended by the agent') : ''}` }));
|
|
276
|
+
if (item.consequences) lines.push(t(' consequences: {v0}', { v0: excerpt(item.consequences, 400) }));
|
|
277
|
+
});
|
|
278
|
+
if (block.recommendation) lines.push(t('Agent recommendation: {v0} — {v1}', { v0: `«${block.recommendation.option}»`, v1: excerpt(block.recommendation.why, 400) }));
|
|
279
|
+
if (block.evidence.length) lines.push(t('Evidence cited (references, not verified): {v0}', { v0: block.evidence.map(item => `${item.kind}:${one(item.ref)}`).join(' · ') }));
|
|
280
|
+
lines.push(t('Scope: {v0} · Reversible: {v1} · Cost of waiting: {v2}', { v0: block.scope, v1: yesNo(block.reversible), v2: block.cost_of_waiting ? excerpt(block.cost_of_waiting, 300) : t('not stated') }));
|
|
281
|
+
return lines;
|
|
282
|
+
}
|
|
283
|
+
const decideBase = ({ message_id, option, body, decision_note, scope }) => digest({ action: 'decide', message_id, option, body, decision_note: decision_note ?? null, scope: scope ?? null }).slice(0, 48);
|
|
284
|
+
export const decideKeys = input => { const base = decideBase(input); return { propose: `comal-adr-propose-${base}`, record: `comal-adr-record-${base}`, reply: `comal-decide-reply-${base}`, ack: idempotencyKey('ack', { delivery_id: operatorDelivery(input.message_id) }) }; };
|
|
285
|
+
const scopeText = scope => scope.id ? `${scope.kind}:${one(scope.id)}` : scope.kind;
|
|
286
|
+
|
|
287
|
+
/** Lo que la tarjeta y la ejecución comparten: mensaje, bloque, opción, tarea, alcance y ADR. */
|
|
288
|
+
async function prepareDecision(call, { message_id, option, decision_note, scope }, signal) {
|
|
289
|
+
const message = await messageOrNull(call, message_id, signal);
|
|
290
|
+
if (!message) return { message: null };
|
|
291
|
+
const block = decisionBlock(message), chosen = chosenOption(block, option);
|
|
292
|
+
const linked = block ? await taskForMessage(call, message.swarm, message_id, signal) : null;
|
|
293
|
+
const { scope: adrScopeValue, note } = block ? adrScope(block, { scope, message, task: linked?.task }) : { scope: null, note: null };
|
|
294
|
+
const gates = chosen ? await adrGates(call, signal) : false;
|
|
295
|
+
const adr = chosen ? draftAdr({ message, block, option, decision_note, scope: adrScopeValue, gates }) : null;
|
|
296
|
+
return { message, block, chosen, linked, adr, scopeNote: note };
|
|
297
|
+
}
|
|
298
|
+
/** ¿Hace cumplir relayd los ADR (`daemon.version.features`)? Uno anterior rechaza el campo `context_only`. */
|
|
299
|
+
async function adrGates(call, signal) {
|
|
300
|
+
try { const v = await call('daemon.version', {}, signal); return Array.isArray(v?.features) && v.features.includes('adr_enforcement'); }
|
|
301
|
+
// Sin respuesta (relayd anterior o simulado): sin compuertas; la mutación siguiente dirá si hay otro problema.
|
|
302
|
+
catch (error) { if (signal?.aborted) throw error; return false; }
|
|
303
|
+
}
|
|
304
|
+
async function existingAdrs(call, message, signal) {
|
|
305
|
+
try { const { decisions = [] } = await call('decision.list', { swarm_id: message.swarm, limit: 200 }, signal); return decisions.filter(item => item.answers === message.id); }
|
|
306
|
+
catch (error) { if (error.rpc) return []; throw error; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function decideSummary(call, input, signal, { caps = () => null } = {}) {
|
|
310
|
+
const { message_id, option, body, ack = false } = input, warnings = [], lines = [];
|
|
311
|
+
const title = t('Record a decision in open-relay · decision.propose + decision.record + msg.reply');
|
|
312
|
+
const { message, block: structured, chosen, linked, adr, scopeNote } = await prepareDecision(call, input, signal);
|
|
313
|
+
if (!message) return { title, lines: [t('Delivery: {v0}', { v0: one(message_id) })], warnings: [t('relayd does not know message {v0}: the reply will fail.', { v0: one(message_id) })] };
|
|
314
|
+
lines.push(t('Question {v0} from {v1} · swarm {v2}', { v0: one(message_id), v1: fromLabel(message), v2: one(message.swarm) }));
|
|
315
|
+
if (!structured) warnings.push(t('The message is not a structured question (no decision block): Decide will fail. Use Reply.'));
|
|
316
|
+
else {
|
|
317
|
+
lines.push(...decisionLines(structured, chosen));
|
|
318
|
+
if (!chosen) warnings.push(t('Option «{v0}» is not one of the question\'s options: Decide will fail.', { v0: one(option) }));
|
|
319
|
+
}
|
|
320
|
+
if (linked?.link === 'park') lines.push(t('Unblocks: task {v0}{v1} · {v2} → back to the queue', { v0: one(linked.task.id), v1: linked.task.title ? ` «${one(linked.task.title)}»` : '', v2: one(linked.task.state) }));
|
|
321
|
+
if (scopeNote) warnings.push(scopeNote);
|
|
322
|
+
if (adr) {
|
|
323
|
+
lines.push(t('ADR that Comal drafted and relayd will record (accepted, signed «operator via comal»):'));
|
|
324
|
+
lines.push(t(' Title: {v0}', { v0: adr.title }), t(' Scope: {v0}', { v0: scopeText(adr.scope) }), t(' Answers: {v0}', { v0: one(adr.answers) }));
|
|
325
|
+
lines.push(t(' Context:'), block4(adr.context), t(' Decision:'), block4(adr.decision));
|
|
326
|
+
if (adr.alternatives.length) lines.push(t(' Discarded alternatives:'), block4(adr.alternatives.map(item => `${item.option}: ${item.why_rejected}`).join('\n')));
|
|
327
|
+
lines.push(t(' Consequences:'), block4(adr.consequences), t(' ADR digest: {v0}', { v0: adrDigest(adr) }));
|
|
328
|
+
for (const item of (await existingAdrs(call, message, signal)).slice(0, 4)) warnings.push(t('Already recorded for this question: {v0}', { v0: `${one(item.id)} (${one(item.status)}${item.in_force ? ', in force' : ''}) «${one(item.title)}»` }));
|
|
329
|
+
}
|
|
330
|
+
lines.push(t('Reply that carries the ADR (msg.reply with decision_id; {v0} characters · sha256 {v1}…):', { v0: body.length, v1: sha256(body).slice(0, 16) }), block(body));
|
|
331
|
+
lines.push(t('Steps: 1. decision.propose (proposed) · 2. decision.record (accepted, in force) · 3. msg.reply with decision_id{v0}', { v0: ack ? t(' · 4. msg.ack') : '' }));
|
|
332
|
+
if (message && !(await pendingDelivery(call, message.swarm, message_id, signal))) warnings.push(t("The delivery is no longer pending in the operator's inbox (acknowledged or expired)."));
|
|
333
|
+
capabilityWarnings(caps(), ['decision.propose', 'decision.record', 'msg.reply', ...(ack ? ['msg.ack'] : [])], warnings);
|
|
334
|
+
const keys = decideKeys(input);
|
|
335
|
+
lines.push(t('Idempotency keys: {v0}', { v0: [keys.propose, keys.record, keys.reply].join(' · ') }));
|
|
336
|
+
return { title, lines, warnings, editable: 'body' };
|
|
337
|
+
}
|
|
338
|
+
const block4 = text => String(text).split('\n').map(line => ` │ ${line}`).join('\n');
|
|
339
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
340
|
+
|
|
341
|
+
export async function decide(call, mutate, input, signal) {
|
|
342
|
+
const { message_id, option, body, ack = false } = input, keys = decideKeys(input);
|
|
343
|
+
const { message, block: structured, chosen, linked, adr } = await prepareDecision(call, input, signal);
|
|
344
|
+
if (!message) throw fail('NOT_FOUND', `relayd does not know message ${one(message_id)}.`);
|
|
345
|
+
if (!structured) throw fail('DECISION_NOT_STRUCTURED', `Message ${one(message_id)} is not a structured question (it has no decision block): use Reply.`);
|
|
346
|
+
if (!chosen || !adr) throw fail('DECISION_UNKNOWN_OPTION', `«${one(option)}» is not one of the options: ${structured.options.map(item => `«${item.label}»`).join(', ')}.`);
|
|
347
|
+
let proposed, recorded, sent;
|
|
348
|
+
try { proposed = await mutate('decision.propose', { idempotency_key: keys.propose, adr }, signal); }
|
|
349
|
+
catch (error) { throw relayFailure('decision.propose', error); }
|
|
350
|
+
const id = proposed.decision?.id;
|
|
351
|
+
try { recorded = await mutate('decision.record', { idempotency_key: keys.record, decision_id: id }, signal); }
|
|
352
|
+
catch (error) { throw relayFailure('decision.record', error, ` (ADR ${id} stays proposed; retrying the same card resumes without duplicating it)`); }
|
|
353
|
+
try { sent = await mutate('msg.reply', { idempotency_key: keys.reply, in_reply_to: message_id, body, decision_id: id }, signal); }
|
|
354
|
+
catch (error) { throw relayFailure('msg.reply', error, ` (ADR ${id} is recorded and in force; retrying the same card sends the reply without duplicating anything)`); }
|
|
355
|
+
let acked = null;
|
|
356
|
+
if (ack) {
|
|
357
|
+
try { const result = await mutate('msg.ack', { idempotency_key: keys.ack, delivery_id: operatorDelivery(message_id) }, signal); acked = { status: 'applied', outcome: result.outcome, delivery_id: result.delivery_id, state: result.state }; }
|
|
358
|
+
catch (error) { acked = { status: 'failed', code: error.code || 'ERROR', message: String(error.message).slice(0, 300) }; }
|
|
359
|
+
}
|
|
360
|
+
const view = recorded.decision || proposed.decision || {};
|
|
361
|
+
const replayed = [proposed, recorded, sent].every(item => item.outcome === 'idempotent_replay');
|
|
362
|
+
return {
|
|
363
|
+
truncated: false, status: replayed ? 'replayed' : 'applied', outcome: sent.outcome,
|
|
364
|
+
decision: { id, status: view.status, in_force: view.in_force, title: view.title, scope: view.scope, answers: view.answers ?? null, proposed_via: view.proposed_via ?? null, accepted_via: view.accepted_via ?? null, adr_digest: adrDigest(adr), propose_outcome: proposed.outcome, record_outcome: recorded.outcome },
|
|
365
|
+
sent: { in_reply_to: message_id, decision_id: id, reply_message_id: sent.message_id, deliveries: sent.deliveries, head_seq: sent.head_seq, body, body_chars: body.length, body_sha256: sha256(body) },
|
|
366
|
+
ack: acked, task: linked ? { id: linked.task.id, state: linked.task.state, link: linked.link } : null,
|
|
367
|
+
idempotency_keys: keys,
|
|
368
|
+
...(replayed ? { note: 'relayd recognized the keys: this decision and its reply had already been recorded and were not duplicated.' } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ── Revisor (open-relay F7) ─────────────────────────────────────────────────
|
|
373
|
+
// Decisiones del humano sobre el revisor, siempre desde una tarjeta suya (capacidad plans):
|
|
374
|
+
// aceptar o rechazar los criterios de éxito (el digest exacto que vio) y aceptar un candidato
|
|
375
|
+
// sobre la objeción del revisor (review.override, con motivo; queda en el log de relayd).
|
|
376
|
+
const outcomeOf = (result, key) => ({ status: result?.outcome === 'idempotent_replay' ? 'replayed' : result?.outcome === 'unchanged' ? 'unchanged' : 'applied', outcome: result?.outcome ?? null, head_seq: result?.head_seq ?? null, idempotency_key: key });
|
|
377
|
+
export async function acceptCriteria(mutate, { swarm_id, digest: wanted }, signal) {
|
|
378
|
+
const key = idempotencyKey('criteria-accept', { swarm_id, digest: wanted });
|
|
379
|
+
try { return outcomeOf(await mutate('criteria.accept', { idempotency_key: key, swarm_id, digest: wanted }, signal), key); }
|
|
380
|
+
catch (error) { throw relayFailure('criteria.accept', error); }
|
|
381
|
+
}
|
|
382
|
+
export async function rejectCriteria(mutate, { swarm_id, digest: wanted, reason }, signal) {
|
|
383
|
+
const key = idempotencyKey('criteria-reject', { swarm_id, digest: wanted, reason });
|
|
384
|
+
try { return outcomeOf(await mutate('criteria.reject', { idempotency_key: key, swarm_id, digest: wanted, reasons: [reason] }, signal), key); }
|
|
385
|
+
catch (error) { throw relayFailure('criteria.reject', error); }
|
|
386
|
+
}
|
|
387
|
+
export async function reopenCriteria(mutate, { swarm_id, digest: wanted, reason }, signal) {
|
|
388
|
+
const key = idempotencyKey('criteria-reopen', { swarm_id, digest: wanted, reason });
|
|
389
|
+
try { return outcomeOf(await mutate('criteria.reopen', { idempotency_key: key, swarm_id, digest: wanted, reasons: [reason] }, signal), key); }
|
|
390
|
+
catch (error) { throw relayFailure('criteria.reopen', error); }
|
|
391
|
+
}
|
|
392
|
+
export async function retryReview(mutate, { task_id, reason }, signal) {
|
|
393
|
+
const key = idempotencyKey('review-retry', { task_id, reason });
|
|
394
|
+
try { return outcomeOf(await mutate('review.retry', { idempotency_key: key, task_id, reason }, signal), key); }
|
|
395
|
+
catch (error) { throw relayFailure('review.retry', error); }
|
|
396
|
+
}
|
|
397
|
+
export async function overrideReview(mutate, { task_id, reason }, signal) {
|
|
398
|
+
const key = idempotencyKey('review-override', { task_id, reason });
|
|
399
|
+
try { return outcomeOf(await mutate('review.override', { idempotency_key: key, task_id, reason }, signal), key); }
|
|
400
|
+
catch (error) { throw relayFailure('review.override', error); }
|
|
401
|
+
}
|
|
@@ -1,57 +1,101 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { constants } from 'node:fs';
|
|
4
|
-
import { open } from 'node:fs/promises';
|
|
5
|
+
import { open, stat } from 'node:fs/promises';
|
|
5
6
|
|
|
6
7
|
// Cliente JSON-RPC 2.0 por líneas sobre el socket administrativo de relayd
|
|
7
|
-
// (`<control>/admin/relay.sock`).
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// (`<control>/admin/relay.sock`). Desde la fase 3 (open-relay ≥ 2.8) se autentica con
|
|
9
|
+
// una sesión DELEGADA (`relay session issue-delegate --client comal`), nunca con
|
|
10
|
+
// `admin.token`: relayd firma lo que hace como «operador vía comal» y sólo admite los
|
|
11
|
+
// métodos de sus capacidades. Las listas blancas de métodos siguen aquí: 19 lecturas
|
|
12
|
+
// (`call`) y seis mutaciones (`mutate`) que el plugin sólo usa tras la tarjeta de
|
|
13
|
+
// confirmación humana. Cualquier otro método se rechaza sin llegar al socket.
|
|
11
14
|
export const READ_METHODS = Object.freeze(new Set([
|
|
12
15
|
'daemon.status', 'daemon.version', 'swarm.list', 'swarm.status', 'task.get', 'task.list',
|
|
13
16
|
'msg.inbox', 'msg.get', 'msg.wait', 'plan.list', 'plan.get', 'job.list', 'job.get', 'job.logs',
|
|
14
|
-
'receipt.get', 'events.since',
|
|
17
|
+
'receipt.get', 'events.since', 'events.wait', 'decision.list', 'decision.get', 'criteria.get',
|
|
15
18
|
]));
|
|
16
|
-
export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject']));
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
+
export const WRITE_METHODS = Object.freeze(new Set(['msg.reply', 'msg.ack', 'plan.approve', 'plan.reject', 'decision.propose', 'decision.record', 'criteria.accept', 'criteria.reject', 'criteria.reopen', 'review.override', 'review.retry']));
|
|
20
|
+
// Capacidad de la delegación que relayd exige para cada mutación (lecturas: `read`).
|
|
21
|
+
export const METHOD_CAPS = Object.freeze({ 'msg.reply': 'converse', 'msg.ack': 'converse', 'plan.approve': 'plans', 'plan.reject': 'plans', 'decision.propose': 'decide', 'decision.record': 'decide', 'criteria.accept': 'plans', 'criteria.reject': 'plans', 'criteria.reopen': 'plans', 'review.override': 'plans', 'review.retry': 'plans' });
|
|
22
|
+
export const RECOMMENDED_CAPS = 'read,converse,plans,decide';
|
|
23
|
+
// El `client` del hello debe ser exactamente el de la delegación: así un token emitido
|
|
24
|
+
// para otro programa (p. ej. la web) no sirve a Comal.
|
|
25
|
+
export const DELEGATE_CLIENT = 'comal';
|
|
26
|
+
// Las claves de idempotencia son del alcance de la sesión delegada; el prefijo comal-
|
|
27
|
+
// se mantiene para reconocerlas en el log de relayd.
|
|
19
28
|
export const IDEMPOTENCY_KEY = /^comal-[A-Za-z0-9][A-Za-z0-9_.:-]{0,121}$/;
|
|
20
29
|
export const PROTOCOL_VERSION = 1;
|
|
30
|
+
const TOKEN_FORMAT = /^orly_[0-9a-f]{64}$/;
|
|
21
31
|
// relayd acota cada frame a 1 MiB por omisión; las respuestas de lectura del CLI admiten 16×.
|
|
22
32
|
const MAX_RESPONSE_BYTES = 16 << 20;
|
|
23
33
|
const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
|
24
34
|
|
|
25
|
-
export function controlPaths(controlDir) {
|
|
26
|
-
const root = path.resolve(controlDir);
|
|
27
|
-
return { root, admin: path.join(
|
|
35
|
+
export function controlPaths(controlDir, tokenFile) {
|
|
36
|
+
const root = path.resolve(controlDir), admin = path.join(root, 'admin');
|
|
37
|
+
return { root, admin, socket: path.join(admin, 'relay.sock'), adminToken: path.join(admin, 'admin.token'), token: tokenFile ? path.resolve(tokenFile) : path.join(root, 'comal.token') };
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
const shellWord = value => /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
41
|
+
const principal = () => { try { return os.userInfo().username || 'operator'; } catch { return 'operator'; } };
|
|
42
|
+
/** El comando exacto que el humano corre (una vez) para emitir el token de Comal. */
|
|
43
|
+
export function issueCommand(paths, caps = RECOMMENDED_CAPS) {
|
|
44
|
+
return `relay --control-dir ${shellWord(paths.root)} session issue-delegate --principal ${shellWord(principal())} --client ${DELEGATE_CLIENT} --caps ${caps} --ttl 30d --out ${shellWord(paths.token)}`;
|
|
45
|
+
}
|
|
46
|
+
const reissue = paths => `Remove ${paths.token} and issue a new one: ${issueCommand(paths)}`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Lee el token delegado en cada conexión (el humano puede reemitirlo sin recargar el
|
|
50
|
+
* plugin). Nunca lee `admin.token`: ni como respaldo ni si la ruta configurada lo es.
|
|
51
|
+
* El valor nunca sale de aquí salvo hacia el hello.
|
|
52
|
+
*/
|
|
53
|
+
async function readToken(paths) {
|
|
54
|
+
if (paths.token === paths.adminToken || path.dirname(paths.token) === paths.admin) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `Comal never authenticates with relayd's admin token. Use a delegate token outside ${paths.admin}: ${issueCommand({ ...paths, token: path.join(paths.root, 'comal.token') })}`);
|
|
32
55
|
let handle;
|
|
33
56
|
try {
|
|
34
|
-
handle = await open(
|
|
57
|
+
handle = await open(paths.token, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
35
58
|
const info = await handle.stat(), uid = process.getuid?.();
|
|
36
|
-
if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE',
|
|
37
|
-
|
|
38
|
-
|
|
59
|
+
if (!info.isFile() || info.size > 4096 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is not a private (0600) file owned by the user.`);
|
|
60
|
+
// Un enlace duro a admin.token también se rechaza (sólo se mira su inodo, nunca su contenido).
|
|
61
|
+
const admin = await stat(paths.adminToken).catch(() => null);
|
|
62
|
+
if (admin && admin.ino === info.ino && admin.dev === info.dev) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `${paths.token} is relayd's admin token, not a delegate session. ${reissue(paths)}`);
|
|
63
|
+
const token = (await handle.readFile('utf8')).replace(/\r?\n$/, '');
|
|
64
|
+
if (!TOKEN_FORMAT.test(token)) throw fail('RELAY_TOKEN_INVALID', `The delegate token ${paths.token} does not have the orly_<64 hex> format. ${reissue(paths)}`);
|
|
39
65
|
return token;
|
|
40
66
|
} catch (error) {
|
|
41
|
-
if (error.code === 'ENOENT')
|
|
67
|
+
if (error.code === 'ENOENT') {
|
|
68
|
+
const running = await stat(paths.adminToken).then(() => true, () => false);
|
|
69
|
+
throw fail('RELAY_DELEGATE_MISSING', running
|
|
70
|
+
? `Comal has no delegate token for relayd at ${paths.token} (Comal never uses admin.token). Ask the human to issue it once: ${issueCommand(paths)}`
|
|
71
|
+
: `No delegate token at ${paths.token}, and relayd does not seem to be running in ${paths.root}. Start relayd, then issue the token: ${issueCommand(paths)}`);
|
|
72
|
+
}
|
|
73
|
+
if (error.code === 'ELOOP') throw fail('RELAY_TOKEN_UNSAFE', `The delegate token ${paths.token} is a symlink; use a regular 0600 file.`);
|
|
42
74
|
throw error;
|
|
43
75
|
} finally { await handle?.close(); }
|
|
44
76
|
}
|
|
45
77
|
|
|
78
|
+
/** Mensaje legible para un error de relayd; NOT_DELEGATED y UNAUTHORIZED dicen qué hacer. */
|
|
79
|
+
function rpcFailure(method, data, paths) {
|
|
80
|
+
const code = data.code || 'RELAY_ERROR', text = String(data.message || 'relayd error').slice(0, 500);
|
|
81
|
+
if (code === 'NOT_DELEGATED') {
|
|
82
|
+
const capability = METHOD_CAPS[method] || /capacidad (\w+)/.exec(text)?.[1] || (READ_METHODS.has(method) ? 'read' : null);
|
|
83
|
+
return fail(code, `${text} — Comal's delegate session cannot do ${method}${capability ? ` (missing capability: ${capability})` : ''}. Not retried: ask the human for a delegation with that capability. ${reissue(paths)}`, { retryable: false, rpc: true, capability });
|
|
84
|
+
}
|
|
85
|
+
// En una llamada, UNAUTHORIZED con «sesión revocada/expirada» es la delegación; en decision.get es «no existe».
|
|
86
|
+
if (code === 'UNAUTHORIZED' && (method === 'daemon.hello' || /revoc|expir/i.test(text))) return fail(code, `relayd rejected Comal's delegate token (${text}): it was revoked, expired, or issued for a client other than "${DELEGATE_CLIENT}". ${reissue(paths)}`, { retryable: false, rpc: true });
|
|
87
|
+
return fail(code, text, { retryable: data.retryable === true, rpc: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
46
90
|
/** Una conexión autenticada. Las esperas largas usan una conexión propia. */
|
|
47
91
|
export class RelayConnection {
|
|
48
|
-
constructor({ controlDir, client =
|
|
49
|
-
Object.assign(this, { paths: controlPaths(controlDir), client, timeoutMs, connect });
|
|
92
|
+
constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect = options => net.createConnection(options) }) {
|
|
93
|
+
Object.assign(this, { paths: controlPaths(controlDir, tokenFile), client, timeoutMs, connect });
|
|
50
94
|
this.pending = new Map(); this.nextId = 1; this.closed = false; this.socket = null; this.session = null;
|
|
51
95
|
}
|
|
52
96
|
async open(signal) {
|
|
53
97
|
signal?.throwIfAborted();
|
|
54
|
-
const token = await readToken(this.paths
|
|
98
|
+
const token = await readToken(this.paths);
|
|
55
99
|
const socket = this.socket = this.connect({ path: this.paths.socket });
|
|
56
100
|
let buffer = Buffer.alloc(0);
|
|
57
101
|
await new Promise((resolve, reject) => {
|
|
@@ -75,10 +119,16 @@ export class RelayConnection {
|
|
|
75
119
|
});
|
|
76
120
|
socket.on('error', error => this.#fail(fail('RELAY_DISCONNECTED', `Connection to relayd lost (${error.code || error.message}).`)));
|
|
77
121
|
socket.on('close', () => this.#fail(fail('RELAY_DISCONNECTED', 'relayd closed the connection.')));
|
|
78
|
-
try {
|
|
79
|
-
|
|
122
|
+
try {
|
|
123
|
+
const session = await this.#request('daemon.hello', { protocol_versions: [PROTOCOL_VERSION], token, client: this.client }, { signal });
|
|
124
|
+
// Sin `delegate` el token es del operador directo (p. ej. una copia de admin.token): no se usa.
|
|
125
|
+
if (!session?.delegate) throw fail('RELAY_ADMIN_TOKEN_REFUSED', `The token in ${this.paths.token} is a direct operator token, not a delegate session; Comal only operates through a delegation. ${reissue(this.paths)}`);
|
|
126
|
+
this.session = session;
|
|
127
|
+
} catch (error) { this.close(); throw error; }
|
|
80
128
|
return this.session;
|
|
81
129
|
}
|
|
130
|
+
/** Lo que relayd dijo de la delegación en el hello: principal, client, caps, persistent. */
|
|
131
|
+
get delegate() { return this.session?.delegate || null; }
|
|
82
132
|
#fail(error) {
|
|
83
133
|
if (this.closed) return; this.closed = true; this.error = error;
|
|
84
134
|
for (const entry of this.pending.values()) entry.finish(null, error);
|
|
@@ -89,7 +139,7 @@ export class RelayConnection {
|
|
|
89
139
|
if (!READ_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for reading: ${method}`));
|
|
90
140
|
return this.#request(method, params, { signal, timeoutMs });
|
|
91
141
|
}
|
|
92
|
-
/** Sólo las
|
|
142
|
+
/** Sólo las mutaciones de la lista blanca y siempre con una clave de idempotencia de Comal. */
|
|
93
143
|
mutate(method, params = {}, { signal, timeoutMs } = {}) {
|
|
94
144
|
if (!WRITE_METHODS.has(method)) return Promise.reject(fail('RELAY_METHOD_FORBIDDEN', `Method not allowed for Comal: ${method}`));
|
|
95
145
|
if (typeof params?.idempotency_key !== 'string' || !IDEMPOTENCY_KEY.test(params.idempotency_key)) return Promise.reject(fail('RELAY_IDEMPOTENCY_KEY', 'Every mutation needs a comal-… idempotency_key of up to 128 characters.'));
|
|
@@ -103,7 +153,7 @@ export class RelayConnection {
|
|
|
103
153
|
const finish = (message, error) => {
|
|
104
154
|
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
105
155
|
if (error) return reject(error);
|
|
106
|
-
if (message.error)
|
|
156
|
+
if (message.error) return reject(rpcFailure(method, { message: message.error.message, ...(message.error.data || {}) }, this.paths));
|
|
107
157
|
resolve(message.result);
|
|
108
158
|
};
|
|
109
159
|
const abort = () => { if (this.pending.delete(id)) finish(null, signal.reason); };
|
|
@@ -116,21 +166,29 @@ export class RelayConnection {
|
|
|
116
166
|
close() { this.#fail(fail('RELAY_DISCONNECTED', 'Connection closed by Comal.')); }
|
|
117
167
|
}
|
|
118
168
|
|
|
119
|
-
// Errores tras los cuales conviene reconectar (relayd reiniciado
|
|
120
|
-
|
|
169
|
+
// Errores tras los cuales conviene reconectar (relayd reiniciado o recuperándose).
|
|
170
|
+
// UNAUTHORIZED, NOT_DELEGATED y los del archivo del token necesitan al humano.
|
|
171
|
+
export const reconnectable = error => ['RELAY_DISCONNECTED', 'RELAY_UNAVAILABLE', 'RELAY_TIMEOUT', 'RELAY_PROTOCOL', 'UNAVAILABLE'].includes(error?.code);
|
|
172
|
+
// Errores que ninguna espera arregla: hace falta otra delegación o que el humano corrija el token.
|
|
173
|
+
export const needsHuman = error => ['NOT_DELEGATED', 'UNAUTHORIZED', 'RELAY_DELEGATE_MISSING', 'RELAY_ADMIN_TOKEN_REFUSED', 'RELAY_TOKEN_UNSAFE', 'RELAY_TOKEN_INVALID'].includes(error?.code);
|
|
121
174
|
|
|
122
175
|
/** Conexión de consultas compartida, con reconexión perezosa y un reintento (las lecturas son idempotentes). */
|
|
123
176
|
export class RelayClient {
|
|
124
|
-
constructor({ controlDir, client =
|
|
125
|
-
Object.assign(this, { controlDir, client, timeoutMs, connect });
|
|
126
|
-
this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null;
|
|
177
|
+
constructor({ controlDir, tokenFile, client = DELEGATE_CLIENT, timeoutMs = 10000, connect }) {
|
|
178
|
+
Object.assign(this, { controlDir, tokenFile, client, timeoutMs, connect, paths: controlPaths(controlDir, tokenFile) });
|
|
179
|
+
this.connection = null; this.connecting = null; this.closed = false; this.connects = 0; this.lastError = null; this.session = null;
|
|
127
180
|
}
|
|
128
|
-
connection_options() { return { controlDir: this.controlDir, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
|
|
129
|
-
/**
|
|
181
|
+
connection_options() { return { controlDir: this.controlDir, tokenFile: this.tokenFile, client: this.client, timeoutMs: this.timeoutMs, ...(this.connect ? { connect: this.connect } : {}) }; }
|
|
182
|
+
/** La delegación vista en el último hello (sin el token). */
|
|
183
|
+
get delegate() { return this.session?.delegate || null; }
|
|
184
|
+
issueCommand() { return issueCommand(this.paths); }
|
|
185
|
+
/** Conexión nueva y dedicada (para events.wait). */
|
|
130
186
|
async dedicated(signal) {
|
|
131
187
|
if (this.closed) throw fail('RELAY_CLOSED', 'open-relay plugin closed.');
|
|
132
188
|
const connection = new RelayConnection(this.connection_options());
|
|
133
|
-
await connection.open(signal);
|
|
189
|
+
try { await connection.open(signal); }
|
|
190
|
+
catch (error) { this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() }; throw error; }
|
|
191
|
+
this.connects++; this.session = connection.session; return connection;
|
|
134
192
|
}
|
|
135
193
|
async #shared(signal) {
|
|
136
194
|
if (this.connection && !this.connection.closed) return this.connection;
|
|
@@ -148,7 +206,7 @@ export class RelayClient {
|
|
|
148
206
|
for (let attempt = 0; ; attempt++) {
|
|
149
207
|
try { const connection = await this.#shared(options.signal); const result = await connection[kind](method, params, options); this.lastError = null; return result; }
|
|
150
208
|
catch (error) {
|
|
151
|
-
this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0,
|
|
209
|
+
this.lastError = { code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() };
|
|
152
210
|
if (attempt || !reconnectable(error) || options.signal?.aborted) throw error;
|
|
153
211
|
this.connection?.close(); this.connection = null;
|
|
154
212
|
}
|