Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
3.2 KiB
JavaScript
100 lines
3.2 KiB
JavaScript
const consoles = new Map();
|
|
const FOLLOW_SLACK = 12;
|
|
const CONSOLE_POLL = 2000;
|
|
const jobOpen = (job) => job.status === "queued" || job.status === "running";
|
|
const withoutOutput = (item) => (item.job ? {...item, job: {...item.job, output: null}} : item);
|
|
const consoleSizes = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const view = consoles.get(entry.target.dataset.job);
|
|
if (view && view.follow) {
|
|
view.out.scrollTop = view.out.scrollHeight;
|
|
}
|
|
}
|
|
});
|
|
|
|
function lasting(seconds) {
|
|
const total = Math.max(0, Math.round(seconds));
|
|
if (total < 60) {
|
|
return `${total} s`;
|
|
}
|
|
if (total < 3600) {
|
|
return `${Math.floor(total / 60)} min ${total % 60} s`;
|
|
}
|
|
return `${Math.floor(total / 3600)} h ${Math.floor((total % 3600) / 60)} min`;
|
|
}
|
|
|
|
function consoleWords(job) {
|
|
if (job.status === "queued") {
|
|
return `waiting for the runner for ${lasting(nowSeconds() - job.created_ts)}`;
|
|
}
|
|
if (job.status === "running") {
|
|
return `running for ${lasting(nowSeconds() - job.started)}`;
|
|
}
|
|
if (job.status === "refused") {
|
|
return "the runner did not accept the signature";
|
|
}
|
|
return `Exit code ${job.code} after ${lasting(job.finished - job.started)}`;
|
|
}
|
|
|
|
function paintConsole(view, job) {
|
|
const text = job.output || (jobOpen(job) ? "No output yet." : "No output.");
|
|
if (view.out.textContent !== text) {
|
|
view.out.textContent = text;
|
|
}
|
|
view.box.dataset.status = job.status;
|
|
view.line.replaceChildren(status(job.status), h("span", {}, consoleWords(job)));
|
|
}
|
|
|
|
function consoleOf(job) {
|
|
let view = consoles.get(job.id);
|
|
if (!view) {
|
|
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Command output", "data-job": job.id});
|
|
const line = h("div", {class: "console-status"});
|
|
view = {box: h("div", {class: "console", "data-job": job.id}, line, out), out, line, follow: true, top: 0};
|
|
out.addEventListener("scroll", () => {
|
|
view.top = out.scrollTop;
|
|
view.follow = out.scrollHeight - out.scrollTop - out.clientHeight <= FOLLOW_SLACK;
|
|
});
|
|
consoles.set(job.id, view);
|
|
consoleSizes.observe(out);
|
|
}
|
|
paintConsole(view, job);
|
|
return view.box;
|
|
}
|
|
|
|
function settleConsoles(items) {
|
|
const shown = new Set(items.filter((item) => item.job).map((item) => item.job.id));
|
|
for (const [id, view] of consoles) {
|
|
if (!shown.has(id)) {
|
|
consoleSizes.unobserve(view.out);
|
|
consoles.delete(id);
|
|
} else if (view.box.isConnected) {
|
|
view.out.scrollTop = view.follow ? view.out.scrollHeight : view.top;
|
|
}
|
|
}
|
|
}
|
|
|
|
function repaintConsoles(items) {
|
|
for (const item of items) {
|
|
const view = item.job ? consoles.get(item.job.id) : null;
|
|
if (view) {
|
|
paintConsole(view, item.job);
|
|
}
|
|
}
|
|
settleConsoles(items);
|
|
}
|
|
|
|
async function dismissJob(job) {
|
|
const result = await sendJson("/api/inbox/dismiss", {id: job.id});
|
|
if (!result.ok) {
|
|
toast("Cannot close", fail(result), false);
|
|
}
|
|
await load();
|
|
}
|
|
|
|
window.setInterval(() => {
|
|
if (ui.screen === "inbox" && state.inbox.some((item) => item.held || (item.job && jobOpen(item.job)))) {
|
|
refresh();
|
|
}
|
|
}, CONSOLE_POLL);
|