@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,159 @@
|
|
|
1
|
+
// Permission cards answered from the paired M5Stack, racing the terminal card: the first surface
|
|
2
|
+
// that DECIDES wins. The desk is the single owner of "decided": a question is settled exactly once
|
|
3
|
+
// (terminal or device), synchronously on the event loop, and every later answer reads stale.
|
|
4
|
+
// A device answer is only ever "allow once" or "deny": it never saves a rule and never widens the
|
|
5
|
+
// policy. It is checked again here, whatever the backend and the relay already checked:
|
|
6
|
+
// arm on → not expired → from the armed device → the question still pending here → the card the
|
|
7
|
+
// person saw (digest) → a card the device may answer → no confirm:'always' → not plan → a decision.
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { stat } from 'node:fs/promises';
|
|
10
|
+
import { t } from './i18n/index.mjs';
|
|
11
|
+
import { buildDeviceCard } from './remote-device-card.mjs';
|
|
12
|
+
|
|
13
|
+
const CONTRACT = JSON.parse(readFileSync(new URL('../backend/contracts/remote-device-v1.json', import.meta.url), 'utf8'));
|
|
14
|
+
const DECISIONS = new Set(CONTRACT.inputs.permission_answer.properties.decision.enum);
|
|
15
|
+
/** What a permission.answer ACK may say: its verdict states, and a decision or a contract refusal. */
|
|
16
|
+
export const PERMISSION_ACK_STATES = new Set(['applied', 'stale', 'refused', 'expired', 'rejected']);
|
|
17
|
+
export const PERMISSION_OUTCOMES = new Set([...DECISIONS, ...CONTRACT.refusals]);
|
|
18
|
+
const LEDGER_MAX = 512;
|
|
19
|
+
const live = value => typeof value === 'function' ? value() : value;
|
|
20
|
+
|
|
21
|
+
export class PermissionDesk {
|
|
22
|
+
constructor({ now = Date.now, sendCard = () => {}, closeCard = () => {}, record = async () => {} } = {}) {
|
|
23
|
+
Object.assign(this, { now, sendCard, closeCard, record });
|
|
24
|
+
this.pending = new Map();
|
|
25
|
+
// Same request_id (a lost ACK redelivers it) → the same answer, never a second decision.
|
|
26
|
+
this.ledger = new Map();
|
|
27
|
+
}
|
|
28
|
+
/** One open question. `decision` resolves once, with whichever surface settled it first. */
|
|
29
|
+
open({ questionId, name, input, confirmAlways = false, mode = 'default', card }) {
|
|
30
|
+
if (this.pending.has(questionId)) throw Error('This permission question is already open.');
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
let resolve, reject;
|
|
33
|
+
const decision = new Promise((done, fail) => { resolve = done; reject = fail; });
|
|
34
|
+
decision.catch(() => {});
|
|
35
|
+
const entry = { questionId, name, input, confirmAlways, mode, card, controller, decided: false, closed: false };
|
|
36
|
+
const settle = (result, failed = false) => {
|
|
37
|
+
if (entry.decided) return false;
|
|
38
|
+
entry.decided = true;
|
|
39
|
+
this.#closeCard(entry);
|
|
40
|
+
if (failed) reject(result); else resolve(result);
|
|
41
|
+
controller.abort();
|
|
42
|
+
return true;
|
|
43
|
+
};
|
|
44
|
+
entry.settle = settle;
|
|
45
|
+
this.pending.set(questionId, entry);
|
|
46
|
+
try { this.sendCard(card); } catch {}
|
|
47
|
+
return {
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
decision,
|
|
50
|
+
/** The terminal card answered (or rejected): first wins, a later device answer reads stale. */
|
|
51
|
+
terminal: value => settle({ allowed: Boolean(value && typeof value === 'object' ? value.allowed : value), by: 'terminal', value }),
|
|
52
|
+
fail: error => settle(error, true),
|
|
53
|
+
close: () => {
|
|
54
|
+
if (this.pending.get(questionId) === entry) this.pending.delete(questionId);
|
|
55
|
+
settle(Object.assign(Error('The permission question closed.'), { name: 'AbortError' }), true);
|
|
56
|
+
this.#closeCard(entry);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
#closeCard(entry) {
|
|
61
|
+
if (entry.closed) return;
|
|
62
|
+
entry.closed = true;
|
|
63
|
+
try { this.closeCard(entry.questionId); } catch {}
|
|
64
|
+
}
|
|
65
|
+
#remember(requestId, result) {
|
|
66
|
+
if (typeof requestId !== 'string' || !requestId) return result;
|
|
67
|
+
this.ledger.set(requestId, result);
|
|
68
|
+
if (this.ledger.size > LEDGER_MAX) this.ledger.delete(this.ledger.keys().next().value);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/** A claimed device input (kind permission.answer). Returns the ACK verdict {state, outcome}. */
|
|
72
|
+
async handle(input, { deviceId = null, armed = () => false } = {}) {
|
|
73
|
+
const requestId = input?.request_id;
|
|
74
|
+
if (typeof requestId === 'string' && this.ledger.has(requestId)) return this.ledger.get(requestId);
|
|
75
|
+
const refuse = (state, outcome) => this.#remember(requestId, { state, outcome });
|
|
76
|
+
const control = input?.control && typeof input.control === 'object' ? input.control : {};
|
|
77
|
+
let isArmed = false;
|
|
78
|
+
try { isArmed = armed() === true; } catch {}
|
|
79
|
+
if (!isArmed) return refuse('refused', 'disarmed');
|
|
80
|
+
const expires = Number(input?.expires_at);
|
|
81
|
+
if (input?.expires_at === undefined || input?.expires_at === null || !Number.isFinite(expires) || expires * 1000 <= this.now()) return refuse('expired', 'expired');
|
|
82
|
+
if (typeof deviceId !== 'string' || !deviceId || input?.device_id !== deviceId) return refuse('refused', 'other_device');
|
|
83
|
+
const entry = typeof control.question_id === 'string' ? this.pending.get(control.question_id) : undefined;
|
|
84
|
+
if (!entry || entry.decided) return refuse('stale', 'stale');
|
|
85
|
+
if (typeof control.card_digest !== 'string' || control.card_digest !== entry.card?.card_digest) return refuse('stale', 'stale');
|
|
86
|
+
if (entry.card.kind === 'terminal') return refuse('refused', 'terminal_only');
|
|
87
|
+
let always = true, mode = 'plan';
|
|
88
|
+
try { always = live(entry.confirmAlways) === true; mode = live(entry.mode); } catch {}
|
|
89
|
+
if (always) return refuse('refused', 'confirm_always');
|
|
90
|
+
if (mode === 'plan') return refuse('refused', 'plan_mode');
|
|
91
|
+
if (!DECISIONS.has(control.decision)) return refuse('refused', 'stale');
|
|
92
|
+
// Settled synchronously: nothing else can decide this question after this line.
|
|
93
|
+
if (!entry.settle({ allowed: control.decision === 'allow_once', by: 'device' })) return refuse('stale', 'stale');
|
|
94
|
+
const result = this.#remember(requestId, { state: 'applied', outcome: control.decision });
|
|
95
|
+
try { await this.record({ type: 'remote_control', action: 'permissions.answer', question_id: entry.questionId, decision: control.decision, device_id: input.device_id }); } catch {}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The confirm path with both surfaces: the terminal card (terminal(signal) → ui.confirm) and the
|
|
102
|
+
* device card. Returns what PermissionPolicy expects from confirm: the terminal's own value, or
|
|
103
|
+
* the device's boolean (never a remember choice).
|
|
104
|
+
*/
|
|
105
|
+
export async function answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice = () => {} }) {
|
|
106
|
+
let entry;
|
|
107
|
+
// A desk that cannot open the question leaves the terminal card, as before the device existed.
|
|
108
|
+
try { entry = desk.open({ questionId, name, input, confirmAlways, mode, card }); } catch { return terminal(signal); }
|
|
109
|
+
try {
|
|
110
|
+
let shown;
|
|
111
|
+
try { shown = Promise.resolve(terminal(signal ? AbortSignal.any([signal, entry.signal]) : entry.signal)); }
|
|
112
|
+
catch (error) { shown = Promise.reject(error); }
|
|
113
|
+
shown.then(value => entry.terminal(value), error => entry.fail(error));
|
|
114
|
+
const result = await entry.decision;
|
|
115
|
+
if (result.by !== 'device') return result.value;
|
|
116
|
+
// The terminal card closed because the device answered: the terminal says so.
|
|
117
|
+
try { notice(deviceAnswerNotice({ allowed: result.allowed, tool: card?.tool ?? name })); } catch {}
|
|
118
|
+
return result.allowed;
|
|
119
|
+
} finally { entry.close(); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The terminal's line when the M5Stack decided a card. */
|
|
123
|
+
export const deviceAnswerNotice = ({ allowed, tool }) => t('Answered on the M5Stack: {decision} · {tool}', { decision: t(allowed ? 'allowed once' : 'denied'), tool: String(tool ?? '') });
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* PermissionPolicy's confirm when /rc may carry the card to the M5Stack. Only an armed host with a
|
|
127
|
+
* question id engages the device; an aborted question, or any failure while preparing the device
|
|
128
|
+
* card, shows the plain terminal card instead (the device is a convenience, never a gate).
|
|
129
|
+
*/
|
|
130
|
+
export async function confirmWithDevice({ armed, desk, questionId, name, input, summary, signal, terminal, resolveWrite, confirmAlways, mode, notice }) {
|
|
131
|
+
if (!armed || !questionId || signal?.aborted) return terminal(signal);
|
|
132
|
+
let card;
|
|
133
|
+
try {
|
|
134
|
+
// confirm:'always' and plan mode stay in the terminal: the device gets a card it cannot answer, and nothing of
|
|
135
|
+
// the command, the change or a summary leaves the machine (an unreadable policy counts as terminal-only too).
|
|
136
|
+
let terminalOnly = true;
|
|
137
|
+
try { terminalOnly = live(confirmAlways) === true || live(mode) === 'plan'; } catch {}
|
|
138
|
+
card = terminalOnly ? buildDeviceCard({ questionId, name, input, terminalOnly: true }) : buildDeviceCard({ questionId, name, input, summary, overwrite: await writeOverwrite(name, input, resolveWrite) });
|
|
139
|
+
}
|
|
140
|
+
catch { return terminal(signal); }
|
|
141
|
+
if (signal?.aborted) return terminal(signal);
|
|
142
|
+
return answerFromEitherSurface({ desk, questionId, name, input, confirmAlways, mode, card, signal, terminal, notice });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const OVERWRITE_TIMEOUT_MS = 250;
|
|
146
|
+
/** Write only: does the path the Write tool would write already exist? undefined when it cannot tell,
|
|
147
|
+
* including when resolving and stat take longer than `timeoutMs` (the terminal card must not wait). */
|
|
148
|
+
export async function writeOverwrite(name, input, resolve, { timeoutMs = OVERWRITE_TIMEOUT_MS } = {}) {
|
|
149
|
+
if (name !== 'Write' || typeof input?.file_path !== 'string') return undefined;
|
|
150
|
+
const probe = (async () => {
|
|
151
|
+
let file;
|
|
152
|
+
try { file = await resolve(input.file_path, { create: true }); } catch { return undefined; }
|
|
153
|
+
try { await stat(file); return true; }
|
|
154
|
+
catch (error) { return error?.code === 'ENOENT' ? false : undefined; }
|
|
155
|
+
})();
|
|
156
|
+
let timer;
|
|
157
|
+
const late = new Promise(done => { timer = setTimeout(() => done(undefined), timeoutMs); timer.unref?.(); });
|
|
158
|
+
try { return await Promise.race([probe, late]); } finally { clearTimeout(timer); }
|
|
159
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { line, criteriaWaiting, criteriaWarnings, warningSummary } from './relay/cards.mjs';
|
|
6
|
+
import { swarmFinished, blockedForOperator, blockedKey, blockedReason } from './relay/follow.mjs';
|
|
7
|
+
import { decisionBlock } from './plugins/open-relay/decision.mjs';
|
|
8
|
+
import { normalizeSwarmView, swarmColumn, SWARM_COLUMNS } from './tui/swarm-panel.mjs';
|
|
9
|
+
|
|
10
|
+
// /rc + /relay: proyección del tablero de /swarm y de las tarjetas de /relay hacia el navegador
|
|
11
|
+
// de Remote Control (fase «ver») y la marca de qué tarjetas puede responder (fase «responder»).
|
|
12
|
+
// Es una proyección, no autoridad: ningún evento relay.* habilita nada. Las respuestas del
|
|
13
|
+
// navegador llegan como controles tipados (remote-relay-answers-v1.json) que el host revalida
|
|
14
|
+
// y aplica con las mismas funciones que la TUI (src/relay/broker.mjs). Contrato de eventos:
|
|
15
|
+
// backend/contracts/remote-relay-v1.schema.json (aditivo: criteria, escalation y la columna
|
|
16
|
+
// reviewing sólo salen hacia un backend que admite relay_answers_v1).
|
|
17
|
+
//
|
|
18
|
+
// Lista blanca, nunca copia de la foto del panel: no salen control_dir, issue_command,
|
|
19
|
+
// tokenFile, la raíz del hub, el repo absoluto (sólo su nombre base), la UI local de relayd,
|
|
20
|
+
// el diff, las capturas ni el diseño. El texto de los agentes no es confiable: se limpia con
|
|
21
|
+
// el line() de las tarjetas, se le quitan rutas de casa, direcciones de loopback y cosas con
|
|
22
|
+
// forma de secreto, se acota y viaja marcado `untrusted`; el navegador lo pinta con textContent.
|
|
23
|
+
|
|
24
|
+
export const RELAY_EVENT_BYTES = 36000; // por evento, bajo el techo de 48 KiB con sobre
|
|
25
|
+
export const RELAY_PLAN_PAGES = 5; // relay.card + ≤ 4 relay.card.page
|
|
26
|
+
export const RELAY_BOARD_TASKS = 40; // tareas con detalle en el tablero (en total)
|
|
27
|
+
export const RELAY_BOARD_SWARMS = 4; // enjambres con detalle en el tablero
|
|
28
|
+
export const RELAY_SESSION_EVENTS = 2000; // presupuesto por sesión de /rc
|
|
29
|
+
export const RELAY_SESSION_BYTES = 16 << 20;
|
|
30
|
+
export const RELAY_BOARD_INTERVAL_MS = 3000; // tablero coalescido
|
|
31
|
+
|
|
32
|
+
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
|
33
|
+
/** Contrato navegador → host de /relay (acciones, campos, texto, capacidades y step-up). */
|
|
34
|
+
export const RELAY_ANSWERS = JSON.parse(readFileSync(new URL('../backend/contracts/remote-relay-answers-v1.json', import.meta.url), 'utf8'));
|
|
35
|
+
const SAFE = Object.fromEntries(['project', 'swarm', 'subject'].map(field => [field, new RegExp(RELAY_ANSWERS.control.properties[field].pattern)]));
|
|
36
|
+
export const relayBytes = value => Buffer.byteLength(JSON.stringify(value));
|
|
37
|
+
|
|
38
|
+
// Lo que nunca debe salir aunque lo escriba un agente: rutas de casa, loopback con puerto y
|
|
39
|
+
// formas típicas de credencial (relayd orly_, claves de proveedores, bearer).
|
|
40
|
+
const REDACTIONS = [
|
|
41
|
+
[/(?:\/home|\/Users)\/[^/\s'"`»«)]+/g, '~'],
|
|
42
|
+
[/(?<![\w.-])\/root(?=\/)/g, '~'],
|
|
43
|
+
[/\borly_[A-Za-z0-9_-]{4,}/g, '[redacted]'],
|
|
44
|
+
[/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, '[redacted]'],
|
|
45
|
+
[/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[redacted]'],
|
|
46
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted]'],
|
|
47
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
|
|
48
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, 'Bearer [redacted]'],
|
|
49
|
+
[/\b(?:https?:\/\/)?(?:127(?:\.\d{1,3}){3}|localhost|\[::1\]|0\.0\.0\.0)(?::\d{1,5})?(?:\/\S*)?/gi, '[local address]'],
|
|
50
|
+
];
|
|
51
|
+
/** Texto ajeno: sin secretos con forma conocida, limpio (line) y acotado a `limit` caracteres. */
|
|
52
|
+
export function scrub(value, limit) {
|
|
53
|
+
let text = String(value ?? '');
|
|
54
|
+
for (const [pattern, replacement] of REDACTIONS) text = text.replace(pattern, replacement);
|
|
55
|
+
return line(text, limit);
|
|
56
|
+
}
|
|
57
|
+
const txt = (value, limit) => scrub(value, limit);
|
|
58
|
+
const opt = (value, limit) => { const text = value == null ? '' : scrub(value, limit); return text || null; };
|
|
59
|
+
const int = value => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.min(Math.floor(n), 2 ** 31 - 1) : 0; };
|
|
60
|
+
const time = value => { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; };
|
|
61
|
+
const hex = (value, size) => { const text = String(value || '').toLowerCase(); return new RegExp(`^[a-f0-9]{${size}}$`).test(text) ? text : null; };
|
|
62
|
+
|
|
63
|
+
/** Identidad estable de una tarjeta: el mismo plan re-propuesto (otro digest) es otra tarjeta. */
|
|
64
|
+
export const relayCardId = (project, swarm, kind, subject, version = '') => sha256(JSON.stringify(['relay-card/1', project || '', swarm || '', kind, subject || '', version || ''])).slice(0, 32);
|
|
65
|
+
/** Etiqueta de una opción tal como la ve el navegador (el host compara su sha256). */
|
|
66
|
+
export const relayOptionLabel = label => scrub(label, 120);
|
|
67
|
+
/**
|
|
68
|
+
* ¿Puede el navegador responder esta tarjeta? Sólo las clases del contrato de respuestas, sólo
|
|
69
|
+
* hacia un backend con relay_answers_v1, sólo con ids que el control admite (sin rutas) y sólo
|
|
70
|
+
* las acciones cuyas capacidades tiene la delegación (null = desconocidas: decide relayd). El
|
|
71
|
+
* armado local (/rc relay on) no va aquí: el navegador lo ve en vivo en el estado de la sesión
|
|
72
|
+
* y el host lo vuelve a comprobar al reclamar cada control.
|
|
73
|
+
*/
|
|
74
|
+
export function relayAnswerable(kind, { answers = false, caps = null, ids = {} } = {}) {
|
|
75
|
+
const names = Object.entries(RELAY_ANSWERS.actions).filter(([, action]) => action.card === kind).map(([name]) => name);
|
|
76
|
+
if (!names.length) return { browser: false, why: 'terminal_only' };
|
|
77
|
+
if (!answers) return { browser: false, why: 'host_legacy' };
|
|
78
|
+
if (!Object.entries(ids).every(([field, value]) => SAFE[field].test(String(value ?? '')))) return { browser: false, why: 'terminal_only' };
|
|
79
|
+
const actions = Array.isArray(caps) ? names.filter(name => RELAY_ANSWERS.actions[name].caps.every(cap => caps.includes(cap))) : names;
|
|
80
|
+
return actions.length ? { browser: true, actions } : { browser: false, why: 'cap_missing' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Tablero ────────────────────────────────────────────────────────────────
|
|
84
|
+
const PRIORITY = ['blocked', 'running', 'reviewing', 'verifying', 'pending', 'other', 'done', 'closed'];
|
|
85
|
+
// La columna «en revisión» de /swarm (revisor, open-relay F7) es aditiva en el contrato: sólo
|
|
86
|
+
// sale hacia un backend que la admite (relay_answers_v1); hacia uno anterior cuenta como
|
|
87
|
+
// `verifying` y la tarea conserva su estado real (`reviewing`).
|
|
88
|
+
const columnsFor = reviewing => SWARM_COLUMNS.filter(column => reviewing || column !== 'reviewing');
|
|
89
|
+
|
|
90
|
+
/** Tablero de /swarm para el navegador: ≤ 4 enjambres con detalle y ≤ 40 tareas, ≤ 36.000 B. */
|
|
91
|
+
export function projectRelayBoard(view, { project = null, repo = null, at = Date.now(), reviewing = false } = {}) {
|
|
92
|
+
const v = normalizeSwarmView(view);
|
|
93
|
+
const rcColumn = state => { const column = swarmColumn(state); return column === 'reviewing' && !reviewing ? 'verifying' : column; };
|
|
94
|
+
const emptyCounts = () => Object.fromEntries(columnsFor(reviewing).map(column => [column, 0]));
|
|
95
|
+
const base = { shape: 'board', version: 1, project: opt(project, 60), repo_name: repo ? opt(path.basename(String(repo)), 120) : null };
|
|
96
|
+
if (!v?.available) return { ...base, available: false, reason: txt(v?.reason || 'unavailable', 40), connection: null, swarms: [], swarms_total: 0, pending: { plans: 0, questions: 0, blocked: 0 }, omitted: { swarms: 0, tasks: 0 }, source: { view_generated_at: time(at) || 1 } };
|
|
97
|
+
const c = v.connection || {};
|
|
98
|
+
const connection = { state: txt(c.state || 'idle', 30), mode: ['cards', 'on', 'off'].includes(c.mode) ? c.mode : 'on', caps: (c.delegate?.caps || []).slice(0, 12).map(cap => txt(cap, 30)), missing_caps: (c.missing_caps || []).slice(0, 8).map(cap => txt(cap, 30)) };
|
|
99
|
+
const all = v.swarms.map(swarm => {
|
|
100
|
+
const counts = emptyCounts();
|
|
101
|
+
for (const task of swarm.tasks) counts[rcColumn(task.state)]++;
|
|
102
|
+
const plans = swarm.plans.filter(plan => plan.proposal_id).length, questions = swarm.pending.filter(item => item.kind === 'question' && item.message_id).length;
|
|
103
|
+
return { swarm, counts, plans, questions, open: swarm.tasks.length - counts.done - counts.closed };
|
|
104
|
+
});
|
|
105
|
+
const pending = { plans: 0, questions: 0, blocked: 0 };
|
|
106
|
+
for (const item of all) { pending.plans += item.plans; pending.questions += item.questions; pending.blocked += item.counts.blocked; }
|
|
107
|
+
// Primero lo que espera al humano, luego lo que sigue abierto; el orden del panel desempata.
|
|
108
|
+
const ranked = all.map((item, index) => ({ ...item, index })).sort((a, b) => (b.plans + b.questions > 0) - (a.plans + a.questions > 0) || (b.open > 0) - (a.open > 0) || a.index - b.index).slice(0, RELAY_BOARD_SWARMS);
|
|
109
|
+
let room = RELAY_BOARD_TASKS, omittedTasks = 0;
|
|
110
|
+
const swarms = ranked.map((item, position) => {
|
|
111
|
+
const share = Math.max(0, Math.ceil(room / (ranked.length - position)));
|
|
112
|
+
const sorted = [...item.swarm.tasks].sort((a, b) => PRIORITY.indexOf(rcColumn(a.state)) - PRIORITY.indexOf(rcColumn(b.state)));
|
|
113
|
+
const shown = sorted.slice(0, share); room -= shown.length; omittedTasks += sorted.length - shown.length;
|
|
114
|
+
return {
|
|
115
|
+
id: txt(item.swarm.id, 120), objective: opt(item.swarm.objective, 300), counts: item.counts, tasks_total: item.swarm.tasks.length,
|
|
116
|
+
pending: { plans: item.plans, questions: item.questions },
|
|
117
|
+
tasks: shown.map(task => ({ id: txt(task.id, 120), title: opt(task.title, 160), state: txt(task.state, 40), column: rcColumn(task.state), role: opt(task.role_name || task.role, 60), attempts_used: int(task.counted_attempts ?? task.attempts_used), max_attempts: int(task.max_attempts), since: time(task.since), infra_exhausted: task.infra_exhausted === true })),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
const board = { ...base, available: true, connection, swarms, swarms_total: Math.max(int(v.swarms_total), all.length), pending, omitted: { swarms: Math.max(0, all.length - swarms.length) + Math.max(0, int(v.swarms_total) - all.length), tasks: omittedTasks }, source: { view_generated_at: time(v.generated_at) || time(at) || 1 } };
|
|
121
|
+
// Por bytes, nunca por número: se quitan tareas enteras del enjambre con más, luego enjambres.
|
|
122
|
+
while (relayBytes(board) > RELAY_EVENT_BYTES) {
|
|
123
|
+
const widest = board.swarms.reduce((best, swarm) => !best || swarm.tasks.length > best.tasks.length ? swarm : best, null);
|
|
124
|
+
if (widest?.tasks.length) { widest.tasks.pop(); board.omitted.tasks++; }
|
|
125
|
+
else if (board.swarms.length) { const dropped = board.swarms.pop(); board.omitted.swarms++; board.omitted.tasks += dropped.tasks.length; }
|
|
126
|
+
else break;
|
|
127
|
+
}
|
|
128
|
+
return board;
|
|
129
|
+
}
|
|
130
|
+
/** Huella del tablero sin su sello de tiempo: dos fotos iguales no generan dos eventos. */
|
|
131
|
+
export const relayBoardDigest = board => { const { source, ...content } = board || {}; return sha256(JSON.stringify(content)); };
|
|
132
|
+
|
|
133
|
+
// ── Tarjetas ───────────────────────────────────────────────────────────────
|
|
134
|
+
const TERMINAL_ONLY = Object.freeze({ browser: false, why: 'terminal_only' });
|
|
135
|
+
const envelope = ({ project, swarm, kind, subject, version, step_up = 'none', answerable = TERMINAL_ONLY }) => ({
|
|
136
|
+
shape: 'card', version: 1, card_id: relayCardId(project, swarm, kind, subject, version), kind, state: 'pending',
|
|
137
|
+
project: txt(project, 60), swarm: txt(swarm, 120), subject: txt(subject, 200),
|
|
138
|
+
// Responder exige además el armado local (/rc relay on), que el host comprueba al reclamar.
|
|
139
|
+
answerable: answerable?.browser ? { browser: true, actions: [...answerable.actions] } : { browser: false, why: answerable?.why || 'terminal_only' }, step_up, untrusted: true,
|
|
140
|
+
});
|
|
141
|
+
const listOf = value => Array.isArray(value) ? value : [];
|
|
142
|
+
const criteriaOf = task => listOf(task?.criteria).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean);
|
|
143
|
+
/** Una tarea del plan, con los mismos topes que la tarjeta de la TUI. */
|
|
144
|
+
function planTask(task) {
|
|
145
|
+
const paths = listOf(task?.paths).map(item => typeof item === 'string' ? item : item?.path).filter(Boolean);
|
|
146
|
+
const criteria = criteriaOf(task), deps = listOf(task?.deps).filter(dep => typeof dep === 'string');
|
|
147
|
+
return {
|
|
148
|
+
id: txt(task?.id || '?', 60), title: opt(task?.title, 120), role: txt(task?.role || task?.required_role || '?', 60), max_attempts: int(task?.max_attempts) || 1,
|
|
149
|
+
paths: paths.slice(0, 6).map(item => txt(item, 80)), paths_total: paths.length,
|
|
150
|
+
criteria: criteria.slice(0, 8).map(item => txt(item, 40)), deps: deps.slice(0, 8).map(item => txt(item, 40)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** sha256 de lo que el navegador debe haber visto entero (digest + todas las tareas, en orden
|
|
154
|
+
* y, si el plan retira tareas, esa lista). `retire` entra SÓLO si no está vacío: los planes sin
|
|
155
|
+
* él conservan su sha de siempre, y una web vieja que no muestra `retire` calcula otro sha y no
|
|
156
|
+
* puede aprobar un plan que cancela tareas (falla segura). web/comal/relay.mjs usa la misma forma. */
|
|
157
|
+
export const relayProjectionSha = (digest, tasks, retire = []) => sha256(JSON.stringify(retire.length ? { digest, tasks, retire } : { digest, tasks }));
|
|
158
|
+
export const RELAY_RETIRE_SHOWN = 64;
|
|
159
|
+
|
|
160
|
+
/** Plan del líder (plan.get) → relay.card con cabecera + primeras tareas y relay.card.page 2..N. */
|
|
161
|
+
export function projectPlanCard({ project, swarm, got, checks = [], answerable = TERMINAL_ONLY }) {
|
|
162
|
+
const proposal = got?.proposal || {}, plan = proposal.plan || {};
|
|
163
|
+
const digest = hex(proposal.digest, 64) || sha256(JSON.stringify(plan));
|
|
164
|
+
const proposal_id = String(proposal.proposal_id || proposal.id || '?');
|
|
165
|
+
const tasks = listOf(plan.tasks).slice(0, 64).map(planTask), roles = listOf(plan.roles);
|
|
166
|
+
const retireAll = listOf(plan.retire).filter(id => typeof id === 'string'), retire = retireAll.slice(0, RELAY_RETIRE_SHOWN).map(id => txt(id, 60));
|
|
167
|
+
const used = new Set(tasks.flatMap(task => task.criteria));
|
|
168
|
+
const catalog = listOf(checks).filter(check => check?.id && used.has(line(check.id, 40))).slice(0, 32).map(check => ({ id: txt(check.id, 40), description: txt(String(check.description || '').replace(/`/g, ''), 70) }));
|
|
169
|
+
const card = {
|
|
170
|
+
...envelope({ project, swarm, kind: 'plan', subject: proposal_id, version: digest, step_up: 'recent_login', answerable }),
|
|
171
|
+
body: {
|
|
172
|
+
proposal_id: txt(proposal_id, 200), digest, proposer_role: txt(proposal.proposer_role || '?', 60), summary: opt(proposal.summary || plan.summary, 400),
|
|
173
|
+
roles: roles.slice(0, 16).map(role => ({ id: txt(role?.id || '?', 60), name: opt(role?.name && role.name !== role.id ? role.name : null, 40), runtime: txt(role?.runtime || role?.bind?.runtime || 'comal', 20), model: opt(role?.model || role?.bind?.model, 40) })),
|
|
174
|
+
roles_total: roles.length, tasks: [], tasks_total: listOf(plan.tasks).length, tasks_omitted: 0, pages: 1, projection_sha256: '0'.repeat(64),
|
|
175
|
+
problems: listOf(got?.problems).slice(0, 6).map(problem => txt(typeof problem === 'string' ? problem : problem?.message || JSON.stringify(problem), 200)),
|
|
176
|
+
checks: catalog,
|
|
177
|
+
// Sólo con tareas que retirar: un plan sin ellas da la misma tarjeta que antes (y el
|
|
178
|
+
// esquema de un backend viejo, que no conoce el campo, sigue aceptándola).
|
|
179
|
+
...(retireAll.length ? { retire, retire_total: retireAll.length } : {}),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
// Empaquetado por bytes: tareas enteras hasta 36.000 B por evento (con margen para el número
|
|
183
|
+
// final de páginas y el sha), como mucho 5 eventos; lo que no cabe se declara en tasks_omitted.
|
|
184
|
+
const room = RELAY_EVENT_BYTES - 64, pages = [];
|
|
185
|
+
let index = 0;
|
|
186
|
+
while (index < tasks.length && relayBytes({ ...card, body: { ...card.body, tasks: [...card.body.tasks, tasks[index]] } }) <= room) card.body.tasks.push(tasks[index++]);
|
|
187
|
+
const pageOf = number => ({ shape: 'page', version: 1, card_id: card.card_id, digest, page: number, pages: RELAY_PLAN_PAGES, tasks: [] });
|
|
188
|
+
while (index < tasks.length && pages.length < RELAY_PLAN_PAGES - 1) {
|
|
189
|
+
const page = pageOf(pages.length + 2);
|
|
190
|
+
while (index < tasks.length && relayBytes({ ...page, tasks: [...page.tasks, tasks[index]] }) <= room) page.tasks.push(tasks[index++]);
|
|
191
|
+
if (!page.tasks.length) break;
|
|
192
|
+
pages.push(page);
|
|
193
|
+
}
|
|
194
|
+
const shown = tasks.slice(0, index);
|
|
195
|
+
card.body.tasks_omitted = card.body.tasks_total - shown.length;
|
|
196
|
+
// Aprobar desde el navegador exige haber visto el plan entero: con tareas fuera de la proyección
|
|
197
|
+
// sólo se puede rechazar allí (o decidir en la terminal).
|
|
198
|
+
if ((card.body.tasks_omitted || retireAll.length > retire.length) && card.answerable.browser) {
|
|
199
|
+
const actions = card.answerable.actions.filter(action => action !== 'plan.approve');
|
|
200
|
+
card.answerable = actions.length ? { browser: true, actions } : { browser: false, why: 'terminal_only' };
|
|
201
|
+
}
|
|
202
|
+
card.body.pages = pages.length + 1;
|
|
203
|
+
card.body.projection_sha256 = relayProjectionSha(digest, shown, retire);
|
|
204
|
+
for (const page of pages) page.pages = card.body.pages;
|
|
205
|
+
return { card, pages };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Pregunta del enjambre (msg.get) → relay.card; mismas opciones y topes que questionCard. */
|
|
209
|
+
export function projectQuestionCard({ project, swarm, message, task, answerable = TERMINAL_ONLY }) {
|
|
210
|
+
const block = decisionBlock(message), message_id = String(message?.message_id || message?.id || '?');
|
|
211
|
+
const from = message?.from?.role ? txt(message.from.role, 60) : txt(message?.from?.kind || '?', 40);
|
|
212
|
+
const body = { message_id: txt(message_id, 200), from, body: opt(message?.body, block ? 500 : 900), decision: null, task: task?.id ? { id: txt(task.id, 60), title: opt(task.title, 100) } : null };
|
|
213
|
+
if (block) body.decision = {
|
|
214
|
+
question: txt(block.question, 300),
|
|
215
|
+
options: block.options.slice(0, 4).map(option => ({ label: relayOptionLabel(option.label), consequences: opt(option.consequences, 240), recommended: option.label === block.recommendation?.option })),
|
|
216
|
+
recommendation: block.recommendation ? { option: txt(block.recommendation.option, 120), why: opt(block.recommendation.why, 300) } : null,
|
|
217
|
+
scope: txt(block.scope, 20), reversible: block.reversible === true, cost_of_waiting: opt(block.cost_of_waiting, 200),
|
|
218
|
+
};
|
|
219
|
+
return { card: { ...envelope({ project, swarm, kind: 'question', subject: message_id, answerable }), body }, pages: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Tarea bloqueada por infraestructura → relay.card. «Reintentar» queda en la terminal (D4). */
|
|
223
|
+
export function projectBlockedCard({ project, swarm, task, reason, since, attempts }) {
|
|
224
|
+
const id = String(task?.id || '?'), at = time(since);
|
|
225
|
+
const body = { task_id: txt(id, 60), title: opt(task?.title, 160), state: txt(task?.state || 'blocked', 40), reason: opt(reason, 512), since: at, attempts: int(attempts ?? task?.counted_attempts ?? task?.attempts_used), retry: { via: 'terminal' } };
|
|
226
|
+
return { card: { ...envelope({ project, swarm, kind: 'blocked', subject: id, version: `${at || ''}:${body.attempts}` }), body }, pages: [] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Criterios de éxito del revisor (criteria.get) que esperan al humano → relay.card. Se acepta
|
|
230
|
+
* el digest exacto que se muestra, como criteriaCard; los ítems se empaquetan por bytes. */
|
|
231
|
+
export function projectCriteriaCard({ project, swarm, got, answerable = TERMINAL_ONLY }) {
|
|
232
|
+
const current = got?.current || {}, items = listOf(current.items), digest = hex(current.digest, 64) || '0'.repeat(64);
|
|
233
|
+
// open-relay v2.33: con avisos (pide más que el enunciado, cita que no aparece, sin cita) se
|
|
234
|
+
// acepta solo en la terminal, donde la tarjeta los pone primero y pide confirmación; el texto
|
|
235
|
+
// lleva el aviso, acotado para no comerse el criterio.
|
|
236
|
+
// Rechazar desde el navegador sigue siendo seguro; aceptar no.
|
|
237
|
+
const warnings = criteriaWarnings(items, { checked: got?.basis_checked === true });
|
|
238
|
+
if (warnings.size) answerable = answerable?.browser && listOf(answerable.actions).includes('criteria.reject') ? { browser: true, actions: ['criteria.reject'] } : TERMINAL_ONLY;
|
|
239
|
+
const statementOf = item => {
|
|
240
|
+
const list = warnings.get(item?.id || '?');
|
|
241
|
+
return txt(list ? `⚠ ${line(warningSummary(list), 100)} · ${item?.statement || ''}` : item?.statement || '', 240);
|
|
242
|
+
};
|
|
243
|
+
const body = {
|
|
244
|
+
swarm_id: txt(got?.swarm_id || swarm, 120), status: got?.status === 'escalated' ? 'escalated' : got?.status === 'draft' && got?.accepted ? 'revised' : 'endorsed', in_force: int(got?.accepted?.version), version: int(current.version), author: txt(current.author || '?', 60), digest, rounds: int(got?.rounds),
|
|
245
|
+
items: items.slice(0, 32).map(item => ({
|
|
246
|
+
id: txt(item?.id || '?', 40), kind: item?.kind === 'judgment' ? 'judgment' : 'check', check: opt(item?.check, 40), statement: statementOf(item),
|
|
247
|
+
stage: listOf(item?.stage).slice(0, 4).map(stage => txt(stage, 12)), evidence: listOf(item?.evidence_required).slice(0, 6).map(value => txt(value, 30)),
|
|
248
|
+
viewports: listOf(item?.viewports).slice(0, 4).map(value => txt(typeof value === 'string' ? value : `${int(value?.width)}×${int(value?.height)}`, 20)),
|
|
249
|
+
})),
|
|
250
|
+
items_total: items.length, items_omitted: 0, out_of_scope: listOf(current.out_of_scope).slice(0, 12).map(value => txt(value, 200)),
|
|
251
|
+
objections: got?.status === 'escalated' ? listOf(got?.objections).slice(-2).map(objection => ({ version: int(objection?.version), reasons: txt(listOf(objection?.reasons).join('; '), 400) })) : [],
|
|
252
|
+
};
|
|
253
|
+
const card = { ...envelope({ project, swarm, kind: 'criteria', subject: swarm, version: digest, step_up: RELAY_ANSWERS.actions['criteria.accept'].step_up, answerable }), body };
|
|
254
|
+
while (relayBytes(card) > RELAY_EVENT_BYTES && body.items.length) body.items.pop();
|
|
255
|
+
body.items_omitted = body.items_total - body.items.length;
|
|
256
|
+
return { card, pages: [] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Revisión escalada (task.get) → relay.card de sólo lectura: decidir sobre el revisor
|
|
260
|
+
* (review.override / review.retry) sigue en la terminal. Sin el texto libre del candidato. */
|
|
261
|
+
export function projectEscalationCard({ project, swarm, task }) {
|
|
262
|
+
const review = task?.review || {}, last = review.last || null, pending = review.pending || {}, id = String(task?.id || '?');
|
|
263
|
+
const body = {
|
|
264
|
+
task_id: txt(id, 60), title: opt(task?.title, 120), review_kind: ['task', 'integration', 'final'].includes(pending.kind) ? pending.kind : 'task', rounds: int(review.rounds ?? task?.review_rounds), objected: last?.effective === 'object',
|
|
265
|
+
failures: listOf(last?.criteria).filter(item => item?.result === 'fail').slice(0, 8).map(item => ({ id: txt(item?.id || '?', 40), why: txt(item?.rationale || '?', 240) })),
|
|
266
|
+
corrections: listOf(last?.corrections).slice(0, 8).map(item => ({ id: txt(item?.id || '?', 20), severity: txt(item?.severity || '?', 10), what: txt(item?.what || '', 240) })),
|
|
267
|
+
notes: opt(last?.notes, 300),
|
|
268
|
+
};
|
|
269
|
+
return { card: { ...envelope({ project, swarm, kind: 'escalation', subject: id, version: String(body.rounds) }), body }, pages: [] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Veredicto final del revisor (open-relay F7) como `evidence` del contrato: por criterio, su
|
|
273
|
+
* resultado y los KiB de las capturas que cita. Solo metadatos de la lista blanca: nunca bytes,
|
|
274
|
+
* rutas, hashes ni el texto del revisor. */
|
|
275
|
+
function reviewEvidence(task, review) {
|
|
276
|
+
const last = review?.last; if (!last || !Array.isArray(last.criteria)) return [];
|
|
277
|
+
const sizes = new Map(listOf(last.evidence).filter(item => /^image\//.test(String(item?.media || ''))).map(item => [String(item?.path || ''), int(item?.bytes)]));
|
|
278
|
+
return last.criteria.slice(0, 8).map(item => ({
|
|
279
|
+
task: txt(task || 't-root', 60), check: txt(item?.id || '?', 40), outcome: txt(String(item?.result || '?').toUpperCase(), 24),
|
|
280
|
+
capture_kib: Math.round(listOf(item?.evidence).reduce((sum, path) => sum + (sizes.get(String(path)) || 0), 0) / 1024),
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Final del enjambre → relay.card de sólo lectura: diffstat sí, el diff nunca (D7). */
|
|
285
|
+
export function projectFinishCard({ project, swarm, objective, counts = {}, ok = true, branch, base, diffStat = '', review = null, reviewTask = null }) {
|
|
286
|
+
const lines = String(diffStat || '').trimEnd().split('\n').filter(item => item.trim());
|
|
287
|
+
const body = { objective: opt(objective, 300), counts: { done: int(counts.done), failed: int(counts.failed), other: int(counts.other) }, ok: ok === true, branch: txt(branch || '?', 120), base: hex(String(base || '').slice(0, 8), 8), diffstat: lines.slice(0, 30).map(item => txt(item, 200)), diffstat_total: lines.length, evidence: reviewEvidence(reviewTask, review) };
|
|
288
|
+
return { card: { ...envelope({ project, swarm, kind: 'finish', subject: swarm }), body }, pages: [] };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Cierre de una tarjeta, desde la terminal o desde fuera de Comal (UI web de relayd, CLI…). */
|
|
292
|
+
export function projectResolved({ card_id, outcome, by = 'terminal', adr_id = null, request_id = null, at = Date.now() }) {
|
|
293
|
+
const resolved = { shape: 'resolved', version: 1, card_id, outcome, by, adr_id: adr_id ? txt(adr_id, 120) : null, at: time(at) || 1 };
|
|
294
|
+
// Aditivo: sólo una respuesta del navegador cita su request_id (así ese navegador sabe que fue la suya).
|
|
295
|
+
if (by === 'browser' && /^[A-Za-z0-9_-]{8,128}$/.test(String(request_id || ''))) resolved.request_id = request_id;
|
|
296
|
+
return resolved;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Seguimiento ────────────────────────────────────────────────────────────
|
|
300
|
+
// Lo que el libro de la sesión registra al decidir una tarjeta (en la TUI o desde el navegador,
|
|
301
|
+
// source: remote_browser) → cierre en el navegador. Clave = la misma que follow.mjs.
|
|
302
|
+
const RECORDED = {
|
|
303
|
+
'plan.approve': row => [`plan:${row.swarm}:${row.proposal_id}`, 'approved'],
|
|
304
|
+
'plan.reject': row => [`plan:${row.swarm}:${row.proposal_id}`, 'rejected'],
|
|
305
|
+
'decision.record': row => [`question:${row.swarm}:${row.message_id}`, 'decided'],
|
|
306
|
+
'msg.reply': row => [`question:${row.swarm}:${row.message_id}`, 'answered'],
|
|
307
|
+
'msg.ack': row => [`question:${row.swarm}:${row.message_id}`, 'acked'],
|
|
308
|
+
'swarm.finished': row => [`finish:${row.swarm}`, 'acked'],
|
|
309
|
+
'criteria.accept': row => [`criteria:${row.swarm}:${row.digest}`, 'approved'],
|
|
310
|
+
'criteria.reject': row => [`criteria:${row.swarm}:${row.digest}`, 'rejected'],
|
|
311
|
+
// La revisión escalada lleva las rondas en su clave: se cierra cualquier episodio de esa tarea.
|
|
312
|
+
'review.override': row => [`escalation:${row.swarm}:${row.task}:`, 'decided', true],
|
|
313
|
+
'review.retry': row => [`escalation:${row.swarm}:${row.task}:`, 'decided', true],
|
|
314
|
+
};
|
|
315
|
+
// Bloqueos del operador (follow.mjs): «Reintentar» (task.resume) sigue en la terminal (D4).
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Fuente relay.* para RemoteHost. La alimentan la foto de /swarm (update) y el libro de
|
|
319
|
+
* /relay (recorded); sólo lee de relayd (plan.get, msg.get, criteria.get, task.get) y de git
|
|
320
|
+
* (diffstat) mientras hay un /rc enganchado (attach). No muta nada ni toca la pantalla: las
|
|
321
|
+
* respuestas del navegador las aplica el broker (src/relay/broker.mjs), que le pide aquí la
|
|
322
|
+
* tarjeta proyectada (card) para comparar lo que vio el navegador.
|
|
323
|
+
*/
|
|
324
|
+
export class RemoteRelayFeed {
|
|
325
|
+
constructor({ flow, state, now = Date.now, timeoutMs = 15000 }) {
|
|
326
|
+
Object.assign(this, { flow, state, now, timeoutMs });
|
|
327
|
+
this.view = null; this.sink = null; this.cards = new Map(); this.closed = new Map(); this.resolved = new Map(); this.skipped = new Map(); this.reading = null; this.dirty = false; this.stopped = false;
|
|
328
|
+
}
|
|
329
|
+
/** Foto nueva del panel /swarm (la misma que recibe la pantalla). */
|
|
330
|
+
update(view) { this.view = normalizeSwarmView(view); if (this.sink) { this.#board(); this.#sync(); } }
|
|
331
|
+
/** Fila relay_control del libro: una tarjeta se decidió en la TUI o desde el navegador. */
|
|
332
|
+
recorded(row) {
|
|
333
|
+
const rule = RECORDED[row?.action]; if (!rule) return;
|
|
334
|
+
const [key, outcome, prefix] = rule(row), browser = row.source === 'remote_browser';
|
|
335
|
+
const keys = prefix ? [...this.cards.keys()].filter(item => item.startsWith(key)) : [key];
|
|
336
|
+
for (const item of keys) this.#close(item, { outcome, by: browser ? 'browser' : 'terminal', adr_id: row.decision_id || null, request_id: browser ? row.remote_request_id : null });
|
|
337
|
+
}
|
|
338
|
+
/** Tarjeta publicada en esta sesión de /rc por su card_id (para el broker), o null. */
|
|
339
|
+
card(card_id) {
|
|
340
|
+
for (const [key, entry] of this.cards) if (entry.card_id === card_id) return { key, ...entry };
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
/** Cómo se cerró una tarjeta de esta sesión (card_id), o null si nunca se cerró. */
|
|
344
|
+
resolution(card_id) { return this.resolved.get(card_id) || null; }
|
|
345
|
+
/** /rc enganchado: tablero y tarjetas pendientes completos, una vez. Lo ya cerrado sigue cerrado. */
|
|
346
|
+
attach(sink) { this.sink = sink; this.cards.clear(); this.skipped.clear(); this.#board({ now: true }); this.#sync(); }
|
|
347
|
+
detach(sink) { if (!sink || this.sink === sink) { this.sink = null; this.cards.clear(); } }
|
|
348
|
+
close() { this.stopped = true; this.sink = null; this.cards.clear(); }
|
|
349
|
+
/** ¿El backend de esta sesión admite lo aditivo (respuestas, revisor, columna reviewing)? */
|
|
350
|
+
get extended() { return this.sink?.extended === true; }
|
|
351
|
+
#answerable(kind, ids) { return relayAnswerable(kind, { answers: this.extended, caps: this.view?.connection?.delegate?.caps ?? null, ids }); }
|
|
352
|
+
|
|
353
|
+
#board({ now = false } = {}) {
|
|
354
|
+
const current = this.flow?.current;
|
|
355
|
+
this.sink?.relayBoard(projectRelayBoard(this.view || { available: false, reason: 'no_view' }, { project: current?.project || null, repo: current?.repo || null, at: this.now(), reviewing: this.extended }), { now });
|
|
356
|
+
}
|
|
357
|
+
/** Lo que espera al humano en el proyecto ligado (D8: sólo flow.current), como follow.mjs. */
|
|
358
|
+
async #candidates() {
|
|
359
|
+
const view = this.view, current = this.flow?.current;
|
|
360
|
+
if (!view?.available || !current?.project) return [];
|
|
361
|
+
const project = current.project, root = current.hub_root, out = [];
|
|
362
|
+
if (this.extended) {
|
|
363
|
+
for (const swarm of view.swarms) if (criteriaWaiting(swarm.criteria) && swarm.criteria.digest) out.push({ key: `criteria:${swarm.id}:${swarm.criteria.digest}`, kind: 'criteria', project, root, swarm: swarm.id, digest: swarm.criteria.digest });
|
|
364
|
+
for (const swarm of view.swarms) for (const task of swarm.tasks) if (task.state === 'needs_attention' && task.review_kind) out.push({ key: `escalation:${swarm.id}:${task.id}:${task.review_rounds}`, kind: 'escalation', project, root, swarm: swarm.id, task });
|
|
365
|
+
}
|
|
366
|
+
for (const swarm of view.swarms) for (const plan of swarm.plans) if (plan.proposal_id) out.push({ key: `plan:${swarm.id}:${plan.proposal_id}`, kind: 'plan', project, root, swarm: swarm.id, proposal_id: plan.proposal_id });
|
|
367
|
+
for (const swarm of view.swarms) for (const item of swarm.pending) if (item.kind === 'question' && item.message_id) out.push({ key: `question:${swarm.id}:${item.message_id}`, kind: 'question', project, root, swarm: swarm.id, message_id: item.message_id, task: item.task ? swarm.tasks.find(task => task.id === item.task) || { id: item.task } : null });
|
|
368
|
+
for (const swarm of view.swarms) for (const task of swarm.tasks) if (blockedForOperator(task)) out.push({ key: blockedKey(swarm.id, task), kind: 'blocked', project, root, swarm: swarm.id, task });
|
|
369
|
+
for (const swarm of view.swarms) if (swarmFinished(swarm)) {
|
|
370
|
+
let tracked = null; try { tracked = await this.state?.swarm?.(project, swarm.id); } catch {}
|
|
371
|
+
if (tracked && !tracked.finished_at) out.push({ key: `finish:${swarm.id}`, kind: 'finish', project, root, swarm: swarm.id, view: swarm, tracked, repo: current.repo });
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
/** Publica lo nuevo y cierra lo que dejó de estar pendiente. Lecturas en serie, nunca en paralelo. */
|
|
376
|
+
#sync() {
|
|
377
|
+
if (this.reading) { this.dirty = true; return this.reading; }
|
|
378
|
+
const sink = this.sink;
|
|
379
|
+
this.reading = (async () => {
|
|
380
|
+
do {
|
|
381
|
+
this.dirty = false;
|
|
382
|
+
let candidates; try { candidates = await this.#candidates(); } catch { candidates = []; }
|
|
383
|
+
if (this.sink !== sink) return;
|
|
384
|
+
const keys = new Set(candidates.map(item => item.key));
|
|
385
|
+
for (const [key, card] of [...this.cards]) if (!keys.has(key)) await this.#vanished(key, card);
|
|
386
|
+
for (const item of candidates) {
|
|
387
|
+
if (this.sink !== sink) return;
|
|
388
|
+
if (this.cards.has(item.key) || this.closed.has(item.key) || this.now() - (this.skipped.get(item.key) || -Infinity) < 30000) continue;
|
|
389
|
+
// Sin delegación, relayd caído o un plan que ya no está pendiente: se reintenta en 30 s, no en cada foto.
|
|
390
|
+
let built = null; try { built = await this.#read(item); } catch { built = null; }
|
|
391
|
+
if (!built) { this.skipped.set(item.key, this.now()); continue; }
|
|
392
|
+
if (this.sink !== sink || this.closed.has(item.key) || this.cards.has(item.key)) continue;
|
|
393
|
+
this.cards.set(item.key, { card_id: built.card.card_id, kind: item.kind, item, card: built.card });
|
|
394
|
+
sink.relayEvent('relay.card', built.card);
|
|
395
|
+
for (const page of built.pages) sink.relayEvent('relay.card.page', page);
|
|
396
|
+
}
|
|
397
|
+
} while (this.dirty && this.sink === sink);
|
|
398
|
+
})().catch(() => {}).finally(() => { this.reading = null; });
|
|
399
|
+
return this.reading;
|
|
400
|
+
}
|
|
401
|
+
/** Espera a que termine la lectura en curso (pruebas y broker). */
|
|
402
|
+
settle() { return this.reading || Promise.resolve(); }
|
|
403
|
+
#signal() { return AbortSignal.timeout(this.timeoutMs); }
|
|
404
|
+
async #read(item) {
|
|
405
|
+
if (item.kind === 'plan') {
|
|
406
|
+
const got = await this.flow.client(item.project, item.root).call('plan.get', { proposal_id: item.proposal_id }, { signal: this.#signal() });
|
|
407
|
+
if (got?.proposal?.status && got.proposal.status !== 'pending') return null;
|
|
408
|
+
return projectPlanCard({ project: item.project, swarm: item.swarm, got, checks: await this.#checks(item.root, item.project), answerable: this.#answerable('plan', { project: item.project, swarm: item.swarm, subject: item.proposal_id }) });
|
|
409
|
+
}
|
|
410
|
+
if (item.kind === 'question') {
|
|
411
|
+
const message = await this.flow.client(item.project, item.root).call('msg.get', { message_id: item.message_id }, { signal: this.#signal() });
|
|
412
|
+
return projectQuestionCard({ project: item.project, swarm: item.swarm, message, task: item.task, answerable: this.#answerable('question', { project: item.project, swarm: item.swarm, subject: item.message_id }) });
|
|
413
|
+
}
|
|
414
|
+
if (item.kind === 'criteria') {
|
|
415
|
+
const got = await this.flow.client(item.project, item.root).call('criteria.get', { swarm_id: item.swarm }, { signal: this.#signal() });
|
|
416
|
+
if (!criteriaWaiting(got) || got?.current?.digest !== item.digest) return null;
|
|
417
|
+
return projectCriteriaCard({ project: item.project, swarm: item.swarm, got, answerable: this.#answerable('criteria', { project: item.project, swarm: item.swarm, subject: item.swarm }) });
|
|
418
|
+
}
|
|
419
|
+
if (item.kind === 'escalation') {
|
|
420
|
+
const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: item.task.id }, { signal: this.#signal() }), task = got?.task || got;
|
|
421
|
+
if (task?.state !== 'needs_attention' || !task?.review?.pending) return null;
|
|
422
|
+
return projectEscalationCard({ project: item.project, swarm: item.swarm, task });
|
|
423
|
+
}
|
|
424
|
+
if (item.kind === 'blocked') {
|
|
425
|
+
// El motivo exacto lo da relayd (infra.last_cause / last_reason); la foto del panel no lo trae.
|
|
426
|
+
let task = item.task; try { const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: item.task.id }, { signal: this.#signal() }); task = { ...item.task, ...(got?.task || got || {}), state: item.task.state }; } catch {}
|
|
427
|
+
return projectBlockedCard({ project: item.project, swarm: item.swarm, task, reason: blockedReason(task, item.task.state), since: item.task.since, attempts: item.task.counted_attempts ?? item.task.attempts_used });
|
|
428
|
+
}
|
|
429
|
+
if (item.kind === 'finish') {
|
|
430
|
+
const link = this.flow.current?.project === item.project ? this.flow.current : {}, info = item.tracked || {};
|
|
431
|
+
const branch = info.branch || link.branch || `relay/${item.project}`, base = info.base_commit || link.base_commit || null;
|
|
432
|
+
let diffStat = ''; try { if (base && item.repo) diffStat = await this.flow.git.diffStat(item.repo, base, branch); } catch {}
|
|
433
|
+
const tasks = item.view?.tasks || [], counts = { done: 0, failed: 0, other: 0 };
|
|
434
|
+
for (const task of tasks) counts[task.state === 'done' ? 'done' : ['failed', 'cancelled'].includes(task.state) ? 'failed' : 'other']++;
|
|
435
|
+
const ok = tasks.some(task => task.state === 'done' && /^role-int/.test(task.role || '')) || tasks.some(task => /^t-root/.test(task.id) && task.state === 'done');
|
|
436
|
+
// Revisor (open-relay F7): el veredicto final de la raíz, como metadatos.
|
|
437
|
+
const rootTask = tasks.find(task => /^t-root/.test(task.id || '') && task.state === 'done');
|
|
438
|
+
let review = null;
|
|
439
|
+
if (rootTask) { try { const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: rootTask.id }, { signal: this.#signal() }); review = (got?.task || got)?.review || null; } catch {} }
|
|
440
|
+
return projectFinishCard({ project: item.project, swarm: item.swarm, objective: info.objective || item.view?.objective, counts, ok, branch, base, diffStat, review, reviewTask: rootTask?.id });
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
/** Descripción de los checks del proyecto (project.json del hub), como la tarjeta de la TUI. */
|
|
445
|
+
async #checks(root, project) {
|
|
446
|
+
if (!root) return [];
|
|
447
|
+
try { return JSON.parse(await readFile(path.join(root, 'projects', project, 'project.json'), 'utf8'))?.proposal?.checks || []; } catch { return []; }
|
|
448
|
+
}
|
|
449
|
+
/** Dejó de estar pendiente sin pasar por esta TUI: otro operador, la UI web de relayd o la CLI. */
|
|
450
|
+
async #vanished(key, card) {
|
|
451
|
+
let outcome = 'stale';
|
|
452
|
+
if (card.kind === 'plan') {
|
|
453
|
+
try {
|
|
454
|
+
const got = await this.flow.client(card.item.project, card.item.root).call('plan.get', { proposal_id: card.item.proposal_id }, { signal: this.#signal() });
|
|
455
|
+
outcome = { approved: 'approved', applied: 'approved', rejected: 'rejected' }[got?.proposal?.status] || 'stale';
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
this.#close(key, { outcome, by: 'elsewhere' });
|
|
459
|
+
}
|
|
460
|
+
#close(key, { outcome, by, adr_id = null, request_id = null }) {
|
|
461
|
+
this.closed.set(key, outcome);
|
|
462
|
+
if (this.closed.size > 2000) this.closed.delete(this.closed.keys().next().value);
|
|
463
|
+
const card = this.cards.get(key); if (!card) return;
|
|
464
|
+
this.cards.delete(key); this.resolved.set(card.card_id, { outcome, by });
|
|
465
|
+
if (this.resolved.size > 2000) this.resolved.delete(this.resolved.keys().next().value);
|
|
466
|
+
this.sink?.relayEvent('relay.resolved', projectResolved({ card_id: card.card_id, outcome, by, adr_id, request_id, at: this.now() }));
|
|
467
|
+
}
|
|
468
|
+
}
|