Compare commits

..
Author SHA1 Message Date
m 54de3f44d0 chore: verify and finalize human-facing chat surface
CI / test (push) Successful in 19s
2026-08-18 12:42:51 +03:00
21 changed files with 983 additions and 3308 deletions
-7
View File
@@ -35,13 +35,6 @@ Do not silently resolve a conflict. Return `BLOCKED_BY_SPEC_GAP` or `BLOCKED_BY_
- NO reading/copying literal passwords from other repositories into this repository.
- NO production secret in source, task packet, commit message, logs or tests.
## Deployment safety (production mutation)
- Production mutation in normal `devctl` workflow must go through `deploy prepare` then `deploy apply`.
- `deploy apply` is rejected when required inputs are `MISSING`, `EMPTY`, `INVALID`, `UNKNOWN`, `UNRESOLVED`, or `STALE`.
- Ambient shell state is not accepted as authoritative required production input; required values must come from an explicit durable source (for example `--env-file`/manifest-classified bootstrap path).
- A prepared plan must be current (inputs, compose, context, revision binding) before apply proceeds.
## Codex freedom
Inside the declared owner/module and allowed paths, choose ordinary local implementation details freely. Prefer explicit, typed, boring Go over reflection, metaprogramming or framework tricks.
-1
View File
@@ -19,7 +19,6 @@ These questions are intentionally unresolved. Codex must not silently turn tempo
| UQ-013 | Multi-user organization governance | LATER | Actor/Principal/Scope designed generally; expose single-user V1 if desired | No | Actual shared workspace requirement |
| UQ-014 | Canonical wire codec longevity | RESEARCH | Versioned schema/envelope; initial protobuf acceptable, not eternal identity | No | First non-Go module or major semantic migration |
| UQ-015 | Exact data retention/privacy policy engine | RESEARCH | Store scope/provenance/sensitivity hooks; implement concrete policies per product deployment | No | Product/legal requirements |
| UQ-016 | Generic memory/knowledge admission from ordinary interaction | RESEARCH | Treat ordinary preferences/preferences-like assertions as candidates first; no predefined per-topic memory schemas | No | When a durable knowledge/persistence mechanism is adopted with explicit policy and owner boundaries |
## 7.1 Spec-gap protocol
+477 -65
View File
@@ -1,76 +1,488 @@
:root {
--ink: #e9eee7;
--muted: #93a096;
--base: #0b100e;
--panel: #101714;
--line: #2a352f;
--signal: #c9a85f;
--bad: #de816f;
--good: #78c6a3;
--radius: 3px;
--ink: #e7efe9;
--muted: #96a49d;
--faint: #5f6e66;
--surface: #0f1512;
--surface-alt: #151d18;
--line: #27322c;
--base: #090d0b;
--good: #7cc7a4;
--signal: #ceb27a;
--bad: #d88d78;
--radius-lg: 16px;
--radius: 10px;
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
--serif: Georgia, "Times New Roman", serif;
--mono: "Cascadia Mono", Consolas, monospace;
--shadow: 0 24px 56px rgba(0, 0, 0, .22);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
body {
margin: 0;
min-height: 100vh;
min-width: 320px;
color: var(--ink);
background:
radial-gradient(circle at 10% 0%, rgba(120, 182, 160, .16), transparent 40%),
radial-gradient(circle at 90% 0%, rgba(201, 168, 95, .16), transparent 35%),
var(--base);
font-family: var(--sans);
}
button, input, textarea { font: inherit; }
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
.chat-shell { min-height: 100vh; display: grid; }
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
input { height: 48px; padding: 0 12px; }
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr); }
.chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); }
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
.chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
#active-focus { margin-left: auto; color: var(--good); }
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
.human-nav { display: flex; align-items: center; gap: 8px; }
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
.operator-link { color: var(--good); }
.surface-panel { min-height: 0; position: relative; }
.active-panel { display: grid; min-height: 0; }
.messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: auto; }
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #151e1a; }
.message.assistant { align-self: flex-start; background: #0f1613; }
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
.composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
.composer button { height: 48px; align-self: end; }
.composer button:disabled { cursor: wait; filter: saturate(.3); }
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
.attention-list, .history-list { display: grid; gap: 12px; }
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
.history-item.user { border-color: #34443c; background: #151e1a; }
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
.empty-state { margin: 24px 0 0; color: var(--muted); }
button { color: inherit; border: 0; border-radius: var(--radius); }
p { margin: 0; }
.assistant-shell,
.chat-view { min-height: 100vh; }
.auth-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: 36px 20px;
}
.auth-card {
width: min(100%, 440px);
padding: 30px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(15, 21, 18, .92);
box-shadow: var(--shadow);
}
.brand-block,
.header-brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--base);
background: linear-gradient(145deg, var(--good), #7aa7bd);
font: 700 24px/1 var(--serif);
box-shadow: 0 0 0 4px rgba(124, 199, 164, .12);
}
h1 {
margin: 0;
font: 500 42px/1.05 var(--serif);
}
.brand-kicker {
margin: 0 0 4px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: .1em;
font-size: 11px;
font-family: var(--mono);
}
.auth-copy {
margin: 18px 0 22px;
color: var(--muted);
line-height: 1.45;
}
label {
display: block;
margin: 0 0 6px;
color: var(--faint);
font-size: 12px;
letter-spacing: .03em;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
background: #080e0c;
}
input { height: 46px; padding: 0 14px; }
input:focus,
textarea:focus,
.primary-button:focus,
.ghost-button:focus,
.nav-tab:focus-visible,
button:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
textarea {
min-height: 96px;
max-height: 220px;
padding: 14px;
line-height: 1.45;
resize: vertical;
}
.input-with-action {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.icon-button,
.ghost-button,
.primary-button {
height: 44px;
border-radius: var(--radius);
}
.icon-button,
.ghost-button {
background: rgba(255, 255, 255, .05);
border: 1px solid var(--line);
color: var(--muted);
}
.primary-button {
background: var(--signal);
color: #111810;
font-weight: 700;
}
#login-button,
.primary-button { width: 100%; }
#login-button,
#send { margin-top: 14px; }
.login-form,
.composer {
display: grid;
gap: 12px;
}
#login-error {
min-height: 20px;
color: var(--bad);
}
.chat-view {
width: min(100%, 980px);
margin: 0 auto;
padding: 14px 16px 24px;
display: grid;
gap: 12px;
}
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.assistant-header h1 {
font-size: clamp(30px, 4.4vw, 38px);
letter-spacing: -.02em;
}
.session-state {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: #0f1512;
font-size: 12px;
}
.human-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px;
width: fit-content;
background: #0c1210;
}
.nav-tab {
height: 34px;
padding: 0 14px;
border-radius: 999px;
background: transparent;
color: var(--muted);
}
.nav-tab.active,
.nav-tab:hover {
background: var(--signal);
color: #11150f;
}
.surface-panel {
display: grid;
align-content: start;
min-height: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(21, 29, 24, .86), rgba(15, 21, 18, .95));
box-shadow: var(--shadow);
overflow: hidden;
}
.surface-panel.active-panel {
display: grid;
}
.panel-toolbar {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: #0c1210;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-toolbar h2 {
margin: 0;
font: 500 26px/1.12 var(--serif);
}
.panel-toolbar p {
margin-top: 6px;
color: var(--muted);
line-height: 1.45;
}
.focus-chip {
margin: 8px 0 0;
font-size: 12px;
color: var(--good);
padding: 6px 10px;
border: 1px solid rgba(124, 199, 164, .45);
border-radius: 999px;
background: rgba(124, 199, 164, .12);
}
.messages {
padding: 24px 18px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
min-height: calc(100vh - 280px);
max-height: calc(100vh - 280px);
}
.message {
width: min(92%, 760px);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
line-height: 1.45;
white-space: pre-wrap;
background: #0f1512;
}
.message.assistant { align-self: flex-start; background: #121a16; }
.message.user { align-self: flex-end; background: #19231c; border-color: rgba(120, 191, 162, .4); }
.message-author { color: var(--faint); font-size: 11px; margin-bottom: 6px; font-weight: 700; }
.message.user .message-author { color: #b6ddb8; }
.message-time { margin-top: 8px; font-size: 11px; color: var(--faint); font-family: var(--mono); }
.message small { display: block; margin-top: 6px; color: var(--faint); }
.composer {
position: sticky;
bottom: 0;
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
background: linear-gradient(transparent, rgba(11, 16, 14, .88));
grid-template-columns: minmax(0, 1fr) auto;
}
.composer-actions { display: grid; align-items: end; }
.composer button:disabled {
cursor: wait;
opacity: .7;
}
.surface-panel:not(.active-panel) { display: none; }
.attention-surface,
.history-surface { padding: 0 0 12px; }
.attention-list,
.history-list {
display: grid;
gap: 12px;
padding: 18px;
}
.attention-card,
.history-item {
border: 1px solid var(--line);
border-radius: 12px;
background: #0b110f;
padding: 14px;
}
.attention-card h3,
.history-item strong { margin: 0 0 8px; font: 600 15px/1.2 var(--serif); }
.attention-card time,
.history-item time,
.attention-card .muted,
.history-item .muted {
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.attention-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.attention-actions .ghost-button,
.action-row button {
height: 34px;
padding: 0 11px;
background: rgba(255, 255, 255, .06);
color: var(--ink);
}
.attention-actions .ghost-button {
border: 1px solid rgba(194, 168, 118, .48);
color: #f2e8d0;
}
.attention-actions .ghost-button.primary { border-color: transparent; background: var(--good); color: #11211b; }
.history-item {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.history-item p {
margin: 0 0 8px;
color: var(--ink);
white-space: pre-wrap;
}
.history-item .history-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
color: var(--faint);
font-family: var(--mono);
font-size: 11px;
}
.history-item .message-kind {
text-transform: uppercase;
letter-spacing: .08em;
color: var(--signal);
}
.status-bar {
margin: 8px auto 0;
max-width: 980px;
color: var(--faint);
padding: 10px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(15, 21, 18, .8);
min-height: 18px;
visibility: hidden;
}
.status-bar.visible { visibility: visible; }
.composer-error {
position: fixed;
right: 16px;
bottom: 16px;
max-width: min(420px, calc(100vw - 32px));
border-radius: 12px;
border: 1px solid rgba(216, 141, 120, .36);
background: #241715;
color: #ffd8ca;
padding: 10px 12px;
transform: translateY(16px);
opacity: 0;
transition: .2s ease;
}
.composer-error.show {
transform: translateY(0);
opacity: 1;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
.empty-state {
color: var(--muted);
padding: 18px;
}
.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
.composer { grid-template-columns: 1fr; }
.composer button { width: 100%; }
.chat-header { gap: 10px; padding: 0 12px; }
.chat-header span { display: none; }
.human-nav { overflow-x: auto; }
.operator-link, .nav-tab { padding: 0 9px; }
@media (max-width: 720px) {
.chat-view {
padding: 10px 10px 18px;
gap: 10px;
}
.assistant-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.session-state { width: 100%; justify-content: space-between; }
.messages {
min-height: calc(100vh - 320px);
max-height: calc(100vh - 320px);
}
.composer {
grid-template-columns: 1fr;
}
.panel-toolbar {
flex-direction: column;
}
.attention-list,
.history-list {
padding: 12px;
}
}
+424 -122
View File
@@ -2,227 +2,518 @@ const loginView = document.querySelector("#login-view");
const chatView = document.querySelector("#chat-view");
const loginForm = document.querySelector("#login-form");
const loginError = document.querySelector("#login-error");
const togglePassword = document.querySelector("#toggle-password");
const usernameInput = document.querySelector("#username");
const passwordInput = document.querySelector("#password");
const messages = document.querySelector("#messages");
const chatForm = document.querySelector("#chat-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const logoutButton = document.querySelector("#logout");
const clearHistoryButton = document.querySelector("#clear-history");
const activeFocus = document.querySelector("#active-focus");
const todayList = document.querySelector("#today-list");
const todaySubtitle = document.querySelector("#today-subtitle");
const refreshToday = document.querySelector("#refresh-today");
const historyList = document.querySelector("#history-list");
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
const statusPill = document.querySelector("#connection-state");
const statusBar = document.querySelector("#status-bar");
const composerError = document.querySelector("#composer-error");
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
const SESSION_KEY = "domovoy.chat.token";
const CONVERSATION_KEY = "domovoy.chat.conversation";
const SECTION_KEY = "domovoy.chat.section";
let token = sessionStorage.getItem(SESSION_KEY) || "";
let conversationID = sessionStorage.getItem(CONVERSATION_KEY) || "";
let localHistory = [];
let todayRequestInFlight = false;
function setConnectionState(text, tone = "neutral") {
if (!statusPill) return;
statusPill.textContent = text;
if (tone === "bad") {
statusPill.style.borderColor = "rgba(216, 141, 120, .6)";
statusPill.style.color = "var(--bad)";
} else {
statusPill.style.borderColor = "var(--line)";
statusPill.style.color = "var(--muted)";
}
}
function showToast(message) {
if (!statusBar) return;
statusBar.textContent = message;
statusBar.classList.add("visible");
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
statusBar.classList.remove("visible");
}, 2800);
}
function showComposerError(message) {
if (!composerError) return;
composerError.textContent = message;
composerError.classList.add("show");
clearTimeout(showComposerError._timer);
showComposerError._timer = setTimeout(() => {
composerError.classList.remove("show");
}, 2600);
}
function formatTime(value = new Date()) {
return new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(value);
}
function showChat() {
setConnectionState("Сессия активна");
loginView.hidden = true;
chatView.hidden = false;
if (!messages.children.length) {
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
appendMessage("assistant", "Я рядом. Пишите просто и по-человечески, я всё пойму.");
}
const storedSection = localStorage.getItem(SECTION_KEY);
activateTab(storedSection === "today-panel" || storedSection === "history-panel" ? storedSection : "chat-panel");
messageInput.focus();
}
function showLogin() {
setConnectionState("Вход не выполнен", "bad");
chatView.hidden = true;
loginView.hidden = false;
usernameInput?.focus();
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("div");
node.className = `message ${kind}`;
node.textContent = text;
if (meta) {
const small = document.createElement("small");
small.textContent = meta;
node.appendChild(small);
}
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({kind, text, meta, at: new Date()});
renderHistory();
}
function updateActiveFocus(value, provenance = "") {
if (!activeFocus) return;
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
activeFocus.hidden = !value;
function endSession(message = "Сессия завершена") {
token = "";
conversationID = "";
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(CONVERSATION_KEY);
messages.replaceChildren();
localHistory = [];
updateActiveFocus("");
setConnectionState("Вход не выполнен", "bad");
showToast(message);
showLogin();
}
async function api(path, options = {}) {
const headers = {"Content-Type": "application/json", ...(options.headers || {})};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(path, {...options, headers});
if (!response.ok) throw new Error(await response.text());
const headers = new Headers(options.headers || {});
if (token) headers.set("Authorization", `Bearer ${token}`);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "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 showError(message) {
showComposerError(message || "Произошла ошибка");
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("article");
node.className = `message ${kind}`;
const who = document.createElement("strong");
who.className = "message-author";
who.textContent = kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = text;
node.append(who, body);
if (meta) {
const metaNode = document.createElement("small");
metaNode.textContent = meta;
node.appendChild(metaNode);
}
const when = document.createElement("time");
when.className = "message-time";
when.textContent = formatTime();
node.appendChild(when);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({
kind,
text,
meta,
at: new Date(),
});
if (localHistory.length > 120) {
localHistory = localHistory.slice(-120);
}
renderHistory();
}
function updateActiveFocus(value) {
if (!activeFocus) return;
if (value) {
activeFocus.textContent = `Сейчас обсуждаем: ${value}`;
activeFocus.hidden = false;
} else {
activeFocus.hidden = true;
activeFocus.textContent = "";
}
}
function humanMeta(result) {
if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
if (result.delegation_id && result.delegation_state) return `Передал в coding-agent: ${humanDelegationState(result.delegation_state)}.`;
if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
if (result.work_planned) return "Я разложил это как работу.";
if (result.reminder_scheduled) {
return "Я зафиксировал напоминание и сообщу, когда наступит время.";
}
if (result.delegation_id && result.delegation_state) {
return `Передал дальше: ${humanDelegationState(result.delegation_state)}.`;
}
if (result.capability_status && result.capability_status !== "AVAILABLE") {
return "Эта задача сейчас проверяется отдельно.";
}
if (result.work_planned) {
return "Я взял задачу в работу и отслежу статус.";
}
return "";
}
function humanDelegationState(state) {
switch (state) {
case "RUNNING":
return "работа запущена";
return "выполняется";
case "WAITING":
return "ожидаю следующий шаг";
return "ожидает следующего шага";
case "BLOCKED":
return "нужно решение человека";
return "нужен человеческий выбор";
case "VERIFYING":
return "результат проверяется";
return "результат на проверке";
case "COMPLETED":
return "работа завершена";
return "готово";
case "FAILED":
return "run завершился ошибкой";
return "завершилось ошибкой";
default:
return state.toLowerCase();
return String(state || "обрабатывается").toLowerCase();
}
}
function renderHistory() {
if (!historyList) return;
historyList.replaceChildren();
const items = localHistory.slice(-30).reverse();
const items = localHistory.slice(-50).slice().reverse();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "История появится после сообщений в этом окне.";
empty.textContent = "История появится после первых сообщений в этом окне.";
historyList.appendChild(empty);
return;
}
for (const item of items) {
items.forEach((item) => {
const row = document.createElement("article");
row.className = `history-item ${item.kind}`;
const title = document.createElement("strong");
title.textContent = item.kind === "user" ? "Вы" : "Домовой";
row.className = "history-item";
const who = document.createElement("strong");
who.className = "message-kind";
who.textContent = item.kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = item.text;
const time = document.createElement("span");
time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
row.append(title, body, time);
const footer = document.createElement("div");
footer.className = "history-meta";
const time = document.createElement("time");
time.textContent = formatTime(item.at);
const meta = document.createElement("span");
meta.textContent = item.meta || "";
footer.append(time, meta);
row.append(who, body, footer);
historyList.appendChild(row);
});
}
function createTodayCard(item) {
const card = document.createElement("article");
card.className = "attention-card";
const title = document.createElement("h3");
const when = document.createElement("time");
const state = document.createElement("p");
const source = document.createElement("p");
const hint = document.createElement("p");
const actions = document.createElement("div");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp
? new Date(stamp).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "текущий момент";
when.className = "muted";
title.textContent = item.Title || item.title || "Событие";
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
state.className = "muted";
const sourceText = [
item.SourceDescription || item.source_description,
item.DeliveryState || item.delivery_state,
]
.filter(Boolean)
.join(" · ");
source.textContent = sourceText || "Источник не задан";
source.className = "muted";
const hintText = item.ResponseHint || item.response_hint || "";
if (hintText) {
hint.textContent = hintText;
}
actions.className = "attention-actions";
const quick = [
{ label: "Готово", text: "Готово", tone: "primary" },
{ label: "Позже", text: "Отложи на 10 минут" },
{ label: "Отменить", text: "Отмени" },
];
quick.forEach((action) => {
const actionButton = document.createElement("button");
actionButton.type = "button";
actionButton.className = `ghost-button ${action.tone || ""}`.trim();
actionButton.textContent = action.label;
actionButton.addEventListener("click", async () => {
const previous = actionButton.textContent;
actionButton.disabled = true;
actionButton.textContent = "Отправляю…";
try {
await sendChatMessage(action.text, { silentUser: false });
await loadToday();
activateTab("chat-panel");
} finally {
actionButton.disabled = false;
actionButton.textContent = previous;
}
});
actions.appendChild(actionButton);
});
card.append(title, when, state, source);
if (hintText) card.append(hint);
card.append(actions);
return card;
}
function renderTodayEmpty(message) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = message;
todayList.appendChild(empty);
}
async function loadToday() {
if (!todayList || !token) return;
if (!todayList || todayRequestInFlight) return;
todayRequestInFlight = true;
todayList.replaceChildren();
const loading = document.createElement("p");
loading.className = "empty-state";
loading.textContent = "Смотрю, что требует внимания...";
todayList.appendChild(loading);
renderTodayEmpty("Собираю актуальные напоминания…");
try {
const result = await api("/api/today", {method: "GET"});
todayList.replaceChildren();
const result = await api("/api/today", { method: "GET" });
const items = result.Items || result.items || [];
todayList.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "Сейчас ничего срочного не требует внимания.";
todayList.appendChild(empty);
todaySubtitle.textContent = "Сейчас спокойный период. Нечего тревожного";
renderTodayEmpty("Сейчас ничего срочного не требует внимания.");
return;
}
todaySubtitle.textContent = `Найдено записей: ${items.length}`;
for (const item of items) {
const card = document.createElement("article");
card.className = "attention-card";
const when = document.createElement("time");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
const title = document.createElement("h2");
title.textContent = item.Title || item.title || "Событие Домового";
const state = document.createElement("p");
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
const source = document.createElement("small");
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
const hintText = item.ResponseHint || item.response_hint || "";
const actions = document.createElement("div");
actions.className = "attention-actions";
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", async () => {
await sendChatMessage(text, {silentUser: false});
await loadToday();
activateTab("chat-panel");
});
actions.appendChild(button);
}
if (hintText) {
const hint = document.createElement("small");
hint.textContent = hintText;
card.append(when, title, state, source, hint, actions);
} else {
card.append(when, title, state, source, actions);
}
todayList.appendChild(card);
todayList.appendChild(createTodayCard(item));
}
} catch {
} catch (error) {
todayList.replaceChildren();
const error = document.createElement("p");
error.className = "empty-state error";
error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
todayList.appendChild(error);
const err = document.createElement("p");
err.className = "empty-state error";
err.textContent = error.message || "Сегодня недоступен на мгновение.";
todayList.appendChild(err);
todaySubtitle.textContent = "Не удалось обновить список.";
} finally {
todayRequestInFlight = false;
}
}
async function sendChatMessage(text, options = {}) {
const clean = text.trim();
if (!clean) return;
if (!options.silentUser) appendMessage("user", clean);
if (!options.silentUser) {
appendMessage("user", clean);
}
sendButton.disabled = true;
messageInput.disabled = true;
const originalLabel = sendButton.textContent;
sendButton.textContent = "Отправляю…";
try {
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
const result = await api("/api/chat/messages", {
method: "POST",
body: JSON.stringify({
conversation_id: conversationID,
message: clean,
}),
});
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
if (conversationID) {
sessionStorage.setItem(CONVERSATION_KEY, conversationID);
}
updateActiveFocus(result.active_focus);
appendMessage("assistant", result.reply_text || "Принял тебя. Подготовлю ответ.", humanMeta(result));
if (/что сейчас требует моего внимания/i.test(clean)) {
await loadToday();
activateTab("today-panel");
}
} catch {
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
showToast("Сообщение отправлено");
} catch (error) {
appendMessage("assistant", "Я не могу отправить сообщение в этот момент. Проверьте сеть и авторизацию.");
showError(error.message);
} finally {
sendButton.disabled = false;
messageInput.focus();
sendButton.textContent = originalLabel;
messageInput.disabled = false;
if (!options.silentUser) {
messageInput.focus();
}
}
}
function activateTab(panelID) {
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
for (const panel of document.querySelectorAll(".surface-panel")) {
let activeSet = false;
navTabs.forEach((tab) => {
const isActive = tab.dataset.tab === panelID;
tab.classList.toggle("active", isActive);
tab.setAttribute("aria-selected", String(isActive));
tab.tabIndex = isActive ? 0 : -1;
if (isActive) activeSet = true;
});
document.querySelectorAll(".surface-panel").forEach((panel) => {
const active = panel.id === panelID;
panel.hidden = !active;
panel.classList.toggle("active-panel", active);
});
if (!activeSet) return;
localStorage.setItem(SECTION_KEY, panelID);
if (panelID === "today-panel") {
loadToday();
}
if (panelID === "history-panel") {
renderHistory();
}
}
function navigateTabs(event) {
const current = navTabs.findIndex((tab) => tab.classList.contains("active"));
if (current === -1) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
const index = (current - 1 + navTabs.length) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (event.key === "ArrowRight") {
event.preventDefault();
const index = (current + 1) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (panelID === "today-panel") loadToday();
if (panelID === "history-panel") renderHistory();
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const loginButton = document.querySelector("#login-button");
loginError.textContent = "";
const username = document.querySelector("#username").value;
const password = document.querySelector("#password").value;
loginButton.disabled = true;
loginButton.textContent = "Проверяю…";
try {
const result = await api("/api/session", {method: "POST", body: JSON.stringify({username, password})});
token = result.Token || result.token;
sessionStorage.setItem("domovoy.chat.token", token);
const result = await api("/api/session", {
method: "POST",
body: JSON.stringify({ username, password }),
});
token = result.Token || result.token || "";
if (!token) {
throw new Error("Пустой токен");
}
sessionStorage.setItem(SESSION_KEY, token);
conversationID = "";
sessionStorage.removeItem(CONVERSATION_KEY);
showChat();
} catch {
loginError.textContent = "Вход не выполнен";
loginError.textContent = "Не удалось войти. Проверьте логин и пароль.";
showToast("Не удалось войти");
} finally {
loginButton.disabled = false;
loginButton.textContent = "Открыть чат";
}
});
togglePassword?.addEventListener("click", () => {
const isPassword = passwordInput.type === "password";
passwordInput.type = isPassword ? "text" : "password";
togglePassword.textContent = isPassword ? "скрыть" : "показать";
togglePassword.setAttribute("aria-label", isPassword ? "Скрыть пароль" : "Показать пароль");
});
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = messageInput.value.trim();
@@ -231,20 +522,31 @@ chatForm.addEventListener("submit", async (event) => {
await sendChatMessage(text);
});
messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
chatForm.requestSubmit();
}
});
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
tab.addEventListener("click", () => {
activateTab(tab.dataset.tab);
});
tab.addEventListener("keydown", navigateTabs);
}
if (refreshToday) refreshToday.addEventListener("click", loadToday);
refreshToday?.addEventListener("click", loadToday);
logoutButton.addEventListener("click", () => {
token = "";
conversationID = "";
sessionStorage.removeItem("domovoy.chat.token");
sessionStorage.removeItem("domovoy.chat.conversation");
clearHistoryButton?.addEventListener("click", () => {
localHistory = [];
messages.replaceChildren();
updateActiveFocus("");
showLogin();
renderHistory();
showToast("История очищена");
});
logoutButton?.addEventListener("click", () => {
endSession("Вы вышли из сеанса");
});
if (token) {
+76 -35
View File
@@ -5,60 +5,101 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#101714">
<title>Домовой - чат</title>
<title>Домовой — твой помощник</title>
<link rel="stylesheet" href="/assets/chat.css">
</head>
<body>
<main class="chat-shell">
<section id="login-view" class="login-panel">
<div class="brand-mark" aria-hidden="true">Д</div>
<h1>Домовой</h1>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<input id="password" type="password" autocomplete="current-password" required>
<button type="submit">Войти</button>
<p id="login-error" role="alert"></p>
</form>
<main class="assistant-shell">
<section id="login-view" class="auth-view">
<div class="auth-card" aria-live="polite">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">личный диалог</p>
<h1>Домовой</h1>
</div>
</div>
<p class="auth-copy">Войдите, чтобы открыть спокойный чат с вашим помощником.</p>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<div class="input-with-action">
<input id="password" type="password" autocomplete="current-password" required>
<button id="toggle-password" class="icon-button" type="button" aria-label="Показать пароль">показать</button>
</div>
<button id="login-button" type="submit">Открыть чат</button>
<p id="login-error" role="alert"></p>
</form>
</div>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header">
<a href="/chat" aria-label="Домовой">Домовой</a>
<nav class="human-nav" aria-label="Разделы Домового">
<button class="nav-tab active" data-tab="chat-panel" type="button">Чат</button>
<button class="nav-tab" data-tab="today-panel" type="button">Сегодня</button>
<button class="nav-tab" data-tab="history-panel" type="button">История</button>
<a href="/" class="operator-link">Оператор</a>
</nav>
<span id="active-focus">фокус: не выбран</span>
<span id="session-state">сессия активна</span>
<button id="logout" type="button" aria-label="Выйти">↗</button>
<header class="assistant-header">
<div class="header-brand">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">твой спокойный ассистент</p>
<h1>Домовой</h1>
</div>
</div>
<div class="session-state" aria-live="polite">
<span id="connection-state" class="status-pill"></span>
<button id="logout" class="ghost-button" type="button" aria-label="Выйти">Выйти</button>
</div>
</header>
<section id="chat-panel" class="surface-panel active-panel">
<nav class="human-nav" aria-label="Разделы Домового" role="tablist">
<button class="nav-tab active" data-tab="chat-panel" role="tab" aria-selected="true" aria-controls="chat-panel" id="tab-chat" type="button">Чат</button>
<button class="nav-tab" data-tab="today-panel" role="tab" aria-selected="false" aria-controls="today-panel" id="tab-today" type="button">Сегодня</button>
<button class="nav-tab" data-tab="history-panel" role="tab" aria-selected="false" aria-controls="history-panel" id="tab-history" type="button">История</button>
</nav>
<section class="surface-panel active-panel" id="chat-panel" role="tabpanel" aria-labelledby="tab-chat">
<div class="panel-toolbar">
<div>
<h2>Чат</h2>
<p>Пишите обычные фразы. Я отвечу спокойно и по делу.</p>
</div>
<p id="active-focus" class="focus-chip" hidden></p>
</div>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="composer">
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<button id="send" type="submit">Отправить</button>
<label class="visually-hidden" for="message">Ваше сообщение</label>
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите, чем помочь" required></textarea>
<div class="composer-actions">
<button id="send" class="primary-button" type="submit">Отправить</button>
</div>
</form>
</section>
<section id="today-panel" class="surface-panel attention-surface" hidden>
<div class="surface-heading">
<p>Сегодня / внимание</p>
<button id="refresh-today" type="button">Обновить</button>
<section id="today-panel" class="surface-panel attention-surface" hidden role="tabpanel" aria-labelledby="tab-today">
<div class="panel-toolbar">
<div>
<h2>Сегодня</h2>
<p id="today-subtitle">Что сейчас требует твоего внимания</p>
</div>
<button id="refresh-today" class="ghost-button" type="button">Обновить</button>
</div>
<div id="today-list" class="attention-list"></div>
</section>
<section id="history-panel" class="surface-panel history-surface" hidden>
<div class="surface-heading">
<p>История</p>
<span>Последние сообщения текущего окна</span>
<section id="history-panel" class="surface-panel history-surface" hidden role="tabpanel" aria-labelledby="tab-history">
<div class="panel-toolbar">
<div>
<h2>История</h2>
<p>Сообщения текущего браузерного сеанса</p>
</div>
<button id="clear-history" class="ghost-button" type="button">Очистить</button>
</div>
<div id="history-list" class="history-list"></div>
</section>
</section>
<p id="status-bar" class="status-bar" aria-live="polite"></p>
</main>
<div id="composer-error" class="composer-error" role="status" aria-live="polite"></div>
<script src="/assets/chat.js"></script>
</body>
</html>
-1611
View File
File diff suppressed because it is too large Load Diff
-644
View File
@@ -1,644 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func TestDiscoverComposeInputsRequiredAndOptional(t *testing.T) {
content := `A=${POSTGRES_SUPERUSER}
B=${POSTGRES_DB:-assistant}
C=${CREATOR_PASSWORD_FILE}
D=${FOO_CREDENTIAL_REF}
`
inputs := discoverComposeInputs(content)
if _, ok := inputs["POSTGRES_SUPERUSER"]; !ok {
t.Fatal("POSTGRES_SUPERUSER should be discovered")
}
if !inputs["POSTGRES_SUPERUSER"].required {
t.Fatalf("POSTGRES_SUPERUSER expected required")
}
if inputs["POSTGRES_DB"].required {
t.Fatalf("POSTGRES_DB expected optional")
}
if !inputs["CREATOR_PASSWORD_FILE"].required {
t.Fatalf("CREATOR_PASSWORD_FILE expected required from compose placeholder")
}
if !inputs["EPISTEMICS_POSTGRES_CREDENTIAL_REF"].required {
t.Fatalf("required production bootstrap variable should be required")
}
if got := len(inputs); got < 4 {
t.Fatalf("expected at least 4 discovered inputs, got %d", got)
}
}
func TestResolveDeploymentInputsRejectsAmbientProduction(t *testing.T) {
t.Setenv("POSTGRES_SUPERUSER", "from_ambient")
inputs := map[string]composeInput{
"POSTGRES_SUPERUSER": {name: "POSTGRES_SUPERUSER", required: true},
}
resolved, _, _, err := resolveDeploymentInputs(inputs, "", true)
if err != nil {
t.Fatalf("resolveDeploymentInputs error: %v", err)
}
if len(resolved) != 1 {
t.Fatalf("expected one resolved input, got %d", len(resolved))
}
if resolved[0].Source != "ambient" || resolved[0].Resolved {
t.Fatalf("expected unresolved ambient resolution, got source=%s resolved=%v", resolved[0].Source, resolved[0].Resolved)
}
}
func TestDeployPrepareRequiresEnvFileForProduction(t *testing.T) {
root := t.TempDir()
composeFile := filepath.Join(root, "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
t.Fatal(err)
}
err := deployPrepare([]string{"--target", "production", "--compose", composeFile})
if err == nil || !strings.Contains(err.Error(), "production prepare requires --env-file") {
t.Fatalf("expected missing env-file failure, got %v", err)
}
}
func TestDeployPrepareBlockedAndApplyRefusesMissingRequiredInput(t *testing.T) {
root := t.TempDir()
repoRoot, err := gitRepositoryRoot(".")
if err != nil {
t.Fatal(err)
}
composeFile := filepath.Join(root, "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "production.env")
parts := make([]string, 0, len(requiredChatOnlyInputs))
for key := range requiredChatOnlyInputs {
if key == "POSTGRES_SUPERUSER" {
continue
}
parts = append(parts, key+"=value-"+key)
}
if err := os.WriteFile(envFile, []byte(strings.Join(parts, "\n")), 0o600); err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
target.ComposeFile = composeFile
plan, err := buildDeploymentPlan(repoRoot, target, envFile, "HEAD", 24, "")
if err != nil {
t.Fatal(err)
}
if plan.Readiness != readinessFailed {
t.Fatalf("expected blocked plan, got readiness=%s", plan.Readiness)
}
if !strings.Contains(strings.Join(plan.ReadinessReasons, "\n"), "POSTGRES_SUPERUSER") {
t.Fatalf("expected missing required input blocker for POSTGRES_SUPERUSER, got %v", plan.ReadinessReasons)
}
planPath := filepath.Join(root, "blocked-plan.json")
payload, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
t.Fatal(err)
}
if err := deployApply([]string{"-plan", planPath}); err == nil {
t.Fatal("expected apply refusal for blocked plan")
}
}
func TestAssertPlanCurrentRejectsStaleInputChange(t *testing.T) {
root := t.TempDir()
composeFile := filepath.Join(root, "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "deploy.env")
if err := os.WriteFile(envFile, []byte("POSTGRES_SUPERUSER=first\n"), 0o600); err != nil {
t.Fatal(err)
}
inputs, err := discoverComposeInputsFromFile(composeFile)
if err != nil {
t.Fatal(err)
}
resolved, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
if err != nil {
t.Fatal(err)
}
plan := &deploymentPlan{
Version: 1,
PlanID: "stale-test",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
Readiness: readinessReady,
ReadinessReasons: nil,
Target: "production",
ComposeFile: composeFile,
DockerContext: "docker-prod",
ProjectName: "assistant",
EnvFile: envFile,
SourceRevision: "11a6a23f6e4f44dd88265a04846419a27f54df98",
SourceRevisionMode: "explicit",
ArtifactRevision: "11a6a23f6e4f44dd88265a04846419a27f54df98",
DeploymentConfigRev: resolveConfigHash(composeFile),
CurrentProductionRev: "",
KnownGoodRevision: "",
Inputs: resolved,
Classify: map[string][]string{},
KnownInputsSource: map[string]string{},
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
plan.PlanBindingHash = buildPlanBindingFromInputs(
resolved,
plan.SourceRevision,
plan.ArtifactRevision,
plan.DeploymentConfigRev,
target.DockerContext,
target.Containers,
target.Volumes,
)
if err := os.WriteFile(envFile, []byte("POSTGRES_SUPERUSER=second\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := assertPlanCurrent(plan); err == nil {
t.Fatal("expected stale plan after input change")
}
}
func TestAssertPlanCurrentRejectsArtifactMismatch(t *testing.T) {
root := t.TempDir()
composeFile := filepath.Join(root, "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "deploy.env")
envParts := make([]string, 0, len(requiredChatOnlyInputs))
for key := range requiredChatOnlyInputs {
envParts = append(envParts, key+"=value-"+strings.ToLower(key))
}
envParts = append(envParts, "POSTGRES_SUPERUSER=from_file")
if err := os.WriteFile(envFile, []byte(strings.Join(envParts, "\n")), 0o600); err != nil {
t.Fatal(err)
}
inputs, err := discoverComposeInputsFromFile(composeFile)
if err != nil {
t.Fatal(err)
}
discovered, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
if err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
configRev := resolveConfigHash(composeFile)
shaA := strings.Repeat("a", 40)
shaB := strings.Repeat("b", 40)
plan := &deploymentPlan{
Version: 1,
PlanID: "artifact-mismatch",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
Readiness: readinessReady,
Target: "production",
ComposeFile: composeFile,
DockerContext: "docker-prod",
ProjectName: "assistant",
EnvFile: envFile,
SourceRevision: shaA,
SourceRevisionMode: "explicit",
ArtifactRevision: shaA,
DeploymentConfigRev: configRev,
Inputs: discovered,
Classify: map[string][]string{},
KnownInputsSource: map[string]string{},
}
plan.PlanBindingHash = buildPlanBindingFromInputs(
discovered,
shaA,
shaA,
configRev,
target.DockerContext,
target.Containers,
target.Volumes,
)
plan.ArtifactRevision = shaB
plan.SourceRevision = shaB
if err := assertPlanCurrent(plan); err == nil {
t.Fatal("expected stale plan due artifact mismatch")
}
}
func TestMaxMigrationPrefixFromListHandlesNestedMigrationPaths(t *testing.T) {
got := maxMigrationPrefixFromList([]string{
"database/migrations/001_bootstrap.sql",
"database/migrations/007_legacy.sql",
"database/migrations/work/015_capability.sql",
"database/migrations/README.md",
"database/migrations/artifact/019_work.sql",
})
if got != "19" {
t.Fatalf("expected max migration prefix 19, got %q", got)
}
}
func TestAssertPlanCurrentRejectsMigrationUpgradeWithoutKnownGood(t *testing.T) {
root, err := gitRepositoryRoot(".")
if err != nil {
t.Fatal(err)
}
headOut, err := runGit(root, "rev-parse", "HEAD")
if err != nil {
t.Fatal(err)
}
targetRevision := strings.TrimSpace(string(headOut))
targetMaxStr, err := maxMigrationPrefixForRevision(root, targetRevision)
if err != nil {
t.Fatal(err)
}
targetMax, err := strconv.Atoi(targetMaxStr)
if err != nil {
t.Fatalf("invalid target migration max %q: %v", targetMaxStr, err)
}
revisionsOut, err := runGit(root, "rev-list", "HEAD", "--max-count", "20")
if err != nil {
t.Fatal(err)
}
ancestorList := strings.Fields(string(revisionsOut))
currentProductionRevision := ""
for _, revision := range ancestorList {
if strings.TrimSpace(revision) == targetRevision {
continue
}
revMaxStr, err := maxMigrationPrefixForRevision(root, revision)
if err != nil {
continue
}
revMax, err := strconv.Atoi(revMaxStr)
if err != nil {
continue
}
if revMax < targetMax {
currentProductionRevision = revision
break
}
}
if currentProductionRevision == "" {
t.Skip("could not find historical revision with lower migration max to validate precondition block")
}
rootDir := t.TempDir()
composeFile := filepath.Join(rootDir, "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}\n"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(rootDir, "bootstrap.env")
requiredValues := make([]string, 0, len(requiredChatOnlyInputs))
for key := range requiredChatOnlyInputs {
requiredValues = append(requiredValues, key+"=value-"+strings.ToLower(key))
}
if err := os.WriteFile(envFile, []byte(strings.Join(requiredValues, "\n")), 0o600); err != nil {
t.Fatal(err)
}
inputs, err := discoverComposeInputsFromFile(composeFile)
if err != nil {
t.Fatal(err)
}
discovered, _, _, err := resolveDeploymentInputs(inputs, envFile, true)
if err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
target.ComposeFile = composeFile
plan := &deploymentPlan{
Version: 1,
PlanID: "migration-precondition",
CreatedAt: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
Readiness: readinessReady,
ReadinessReasons: nil,
Target: "production",
ComposeFile: composeFile,
DockerContext: target.DockerContext,
ProjectName: target.ProjectName,
EnvFile: envFile,
SourceRevision: targetRevision,
SourceRevisionMode: "explicit",
ArtifactRevision: targetRevision,
DeploymentConfigRev: resolveConfigHash(composeFile),
CurrentProductionRev: currentProductionRevision,
KnownGoodRevision: "",
Classify: map[string][]string{},
Inputs: discovered,
KnownInputsSource: map[string]string{},
}
plan.Migrations, err = migrationState(root, targetRevision, currentProductionRevision)
if err != nil {
t.Fatal(err)
}
if !plan.Migrations.NeedKnownGood {
t.Fatal("expected migration state to require known good for this test")
}
plan.PlanBindingHash = buildPlanBindingFromInputs(
plan.Inputs,
plan.SourceRevision,
plan.ArtifactRevision,
plan.DeploymentConfigRev,
target.DockerContext,
target.Containers,
target.Volumes,
)
if err := assertPlanCurrent(plan); err == nil {
t.Fatal("expected assertPlanCurrent to reject migration precondition without known-good")
} else if !strings.Contains(err.Error(), "migration precondition failed") {
t.Fatalf("expected migration precondition failure, got: %v", err)
}
}
func TestDeploymentPlanDoesNotLeakSecretValues(t *testing.T) {
root, err := gitRepositoryRoot(".")
if err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
composeFile := filepath.Join(t.TempDir(), "docker-compose.prod.yml")
if err := os.WriteFile(composeFile, []byte("a=${POSTGRES_SUPERUSER}\n"), 0o600); err != nil {
t.Fatal(err)
}
target.ComposeFile = composeFile
envFile := filepath.Join(t.TempDir(), "production.env")
if err := os.WriteFile(envFile, []byte(strings.Join([]string{
"POSTGRES_SUPERUSER_PASSWORD=super-secret-123",
"CREATOR_PASSWORD_FILE=creator-password-file-456",
"COMMIT_EXECUTOR_TOKEN=executor-token-789",
}, "\n")), 0o600); err != nil {
t.Fatal(err)
}
plan, err := buildDeploymentPlan(root, target, envFile, "HEAD", 24, "")
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
serialized := string(payload)
for _, secretValue := range []string{
"super-secret-123",
"creator-password-file-456",
"executor-token-789",
} {
if strings.Contains(serialized, secretValue) {
t.Fatalf("plain secret value leaked into plan: %q", secretValue)
}
}
if got := len(plan.SecretReferences); got < 3 {
t.Fatalf("expected at least 3 secret references in plan, got %d", got)
}
}
func TestDefaultKnownGoodManifestPathStableWithoutLocalAppData(t *testing.T) {
t.Setenv("LOCALAPPDATA", "")
t.Setenv("LOCALAPPDATA_ALT", "")
if strings.TrimSpace(defaultKnownGoodManifestPath()) != "" {
t.Fatal("expected empty path when LOCALAPPDATA is unset")
}
}
func TestDeployApplyRunsHealthGateWithReadyPlanAndSimulatedComposeDocker(t *testing.T) {
originalPath := os.Getenv("PATH")
fakeDockerDir := t.TempDir()
fakeDocker := filepath.Join(fakeDockerDir, "docker.bat")
dockerBatch := "@echo off\n" +
"if \"%~1\"==\"--context\" (\n" +
" if \"%~3\"==\"compose\" exit /B 0\n" +
" if \"%~3\"==\"inspect\" (\n" +
" if \"%~6\"==\"{{index .Config.Labels \\\"org.opencontainers.image.revision\\\"}}\" echo %FAKE_DOCKER_REVISION%\n" +
" if \"%~6\"==\"{{.State.Status}}\" echo running\n" +
" exit /B 0\n" +
" )\n" +
" if \"%~3\"==\"volume\" if \"%~4\"==\"inspect\" exit /B 0\n" +
")\n" +
"exit /B 1\n"
if err := os.WriteFile(fakeDocker, []byte(dockerBatch), 0o600); err != nil {
t.Fatal(err)
}
os.Setenv("PATH", fakeDockerDir+";"+originalPath)
defer os.Setenv("PATH", originalPath)
root, err := gitRepositoryRoot(".")
if err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
composeFile := filepath.Join(fakeDockerDir, "docker-compose.prod.yml")
target.ComposeFile = composeFile
if err := os.WriteFile(composeFile, []byte("services:\\n app:\\n image: alpine\\n environment:\\n - POSTGRES_SUPERUSER=${POSTGRES_SUPERUSER}\\n"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(fakeDockerDir, "production.env")
env := []string{
"POSTGRES_SUPERUSER=deploy-apply-test",
"POSTGRES_SUPERUSER_PASSWORD=pw-super",
"CONTINUITY_DB_PASSWORD=pw-cont",
"WORK_DB_PASSWORD=pw-work",
"CAPABILITY_DB_PASSWORD=pw-cap",
"COMMIT_DB_PASSWORD=pw-commit",
"AUTHORITY_DB_PASSWORD=pw-auth",
"IDENTITY_DB_PASSWORD=pw-id",
"SITUATION_DB_PASSWORD=pw-sit",
"CREATOR_USERNAME=creator",
"CREATOR_PASSWORD_FILE=creator-password",
"CREATOR_BOOTSTRAP_TOKEN=creator-bootstrap",
"CREATOR_SESSION_SIGNING_KEY=creator-session",
"COMMIT_EXECUTOR_TOKEN=commit-exec",
"EPISTEMICS_POSTGRES_CREDENTIAL_REF=epi-cred-ref",
"INTERACTION_POSTGRES_CREDENTIAL_REF=int-cred-ref",
"EPISTEMICS_POSTGRES_PASSWORD=epi-password",
"INTERACTION_POSTGRES_PASSWORD=int-password",
}
if err := os.WriteFile(envFile, []byte(strings.Join(env, "\n")), 0o600); err != nil {
t.Fatal(err)
}
knownGood, _, err := captureKnownGood(target, "")
if err != nil || strings.TrimSpace(knownGood) == "" {
knownGood = "11a6a23f6e4f44dd88265a04846419a27f54df98"
}
plan, err := buildDeploymentPlan(root, target, envFile, knownGood, 24, "")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusOK)
}))
defer server.Close()
originalHealthEndpoints := append([]string{}, deploymentExternalHealthEndpoints...)
deploymentExternalHealthEndpoints = []string{server.URL + "/healthz", server.URL + "/chat"}
defer func() {
deploymentExternalHealthEndpoints = originalHealthEndpoints
}()
plan.PlanBindingHash = buildPlanBindingFromInputs(
plan.Inputs,
plan.SourceRevision,
plan.ArtifactRevision,
plan.DeploymentConfigRev,
target.DockerContext,
target.Containers,
target.Volumes,
)
planPath := filepath.Join(fakeDockerDir, "ready-plan.json")
payload, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("FAKE_DOCKER_REVISION", plan.ArtifactRevision)
if err := deployApply([]string{"-plan", planPath}); err != nil {
t.Fatalf("expected successful apply with simulated docker and passing health checks, got %v", err)
}
}
func TestDeployApplyHealthGateRejectsOnFailedHTTPHealth(t *testing.T) {
originalPath := os.Getenv("PATH")
fakeDockerDir := t.TempDir()
fakeDocker := filepath.Join(fakeDockerDir, "docker.bat")
dockerBatch := "@echo off\n" +
"if \"%~1\"==\"--context\" (\n" +
" if \"%~3\"==\"compose\" exit /B 0\n" +
" if \"%~3\"==\"inspect\" (\n" +
" if \"%~6\"==\"{{index .Config.Labels \\\"org.opencontainers.image.revision\\\"}}\" echo %FAKE_DOCKER_REVISION%\n" +
" if \"%~6\"==\"{{.State.Status}}\" echo running\n" +
" exit /B 0\n" +
" )\n" +
" if \"%~3\"==\"volume\" if \"%~4\"==\"inspect\" exit /B 0\n" +
")\n" +
"exit /B 1\n"
if err := os.WriteFile(fakeDocker, []byte(dockerBatch), 0o600); err != nil {
t.Fatal(err)
}
os.Setenv("PATH", fakeDockerDir+";"+originalPath)
defer os.Setenv("PATH", originalPath)
root, err := gitRepositoryRoot(".")
if err != nil {
t.Fatal(err)
}
target, err := resolveDeploymentTarget("production")
if err != nil {
t.Fatal(err)
}
composeFile := filepath.Join(fakeDockerDir, "docker-compose.prod.yml")
target.ComposeFile = composeFile
if err := os.WriteFile(composeFile, []byte("services:\\n app:\\n image: alpine\\n environment:\\n - POSTGRES_SUPERUSER=${POSTGRES_SUPERUSER}\\n"), 0o600); err != nil {
t.Fatal(err)
}
envFile := filepath.Join(fakeDockerDir, "production.env")
env := []string{
"POSTGRES_SUPERUSER=deploy-apply-test",
"POSTGRES_SUPERUSER_PASSWORD=pw-super",
"CONTINUITY_DB_PASSWORD=pw-cont",
"WORK_DB_PASSWORD=pw-work",
"CAPABILITY_DB_PASSWORD=pw-cap",
"COMMIT_DB_PASSWORD=pw-commit",
"AUTHORITY_DB_PASSWORD=pw-auth",
"IDENTITY_DB_PASSWORD=pw-id",
"SITUATION_DB_PASSWORD=pw-sit",
"CREATOR_USERNAME=creator",
"CREATOR_PASSWORD_FILE=creator-password",
"CREATOR_BOOTSTRAP_TOKEN=creator-bootstrap",
"CREATOR_SESSION_SIGNING_KEY=creator-session",
"COMMIT_EXECUTOR_TOKEN=commit-exec",
"EPISTEMICS_POSTGRES_CREDENTIAL_REF=epi-cred-ref",
"INTERACTION_POSTGRES_CREDENTIAL_REF=int-cred-ref",
"EPISTEMICS_POSTGRES_PASSWORD=epi-password",
"INTERACTION_POSTGRES_PASSWORD=int-password",
}
if err := os.WriteFile(envFile, []byte(strings.Join(env, "\n")), 0o600); err != nil {
t.Fatal(err)
}
knownGood, _, err := captureKnownGood(target, "")
if err != nil || strings.TrimSpace(knownGood) == "" {
knownGood = "11a6a23f6e4f44dd88265a04846419a27f54df98"
}
plan, err := buildDeploymentPlan(root, target, envFile, knownGood, 24, "")
if err != nil {
t.Fatal(err)
}
plan.PlanBindingHash = buildPlanBindingFromInputs(
plan.Inputs,
plan.SourceRevision,
plan.ArtifactRevision,
plan.DeploymentConfigRev,
target.DockerContext,
target.Containers,
target.Volumes,
)
planPath := filepath.Join(fakeDockerDir, "ready-plan-bad-health.json")
payload, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(planPath, payload, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("FAKE_DOCKER_REVISION", plan.ArtifactRevision)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/healthz":
writer.WriteHeader(http.StatusInternalServerError)
default:
writer.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
originalHealthEndpoints := append([]string{}, deploymentExternalHealthEndpoints...)
deploymentExternalHealthEndpoints = []string{server.URL + "/healthz", server.URL + "/chat"}
defer func() {
deploymentExternalHealthEndpoints = originalHealthEndpoints
}()
if err := deployApply([]string{"-plan", planPath}); err == nil {
t.Fatalf("expected apply to fail when health check fails")
}
}
+1 -3
View File
@@ -56,8 +56,6 @@ func main() {
err = preflight(os.Args[2:])
case "check-diff":
err = checkDiff(os.Args[2:])
case "deploy":
err = deploy(os.Args[2:])
default:
usage()
os.Exit(2)
@@ -69,7 +67,7 @@ func main() {
}
func usage() {
fmt.Println("devctl doctor | check-architecture | show-registry | preflight (-active <json> | -task <json>) | check-diff [-active <json> | -task <json>] | deploy <prepare|apply> ...")
fmt.Println("devctl doctor | check-architecture | show-registry | preflight (-active <json> | -task <json>) | check-diff [-active <json> | -task <json>]")
}
func loadRegistry() (Registry, error) {
+2 -2
View File
@@ -1,5 +1,5 @@
{
"task_file": "dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json",
"task_id": "DEV-000073",
"task_file": "dev/tasks/DEV-000072-semantic-interpretation-deferral-boundary.json",
"task_id": "DEV-000072",
"task_revision": 1
}
@@ -5,14 +5,6 @@
"base_repo_revision": "64870798b9a50f895c99d24441703f366df5c98a",
"risk_tier": "E",
"intent": "Implement semantic interpretation and deferral boundary V1 so deterministic interpretation acts as a fast-path candidate rather than unquestionable final truth; preserve provenance between deterministic and cognitive candidates; support mixed intent and safer deferral without activating new providers.",
"non_goal_scope": [
"Do not build a dedicated naming subsystem in this task (for example, no assistant_name table/field).",
"Do not implement dedicated favorite_color/disliked_food/project_role-like preference schemas in this task.",
"Do not introduce a hardcoded 'запомни' keyword trigger or naming-specific memory trigger.",
"Do not introduce dedicated naming memory, universal ontology, or graph/knowledge DB in this task.",
"Treat naming/name-preferral utterances as generic semantic interpretation cases and test fixtures, not the target for persistence architecture changes.",
"Any future durable memory/knowledge mechanism is out of scope for this task and must be captured as a follow-up requirement."
],
"requirement_ids": [
"DEV-000071 measured baseline",
"Deterministic candidate can decline/defer",
@@ -144,11 +136,6 @@
"reality evaluation comparisons",
"production /chat acceptance"
],
"future_scope_requirements": [
"Generic interaction-to-memory pipeline (common conceptual flow): interaction -> understood meaning -> potential memory/knowledge candidate -> subject/scope/provenance -> salience/relevance -> persistence decision -> relations/associations -> retrieval -> correction/consolidation.",
"Support multiple ordinary human preference/intention domains (names, preferred forms of address, colors, dislikes, habits, relationships, project facts, machine characteristics, temporary constraints) without special schema-per-example.",
"Use existing Epistemics/Experience/Identity/Situation foundations first; introduce new canonical concepts only when requirements and architecture review justify them."
],
"terminal_gap_states": [
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_ARCHITECTURE_GAP",
@@ -1,72 +0,0 @@
{
"task_id": "DEV-000073",
"task_revision": 1,
"architecture_revision": "DEV-000072-baseline",
"base_repo_revision": "36fec6c05cdf7aaf413ffe6b9437f07b03fc7b6b",
"risk_tier": "E",
"intent": "Introduce deterministic two-phase production deployment (prepare/apply) and stale/health/state gates in devctl without changing runtime semantics, so missing required production deployment inputs can never trigger an unsafe recreate.",
"requirement_ids": [
"prepare/apply separation",
"fail-closed required input resolution",
"exact artifact pinning",
"stale plan rejection",
"known-good/rollback capture",
"health verification on apply",
"no production mutation from ambient shell"
],
"allowed_change_scope": {
"paths": [
"AGENTS.md",
"cmd/devctl/**",
"docs/DEPLOYMENT.md",
"docs/PROJECT_STATUS.md",
"docs/evidence/deployment-continuity-dev000073.md",
"architecture/UNRESOLVED_QUESTIONS.md",
"dev/tasks/DEV-000072-semantic-interpretation-deferral-boundary.json",
"docs/evidence/production-recovery-incident-20260818.md",
"dev/ACTIVE_TASK.json",
"dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json"
]
},
"forbidden_change_scope": {
"paths": []
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_provider_adapters": 0,
"new_database_owner_roles": 0
},
"semantic_owner": "devctl",
"contracts": {
"consume": [
"contracts.identity"
],
"may_change": []
},
"acceptance_scenarios": [
"fresh shell prepare without ambient production env fails unless required values are in --env-file",
"deploy apply refuses when plan is stale due input/config/revision changes",
"deploy prepare can create READY and BLOCKED plans with machine-readable reasons",
"deploy apply succeeds only for READY plans and verifies deployment artifacts and /health endpoints",
"known-good revision is captured deterministically from production manifest or explicit recovery snapshot",
"healthy production is preserved when PREPARE or APPLY refuse to proceed"
],
"required_tests": [
"go test ./cmd/devctl",
"go run ./cmd/devctl preflight -task dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json",
"go run ./cmd/devctl check-diff -task dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json",
"go run ./cmd/devctl deploy prepare --target production --compose deploy/docker-compose.prod.yml --env-file <production-bootstrap.env> --plan .devctl/deployment-plans/test-dev073.json",
"go run ./cmd/devctl deploy apply --plan .devctl/deployment-plans/test-dev073.json --dry-run",
"go run ./cmd/devctl deploy apply --plan .devctl/deployment-plans/missing.json"
],
"terminal_gap_states": [
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_ARCHITECTURE_GAP",
"BLOCKED_BY_MISSING_CAPABILITY",
"BLOCKED_BY_CONFLICT",
"REQUIRES_MIGRATION_DECISION"
]
}
-25
View File
@@ -23,31 +23,6 @@ powershell -ExecutionPolicy Bypass -File .\deploy\windows\Confirm-DomovoyProduct
The confirmation script accepts the promotion only when every project-built container has that revision label, then atomically records its exact Docker image ID in `%LOCALAPPDATA%\Domovoy\monitoring\deployment-manifest.json` and the private NAS monitoring directory. Production health compares every running label and image ID to this manifest and becomes degraded on drift. The manifest is non-secret operational evidence; it is not canonical Assistant state.
## Production deploy pipeline V1
`cmd/devctl` is now the normal production deployment entrypoint and uses two explicit stages:
- `deploy prepare` reads the compose manifest and an explicit durable bootstrap source (for production: `--env-file`) and resolves required inputs.
- `deploy apply` consumes a prepared deployment plan and is the only normal mutation path.
Safety properties:
- Missing/empty/unresolvable required input blocks prepare readiness (`BLOCKED`) and blocks apply.
- Ambient environment is not authoritative for required production inputs.
- A prepare includes the resolved source revision, config revision, secret references, resolved input classification, migration readiness, known-good snapshot (if available), and a materialized plan binding hash.
- `deploy apply` refuses to execute stale plans (expired, mismatched compose, changed source/config, input drift).
- `apply` verifies running containers, required volumes, and external health (`/healthz`, `/chat`) before returning ready.
- Rollback path to recorded known-good is prepared for recovery when explicitly allowed (`--auto-rollback`).
Operational sequence example:
```powershell
go run ./cmd/devctl deploy prepare --target production --compose deploy/docker-compose.prod.yml --env-file <fresh-production.env> --plan .devctl/deployment-plans/prod-<rev>.json
go run ./cmd/devctl deploy apply --plan .devctl/deployment-plans/prod-<rev>.json
```
`prepare`/`apply` are intentionally decoupled so a fresh Codex session with only repository checkout and durable sources can complete production deployment safely.
The first external operation adds `assistant-notification-receiver-1` on production host port `18081`. Caddy applies `deploy/caddy/assistent.caddy`, routing only `https://assistent.cin.su/delivery/*` to that port and all other paths to Creator Web on `18080`. The receiver has an isolated durable volume and no Assistant database access. Commit success means an immutable receiver receipt exists; Windows display and human reading are not Commit outcomes.
Migrations `010_capability_notification_binding.sql` and `011_commit_notification_envelope.sql` must be applied before the new runtimes start. The initial Capability binding is owner-local for `domovoy-creator-principal`, revision 1, capability `prospective-notification-delivery`, origin `https://assistent.cin.su`, delivery path `/delivery/v1/deliveries`, receipt path `/delivery/v1/receipts`, protocol `IDEMPOTENT_RECEIPT_V1`, and secret reference `domovoy-notification-write-v1`.
+2 -50
View File
@@ -1,52 +1,6 @@
# Assistant project status
Audit date: 2026-08-18. Active implementation: DEV-000072 revision 1.
## Production recovery checkpoint (2026-08-18)
- `PRODUCTION_RECOVERY_STATUS = RESTORED_TO_KNOWN_GOOD`
- `TARGET_REVISION = 11a6a23f6e4f44dd88265a04846419a27f54df98`
- `PREVIOUS_RUNNING_REVISION = 36fec6c4f8aabf5e9f5f4ac0d5e2f9c0b8a5a9f0`
- `CURRENT_RUNNING_REVISION = 11a6a23f6e4f44dd88265a04846419a27f54df98`
- `PRODUCTION_HEALTH = healthy`
- `POSTGRES_HEALTH = healthy`
- `CORE_RUNTIME_HEALTH = healthy`
- `CONTINUITY_RUNTIME_HEALTH = healthy`
- `COMMIT_RUNTIME_HEALTH = healthy`
- `CREATOR_WEB_HEALTH = healthy`
- `EXTERNAL_HEALTHZ = 200`
- `CHAT_UI = 200`
- `INCIDENT_EVIDENCE_PATH = docs/evidence/production-recovery-incident-20260818.md`
- `DEV_000072_STATUS = PRODUCTION_ACCEPTANCE_BLOCKED_BY_DEPLOYMENT_INCIDENT`
- `NEXT_REQUIRED_ACTION = run post-recovery evidence tasks for DEV-000072 acceptance and then continue with production deployment`
## DEV-000073 checkpoint
- `DEV_000073_STATUS = BLOCKED`
- `STATUS = PAUSED_PENDING_LONG_HORIZON_MODEL`
- `CURRENT_PHASE = DURABLE_BOOTSTRAP_PARTIAL_IMPLEMENTATION_STABILIZED`
- `DEV_000073_SCOPE = production deployment continuity + safe redeploy V1 (deploy prepare/apply)`
- `START_DATE = 2026-08-18`
- `ACTIVE_TASK = dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json`
- `LAST_VERIFIED_COMMIT = 36fec6c05cdf7aaf413ffe6b9437f07b03fc7b6b`
- `CURRENT_UNCOMMITTED_WORK = cmd/devctl/deploy.go (untracked in git index); cmd/devctl/deploy_test.go (untracked in git index); docs/evidence/deployment-continuity-dev000073.md (untracked in git index); supporting task/evidence files`
- `DEV_000073_EVIDENCE_PATH = docs/evidence/deployment-continuity-dev000073.md`
- `NEXT_ACTION = resume DEV-000073 from this checkpoint using a longer-horizon model to complete durable bootstrap review and production-safe seed plan`
- `BLOCKER = real sanitized production prepare cannot resolve all required inputs from intentional durable/protected sources`
- `GO_TEST_DEVCTL = EXECUTED_PASS`
- `CORE_OUTPUTS = cmd/devctl/deploy.go, cmd/devctl/deploy_test.go`
- `DOC_OUTPUTS = AGENTS.md, docs/DEPLOYMENT.md, docs/PROJECT_STATUS.md`
- `DEPLOYMENT_RULE = production mutation requires explicit prepared plan and fail-closed unresolved required input checks`
- `SEMANTIC_SAFETY = no runtime behavior change; no provider naming/memory changes; DEV-000072 remains unchanged and not promoted`
- `TEMP_ARTIFACT_CLEANUP = cmd/devctl/__tmp_devctl_patch_test.txt absent`
- `BLOCKERS = final durable bootstrap design/implementation review remains; pre-runtime protected Secret Resolver bridge and one-time production seed are not yet completed`
- `IMPORTANT_FINDINGS = deploy prepare/apply gates and negative path protections are functionally exercised; production continuity still depends on durable input source for real use`
- `EVIDENCE_OBTAINED = docs/evidence/deployment-continuity-dev000073.md`
- `COMPLETED = deployment prepare/apply safety mechanics; missing input fail-closed; stale-plan rejection; exact-artifact binding; secret-leak protection; health gate; migration discovery/precondition; seven-input ownership audit; canonical-vs-derived credential decision; current deploy.go partial bootstrap implementation compiles; go test ./cmd/devctl passes`
- `ARCHITECTURAL_DECISIONS_ALREADY_ACCEPTED = do not repeat seven-input ownership audit; DB CredentialRef is canonical; raw DB password is derived deployment/runtime transport; CREATOR_PASSWORD_FILE is runtime/mount transport; creator bootstrap token and session signing key require durable protected ownership`
- `IN_PROGRESS = final durable bootstrap design/implementation review; pre-runtime protected secret resolver bridge; one-time production canonical seed/migration; real fresh sanitized production prepare = READY`
- `REASON_FOR_PAUSE = available model is optimized for short interactive edits; remaining work requires long-horizon architecture + cross-component reasoning and production-state migration`
- `NEXT_REQUIRED_ACTION = resume DEV-000073 from this checkpoint using a stronger long-horizon Codex model`
Audit date: 2026-08-17. Active implementation: DEV-000072 revision 1.
## DEV-000072 checkpoint
@@ -66,10 +20,8 @@ Audit date: 2026-08-18. Active implementation: DEV-000072 revision 1.
- `RAW_MODEL_BOUNDARY = recorded explicitly: raw cognitive models identify themselves as provider/model provenance; this is not patched inside Interaction and remains a later context-compilation milestone`
- `TESTS_PASS = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1; tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1; ASSISTANT_LIVE_SEMANTIC_TRACE=1 go test ./cmd/cognitive-worker -run TestLiveInterpretationTrace -v`
- `COGNITIVE_CONTEXT_GAP = still open by design in this milestone: raw providers identify themselves as Qwen/Gemma provenance when Domovoy identity/context is not compiled into the prompt`
- `NON_GOAL_SCOPE_DEV_000072 = naming as memory domain is a semantic test case only in this task; no assistant_name subsystem, no favorite_color fields, no naming-specific memory table/schema, no explicit 'запомни'-only trigger, no universal ontology, and no graph DB are introduced for this deployment boundary`
- `GENERAL_MEMORY_REQUIREMENT = broader generic knowledge-retention admission (for preferences, names, constraints, relations, project facts, recurring patterns, and analogous domains) is out-of-scope for DEV-000072 and is recorded as UQ-016 in architecture/UNRESOLVED_QUESTIONS.md`
- `PRODUCTION_STATUS = pending promotion/deploy from this checkpoint`
- `NEXT_RECOMMENDED_STEP = finish DEV-000072 production promotion, apply migration 027, redeploy runtime, verify live /chat against the promoted artifact, then address broader memory/knowledge retention via a separate evidence-backed milestone`
- `NEXT_RECOMMENDED_STEP = finish DEV-000072 production promotion, apply migration 027, redeploy runtime, and verify live /chat against the promoted artifact`
## DEV-000071 checkpoint
@@ -1,169 +0,0 @@
# DEV-000073 Deployment Continuity Evidence (Checkpoint)
Audit date: 2026-08-18
Task ID: `DEV-000073`
Task revision: `1`
Base revision: `36fec6c05cdf7aaf413ffe6b9437f07b03fc7b6c`
## Status summary
- `DEV_000073_STATUS = BLOCKED`
- `CURRENT_PHASE = DURABLE_BOOTSTRAP_PARTIAL_IMPLEMENTATION_STABILIZED`
- `CHECKPOINT_STATUS = IN_PROGRESS`
- `LAST_VERIFIED_COMMIT = 36fec6c05cdf7aaf413ffe6b9437f07b03fc7b6b`
- `LAST_VERIFIED_TEST_COMMAND = go test ./cmd/devctl`
- `LAST_VERIFIED_TEST_RESULT = EXECUTED_PASS`
- `CURRENT_UNCOMMITTED_DIFF = cmd/devctl/deploy.go, cmd/devctl/deploy_test.go, docs/DEPLOYMENT.md, AGENTS.md, architecture/UNRESOLVED_QUESTIONS.md, dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json, docs/evidence/deployment-continuity-dev000073.md, docs/evidence/production-recovery-incident-20260818.md, docs/PROJECT_STATUS.md, dev/ACTIVE_TASK.json`
- `CURRENT_TASK_STATE = PAUSED_PENDING_LONG_HORIZON_MODEL`
## Executed checks
- `go test ./cmd/devctl` → `EXECUTED_PASS`
- `go run ./cmd/devctl preflight -task dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json` → `EXECUTED_PASS`
- `go run ./cmd/devctl check-diff -task dev/tasks/DEV-000073-production-deployment-continuity-safe-redeploy.json` → `EXECUTED_PASS`
- `go test ./cmd/devctl` targeted acceptance set (`TestDeployPrepareBlockedAndApplyRefusesMissingRequiredInput`, `TestAssertPlanCurrentRejectsStaleInputChange`, `TestAssertPlanCurrentRejectsArtifactMismatch`, `TestDeploymentPlanDoesNotLeakSecretValues`, `TestDeployApplyRunsHealthGateWithReadyPlanAndSimulatedComposeDocker`, `TestDeployApplyHealthGateRejectsOnFailedHTTPHealth`, migration precondition tests) → `EXECUTED_PASS`
## Task-specific acceptance probes
- `MISSING_INPUT_NEGATIVE_TEST = EXECUTED_PASS`
- missing required input in prepare → `readiness=BLOCKED`
- `deploy apply` with blocked plan returns non-zero
- `STALE_PLAN_NEGATIVE_TEST = EXECUTED_PASS`
- compose/input/content assumption changes in plan → `assertPlanCurrent` detects stale state
- `EXACT_ARTIFACT_NEGATIVE_TEST = EXECUTED_PASS`
- artifact change without plan refresh → `assertPlanCurrent` detects stale mismatch
- `SECRET_LEAK_TEST = EXECUTED_PASS`
- plan output and tests show no plaintext secret values; only source class and hashes persisted
- `HEALTH_GATE_TEST = EXECUTED_PASS`
- docker compose success + passing HTTP checks + docker inspect => apply may complete
- docker compose success + failed `/healthz` => apply fails
## Production continuity probe (sanitized shell)
- `PRODUCTION_REVISION_VERIFIED = 11a6a23f6e4f44dd88265a04846419a27f54df98`
- `REAL_PRODUCTION_PREPARE_READINESS = BLOCKED`
- `REAL_PRODUCTION_SANITIZED_PREPARE = NOT_READY`
- `REAL_PRODUCTION_CONFIG_SOURCE = TEMPORARY_SYNTHETIC`
- `DOCKER_PROD_BOOTSTRAP_ENV_SOURCE = secrets/docker-prod-bootstrap.env`
- `LEGACY_AMBIENT_DEPENDENCIES = none`
- `UNKNOWN_INPUTS = none`
- `KNOWN_INPUT_SOURCE_DETAIL` for required fields:
- `ASSISTANT_SOURCE_REVISION = missing`
- `CREATOR_WEB_PORT = missing`
- `NOTIFICATION_RECEIVER_PORT = missing`
- `POSTGRES_DB = file`
- all resolved/required production inputs include:
- resolved from file: `POSTGRES_SUPERUSER`, `POSTGRES_SUPERUSER_PASSWORD`, `CONTINUITY_DB_PASSWORD`, `WORK_DB_PASSWORD`, `CAPABILITY_DB_PASSWORD`, `COMMIT_DB_PASSWORD`, `AUTHORITY_DB_PASSWORD`, `IDENTITY_DB_PASSWORD`, `SITUATION_DB_PASSWORD`, `CREATOR_USERNAME`, `COMMIT_EXECUTOR_TOKEN`
- missing (blocking): `CREATOR_BOOTSTRAP_TOKEN`, `CREATOR_PASSWORD_FILE`, `CREATOR_SESSION_SIGNING_KEY`, `EPISTEMICS_POSTGRES_CREDENTIAL_REF`, `EPISTEMICS_POSTGRES_PASSWORD`, `INTERACTION_POSTGRES_CREDENTIAL_REF`, `INTERACTION_POSTGRES_PASSWORD`
## Configuration source classification (required production inputs)
- `POSTGRES_SUPERUSER -> BOOTSTRAP` (from env file path)
- `POSTGRES_SUPERUSER_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `CONTINUITY_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `WORK_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `CAPABILITY_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `COMMIT_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `AUTHORITY_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `IDENTITY_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `SITUATION_DB_PASSWORD -> SECRET_REFERENCE` (from env file path)
- `CREATOR_USERNAME -> BOOTSTRAP` (from env file path)
- `CREATOR_PASSWORD_FILE -> DOMOVOY_MANAGED` (missing)
- `CREATOR_BOOTSTRAP_TOKEN -> SECRET_REFERENCE` (missing)
- `CREATOR_SESSION_SIGNING_KEY -> SECRET_REFERENCE` (missing)
- `COMMIT_EXECUTOR_TOKEN -> SECRET_REFERENCE` (from env file path)
- `EPISTEMICS_POSTGRES_CREDENTIAL_REF -> SECRET_REFERENCE` (missing)
- `INTERACTION_POSTGRES_CREDENTIAL_REF -> SECRET_REFERENCE` (missing)
- `EPISTEMICS_POSTGRES_PASSWORD -> SECRET_REFERENCE` (missing)
- `INTERACTION_POSTGRES_PASSWORD -> SECRET_REFERENCE` (missing)
- `CONFIG_SOURCE_CLASSIFICATION = TEMPORARY_SYNTHETIC`
## Migration readiness
- `CURRENT_MIGRATION_STATE_SOURCE = git ls-tree -r --name-only <revision> database/migrations`
- `CANDIDATE_MIGRATION_REQUIREMENT = current_max=27 target_max=27 status=matched need_known_good=false (for plan created from 11a6a23f6e4f...)`
- `MIGRATION_DISCOVERY_STATUS = PASS`
- `PRECONDITION_ENFORCEMENT = PASS`
- `MIGRATION_PRECONDITION_STATUS = PASS` (plan execution enforces migration precondition and rejects upgrade-without-known-good as blocking)
- `ROLLBACK_COMPATIBILITY_STATUS = not reached by this evidence run; migration check is enforced before compose mutation`
## Gaps / blockers
- `BLOCKER = REAL_PRODUCTION_SANITIZED_PREPARE depends on `secrets/docker-prod-bootstrap.env` and still has 7 required missing inputs`
- `BLOCKER = production bootstrap source is temporary/synthetic in this environment; no durable/protected production bootstrap source has been established here`
- `REASON_FOR_PAUSE = remaining work requires long-horizon model for durable bootstrap review, resolver bridge completion, and production seed migration planning`
## Evidence persisted
- `evidence/evidence file: docs/evidence/deployment-continuity-dev000073.md`
- `checkpoint: docs/PROJECT_STATUS.md`
## Durable Ownership Audit (DEV-000073)
INPUT_OWNERSHIP_MATRIX
| INPUT_NAME | WHY_NEEDED | SEMANTIC_OWNER | CANONICAL_STATE | SECRET | INTENDED_DURABLE_SOURCE | CURRENT_ACTUAL_SOURCE | RESOLUTION_PATH | WHY_CURRENT_PREPARE_CANNOT_RESOLVE_IT |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| CREATOR_BOOTSTRAP_TOKEN | bootstrap token for the Creator domain startup/authentication bootstrap flow | identity | yes | yes | durable secret input for production bootstrap (protected secret value) | missing from sanitized workspace prepare inputs | deploy.prepare file/env resolution only (`inputSourceClass=SECRET_REFERENCE`) | no durable bootstrap record supplied for this token in this environment; prepare does not have a secret backend lookup step |
| CREATOR_PASSWORD_FILE | creator password secret-file locator consumed by core runtime identity startup | identity | no | no | domovoy-managed runtime mount under `/run/secrets` (`core-runtime` creator password path) | provided in compose as a fixed container path but not as resolved prepare input | deploy.prepare accepts `_FILE` as an input class but only from env file/ambient and does not consume compose defaults | compose default is outside deploy.prepare source model, so required input remains unresolved even when container expects the path |
| CREATOR_SESSION_SIGNING_KEY | key for secure creator session signing | identity | yes | yes | durable secret input for production bootstrap (protected secret value) | missing from sanitized workspace prepare inputs | deploy.prepare file/env resolution only (`inputSourceClass=SECRET_REFERENCE`) | no durable bootstrap source in this environment; no value in env file and not provided by resolver bootstrap path |
| EPISTEMICS_POSTGRES_CREDENTIAL_REF | identity of epistemics credential record for DB secret resolution | epistemics | yes | no | protected durable secret-reference declaration for the epistemics DB credential record | missing from sanitized workspace prepare inputs | deploy.prepare expects direct env value (`SECRET_REFERENCE`) | credential reference value is absent and there is no durable bootstrap source for this reference name |
| EPISTEMICS_POSTGRES_PASSWORD | DB password transport input currently listed as required by deploy | epistemics | no | yes | derived at runtime from `EPISTEMICS_POSTGRES_CREDENTIAL_REF` + `EPISTEMICS_POSTGRES_PASSWORD_FILE` through local secret resolver | not present as input in prepare; compose sets file-based runtime path for password value | unresolved by deploy.prepare because it is treated as legacy required input, while runtime reads file-backed path | this value is rendered runtime transport data; deploy.prepare contract has no mapping from `*_CREDENTIAL_REF` to `*_PASSWORD` |
| INTERACTION_POSTGRES_CREDENTIAL_REF | identity of interaction credential record for DB secret resolution | interaction | yes | no | protected durable secret-reference declaration for the interaction DB credential record | missing from sanitized workspace prepare inputs | deploy.prepare expects direct env value (`SECRET_REFERENCE`) | credential reference value is absent and there is no durable bootstrap source for this reference name |
| INTERACTION_POSTGRES_PASSWORD | DB password transport input currently listed as required by deploy | interaction | no | yes | derived at runtime from `INTERACTION_POSTGRES_CREDENTIAL_REF` + `INTERACTION_POSTGRES_PASSWORD_FILE` through local secret resolver | not present as input in prepare; compose sets file-based runtime path for password value | unresolved by deploy.prepare because it is treated as legacy required input, while runtime reads file-backed path | this value is rendered runtime transport data; deploy.prepare contract has no mapping from `*_CREDENTIAL_REF` to `*_PASSWORD` |
CREDENTIAL_REF_PASSWORD_DUPLICATION_ASSESSMENT
`B) CredentialRef is canonical and PASSWORD is derived/rendered runtime transport value.`
Rationale:
- `cmd/core-runtime/main.go` `readCredentialRefSecret(name, refName)` rejects direct `name` secret values (`direct environment secret is forbidden`) and requires `name_FILE` + `<name>_CREDENTIAL_REF`.
- It resolves credentials through `platformsecrets.NewFileResolver` using `SecretFileRef{CredentialRef: ref, Purpose: DatabasePassword}`.
- The compose layout includes `<OWNER>_POSTGRES_CREDENTIAL_REF` and `<OWNER>_POSTGRES_PASSWORD_FILE`, reinforcing the canonical-reference + rendered-file pattern.
CREATOR_INPUT_OWNERSHIP
- `CREATOR_BOOTSTRAP_TOKEN`
- Real owner: `identity`.
- Real durable source class: protected secret (identity bootstrap token), **not** in repo and not present in sanitized prepare input.
- Current classification: missing required input in this environment.
- `CREATOR_SESSION_SIGNING_KEY`
- Real owner: `identity`.
- Real durable source class: protected secret (identity session signing key), **not** in repo and not present in sanitized prepare input.
- Current classification: missing required input in this environment.
- `CREATOR_PASSWORD_FILE`
- Real owner: `identity` (runtime auth material locator).
- It is a **rendered runtime path** backed by Domovoy/runtime secret mount conventions (`/run/secrets`) and is **not** a canonical secret value.
- Current classification: compose-fixed path, not a standalone durable deploy input for this phase.
DOCKER_PROD_BOOTSTRAP_ENV_ROLE
`TEMPORARY_SYNTHETIC`
Why: the file is a local repository bootstrap artifact used for sanitized shell-level prepare/apply probing; it is not an intentional durable/protected production source and still lacks required durable secret inputs. It is neither a Domovoy-managed state artifact nor a recoverable provenance artifact for real production bootstrap.
EXISTING_SECRET_RESOLVER_CAN_SUPPLY_MISSING_INPUTS
`partial`
- adapter? `no` (deploy.prepare does not invoke runtime secret adapter; this check is done in runtime after plan creation)
- bootstrap locator? `missing` (no durable, environment-wide bootstrap secret locator for all 7 inputs is present in this workspace)
- credential record? `partially present in contracts, not provisioned here` (code path expects `*_PASSWORD_FILE` + `*_CREDENTIAL_REF`, but missing refs/records in this environment)
- protected backend entry? `missing` (no proven production-grade protected entry available via the current prepare evidence run)
- ownership mapping? `partial` (owners/areas can be inferred from contracts and module code, but prepare-time ownership policy is not the missing-input resolver)
- pre-runtime bootstrap cycle? `not demonstrated` (no cycle blocker)
BOOTSTRAP_CYCLE_EXISTS
`no`
No real cycle was found because secret resolution uses local file-backed resolver in core runtime and is not a dependency of Domovoy boot for deployment plan creation in this repository evidence path.
MINIMUM_IMPLEMENTATION_GAP
- Provide a durable/protected bootstrap source for the remaining required identity inputs (`CREATOR_BOOTSTRAP_TOKEN`, `CREATOR_SESSION_SIGNING_KEY`, `CREATOR_PASSWORD_FILE`) and epistemics/interaction credential refs.
- Align deploy input contract so production input readiness depends on the canonical pair inputs (`*_CREDENTIAL_REF` + `*_PASSWORD_FILE`) and not legacy `<OWNER>_POSTGRES_PASSWORD` as a required direct input.
- Add evidence-backed mapping for each canonical input’s ownership/source class and bootstrap provisioning path before proceeding to acceptance/ready status.
CHECKPOINT_UPDATED = yes
EVIDENCE_PERSISTED = yes
@@ -1,166 +0,0 @@
[
{
"id": "A",
"status": 200,
"message": "ты кто",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "86fb129b40d2f2fbb4ab798d863bf35d",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Я искусственный интеллект, созданный для помощи. Как я могу быть полезен сейчас?"
},
{
"id": "B",
"status": 200,
"message": "сколько будет 2 + 2",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "570663950030add34f71cedb24f4256d",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "2 + 2 equals 4."
},
{
"id": "C",
"status": 200,
"message": "как с утра",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "008192deff274c8ab7678c180ab7e04d",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Утро - это прекрасное время для начала нового дня. Начните с здорового питания, которое поможет вам начать день с бодрости и энергии. Не забудьте о hydratation, выпив适量的水. Уделите время саморазвитию, например, чтению или медитации, чтобы зарядить позитивом весь день."
},
{
"id": "D",
"status": 200,
"message": "проведи анализ: почему тормозит база, и потом добавь в задачу пометку для проверки",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "4607460cd39d273d99ae50a7d8315517",
"speech_act": "QUESTION",
"cognition": "not_requested",
"clarification": "REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "У меня есть несколько возможных ожиданий. Уточните, о каком именно речь: дедлайн в пятницу; дедлайн в пятницу; дедлайн в пятницу"
},
{
"id": "E",
"message": "исправь прошлую формулировку",
"status": 502,
"body": "chat unavailable\n"
},
{
"id": "F",
"message": "обратись к модели gemma4 и спроси сколько звезд на небе",
"status": 502,
"body": "chat unavailable\n"
},
{
"id": "G",
"status": 200,
"message": "просто напомни, что нужно проверить",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "89b69b8251258a689f38de98552cbed4",
"speech_act": "QUESTION",
"cognition": "not_requested",
"clarification": "REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Нужно короткое уточнение, прежде чем выбирать capability или строить более тяжёлый маршрут."
},
{
"id": "H",
"status": 200,
"message": "Запомни тебя зовут Домовой, я тебя буду звать Домовым",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "0c3bcf9b8f423e93b1b255aa33a557db",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Записал как утверждение человека. Это пока не проверенный факт."
},
{
"id": "H2",
"status": 200,
"message": "для меня ты Домовой",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "7847420f28c85894400c6a5be3e760d0",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Записал как утверждение человека. Это пока не проверенный факт."
},
{
"id": "H3",
"status": 200,
"message": "давай я буду звать тебя Домовым",
"conversation_id": "2ec5b5b9c3e742a7a3dce894020da7ca",
"utterance_id": "085e4194a61cbfd097118553342e063a",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"project_provenance": "",
"active_focus": "",
"capability_status": "",
"capability_kind": "",
"delegation_id": "",
"delegation_state": "",
"work_planned": false,
"reminder_scheduled": false,
"reply": "Записал как утверждение человека. Это пока не проверенный факт."
}
]
@@ -1,184 +0,0 @@
[
{
"id": "A",
"status": 200,
"message": "ты кто",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "7d14f020fc2134f0ba995c35ebe31523",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"reply": "Я искусственный интеллект, созданный для помощи. Как я могу быть полезен сейчас?",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "B",
"status": 200,
"message": "сколько будет 2 + 2",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "0a739b4810420e0eb0615d8d4cd4b60b",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"reply": "2 + 2 equals 4.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "C",
"status": 200,
"message": "как с утра",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "4463c0cc31a552a23c0c0ba4eff06554",
"speech_act": "QUESTION",
"cognition": "COMPLETED",
"clarification": "NOT_REQUIRED",
"reply": "Утро - это прекрасное время для начала нового дня. Начните с здорового питания, которое поможет вам начать день с бодрости и энергии. Не забудьте о hydratation, выпив适量的水. Уделите время саморазвитию, например, чтению или медитации, чтобы зарядить позитивом весь день.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "D",
"status": 200,
"message": "проведи анализ: почему тормозит база, и потом добавь в задачу пометку для проверки",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "c80c1eb2ce08edca3add568825c444d7",
"speech_act": "QUESTION",
"cognition": "not_requested",
"clarification": "REQUIRED",
"reply": "У меня есть несколько возможных ожиданий. Уточните, о каком именно речь: дедлайн в пятницу; дедлайн в пятницу; дедлайн в пятницу",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "EXPLICIT",
"active_focus": "УПО"
},
{
"id": "E",
"status": 502,
"message": "исправь прошлую формулировку",
"error": "request_failed"
},
{
"id": "F",
"status": 502,
"message": "обратись к модели gemma4 и спроси сколько звезд на небе",
"error": "request_failed"
},
{
"id": "G",
"status": 200,
"message": "просто напомни, что нужно проверить",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "0519382bed09ae5ea9c9a3b2f2af2641",
"speech_act": "QUESTION",
"cognition": "not_requested",
"clarification": "REQUIRED",
"reply": "Нужно короткое уточнение, прежде чем выбирать capability или строить более тяжёлый маршрут.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "H",
"status": 200,
"message": "Запомни тебя зовут Домовой",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "aae512074bf81adb405c0c80331c9096",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"reply": "Записал как утверждение человека. Это пока не проверенный факт.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "H2",
"status": 200,
"message": "для меня ты Домовой",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "78a171bb2f405e72f98f3b19d7422514",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"reply": "Записал как утверждение человека. Это пока не проверенный факт.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "H3",
"status": 200,
"message": "давай я буду звать тебя Домовым",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "382bd2310ed4a86f671f02c45afe3aee",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"reply": "Записал как утверждение человека. Это пока не проверенный факт.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
},
{
"id": "H4",
"status": 200,
"message": "Вернемся к УПО",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "8cb57dcf2599947673d1c1a4bb154266",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"reply": "Фокус переключён: УПО.",
"work": false,
"capability_status": "",
"capability_kind": "",
"delegation_state": "",
"project_provenance": "EXPLICIT",
"active_focus": "УПО"
},
{
"id": "I",
"status": 200,
"message": "помоги ответить на письмо бухгалтеру",
"conversation_id": "5e5ba4546dc20a4f6180d674a276fb5a",
"utterance_id": "ebeacce8340779490936e702b67543fa",
"speech_act": "ASSERTION",
"cognition": "not_requested",
"clarification": "NOT_REQUIRED",
"reply": "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксировал capability gap, без ложного выполнения.",
"work": false,
"capability_status": "UNAVAILABLE",
"capability_kind": "SEND_EMAIL",
"delegation_state": "",
"project_provenance": "",
"active_focus": ""
}
]
@@ -1,82 +0,0 @@
## Production Recovery Incident Log
Date: 2026-08-18
Objective: Restore last known-good production with minimal changes, no code deploy from DEV-000072.
### 1) Pre-recovery verification (reconfirmed)
- Last observed bad running revision: `36fec6c4f8aabf5e9f5f4ac0d5e2f9c0b8a5a9f0`.
- Last known-good revision from durable manifests: `11a6a23f6e4f44dd88265a04846419a27f54df98`.
- Known-good deployment artifact IDs were present in `docker-prod` and matched both:
- `%LOCALAPPDATA%\Domovoy\monitoring\deployment-manifest.json`
- `\\nas\MST\codex\_monitoring\domovoy\deployment-manifest.json`
- Pre-recovery container state:
- `assistant-postgres-1` was running but unhealthy.
- `assistant-notification-receiver-1` healthy.
- `assistant-core-runtime-1`, `assistant-commit-runtime-1`, `assistant-cognitive-worker-1`, `assistant-creator-web-1` were `Created`.
- `assistant-continuity-runtime-1` was restarting.
- Continuity startup logs repeatedly reported `COMMIT_EXECUTOR_TOKEN is required`.
- DB read checks were reachable through `postgres` role and owner roles existed; schema objects in main owner schemas were present, including `interaction.utterance_interpretations` + `interaction.semantic_candidates` and expected 4 semantic columns.
### 2) Recovered configuration inputs
Classification used:
- `DURABLE_CANONICAL`: long-lived non-secret production evidence.
- `PROTECTED_DURABLE`: host/docker secret artifacts that are persistent, permission-protected inputs.
- `BREAK_GLASS_RUNTIME_EVIDENCE`: local runtime incident recovery metadata used only after verification.
- `POSTGRES_SUPERUSER` / passwords:
- `DURABLE_CANONICAL`: compose contract requires them.
- `PROTECTED_DURABLE`: secret-backed postgres volumes for password material.
- `BREAK_GLASS_RUNTIME_EVIDENCE`: `secrets/production-recovery.env` provided non-empty values and enabled rollback configuration.
- `COMMIT_EXECUTOR_TOKEN`:
- `DURABLE_CANONICAL`: required by continuity/commit contracts.
- `BREAK_GLASS_RUNTIME_EVIDENCE`: populated from `secrets/production-recovery.env`.
- `CREATOR_USERNAME`:
- `BREAK_GLASS_RUNTIME_EVIDENCE`: `secrets/production-recovery.env` (`m`).
- `epistemics_db_password`, `interaction_db_password`, `creator_bootstrap_token`, `creator_password`, `creator_session_signing_key`,
`notification writer/reader tokens`:
- `PROTECTED_DURABLE`: existing Docker volumes `assistant_core_secrets`,
`assistant_notification_writer_secret`, `assistant_notification_reader_secret`.
### 3) Recovery plan (finalized)
- Target revision: `11a6a23f6e4f44dd88265a04846419a27f54df98`.
- Keep durable data volumes unchanged:
- `assistant_assistant_pg`
- `assistant_core_secrets`
- `assistant_notification_writer_secret`
- `assistant_notification_reader_secret`
- `assistant_notification_receipts`
- `assistant_nats`
- Action set:
1. Retag known-good images from local/NAS manifests as active `assistant-* : latest`.
2. Recreate assistant stack on `docker-prod` with `--env-file secrets/production-recovery.env`, `--no-build`, `--force-recreate`.
3. Verify health and endpoint checks listed in section 5.
### 4) Recovery actions executed
- Recreated all assistant services on `docker-prod` (and `nats`) from known-good image IDs.
- Loaded production recovery env (`secrets/production-recovery.env`) into compose execution.
- Confirmed required runtime env is now present in service env:
- continuity/commit `COMMIT_EXECUTOR_TOKEN` is set.
- db user/passwords + creator bootstrap wiring are set.
- Verified all services now run with revision `11a6a23f6e4f44dd88265a04846419a27f54df98`.
- No credential values were printed in logs or evidence text.
### 5) Verification results
- `POSTGRES_HEALTH`: OK (postgres accepts connections).
- `CORE_RUNTIME_HEALTH`: OK (`/healthz` returns runtime ok).
- `CONTINUITY_RUNTIME_HEALTH`: OK (`/healthz` returns runtime ok).
- `COMMIT_RUNTIME_HEALTH`: OK (`/healthz` returns runtime ok).
- `CREATOR_WEB_HEALTH`: OK (`/healthz` returns runtime ok; container healthy).
- `EXTERNAL_HEALTH`: `https://assistant.cin.su/healthz` returns `HTTP 200`.
- `CHAT_SMOKE`: `https://assistant.cin.su/chat` returns `HTTP 200`; authenticated smoke message sent successfully via `POST /api/chat/messages` after session creation (harmless message `health check`), returning `HTTP 200`.
- Production artifact confirmation succeeded for revision `11a6a23f6e4f44dd88265a04846419a27f54df98` with 7 services.
### 6) Final state
- `KNOWN_GOOD_IMAGE_STATUS`: all required known-good image IDs present and active.
- `PRODUCTION_REVISION_AFTER_RECOVERY`: `11a6a23f6e4f44dd88265a04846419a27f54df98`.
- `DATA_INTEGRITY_STATUS`: preserved; existing schemas and `interaction.utterance_interpretations` semantic columns remain present.
- `CREDENTIAL_ROTATIONS`: none (used recovered durable config; no new credentials generated).
- Remaining limitation: commit/execution of this recovery changed image tags and container recreate timing only; no code/database migrations were altered.
### 7) Task status after recovery
- `DEV-000072_STATUS`: `PRODUCTION_ACCEPTANCE_BLOCKED_BY_DEPLOYMENT_INCIDENT` remains.
- Continue production readiness requires normal restart of production acceptance run from `docs/PROJECT_STATUS` after this incident checkpoint.
-23
View File
@@ -125,10 +125,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter
tokens := semanticTokens(message)
hasQuestionMark := strings.Contains(message, "?")
scores := semanticScores(tokens, hasQuestionMark, corrects != "")
if isIdentityNamingPreferenceDirective(tokens) {
scores[interaction.SpeechActDirective] += 5
scores[interaction.SpeechActPreference] += 4
}
primary, primaryScore := topAct(scores)
secondary, secondaryScore := secondAct(scores, primary)
if (startsWithModalRequest(tokens) || startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж")) &&
@@ -174,22 +170,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter
return candidate
}
func isIdentityNamingPreferenceDirective(tokens []string) bool {
if !containsToken(tokens, "ты") && !containsToken(tokens, "тебя") && !containsToken(tokens, "тебе") && !containsToken(tokens, "вам") {
return false
}
if containsStemInTokens(tokens, "запомни", "назови", "назвать", "назвав", "зови", "название", "называ", "обраща", "обращай", "обращайся", "звать", "зов", "зовут") {
return true
}
if containsStemInTokens(tokens, "домов", "assist", "ассист", "assistant", "assistant") {
return true
}
if containsStemInTokens(tokens, "буду", "будешь") && containsStemInTokens(tokens, "звать", "обращ", "называть") {
return true
}
return false
}
func semanticTokens(message string) []string {
lower := strings.ToLower(strings.TrimSpace(message))
replacer := strings.NewReplacer(".", " ", ",", " ", "!", " ", "?", " ", ":", " ", ";", " ", "(", " ", ")", " ", "\"", " ", "'", " ", "«", " ", "»", " ", "+", " + ", "-", " - ", "/", " / ")
@@ -389,9 +369,6 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se
}
func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens []string) bool {
if primary == interaction.SpeechActDirective && secondary == interaction.SpeechActPreference && isIdentityNamingPreferenceDirective(tokens) {
return false
}
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate {
return true
}
-19
View File
@@ -93,25 +93,6 @@ func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWit
}
}
func TestDeterministicCandidateClassifiesIdentityNamingPreferenceAsDirective(t *testing.T) {
for _, message := range []string{
"Запомни тебя зовут Домовой, я тебя буду звать Домовым",
"я буду звать тебя Домовым",
"давай я буду звать тебя Домовой",
"теперь буду обращаться к тебе как к Домовой",
"запомни, я называю тебя Домовым",
"для меня ты Домовой",
} {
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive for %q, got %+v", message, candidate)
}
if candidate.NeedsCognition {
t.Fatalf("expected no cognition for naming preference by default for %q, got %+v", message, candidate)
}
}
}
func TestDeterministicCandidateTreatsModelRequestAsDirectiveNeedingCognition(t *testing.T) {
candidate := BuildDeterministicCandidate("обратись к модели gemma4 и спроси сколько звезд на небе", "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
+1 -5
View File
@@ -30,10 +30,6 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
deterministicCandidate := BuildDeterministicCandidate(utterance.Content, utterance.CorrectsUtteranceID, millis)
deterministicCandidate.UtteranceID = utterance.ID
selectedCandidate := deterministicCandidate
assertionVerification := string(epistemics.VerificationNotApplicable)
if selectedCandidate.PrimarySpeechAct == interaction.SpeechActAssertion {
assertionVerification = string(epistemics.VerificationUnverified)
}
result := SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
ID: interaction.InterpretationID(interpretationID),
@@ -42,7 +38,7 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
SecondarySpeechAct: selectedCandidate.SecondarySpeechAct,
ConfidenceMillis: selectedCandidate.ConfidenceMillis,
Source: string(selectedCandidate.Source),
AssertionVerification: assertionVerification,
AssertionVerification: string(epistemics.VerificationUnverified),
RequestedOutcome: selectedCandidate.RequestedOutcome,
Clarification: selectedCandidate.Clarification,
NeedsCognition: selectedCandidate.NeedsCognition,
@@ -66,16 +66,6 @@ func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) {
}
}
func TestInterpretUtteranceIdentityNamingPreferenceNotAssertion(t *testing.T) {
result := interpretForTest(t, "Запомни тебя зовут Домовой, я тебя буду звать Домовым", nil, "")
if result.Interpretation.SpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive for naming preference, got %s", result.Interpretation.SpeechAct)
}
if result.Interpretation.AssertionVerification == "UNVERIFIED" {
t.Fatalf("expected no assertion semantics path for naming preference, got %#v", result.Interpretation)
}
}
func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "")
if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {