777 lines
34 KiB
JavaScript
777 lines
34 KiB
JavaScript
const byId = (id) => document.getElementById(id);
|
||
const loginView = byId("login-view");
|
||
const consoleView = byId("console-view");
|
||
const loginForm = byId("login-form");
|
||
const contextForm = byId("context-form");
|
||
const advisoryForm = byId("advisory-form");
|
||
const loginError = byId("login-error");
|
||
const toast = byId("toast");
|
||
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
|
||
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
|
||
let toastTimer;
|
||
let currentTask = null;
|
||
let currentProspective = null;
|
||
let pendingCommand = null;
|
||
let pendingNotification = null;
|
||
let lastAuthorizedActionID = "";
|
||
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
|
||
|
||
function setText(id, value, fallback = "—") {
|
||
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
|
||
}
|
||
|
||
function setSignal(name, text, state) {
|
||
const signal = byId(`${name}-signal`);
|
||
signal.textContent = text;
|
||
signal.dataset.state = state;
|
||
}
|
||
|
||
function setBadge(name, text, tone) {
|
||
const badge = byId(`${name}-badge`);
|
||
badge.textContent = text;
|
||
badge.className = `status-badge ${tone}`;
|
||
}
|
||
|
||
function notify(message) {
|
||
toast.textContent = message;
|
||
toast.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { toast.hidden = true; }, 4200);
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const headers = new Headers(options.headers || {});
|
||
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
|
||
headers.set("Accept", "application/json");
|
||
const response = await fetch(path, {...options, headers});
|
||
if (response.status === 401 && path !== "/api/session") {
|
||
endSession("Сессия истекла. Войдите снова.");
|
||
throw new Error("Сессия отклонена");
|
||
}
|
||
if (!response.ok) {
|
||
const detail = (await response.text()).trim();
|
||
throw new Error(detail || `Ошибка ${response.status}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
function restoreContext() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem("domovoy.creator.context") || "{}");
|
||
contextFields.forEach((id) => {
|
||
if (saved[id]) byId(id).value = saved[id];
|
||
});
|
||
} catch (_) {
|
||
localStorage.removeItem("domovoy.creator.context");
|
||
}
|
||
}
|
||
|
||
function saveContext() {
|
||
const saved = {};
|
||
contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); });
|
||
localStorage.setItem("domovoy.creator.context", JSON.stringify(saved));
|
||
return saved;
|
||
}
|
||
|
||
async function showConsole() {
|
||
const session = await request("/api/session/current");
|
||
setText("session-actor", session.ActorID);
|
||
const expiry = session.ExpiresAtUnix ? new Date(session.ExpiresAtUnix * 1000).toLocaleString("ru-RU") : "—";
|
||
setText("session-expiry", expiry);
|
||
loginView.hidden = true;
|
||
consoleView.hidden = false;
|
||
restoreContext();
|
||
await loadAdvisoryPolicy();
|
||
const context = saveContext();
|
||
if (context["session-id"] && context["workspace-id"]) loadDashboard();
|
||
else {
|
||
loadActionHistory();
|
||
loadLearning();
|
||
}
|
||
}
|
||
|
||
function endSession(message = "Сессия завершена.") {
|
||
sessionToken = "";
|
||
sessionStorage.removeItem("domovoy.creator.session");
|
||
consoleView.hidden = true;
|
||
loginView.hidden = false;
|
||
loginForm.reset();
|
||
loginError.textContent = "";
|
||
currentTask = null;
|
||
currentProspective = null;
|
||
pendingCommand = null;
|
||
pendingNotification = null;
|
||
lastAuthorizedActionID = "";
|
||
advisoryForm.reset();
|
||
advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
|
||
renderAdvisoryPolicy();
|
||
byId("advisory-result").hidden = true;
|
||
notify(message);
|
||
}
|
||
|
||
loginForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const button = byId("login-button");
|
||
const username = byId("creator-username").value.trim();
|
||
const password = byId("creator-password").value;
|
||
loginError.textContent = "";
|
||
button.disabled = true;
|
||
button.firstElementChild.textContent = "Проверяю данные…";
|
||
try {
|
||
const result = await request("/api/session", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({username, password})
|
||
});
|
||
sessionToken = result.Token;
|
||
sessionStorage.setItem("domovoy.creator.session", sessionToken);
|
||
byId("creator-password").value = "";
|
||
await showConsole();
|
||
} catch (error) {
|
||
loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.firstElementChild.textContent = "Открыть консоль";
|
||
}
|
||
});
|
||
|
||
byId("toggle-secret").addEventListener("click", () => {
|
||
const input = byId("creator-password");
|
||
input.type = input.type === "password" ? "text" : "password";
|
||
byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать пароль" : "Скрыть пароль");
|
||
byId("toggle-secret").setAttribute("title", input.type === "password" ? "Показать пароль" : "Скрыть пароль");
|
||
});
|
||
|
||
contextForm.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
saveContext();
|
||
loadDashboard();
|
||
});
|
||
|
||
byId("refresh-all").addEventListener("click", loadDashboard);
|
||
byId("refresh-actions").addEventListener("click", loadActionHistory);
|
||
byId("refresh-learning").addEventListener("click", loadLearning);
|
||
byId("logout").addEventListener("click", () => endSession());
|
||
|
||
advisoryForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const context = saveContext();
|
||
const message = byId("advisory-message").value.trim();
|
||
const button = byId("advisory-submit");
|
||
if (!context["session-id"] || !context["workspace-id"]) {
|
||
notify("Сначала укажите сессию и рабочую область контекста.");
|
||
return;
|
||
}
|
||
if (!message) return;
|
||
if (advisoryPolicy.AcknowledgementRequired && !byId("advisory-egress-ack").checked) {
|
||
notify("Подтвердите передачу этого вопроса внешнему провайдеру.");
|
||
byId("advisory-egress-ack").focus();
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
button.firstElementChild.textContent = "Проверяю контекст…";
|
||
byId("advisory-result").hidden = true;
|
||
try {
|
||
const result = await request("/api/advisory", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify({message, session_id: context["session-id"], workspace_id: context["workspace-id"], egress_acknowledged: advisoryPolicy.AcknowledgementRequired && byId("advisory-egress-ack").checked})
|
||
});
|
||
const state = result.FinishReason || "INVALID_PROVIDER_RESPONSE";
|
||
setText("advisory-state", state);
|
||
byId("advisory-state").dataset.state = state;
|
||
const unavailable = advisoryPolicy.ExternalEgress ? "Внешний провайдер недоступен. Ответ не получен." : "Когнитивный провайдер пока не подключён. Наружу ничего не отправлено.";
|
||
setText("advisory-text", state === "PROVIDER_UNAVAILABLE" ? unavailable : result.AdvisoryText, "Ответ не получен.");
|
||
byId("advisory-result").hidden = false;
|
||
} catch (error) {
|
||
setText("advisory-state", "ОШИБКА");
|
||
setText("advisory-text", error.message);
|
||
byId("advisory-result").hidden = false;
|
||
} finally {
|
||
byId("advisory-egress-ack").checked = false;
|
||
button.disabled = false;
|
||
button.firstElementChild.textContent = "Получить совет";
|
||
}
|
||
});
|
||
|
||
async function loadAdvisoryPolicy() {
|
||
advisoryPolicy = await request("/api/advisory-policy");
|
||
renderAdvisoryPolicy();
|
||
}
|
||
|
||
function renderAdvisoryPolicy() {
|
||
const external = advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true;
|
||
byId("advisory-egress").hidden = !external;
|
||
byId("advisory-egress-ack").checked = false;
|
||
byId("advisory-egress-ack").required = external;
|
||
setText("advisory-disclosure", advisoryPolicy.Disclosure, "Текст вопроса будет передан согласно активной политике данных.");
|
||
setText("advisory-privacy", external ? "внешняя передача по активной политике" : "активной внешней привязки нет");
|
||
const privacy = byId("advisory-privacy");
|
||
privacy.classList.toggle("external", external);
|
||
const marker = document.createElement("i");
|
||
marker.setAttribute("aria-hidden", "true");
|
||
privacy.prepend(marker);
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
const context = saveContext();
|
||
const sessionID = context["session-id"];
|
||
const workspaceID = context["workspace-id"];
|
||
const query = new URLSearchParams({session_id: sessionID, workspace_id: workspaceID});
|
||
byId("connection-state").classList.remove("offline");
|
||
setText("last-update", "Обновление…");
|
||
|
||
const work = [];
|
||
if (sessionID && workspaceID) work.push(loadFocus(query));
|
||
else {
|
||
setSignal("focus", "нет контекста", "idle");
|
||
setBadge("focus", "НЕ ЗАГРУЖЕН", "neutral");
|
||
setText("focus-message", "Введите идентификаторы сессии и рабочей области.");
|
||
}
|
||
if (sessionID && workspaceID && context["task-id"]) work.push(loadTask(context["task-id"], query));
|
||
else {
|
||
currentTask = null;
|
||
setSignal("task", "нет задачи", "idle");
|
||
setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral");
|
||
setText("task-message", "Укажите задачу для проверки относительно активного фокуса.");
|
||
updateActionReadiness();
|
||
}
|
||
work.push(loadProspectiveRegister(context["prospective-id"]));
|
||
work.push(loadActionHistory());
|
||
work.push(loadLearning());
|
||
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
||
else setSignal("capability", "не выбрана", "idle");
|
||
|
||
await Promise.allSettled(work);
|
||
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
|
||
}
|
||
|
||
async function loadLearning() {
|
||
const experienceList = byId("experience-list");
|
||
const reflectionList = byId("reflection-list");
|
||
const skillList = byId("skill-list");
|
||
[experienceList, reflectionList, skillList].forEach((list) => {
|
||
list.setAttribute("aria-busy", "true");
|
||
list.replaceChildren();
|
||
const loading = document.createElement("p");
|
||
loading.className = "register-empty";
|
||
loading.textContent = "Читаю learning evidence…";
|
||
list.append(loading);
|
||
});
|
||
try {
|
||
const result = await request("/api/learning/recent?limit=8");
|
||
const experiences = Array.isArray(result.Experiences) ? result.Experiences : [];
|
||
const reflections = Array.isArray(result.Reflections) ? result.Reflections : [];
|
||
const skills = Array.isArray(result.Skills) ? result.Skills : [];
|
||
setText("learning-count", `${experiences.length + reflections.length + skills.length} СИГНАЛОВ`);
|
||
renderLearningList(experienceList, experiences, createExperienceRow, "Experience пока не записан.");
|
||
renderLearningList(reflectionList, reflections, createReflectionRow, "ReflectionCandidate пока нет.");
|
||
renderLearningList(skillList, skills, createSkillRow, "SkillProfile пока не загружен.");
|
||
} catch (error) {
|
||
[experienceList, reflectionList, skillList].forEach((list) => {
|
||
list.replaceChildren();
|
||
const failed = document.createElement("p");
|
||
failed.className = "register-empty activity-error";
|
||
failed.textContent = error.message;
|
||
list.append(failed);
|
||
});
|
||
setText("learning-count", "ОШИБКА");
|
||
} finally {
|
||
[experienceList, reflectionList, skillList].forEach((list) => list.setAttribute("aria-busy", "false"));
|
||
}
|
||
}
|
||
|
||
function renderLearningList(list, items, rowFactory, emptyText) {
|
||
list.replaceChildren();
|
||
if (items.length === 0) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "register-empty";
|
||
empty.textContent = emptyText;
|
||
list.append(empty);
|
||
return;
|
||
}
|
||
items.forEach((item) => list.append(rowFactory(item)));
|
||
}
|
||
|
||
function createExperienceRow(item) {
|
||
const row = document.createElement("article");
|
||
row.className = "learning-row";
|
||
const state = document.createElement("em");
|
||
state.className = "learning-state";
|
||
state.dataset.state = item.ActualOutcome || "UNKNOWN";
|
||
state.textContent = item.ActualOutcome || "UNKNOWN";
|
||
const title = document.createElement("strong");
|
||
title.textContent = item.HelpMethod || "help method";
|
||
const detail = document.createElement("span");
|
||
detail.textContent = item.OutcomeDetail || item.OutcomeRef || "evidence recorded";
|
||
const time = document.createElement("time");
|
||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||
row.append(state, title, detail, time);
|
||
return row;
|
||
}
|
||
|
||
function createReflectionRow(item) {
|
||
const row = document.createElement("article");
|
||
row.className = "learning-row";
|
||
const state = document.createElement("em");
|
||
state.className = "learning-state";
|
||
state.dataset.state = item.State || "OPEN";
|
||
state.textContent = item.Trigger || "REFLECTION";
|
||
const title = document.createElement("strong");
|
||
title.textContent = item.State || "OPEN";
|
||
const detail = document.createElement("span");
|
||
detail.textContent = item.Summary || "reflection candidate";
|
||
const time = document.createElement("time");
|
||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||
row.append(state, title, detail, time);
|
||
return row;
|
||
}
|
||
|
||
function createSkillRow(item) {
|
||
const row = document.createElement("article");
|
||
row.className = "learning-row";
|
||
const state = document.createElement("em");
|
||
state.className = "learning-state";
|
||
state.dataset.state = item.Status || "UNKNOWN";
|
||
state.textContent = item.Status || "UNKNOWN";
|
||
const title = document.createElement("strong");
|
||
title.textContent = item.Name || "skill profile";
|
||
const detail = document.createElement("span");
|
||
const support = Array.isArray(item.SupportingExperienceRefs) ? item.SupportingExperienceRefs.length : 0;
|
||
const failures = Array.isArray(item.KnownFailureExperienceRefs) ? item.KnownFailureExperienceRefs.length : 0;
|
||
detail.textContent = `confidence ${item.ConfidenceMillis ?? "—"} · +${support} / -${failures}`;
|
||
const time = document.createElement("time");
|
||
time.textContent = item.RecordedAtUnixMillis ? new Date(item.RecordedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||
row.append(state, title, detail, time);
|
||
return row;
|
||
}
|
||
|
||
async function loadActionHistory() {
|
||
const list = byId("action-history");
|
||
list.setAttribute("aria-busy", "true");
|
||
list.replaceChildren();
|
||
const loading = document.createElement("p");
|
||
loading.className = "register-empty";
|
||
loading.textContent = "Читаю канонический журнал Commit…";
|
||
list.append(loading);
|
||
try {
|
||
const result = await request("/api/actions?limit=25");
|
||
const items = Array.isArray(result.Items) ? result.Items : [];
|
||
setText("action-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`);
|
||
list.replaceChildren();
|
||
if (items.length === 0) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "register-empty";
|
||
empty.textContent = "У текущего Создателя пока нет авторизованных действий.";
|
||
list.append(empty);
|
||
return;
|
||
}
|
||
items.forEach((item) => list.append(createActionHistoryRow(item)));
|
||
} catch (error) {
|
||
list.replaceChildren();
|
||
const failed = document.createElement("p");
|
||
failed.className = "register-empty activity-error";
|
||
failed.textContent = error.message;
|
||
list.append(failed);
|
||
setText("action-count", "ОШИБКА");
|
||
} finally {
|
||
list.setAttribute("aria-busy", "false");
|
||
}
|
||
}
|
||
|
||
function createActionHistoryRow(item) {
|
||
const row = document.createElement("article");
|
||
row.className = "activity-row";
|
||
const state = document.createElement("strong");
|
||
state.className = "activity-state";
|
||
state.dataset.state = item.State || "UNKNOWN";
|
||
state.textContent = item.State || "UNKNOWN";
|
||
const operation = document.createElement("div");
|
||
operation.className = "activity-operation";
|
||
const operationName = document.createElement("b");
|
||
operationName.textContent = item.Operation || "неизвестная операция";
|
||
const subject = document.createElement("span");
|
||
subject.textContent = item.ProspectiveItemID || "без subject";
|
||
operation.append(operationName, subject);
|
||
const identity = document.createElement("div");
|
||
identity.className = "activity-identity";
|
||
const actionID = document.createElement("code");
|
||
actionID.textContent = item.AuthorizedActionID || "—";
|
||
actionID.title = item.AuthorizedActionID || "";
|
||
const attemptID = document.createElement("small");
|
||
attemptID.textContent = item.ActionAttemptID ? `attempt ${item.ActionAttemptID}` : "попытка ещё не создана";
|
||
attemptID.title = item.ActionAttemptID || "";
|
||
identity.append(actionID, attemptID);
|
||
const time = document.createElement("time");
|
||
time.dateTime = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toISOString() : "";
|
||
time.textContent = item.AuthorizedAtUnixMillis ? new Date(item.AuthorizedAtUnixMillis).toLocaleString("ru-RU") : "время неизвестно";
|
||
row.append(state, operation, identity, time);
|
||
if (item.State === "UNKNOWN_OUTCOME" && item.AuthorizedActionID) {
|
||
const reconcile = document.createElement("button");
|
||
reconcile.type = "button";
|
||
reconcile.className = "activity-reconcile";
|
||
reconcile.textContent = "Сверить";
|
||
reconcile.setAttribute("aria-label", `Сверить исход действия ${item.AuthorizedActionID}`);
|
||
reconcile.addEventListener("click", () => reconcileHistoryAction(item.AuthorizedActionID, reconcile));
|
||
row.append(reconcile);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
async function reconcileHistoryAction(actionID, button) {
|
||
button.disabled = true;
|
||
button.textContent = "Сверяю…";
|
||
try {
|
||
const result = await request(`/api/actions/${encodeURIComponent(actionID)}/reconcile`, {
|
||
method: "POST", headers: {"Content-Type": "application/json"}, body: "{}",
|
||
});
|
||
renderActionResult(result);
|
||
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
|
||
await loadActionHistory();
|
||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||
} catch (error) {
|
||
notify(error.message);
|
||
button.disabled = false;
|
||
button.textContent = "Сверить";
|
||
}
|
||
}
|
||
|
||
async function loadFocus(query) {
|
||
setSignal("focus", "загрузка", "loading");
|
||
try {
|
||
const focus = await request(`/api/focus?${query}`);
|
||
setText("focus-project", focus.ProjectID);
|
||
setText("focus-goal", focus.GoalID);
|
||
setText("focus-scope", focus.ScopeID);
|
||
setText("focus-binding", focus.ID);
|
||
setText("focus-message", "Фокус принадлежит текущему Actor и прошёл проверку сессии.");
|
||
setSignal("focus", "активен", "ok");
|
||
setBadge("focus", focus.Active ? "АКТИВЕН" : "НЕАКТИВЕН", focus.Active ? "good" : "warn");
|
||
} catch (error) {
|
||
setSignal("focus", "ошибка", "error");
|
||
setBadge("focus", "НЕДОСТУПЕН", "bad");
|
||
setText("focus-message", error.message);
|
||
byId("connection-state").classList.add("offline");
|
||
}
|
||
}
|
||
|
||
async function loadTask(taskID, query) {
|
||
setSignal("task", "загрузка", "loading");
|
||
try {
|
||
const task = await request(`/api/tasks/${encodeURIComponent(taskID)}?${query}`);
|
||
currentTask = task;
|
||
setText("task-value", task.TaskID);
|
||
setText("task-goal", task.GoalID);
|
||
setText("task-scope", task.ScopeID);
|
||
setText("task-revision", task.Revision);
|
||
setText("task-message", "Цель и область задачи совпадают с активным фокусом.");
|
||
setSignal("task", "в фокусе", "ok");
|
||
setBadge("task", "ПРОВЕРЕНА", "good");
|
||
updateActionReadiness();
|
||
} catch (error) {
|
||
currentTask = null;
|
||
setSignal("task", "отклонена", "error");
|
||
setBadge("task", "ВНЕ ФОКУСА", "bad");
|
||
setText("task-message", error.message);
|
||
updateActionReadiness();
|
||
}
|
||
}
|
||
|
||
async function loadProspectiveRegister(selectedID) {
|
||
const list = byId("prospective-list");
|
||
list.replaceChildren();
|
||
const loading = document.createElement("p");
|
||
loading.className = "register-empty";
|
||
loading.textContent = "Загружаю владельческий реестр…";
|
||
list.append(loading);
|
||
setSignal("prospective", "загрузка", "loading");
|
||
try {
|
||
const result = await request("/api/prospective-items?limit=25");
|
||
const items = Array.isArray(result.Items) ? result.Items : [];
|
||
setText("prospective-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`);
|
||
list.replaceChildren();
|
||
if (items.length === 0) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "register-empty";
|
||
empty.textContent = "Для текущего Principal ожиданий нет.";
|
||
list.append(empty);
|
||
setSignal("prospective", "пусто", "idle");
|
||
setBadge("prospective", "НЕТ ЗАПИСЕЙ", "neutral");
|
||
return;
|
||
}
|
||
let effectiveID = selectedID;
|
||
if (!effectiveID) {
|
||
effectiveID = items[0].ID;
|
||
byId("prospective-id").value = effectiveID;
|
||
saveContext();
|
||
}
|
||
items.forEach((item) => list.append(createProspectiveRow(item, effectiveID)));
|
||
await loadProspective(effectiveID);
|
||
} catch (error) {
|
||
list.replaceChildren();
|
||
const failed = document.createElement("p");
|
||
failed.className = "register-empty";
|
||
failed.textContent = error.message;
|
||
list.append(failed);
|
||
setText("prospective-count", "ОШИБКА");
|
||
setSignal("prospective", "ошибка", "error");
|
||
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
||
setText("prospective-message", error.message);
|
||
}
|
||
}
|
||
|
||
function createProspectiveRow(item, selectedID) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "register-row";
|
||
row.setAttribute("aria-current", item.ID === selectedID ? "true" : "false");
|
||
row.setAttribute("aria-label", `Открыть ожидание ${item.ID}, состояние ${item.State}`);
|
||
const id = document.createElement("strong");
|
||
id.textContent = item.ID;
|
||
const goal = document.createElement("span");
|
||
goal.textContent = item.GoalID || "без цели";
|
||
const trigger = document.createElement("time");
|
||
trigger.textContent = item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "без времени";
|
||
const state = document.createElement("em");
|
||
state.textContent = item.State || "UNKNOWN";
|
||
if (["TRIGGERED", "REVALIDATING", "DELIVERED"].includes(item.State)) state.className = "warn";
|
||
if (["SUPPRESSED", "EXPIRED"].includes(item.State)) state.className = "bad";
|
||
row.append(id, goal, trigger, state);
|
||
row.addEventListener("click", async () => {
|
||
byId("prospective-id").value = item.ID;
|
||
saveContext();
|
||
row.parentElement.querySelectorAll(".register-row").forEach((candidate) => candidate.setAttribute("aria-current", candidate === row ? "true" : "false"));
|
||
await loadProspective(item.ID);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
async function loadProspective(prospectiveID) {
|
||
setSignal("prospective", "загрузка", "loading");
|
||
try {
|
||
const item = await request(`/api/prospective-items/${encodeURIComponent(prospectiveID)}`);
|
||
currentProspective = item;
|
||
const state = item.State || "UNKNOWN";
|
||
const goodStates = ["REGISTERED", "ARMED", "SATISFIED"];
|
||
const warningStates = ["TRIGGERED", "REVALIDATING", "DELIVERED"];
|
||
const tone = goodStates.includes(state) ? "good" : warningStates.includes(state) ? "warn" : "bad";
|
||
setText("prospective-state", state);
|
||
setText("prospective-trigger", item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "не указано");
|
||
setText("prospective-value", item.ID);
|
||
setText("prospective-commitment", item.CommitmentID);
|
||
setText("prospective-goal", item.GoalID);
|
||
setText("prospective-scope", item.ScopeID);
|
||
setText("prospective-owner", item.OwnerPrincipalID);
|
||
setText("prospective-revision", item.Revision);
|
||
setText("prospective-message", "Запись принадлежит текущему Principal и прочитана из канонического Continuity state.");
|
||
setSignal("prospective", state.toLowerCase(), tone === "bad" ? "error" : "ok");
|
||
setBadge("prospective", state, tone);
|
||
updateActionReadiness();
|
||
} catch (error) {
|
||
currentProspective = null;
|
||
setSignal("prospective", "ошибка", "error");
|
||
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
||
setText("prospective-state", "UNKNOWN");
|
||
setText("prospective-message", error.message);
|
||
updateActionReadiness();
|
||
}
|
||
}
|
||
|
||
async function loadCapability(capabilityID) {
|
||
setSignal("capability", "загрузка", "loading");
|
||
try {
|
||
const health = await request(`/api/capabilities/${encodeURIComponent(capabilityID)}/health`);
|
||
const state = health.State || "UNKNOWN";
|
||
const tone = state === "AVAILABLE" ? "good" : state === "DEGRADED" ? "warn" : "bad";
|
||
setText("capability-state", state);
|
||
setText("capability-reason", health.Reason, "NONE");
|
||
setText("capability-value", health.CapabilityID);
|
||
setText("capability-revision", health.Revision);
|
||
setText("capability-observed", health.ObservedAtUnixMillis ? new Date(health.ObservedAtUnixMillis).toLocaleString("ru-RU") : "не указано");
|
||
setSignal("capability", state.toLowerCase(), tone === "bad" ? "error" : "ok");
|
||
setBadge("capability", state, tone);
|
||
} catch (error) {
|
||
setSignal("capability", "ошибка", "error");
|
||
setBadge("capability", "НЕДОСТУПНА", "bad");
|
||
setText("capability-state", "UNKNOWN");
|
||
setText("capability-reason", error.message);
|
||
}
|
||
}
|
||
|
||
function updateActionReadiness() {
|
||
const button = byId("prepare-satisfaction");
|
||
const notificationButton = byId("prepare-notification");
|
||
if (!button) return;
|
||
const evidence = byId("satisfaction-evidence").value.trim();
|
||
let message = "Доступно только для доставленного ожидания с проверенной Work-задачей.";
|
||
let ready = false;
|
||
if (!currentProspective) message = "Сначала выберите и загрузите ожидание.";
|
||
else if (currentProspective.State !== "DELIVERED") message = `Состояние ${currentProspective.State || "UNKNOWN"}: переход доступен только из DELIVERED.`;
|
||
else if (!currentTask) message = "Загрузите проверенную Work-задачу как обоснование.";
|
||
else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) message = "Цель или область Work-задачи не совпадает с ожиданием.";
|
||
else if (!evidence) message = "Добавьте ссылку на подтверждение результата.";
|
||
else {
|
||
ready = true;
|
||
message = `Готово: ${currentProspective.ID} · rev ${currentProspective.Revision} будет связано с ${currentTask.TaskID} · rev ${currentTask.Revision}.`;
|
||
}
|
||
button.disabled = !ready;
|
||
setText("action-readiness", message);
|
||
|
||
let notificationReady = false;
|
||
let notificationMessage = "Доступно для доставленного ожидания с проверенной Work-задачей.";
|
||
if (!currentProspective) notificationMessage = "Сначала выберите и загрузите ожидание.";
|
||
else if (currentProspective.State !== "DELIVERED") notificationMessage = `Состояние ${currentProspective.State || "UNKNOWN"}: уведомление доступно только для DELIVERED.`;
|
||
else if (!currentTask) notificationMessage = "Загрузите проверенную Work-задачу как обоснование.";
|
||
else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) notificationMessage = "Цель или область Work-задачи не совпадает с ожиданием.";
|
||
else {
|
||
notificationReady = true;
|
||
notificationMessage = `Готово: receiver получит фиксированный шаблон для ${currentProspective.ID} · rev ${currentProspective.Revision}.`;
|
||
}
|
||
notificationButton.disabled = !notificationReady;
|
||
setText("notification-readiness", notificationMessage);
|
||
}
|
||
|
||
byId("satisfaction-evidence").addEventListener("input", updateActionReadiness);
|
||
|
||
byId("prepare-satisfaction").addEventListener("click", () => {
|
||
updateActionReadiness();
|
||
if (byId("prepare-satisfaction").disabled || !currentTask || !currentProspective) return;
|
||
pendingCommand = {
|
||
Target: {
|
||
TaskID: currentTask.TaskID,
|
||
GoalID: currentTask.GoalID,
|
||
ScopeID: currentTask.ScopeID,
|
||
Revision: currentTask.Revision,
|
||
},
|
||
ProspectiveItemID: currentProspective.ID,
|
||
ExpectedRevision: currentProspective.Revision,
|
||
EvidenceRef: byId("satisfaction-evidence").value.trim(),
|
||
};
|
||
setText("confirm-prospective", pendingCommand.ProspectiveItemID);
|
||
setText("confirm-prospective-revision", pendingCommand.ExpectedRevision);
|
||
setText("confirm-task", pendingCommand.Target.TaskID);
|
||
setText("confirm-task-revision", pendingCommand.Target.Revision);
|
||
setText("confirm-evidence", pendingCommand.EvidenceRef);
|
||
setText("confirmation-error", "", "");
|
||
byId("satisfaction-dialog").showModal();
|
||
});
|
||
|
||
byId("cancel-satisfaction").addEventListener("click", () => {
|
||
pendingCommand = null;
|
||
byId("satisfaction-dialog").close();
|
||
});
|
||
|
||
byId("prepare-notification").addEventListener("click", () => {
|
||
updateActionReadiness();
|
||
if (byId("prepare-notification").disabled || !currentTask || !currentProspective) return;
|
||
pendingNotification = {
|
||
Target: {TaskID: currentTask.TaskID, GoalID: currentTask.GoalID, ScopeID: currentTask.ScopeID, Revision: currentTask.Revision},
|
||
ProspectiveItemID: currentProspective.ID,
|
||
ExpectedRevision: currentProspective.Revision,
|
||
Confirm: true,
|
||
};
|
||
setText("notify-confirm-prospective", pendingNotification.ProspectiveItemID);
|
||
setText("notify-confirm-prospective-revision", pendingNotification.ExpectedRevision);
|
||
setText("notify-confirm-task", pendingNotification.Target.TaskID);
|
||
setText("notify-confirm-task-revision", pendingNotification.Target.Revision);
|
||
setText("notification-confirmation-error", "", "");
|
||
byId("notification-dialog").showModal();
|
||
});
|
||
|
||
byId("cancel-notification").addEventListener("click", () => {
|
||
pendingNotification = null;
|
||
byId("notification-dialog").close();
|
||
});
|
||
|
||
byId("confirm-notification").addEventListener("click", async () => {
|
||
if (!pendingNotification) return;
|
||
const command = pendingNotification;
|
||
const button = byId("confirm-notification");
|
||
const prepare = byId("prepare-notification");
|
||
button.disabled = true;
|
||
prepare.disabled = true;
|
||
button.textContent = "Отправляю…";
|
||
setText("notification-confirmation-error", "", "");
|
||
try {
|
||
const result = await request("/api/actions/notify-delivered", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(command)});
|
||
pendingNotification = null;
|
||
byId("notification-dialog").close();
|
||
renderActionResult(result);
|
||
await loadActionHistory();
|
||
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
|
||
} catch (error) {
|
||
setText("notification-confirmation-error", error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "Подтверждаю отправку";
|
||
updateActionReadiness();
|
||
}
|
||
});
|
||
|
||
byId("confirm-satisfaction").addEventListener("click", async () => {
|
||
if (!pendingCommand) return;
|
||
const command = pendingCommand;
|
||
const button = byId("confirm-satisfaction");
|
||
const prepare = byId("prepare-satisfaction");
|
||
button.disabled = true;
|
||
prepare.disabled = true;
|
||
button.textContent = "Фиксирую…";
|
||
setText("confirmation-error", "", "");
|
||
try {
|
||
const result = await request("/api/actions/satisfy-delivered", {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: JSON.stringify(command),
|
||
});
|
||
pendingCommand = null;
|
||
byId("satisfaction-dialog").close();
|
||
renderActionResult(result);
|
||
await loadActionHistory();
|
||
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
|
||
await loadProspectiveRegister(command.ProspectiveItemID);
|
||
} catch (error) {
|
||
setText("confirmation-error", error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "Подтверждаю переход";
|
||
updateActionReadiness();
|
||
}
|
||
});
|
||
|
||
function renderActionResult(result) {
|
||
lastAuthorizedActionID = result.AuthorizedActionID || "";
|
||
setText("action-state", result.State);
|
||
byId("action-state").dataset.state = result.State || "UNKNOWN";
|
||
setText("action-id", result.AuthorizedActionID);
|
||
setText("attempt-id", result.ActionAttemptID);
|
||
byId("action-result").hidden = false;
|
||
byId("reconcile-action").hidden = result.State !== "UNKNOWN_OUTCOME" || !lastAuthorizedActionID;
|
||
}
|
||
|
||
byId("reconcile-action").addEventListener("click", async () => {
|
||
if (!lastAuthorizedActionID) return;
|
||
const button = byId("reconcile-action");
|
||
button.disabled = true;
|
||
button.textContent = "Проверяю…";
|
||
try {
|
||
const result = await request(`/api/actions/${encodeURIComponent(lastAuthorizedActionID)}/reconcile`, {
|
||
method: "POST",
|
||
headers: {"Content-Type": "application/json"},
|
||
body: "{}",
|
||
});
|
||
renderActionResult(result);
|
||
await loadActionHistory();
|
||
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
|
||
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
|
||
} catch (error) {
|
||
notify(error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "Проверить исход";
|
||
}
|
||
});
|
||
|
||
if (sessionToken) {
|
||
showConsole().catch(() => endSession("Сохранённая сессия больше не действует."));
|
||
}
|