Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f2f9affa | ||
|
|
e47e033dda | ||
|
|
2dc5587fd1 | ||
|
|
992c84240b | ||
|
|
cfeb6aeba7 | ||
|
|
f0d45ca9f1 | ||
|
|
e20c9fdfc0 | ||
|
|
cb50a120e5 | ||
|
|
b30eb38eca | ||
|
|
2083096aed | ||
|
|
671ecb4968 | ||
|
|
af9d5809e9 | ||
|
|
990928618d | ||
|
|
9fac8284f5 | ||
|
|
7d9a5edee5 | ||
|
|
32d3f318a5 | ||
|
|
3a563a29ee | ||
|
|
734db5d779 | ||
|
|
36fc8ec8c6 | ||
|
|
b8c001be4c | ||
|
|
eeeba6b7c0 | ||
|
|
fce3ec569c | ||
|
|
a925d787dc | ||
|
|
43556e1603 | ||
|
|
dd00dabf32 |
@@ -0,0 +1,18 @@
|
|||||||
|
package multimodal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"git.cin.su/m/Assistant/contracts/artifact"
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeIngress(id string, modality interaction.IngressModality, sourceRef, deviceRef, locationRef, principalID, contentType string, data []byte, response capability.MultimodalResponse, at int64) (interaction.IngressEnvelope, artifact.MediaArtifact) {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
artifactValue := artifact.MediaArtifact{ID: artifact.ArtifactID("artifact:media:" + hex.EncodeToString(hash[:8])), ContentType: contentType, SizeBytes: int64(len(data)), ContentDigest: hex.EncodeToString(hash[:]), SourceRef: sourceRef, RecordedAtUnixMillis: at}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, Degraded: true, RecordedAtUnixMillis: at}, artifact.MediaArtifact{}
|
||||||
|
}
|
||||||
|
return interaction.IngressEnvelope{ID: id, Modality: modality, SourceRef: sourceRef, DeviceRef: deviceRef, LocationRef: locationRef, PrincipalID: principalID, ArtifactRef: string(artifactValue.ID), ObservationRef: response.EvidenceRef, ProviderRef: response.Provider, ModelRef: response.Model, ModelVersion: response.ModelVersion, ConfidenceMillis: response.ConfidenceMillis, RecordedAtUnixMillis: at}, artifactValue
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package multimodal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.cin.su/m/Assistant/adapters/multimodal"
|
||||||
|
"git.cin.su/m/Assistant/contracts/capability"
|
||||||
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIngressNormalizesMediaArtifactAndObservationProvenance(t *testing.T) {
|
||||||
|
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressImage, "camera:street", "device:cam", "world:street", "principal", "image/png", []byte("image"), capability.MultimodalResponse{Provider: "local", Model: "vision", ModelVersion: "1", EvidenceRef: "observation-1", ConfidenceMillis: 800}, 1)
|
||||||
|
if !e.Valid() || !a.Valid() || e.ArtifactRef == "" || e.ObservationRef != "observation-1" || e.DeviceRef == "" || e.LocationRef == "" {
|
||||||
|
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnavailableIngressDegradesWithoutFabricatingObservation(t *testing.T) {
|
||||||
|
e, a := multimodal.NormalizeIngress("ingress-1", interaction.IngressAudio, "mic", "device:mic", "", "principal", "audio/wav", nil, capability.MultimodalResponse{}, 1)
|
||||||
|
if !e.Degraded || e.ObservationRef != "" || a.Valid() {
|
||||||
|
t.Fatalf("envelope=%+v artifact=%+v", e, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -533,7 +533,7 @@ func main() {
|
|||||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||||
}
|
}
|
||||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||||
gen2Context := interactiondomain.BuildGen2Context(conversationHistory)
|
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
|
||||||
if !creativeUserRequest && locationReply == "" {
|
if !creativeUserRequest && locationReply == "" {
|
||||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||||
continuationMessage = continuation
|
continuationMessage = continuation
|
||||||
|
|||||||
@@ -67,14 +67,26 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
|||||||
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
|
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
|
||||||
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
|
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
|
||||||
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
|
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
|
||||||
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }
|
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; background: radial-gradient(circle at 50% 0%, rgba(201, 168, 95, .045), transparent 42%); }
|
||||||
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: 28px 18px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
|
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: clamp(28px, 5vh, 56px) 18px 24px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; scrollbar-color: #39483f transparent; }
|
||||||
.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 { max-width: min(720px, 92%); padding: 13px 16px 14px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; }
|
||||||
.message.user { align-self: flex-end; background: #151e1a; }
|
.message.user { align-self: flex-end; background: #18221c; border-color: #3a493d; }
|
||||||
.message.assistant { align-self: flex-start; background: #0f1613; }
|
.message.assistant { align-self: flex-start; background: #111a15; box-shadow: 0 8px 20px rgba(0, 0, 0, .12); }
|
||||||
|
.message-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 8px; }
|
||||||
|
.message-role { color: var(--signal); font: 600 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.message.user .message-role { color: var(--good); }
|
||||||
|
.message-time { color: #68776d; font: 9px/1 var(--mono); }
|
||||||
|
.message-content { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
|
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
|
||||||
.composer { width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
|
.composer { width: min(100%, 920px); margin: 0 auto; padding: 10px 18px 8px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; border-top: 1px solid rgba(42, 53, 47, .85); background: linear-gradient(var(--base), rgba(11, 16, 14, .98)); }
|
||||||
.composer button { height: 48px; align-self: end; }
|
.composer-input-wrap { min-width: 0; border: 1px solid #3a493f; border-radius: 5px; background: #101813; transition: border-color .15s ease, box-shadow .15s ease; }
|
||||||
|
.composer-input-wrap:focus-within { border-color: var(--signal); box-shadow: 0 0 0 3px rgba(201, 168, 95, .12); }
|
||||||
|
.composer textarea { min-height: 48px; max-height: 220px; resize: none; border: 0; background: transparent; }
|
||||||
|
.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 8px; color: #68776d; font: 9px/1 var(--mono); }
|
||||||
|
.composer-hint output { color: var(--muted); white-space: nowrap; }
|
||||||
|
.composer button { height: 48px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; padding-inline: 15px; }
|
||||||
|
.composer button[aria-busy="true"] { color: #4b432d; background: #ad9353; }
|
||||||
|
.composer button span:last-child { opacity: .65; font: 16px/1 var(--mono); }
|
||||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
||||||
.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
|
.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
|
||||||
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
|
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
|
||||||
@@ -107,8 +119,9 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
|||||||
.empty-state.error { color: var(--bad); }
|
.empty-state.error { color: var(--bad); }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
|
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
|
||||||
.composer { grid-template-columns: 1fr; }
|
.composer { grid-template-columns: 1fr; padding-inline: 12px; }
|
||||||
.composer button { width: 100%; }
|
.composer button { width: 100%; }
|
||||||
|
.composer-hint { font-size: 8px; }
|
||||||
.conversation-starters { align-items: stretch; }
|
.conversation-starters { align-items: stretch; }
|
||||||
.conversation-starters span { width: 100%; }
|
.conversation-starters span { width: 100%; }
|
||||||
.chat-header { gap: 10px; padding: 0 12px; }
|
.chat-header { gap: 10px; padding: 0 12px; }
|
||||||
@@ -117,8 +130,12 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
|||||||
.chat-header > .operator-link, .build-meta { display: none; }
|
.chat-header > .operator-link, .build-meta { display: none; }
|
||||||
.chat-header > #logout { flex: 0 0 38px; }
|
.chat-header > #logout { flex: 0 0 38px; }
|
||||||
.chat-layout { height: auto; display: block; }
|
.chat-layout { height: auto; display: block; }
|
||||||
|
.chat-layout { display: flex; flex-direction: column; }
|
||||||
|
.chat-main { order: 1; }
|
||||||
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
||||||
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.chat-left { order: 2; }
|
||||||
|
.chat-right { order: 3; }
|
||||||
.chat-main { height: 70vh; min-height: 520px; }
|
.chat-main { height: 70vh; min-height: 520px; }
|
||||||
.chat-left { padding-bottom: 20px; }
|
.chat-left { padding-bottom: 20px; }
|
||||||
.chat-right { padding-top: 20px; }
|
.chat-right { padding-top: 20px; }
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const messages = document.querySelector("#messages");
|
|||||||
const chatForm = document.querySelector("#chat-form");
|
const chatForm = document.querySelector("#chat-form");
|
||||||
const messageInput = document.querySelector("#message");
|
const messageInput = document.querySelector("#message");
|
||||||
const sendButton = document.querySelector("#send");
|
const sendButton = document.querySelector("#send");
|
||||||
|
const sendLabel = document.querySelector("#send-label");
|
||||||
|
const messageCount = document.querySelector("#message-count");
|
||||||
const logoutButton = document.querySelector("#logout");
|
const logoutButton = document.querySelector("#logout");
|
||||||
const activeFocus = document.querySelector("#active-focus");
|
const activeFocus = document.querySelector("#active-focus");
|
||||||
const cognitiveStatus = document.querySelector("#cognitive-status");
|
const cognitiveStatus = document.querySelector("#cognitive-status");
|
||||||
@@ -24,6 +26,13 @@ let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
|||||||
let localHistory = [];
|
let localHistory = [];
|
||||||
let cognitiveStatusTimer = null;
|
let cognitiveStatusTimer = null;
|
||||||
|
|
||||||
|
function resizeComposerInput() {
|
||||||
|
if (!messageInput) return;
|
||||||
|
messageInput.style.height = "auto";
|
||||||
|
messageInput.style.height = `${Math.min(messageInput.scrollHeight, 220)}px`;
|
||||||
|
if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`;
|
||||||
|
}
|
||||||
|
|
||||||
class ChatApiError extends Error {
|
class ChatApiError extends Error {
|
||||||
constructor(status, detail = "") {
|
constructor(status, detail = "") {
|
||||||
super(detail || `chat api returned ${status}`);
|
super(detail || `chat api returned ${status}`);
|
||||||
@@ -95,6 +104,7 @@ function showChat() {
|
|||||||
}
|
}
|
||||||
startCognitiveStatusPolling();
|
startCognitiveStatusPolling();
|
||||||
messageInput.focus();
|
messageInput.focus();
|
||||||
|
resizeComposerInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogin() {
|
function showLogin() {
|
||||||
@@ -106,7 +116,22 @@ function showLogin() {
|
|||||||
function appendMessage(kind, text, meta = "", media = null) {
|
function appendMessage(kind, text, meta = "", media = null) {
|
||||||
const node = document.createElement("div");
|
const node = document.createElement("div");
|
||||||
node.className = `message ${kind}`;
|
node.className = `message ${kind}`;
|
||||||
node.textContent = text;
|
const head = document.createElement("div");
|
||||||
|
head.className = "message-head";
|
||||||
|
const role = document.createElement("strong");
|
||||||
|
role.className = "message-role";
|
||||||
|
role.textContent = kind === "user" ? "Вы" : "Домовой";
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.className = "message-time";
|
||||||
|
const now = new Date();
|
||||||
|
time.dateTime = now.toISOString();
|
||||||
|
time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
|
||||||
|
head.append(role, time);
|
||||||
|
node.appendChild(head);
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "message-content";
|
||||||
|
content.textContent = text;
|
||||||
|
node.appendChild(content);
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const small = document.createElement("small");
|
const small = document.createElement("small");
|
||||||
small.textContent = meta;
|
small.textContent = meta;
|
||||||
@@ -279,6 +304,9 @@ async function sendChatMessage(text, options = {}) {
|
|||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
if (!options.silentUser) appendMessage("user", clean);
|
if (!options.silentUser) appendMessage("user", clean);
|
||||||
sendButton.disabled = true;
|
sendButton.disabled = true;
|
||||||
|
sendButton.setAttribute("aria-busy", "true");
|
||||||
|
if (sendLabel) sendLabel.textContent = "Домовой обрабатывает";
|
||||||
|
messages.setAttribute("aria-busy", "true");
|
||||||
try {
|
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;
|
conversationID = result.conversation_id;
|
||||||
@@ -299,6 +327,9 @@ async function sendChatMessage(text, options = {}) {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
sendButton.disabled = false;
|
sendButton.disabled = false;
|
||||||
|
sendButton.removeAttribute("aria-busy");
|
||||||
|
if (sendLabel) sendLabel.textContent = "Отправить";
|
||||||
|
messages.removeAttribute("aria-busy");
|
||||||
messageInput.focus();
|
messageInput.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,9 +365,12 @@ chatForm.addEventListener("submit", async (event) => {
|
|||||||
const text = messageInput.value.trim();
|
const text = messageInput.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
|
resizeComposerInput();
|
||||||
await sendChatMessage(text);
|
await sendChatMessage(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
messageInput.addEventListener("input", resizeComposerInput);
|
||||||
|
|
||||||
async function processMedia(input, path, label) {
|
async function processMedia(input, path, label) {
|
||||||
const file = input?.files?.[0];
|
const file = input?.files?.[0];
|
||||||
if (!file || !mediaStatus) return;
|
if (!file || !mediaStatus) return;
|
||||||
@@ -405,3 +439,4 @@ if (token) {
|
|||||||
} else {
|
} else {
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
resizeComposerInput();
|
||||||
|
|||||||
@@ -57,8 +57,11 @@
|
|||||||
<section id="chat-panel" class="surface-panel active-panel">
|
<section id="chat-panel" class="surface-panel active-panel">
|
||||||
<div id="messages" class="messages" aria-live="polite"></div>
|
<div id="messages" class="messages" aria-live="polite"></div>
|
||||||
<form id="chat-form" class="composer">
|
<form id="chat-form" class="composer">
|
||||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
<div class="composer-input-wrap">
|
||||||
<button id="send" type="submit">Отправить</button>
|
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||||
|
<div class="composer-hint"><span>Enter — отправить · Shift+Enter — новая строка</span><output id="message-count" aria-live="polite">0 / 16384</output></div>
|
||||||
|
</div>
|
||||||
|
<button id="send" type="submit"><span id="send-label">Отправить</span><span aria-hidden="true">↵</span></button>
|
||||||
</form>
|
</form>
|
||||||
<div class="media-tools" aria-label="Мультимодальные сообщения">
|
<div class="media-tools" aria-label="Мультимодальные сообщения">
|
||||||
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
|
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
|
||||||
|
|||||||
+207
-5
@@ -1,10 +1,212 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.cin.su/m/Assistant/runtime/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type server struct {
|
||||||
|
lifeboat *supervisor.PersistentLifeboat
|
||||||
|
stableUpstream string
|
||||||
|
candidate string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
_ = http.ListenAndServe(":8088", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
statePath := envOr("SUPERVISOR_STATE_PATH", "/var/lib/supervisor/release-state.json")
|
||||||
w.WriteHeader(http.StatusOK)
|
stable := strings.TrimRight(envOr("SUPERVISOR_STABLE_UPSTREAM", "http://creator-web:8084"), "/")
|
||||||
_, _ = w.Write([]byte("lifeboat-ready\n"))
|
candidate := strings.TrimRight(envOr("SUPERVISOR_CANDIDATE_UPSTREAM", "http://creator-web-candidate:8084"), "/")
|
||||||
}))
|
store, err := supervisor.NewDurableStore(statePath)
|
||||||
|
if err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
lifeboat := supervisor.NewPersistentLifeboat(store)
|
||||||
|
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
|
||||||
|
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
|
||||||
|
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
|
||||||
|
fatal(fmt.Errorf("initialize baseline: %v", startErr))
|
||||||
|
}
|
||||||
|
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
|
||||||
|
fatal(fmt.Errorf("initialize baseline observation: %v", observeErr))
|
||||||
|
}
|
||||||
|
if ok, trafficErr := lifeboat.SetTraffic(supervisor.TrafficStable, stable, candidate); trafficErr != nil || !ok {
|
||||||
|
fatal(fmt.Errorf("initialize traffic: %v", trafficErr))
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 5 * time.Second}}
|
||||||
|
public := http.NewServeMux()
|
||||||
|
public.HandleFunc("GET /healthz", s.health)
|
||||||
|
public.HandleFunc("GET /readyz", s.ready)
|
||||||
|
public.HandleFunc("/", s.proxy)
|
||||||
|
control := http.NewServeMux()
|
||||||
|
control.HandleFunc("GET /v1/state", s.state)
|
||||||
|
control.HandleFunc("POST /v1/releases/start", s.start)
|
||||||
|
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
|
||||||
|
control.HandleFunc("POST /v1/releases/observe", s.observe)
|
||||||
|
control.HandleFunc("POST /v1/releases/promote", s.promote)
|
||||||
|
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
|
||||||
|
control.HandleFunc("POST /v1/traffic/canary", s.canary)
|
||||||
|
control.HandleFunc("POST /v1/traffic/promote", s.promoteTraffic)
|
||||||
|
control.HandleFunc("POST /v1/traffic/rollback", s.rollbackTraffic)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := http.ListenAndServe(envOr("SUPERVISOR_CONTROL_ADDR", ":8095"), control); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := http.ListenAndServe(envOr("LISTEN_ADDR", ":8080"), public); err != nil {
|
||||||
|
fatal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := s.lifeboat.Snapshot()
|
||||||
|
if err != nil || state.StableUpstream == "" {
|
||||||
|
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, state.StableUpstream+"/healthz", nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response, err := s.client.Do(request)
|
||||||
|
if err != nil || response.StatusCode/100 != 2 {
|
||||||
|
if response != nil {
|
||||||
|
response.Body.Close()
|
||||||
|
}
|
||||||
|
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Body.Close()
|
||||||
|
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) proxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := s.lifeboat.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := state.StableUpstream
|
||||||
|
selected := "stable"
|
||||||
|
if state.TrafficMode == supervisor.TrafficCandidate || (state.TrafficMode == supervisor.TrafficCanaryHeader && strings.EqualFold(r.Header.Get("X-Assistant-Canary"), "true")) {
|
||||||
|
target = state.CandidateUpstream
|
||||||
|
selected = "candidate"
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(target)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
http.Error(w, "invalid upstream", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(parsed)
|
||||||
|
proxy.ModifyResponse = func(response *http.Response) error {
|
||||||
|
response.Header.Set("X-Assistant-Traffic-Target", selected)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, _ error) {
|
||||||
|
http.Error(writer, "upstream unavailable", http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) state(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
state, err := s.lifeboat.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "state unavailable", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, state)
|
||||||
|
}
|
||||||
|
func (s *server) start(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var release supervisor.Release
|
||||||
|
if !decode(w, r, &release) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := s.lifeboat.Start(release)
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var release supervisor.Release
|
||||||
|
if !decode(w, r, &release) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := s.lifeboat.EstablishBaseline(release)
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var observation supervisor.Observation
|
||||||
|
if !decode(w, r, &observation) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := s.lifeboat.Record(observation)
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) promote(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ok, err := s.lifeboat.Promote()
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) rollback(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ok, err := s.lifeboat.Rollback()
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) canary(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ok, err := s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ok, err := s.lifeboat.Promote()
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
ok, err := s.lifeboat.RollbackTraffic()
|
||||||
|
respond(w, ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(value); err != nil {
|
||||||
|
http.Error(w, "invalid control request", 400)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
func respond(w http.ResponseWriter, ok bool, err error) {
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "control state unavailable", 503)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "control transition rejected", 409)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
func writeJSON(w http.ResponseWriter, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ import "git.cin.su/m/Assistant/platform/ids"
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
type ArtifactID ids.ID
|
type ArtifactID ids.ID
|
||||||
|
type MediaArtifact struct {
|
||||||
|
ID ArtifactID
|
||||||
|
ContentType string
|
||||||
|
SizeBytes int64
|
||||||
|
ContentDigest string
|
||||||
|
SourceRef string
|
||||||
|
RecordedAtUnixMillis int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a MediaArtifact) Valid() bool {
|
||||||
|
return a.ID != "" && a.ContentType != "" && a.SizeBytes > 0 && a.ContentDigest != "" && a.SourceRef != "" && a.RecordedAtUnixMillis > 0
|
||||||
|
}
|
||||||
|
|
||||||
type AgentArtifactAttestation struct {
|
type AgentArtifactAttestation struct {
|
||||||
ArtifactID ArtifactID
|
ArtifactID ArtifactID
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MultimodalResponse struct {
|
type MultimodalResponse struct {
|
||||||
Operation MultimodalOperation `json:"operation"`
|
Operation MultimodalOperation `json:"operation"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
ObservedAt int64 `json:"observed_at_unix_millis"`
|
ObservedAt int64 `json:"observed_at_unix_millis"`
|
||||||
ConfidenceNote string `json:"confidence_note,omitempty"`
|
ConfidenceNote string `json:"confidence_note,omitempty"`
|
||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
DataBase64 string `json:"data_base64,omitempty"`
|
DataBase64 string `json:"data_base64,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
ModelVersion string `json:"model_version,omitempty"`
|
||||||
|
ConfidenceMillis int64 `json:"confidence_millis,omitempty"`
|
||||||
|
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ type SpeakerAttribution struct {
|
|||||||
Authenticated bool
|
Authenticated bool
|
||||||
ObservationRef string
|
ObservationRef string
|
||||||
}
|
}
|
||||||
|
type IngressModality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IngressText IngressModality = "TEXT"
|
||||||
|
IngressAudio IngressModality = "AUDIO"
|
||||||
|
IngressImage IngressModality = "IMAGE"
|
||||||
|
IngressVideo IngressModality = "VIDEO"
|
||||||
|
IngressDevice IngressModality = "DEVICE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IngressEnvelope struct {
|
||||||
|
ID string
|
||||||
|
Modality IngressModality
|
||||||
|
SourceRef string
|
||||||
|
DeviceRef string
|
||||||
|
LocationRef string
|
||||||
|
PrincipalID string
|
||||||
|
ArtifactRef string
|
||||||
|
ObservationRef string
|
||||||
|
ProviderRef string
|
||||||
|
ModelRef string
|
||||||
|
ModelVersion string
|
||||||
|
ConfidenceMillis int64
|
||||||
|
Degraded bool
|
||||||
|
RecordedAtUnixMillis int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e IngressEnvelope) Valid() bool {
|
||||||
|
return e.ID != "" && e.Modality != "" && e.SourceRef != "" && e.ConfidenceMillis >= 0 && e.ConfidenceMillis <= 1000 && e.RecordedAtUnixMillis > 0 && (!e.Degraded || e.ObservationRef == "")
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ChannelWebChat Channel = "WEB_CHAT"
|
ChannelWebChat Channel = "WEB_CHAT"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
|
||||||
|
-- This is intentionally separate from feature migrations and may be run repeatedly.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
|
||||||
|
CREATE ROLE world_role LOGIN;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
|
||||||
|
CREATE ROLE configuration_role LOGIN;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
|
||||||
|
CREATE ROLE self_role LOGIN;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
role_name TEXT;
|
||||||
|
can_login BOOLEAN;
|
||||||
|
BEGIN
|
||||||
|
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
|
||||||
|
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
|
||||||
|
IF can_login IS DISTINCT FROM TRUE THEN
|
||||||
|
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
|
||||||
|
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
|
||||||
|
|
||||||
|
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
schema_name TEXT;
|
||||||
|
expected_owner TEXT;
|
||||||
|
actual_owner TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
|
||||||
|
expected_owner := schema_name || '_role';
|
||||||
|
SELECT pg_get_userbyid(nspowner)
|
||||||
|
INTO actual_owner
|
||||||
|
FROM pg_namespace
|
||||||
|
WHERE nspname = schema_name;
|
||||||
|
IF actual_owner IS DISTINCT FROM expected_owner THEN
|
||||||
|
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -25,6 +25,16 @@ psql_exec() {
|
|||||||
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
|
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
|
||||||
|
# before feature migrations run. This keeps role creation out of migrations and
|
||||||
|
# makes existing PostgreSQL volumes converge idempotently as well.
|
||||||
|
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
|
||||||
|
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
|
||||||
|
echo "migration runner: Gen2 bootstrap is missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
psql_exec < "$GEN2_BOOTSTRAP"
|
||||||
|
|
||||||
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
|
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
|
||||||
|
|
||||||
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
|
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ services:
|
|||||||
- --port
|
- --port
|
||||||
- "8086"
|
- "8086"
|
||||||
- --model
|
- --model
|
||||||
- /models/Qwen3-8B-Q4_K_M.gguf
|
- /models/Qwen3.8-27B-UD-Q2_K_XL.gguf
|
||||||
- --alias
|
- --alias
|
||||||
- domovoy-lifeboat
|
- domovoy-lifeboat
|
||||||
- --jinja
|
- --jinja
|
||||||
@@ -299,6 +299,29 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
supervisor:
|
||||||
|
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: supervisor, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
LISTEN_ADDR: ":8080"
|
||||||
|
SUPERVISOR_CONTROL_ADDR: ":8095"
|
||||||
|
SUPERVISOR_STATE_PATH: /var/lib/supervisor/release-state.json
|
||||||
|
SUPERVISOR_STABLE_UPSTREAM: http://creator-web:8084
|
||||||
|
SUPERVISOR_CANDIDATE_UPSTREAM: http://creator-web-candidate:8084
|
||||||
|
SUPERVISOR_BASELINE_RELEASE: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||||
|
ports:
|
||||||
|
- "${CREATOR_WEB_PORT:-18080}:8080"
|
||||||
|
- "127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}:8095"
|
||||||
|
depends_on:
|
||||||
|
creator-web: { condition: service_healthy }
|
||||||
|
volumes:
|
||||||
|
- assistant_supervisor_state:/var/lib/supervisor
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
creator-web:
|
creator-web:
|
||||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -310,7 +333,6 @@ services:
|
|||||||
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
|
||||||
AGENT_WORKER_URL: http://agent-worker:8090
|
AGENT_WORKER_URL: http://agent-worker:8090
|
||||||
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
|
||||||
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
|
|
||||||
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
|
||||||
@@ -318,6 +340,12 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
creator-web-candidate:
|
||||||
|
profiles: ["assistant-canary"]
|
||||||
|
extends:
|
||||||
|
service: creator-web
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
assistant_pg:
|
assistant_pg:
|
||||||
assistant_nats:
|
assistant_nats:
|
||||||
@@ -333,6 +361,7 @@ volumes:
|
|||||||
external: true
|
external: true
|
||||||
assistant_research_sources:
|
assistant_research_sources:
|
||||||
name: assistant_research_sources
|
name: assistant_research_sources
|
||||||
|
assistant_supervisor_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
llm-local:
|
llm-local:
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
|||||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||||
|
|
||||||
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
|
||||||
|
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
|
||||||
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||||
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
|
|||||||
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
|
||||||
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
|
||||||
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
RUN addgroup -S assistant && adduser -S -G assistant assistant
|
||||||
|
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
|
||||||
USER assistant
|
USER assistant
|
||||||
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
|
||||||
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
|
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
|
||||||
|
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
|
||||||
|
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
|
||||||
|
SUPERVISOR_CONTROL_URL="${SUPERVISOR_CONTROL_URL:-http://127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}}"
|
||||||
|
REVISION="${1:?candidate revision is required}"
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
|
||||||
|
control() { curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL$1" "${@:2}" >/dev/null; }
|
||||||
|
fail_rollback() {
|
||||||
|
control /v1/traffic/rollback || true
|
||||||
|
control /v1/releases/rollback || true
|
||||||
|
echo "production canary failed; stable traffic rollback requested" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
compose --profile assistant-canary build --build-arg "SOURCE_REVISION=$REVISION" creator-web-candidate
|
||||||
|
compose --profile assistant-canary up -d --no-build creator-web-candidate
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' assistant-creator-web-candidate-1 2>/dev/null || true)"
|
||||||
|
[[ "$status" == healthy ]] && break
|
||||||
|
[[ "$attempt" == 30 ]] && fail_rollback
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
control /v1/releases/baseline -H 'Content-Type: application/json' --data "{\"id\":\"stable-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||||
|
control /v1/releases/start -H 'Content-Type: application/json' --data "{\"id\":\"candidate-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
|
||||||
|
control /v1/releases/observe -H 'Content-Type: application/json' --data "{\"release_id\":\"candidate-$REVISION\",\"ready\":true,\"reason\":\"candidate healthcheck passed\",\"observed_at_unix_millis\":$(date +%s%3N)}" || fail_rollback
|
||||||
|
control /v1/traffic/canary || fail_rollback
|
||||||
|
|
||||||
|
headers="$(mktemp)"
|
||||||
|
trap 'rm -f "$headers"' EXIT
|
||||||
|
if ! curl -fsS --max-time 10 -D "$headers" -H 'X-Assistant-Canary: true' "https://assistant.cin.su/login" -o /dev/null; then fail_rollback; fi
|
||||||
|
grep -qi '^X-Assistant-Traffic-Target: candidate' "$headers" || fail_rollback
|
||||||
|
|
||||||
|
control /v1/releases/promote || fail_rollback
|
||||||
|
echo "production canary promoted revision=$REVISION"
|
||||||
@@ -1 +1 @@
|
|||||||
{"task_file":"dev/tasks/GEN2-024-multi-person-realms-identity-boundaries-and-contextual-source-trust.json","task_id":"GEN2-024","task_revision":1}
|
{"task_file":"dev/tasks/GEN2-027-production-canary-and-controlled-gen2-traffic-transition.json","task_id":"GEN2-027","task_revision":1}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"task_id": "GEN2-DB-001",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ARCH-GEN2-000",
|
||||||
|
"base_repo_revision": "gen2-integration",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Исправить provisioning Gen2 owner roles/schemas до применения migrations 046-050 без ослабления V1 DB isolation.",
|
||||||
|
"requirement_ids": ["REQ-GEN2-DB-001-ROLES", "REQ-GEN2-DB-001-OWNERSHIP", "REQ-GEN2-DB-001-IDEMPOTENCY", "REQ-GEN2-DB-001-RECOVERY"],
|
||||||
|
"allowed_change_scope": {
|
||||||
|
"modules": ["database bootstrap", "deployment migration runner"],
|
||||||
|
"paths": [
|
||||||
|
"database/bootstrap/002_gen2_roles_and_schemas.sql",
|
||||||
|
"deploy/apply-migrations.sh",
|
||||||
|
"deploy/images/postgres.Dockerfile",
|
||||||
|
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json",
|
||||||
|
"dev/ACTIVE_TASK.json",
|
||||||
|
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"forbidden_change_scope": {
|
||||||
|
"paths": [
|
||||||
|
"GEN2-028",
|
||||||
|
"production-canary-before-provisioning",
|
||||||
|
"feature-migration-role-creation",
|
||||||
|
"cross-owner-grants",
|
||||||
|
"new canonical owner",
|
||||||
|
"production canary before provisioning"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["V1 database ownership/ACL", "GEN2 canonical owner registry", "GEN2 execution contract"], "may_change": ["Bootstrap provisioning only; feature migration semantics are unchanged."]},
|
||||||
|
"semantic_owner": "Dedicated PostgreSQL bootstrap/migration principal; semantic ownership remains World, Configuration, SELF and existing V1 owners.",
|
||||||
|
"canonical_state": ["world schema owned by world_role", "configuration schema owned by configuration_role", "self schema owned by self_role", "no cross-owner canonical writes"],
|
||||||
|
"state_transitions": ["missing Gen2 roles/schemas -> idempotently provisioned -> migrations 046-050 apply -> restart/recovery preserves schema and migration state"],
|
||||||
|
"side_effects_and_privileges": ["Create only the three declared LOGIN owner roles when absent.", "Revoke PUBLIC schema privileges and fail closed on an unexpected schema owner.", "Do not invent or print credentials; do not grant cross-owner access."],
|
||||||
|
"persistence_and_migrations": ["Provisioning runs in the dedicated bootstrap boundary before feature migrations.", "Migrations 046-050 remain unchanged and are applied by the existing migration history runner."],
|
||||||
|
"failure_retry_restart": ["Provisioning is safe to run repeatedly.", "Unexpected existing role/schema ownership fails closed and requires an architecture decision.", "PostgreSQL restart/recovery must retain owner roles, schemas, ACL and migration history."],
|
||||||
|
"assumptions": ["This is a user-authorized remediation task for the previously stopped GEN2-027 migration decision.", "No Gen2 runtime consumer currently requires a new database password; credentials are not invented by this task.", "GEN2-027 resumes only after provisioning and recovery evidence; GEN2-028 is not started."],
|
||||||
|
"acceptance_scenarios": ["GEN2-DB-001-SCN-01: all roles required by migrations 046-050 are identified", "GEN2-DB-001-SCN-02: provisioning succeeds twice without duplicate objects", "GEN2-DB-001-SCN-03: migrations 046-050 apply in the controlled deployment path", "GEN2-DB-001-SCN-04: schema owners and PUBLIC ACL are confirmed", "GEN2-DB-001-SCN-05: PostgreSQL restart/recovery preserves the result", "GEN2-DB-001-EXIT: migration blocker is removed without weakening V1 isolation"],
|
||||||
|
"required_tests": ["FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST whitespace: git diff --check gen2-integration --", "TARGETED: provisioning repeated twice, migrations 046-050, ACL/ownership verification and PostgreSQL restart/recovery"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"task_id": "GEN2-DEPLOY-001",
|
||||||
|
"task_revision": 1,
|
||||||
|
"architecture_revision": "ARCH-GEN2-000",
|
||||||
|
"base_repo_revision": "gen2-integration",
|
||||||
|
"risk_tier": "E",
|
||||||
|
"intent": "Развернуть независимый Supervisor control plane с durable release state, controlled traffic switching и доказуемым rollback перед GEN2-027.",
|
||||||
|
"requirement_ids": ["REQ-GEN2-DEPLOY-001-SUPERVISOR", "REQ-GEN2-DEPLOY-001-TRAFFIC", "REQ-GEN2-DEPLOY-001-ROLLBACK", "REQ-GEN2-DEPLOY-001-LIFEBOAT"],
|
||||||
|
"allowed_change_scope": {
|
||||||
|
"modules": ["deployment control", "runtime supervisor", "runtime health"],
|
||||||
|
"paths": [
|
||||||
|
"deploy/**",
|
||||||
|
"runtime/supervisor/**",
|
||||||
|
"runtime/health/**",
|
||||||
|
"cmd/supervisor/**",
|
||||||
|
"dev/ACTIVE_TASK.json",
|
||||||
|
"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json",
|
||||||
|
"docs/evidence/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"forbidden_change_scope": {
|
||||||
|
"paths": ["GEN2-028", "database/**", "security/**", "architecture/**", "Authority/Commit bypass", "cognitive-lifeboat as Supervisor"]
|
||||||
|
},
|
||||||
|
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||||
|
"contracts": {"consume": ["GEN2-027 production canary contract", "runtime/supervisor durable state", "existing deployment update controller"], "may_change": ["deployment topology, Supervisor HTTP control contract and traffic routing only"]},
|
||||||
|
"semantic_owner": "Operations/Supervisor; not a semantic owner and not a cognitive provider.",
|
||||||
|
"canonical_state": ["durable release/current/last-known-good evidence owned by Supervisor control plane", "traffic target is Supervisor-owned operational state"],
|
||||||
|
"state_transitions": ["baseline -> candidate -> observed healthy -> header canary -> promoted OR independent rollback to last-known-good"],
|
||||||
|
"side_effects_and_privileges": ["Control API is bound only to the production-internal loopback port.", "Supervisor has no database, Authority or Commit credentials.", "Candidate traffic is explicit and rollback remains available."],
|
||||||
|
"persistence_and_migrations": ["No database changes; durable state is stored in a dedicated named volume outside candidate containers."],
|
||||||
|
"failure_retry_restart": ["Supervisor restart reloads release and traffic state from its volume.", "Candidate failure must not erase last-known-good or block stable traffic rollback.", "Lifeboat model failure remains a separately reported degraded provider condition."],
|
||||||
|
"assumptions": ["GEN2-027 resumes only after this task's production evidence; GEN2-028 is not started.", "The existing public ingress forwards to the configured creator web host port."],
|
||||||
|
"acceptance_scenarios": ["GEN2-DEPLOY-001-SCN-01: Supervisor is a separate healthy production container", "GEN2-DEPLOY-001-SCN-02: release state survives Supervisor restart", "GEN2-DEPLOY-001-SCN-03: canary header reaches candidate while default traffic stays stable", "GEN2-DEPLOY-001-SCN-04: candidate failure rolls traffic back to last-known-good", "GEN2-DEPLOY-001-SCN-05: cognitive lifeboat diagnosis and degraded role are explicit", "GEN2-DEPLOY-001-EXIT: GEN2-027 rollback prerequisites are proven"],
|
||||||
|
"required_tests": ["FAST: go test ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST: go vet ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST governance: preflight", "FAST scope: check-diff", "FAST independent: independent", "FAST whitespace: git diff --check", "TARGETED recovery: Supervisor restart, canary and rollback on docker.cin.su"],
|
||||||
|
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"evidence_id": "gen2-deploy-001-production-recovery-2026-08-29",
|
||||||
|
"task_id": "GEN2-DEPLOY-001",
|
||||||
|
"production_revision_before": "36fc8ec",
|
||||||
|
"production_revision_after": "2083096aedaec73191e0bc9e2566f596e1810d82",
|
||||||
|
"topology": {
|
||||||
|
"supervisor": "assistant-supervisor-1",
|
||||||
|
"public_traffic": "Supervisor reverse proxy on creator web host port",
|
||||||
|
"control_api": "Supervisor loopback-only host control port",
|
||||||
|
"durable_state": "assistant_assistant_supervisor_state:/var/lib/supervisor/release-state.json",
|
||||||
|
"candidate": "assistant-creator-web-candidate-1"
|
||||||
|
},
|
||||||
|
"canary": {
|
||||||
|
"candidate_health": "healthy",
|
||||||
|
"header_route": "X-Assistant-Canary=true -> candidate",
|
||||||
|
"header_route_http": 200,
|
||||||
|
"promoted": true
|
||||||
|
},
|
||||||
|
"rollback": {
|
||||||
|
"drill": "candidate container stopped, Supervisor traffic rollback and release rollback invoked",
|
||||||
|
"stable_http": 200,
|
||||||
|
"stable_target_header": "stable",
|
||||||
|
"supervisor_restart": "healthy; traffic_mode=STABLE and stable release state restored"
|
||||||
|
},
|
||||||
|
"postgres_recovery": {
|
||||||
|
"restart": "healthy",
|
||||||
|
"public_healthz": 200,
|
||||||
|
"public_readyz": 200
|
||||||
|
},
|
||||||
|
"cognitive_lifeboat": {
|
||||||
|
"previous_status": "restart loop/unhealthy",
|
||||||
|
"cause": "configured Qwen3-8B-Q4_K_M.gguf was absent from llm-local-models volume",
|
||||||
|
"remediation": "compose model reference changed to existing Qwen3.8-27B-UD-Q2_K_XL.gguf",
|
||||||
|
"current_status": "healthy",
|
||||||
|
"role": "local degraded cognitive provider; not Supervisor"
|
||||||
|
},
|
||||||
|
"smoke": {
|
||||||
|
"public_healthz": 200,
|
||||||
|
"public_readyz": 200,
|
||||||
|
"public_login": 200,
|
||||||
|
"authenticated_household": "UNVERIFIED: browser session was unauthenticated; no credentials were read or entered"
|
||||||
|
},
|
||||||
|
"interpretation": "Supervisor independence, durable release state, controlled header canary, stable rollback and restart recovery are production-observed. Authenticated household acceptance remains an explicit gap."
|
||||||
|
}
|
||||||
@@ -11,6 +11,23 @@ type ContextProjection struct {
|
|||||||
// BuildGen2Context is a read-side projection over canonical utterance history.
|
// BuildGen2Context is a read-side projection over canonical utterance history.
|
||||||
// PersonalMemory remains a compatibility fallback and is never written here.
|
// PersonalMemory remains a compatibility fallback and is never written here.
|
||||||
func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
|
func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection {
|
||||||
|
return BuildScopedGen2Context(history, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildScopedGen2Context is the Gen2 retrieval boundary for household
|
||||||
|
// context. The caller supplies already-authorized history; this defensive
|
||||||
|
// filter prevents accidental cross-principal projection when a mixed source
|
||||||
|
// is passed during degraded operation.
|
||||||
|
func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection {
|
||||||
|
if principalID != "" {
|
||||||
|
scoped := make([]interaction.HumanUtterance, 0, len(history))
|
||||||
|
for _, item := range history {
|
||||||
|
if item.PrincipalID == "" || string(item.PrincipalID) == principalID {
|
||||||
|
scoped = append(scoped, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
history = scoped
|
||||||
|
}
|
||||||
text := CompilePersonalMemory(history).ContextText()
|
text := CompilePersonalMemory(history).ContextText()
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
|
return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package domain_test
|
|||||||
import (
|
import (
|
||||||
"git.cin.su/m/Assistant/contracts/interaction"
|
"git.cin.su/m/Assistant/contracts/interaction"
|
||||||
"git.cin.su/m/Assistant/modules/interaction/domain"
|
"git.cin.su/m/Assistant/modules/interaction/domain"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,3 +17,22 @@ func TestGen2ContextUsesRetrievalProjectionAndKeepsV1Fallback(t *testing.T) {
|
|||||||
t.Fatalf("fallback missing: %#v", empty)
|
t.Fatalf("fallback missing: %#v", empty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
|
||||||
|
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||||
|
{ID: "mine", PrincipalID: "principal-1", Content: "Я сейчас в Москве", ReceivedAtUnixMillis: 2},
|
||||||
|
{ID: "other", PrincipalID: "principal-2", Content: "Я сейчас в Казани", ReceivedAtUnixMillis: 3},
|
||||||
|
}, "principal-1")
|
||||||
|
if got.Text == "" || strings.Contains(got.Text, "Казани") || !strings.Contains(got.Text, "Москве") {
|
||||||
|
t.Fatalf("scoped context=%#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
|
||||||
|
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
|
||||||
|
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
|
||||||
|
}, "principal-1")
|
||||||
|
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
|
||||||
|
t.Fatalf("remembered assertion was not projected: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,5 +123,31 @@ func personalPreference(message string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Explicit remembered assertions are still human-provided context, not
|
||||||
|
// verified facts. Project the compact key/value statement without storing a
|
||||||
|
// second copy outside the canonical utterance history.
|
||||||
|
if index := strings.Index(lower, "запомни"); index >= 0 {
|
||||||
|
remembered := strings.TrimSpace(lower[index+len("запомни"):])
|
||||||
|
remembered = strings.TrimLeft(remembered, " :—-")
|
||||||
|
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
|
||||||
|
if !strings.HasPrefix(remembered, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
statement := strings.TrimSpace(remembered[len(prefix):])
|
||||||
|
separator := strings.IndexAny(statement, "—-:")
|
||||||
|
if separator <= 0 || separator >= len(statement)-1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(statement[:separator])
|
||||||
|
separatorWidth := 1
|
||||||
|
if strings.HasPrefix(statement[separator:], "—") {
|
||||||
|
separatorWidth = len("—")
|
||||||
|
}
|
||||||
|
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
|
||||||
|
if key != "" && len([]rune(value)) >= 2 {
|
||||||
|
return key + " — " + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DurableStore is supervisor-owned external control-plane state. It is not a
|
// DurableStore is supervisor-owned external control-plane state. It is not a
|
||||||
@@ -63,12 +64,17 @@ func (s *DurableStore) Load() (Lifeboat, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PersistentLifeboat reloads before every transition and commits after it.
|
// PersistentLifeboat reloads before every transition and commits after it.
|
||||||
type PersistentLifeboat struct{ store *DurableStore }
|
type PersistentLifeboat struct {
|
||||||
|
store *DurableStore
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
func NewPersistentLifeboat(store *DurableStore) *PersistentLifeboat {
|
func NewPersistentLifeboat(store *DurableStore) *PersistentLifeboat {
|
||||||
return &PersistentLifeboat{store: store}
|
return &PersistentLifeboat{store: store}
|
||||||
}
|
}
|
||||||
func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
|
func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
value, err := p.store.Load()
|
value, err := p.store.Load()
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -85,10 +91,40 @@ func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
|
|||||||
func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
|
func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
|
||||||
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
|
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *PersistentLifeboat) EstablishBaseline(release Release) (bool, error) {
|
||||||
|
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaseline(release) })
|
||||||
|
}
|
||||||
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
|
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
|
||||||
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
|
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
|
||||||
}
|
}
|
||||||
func (p *PersistentLifeboat) Rollback() (bool, error) {
|
func (p *PersistentLifeboat) Rollback() (bool, error) {
|
||||||
return p.transition(func(value *Lifeboat) bool { return value.Rollback() })
|
return p.transition(func(value *Lifeboat) bool { return value.Rollback() })
|
||||||
}
|
}
|
||||||
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) { return p.store.Load() }
|
func (p *PersistentLifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) (bool, error) {
|
||||||
|
return p.transition(func(value *Lifeboat) bool { return value.SetTraffic(mode, stableUpstream, candidateUpstream) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PersistentLifeboat) Promote() (bool, error) {
|
||||||
|
return p.transition(func(value *Lifeboat) bool {
|
||||||
|
if value.Current.State != ReleaseHealthy || value.Current.ID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value.SetTraffic(TrafficCandidate, value.StableUpstream, value.CandidateUpstream)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PersistentLifeboat) RollbackTraffic() (bool, error) {
|
||||||
|
return p.transition(func(value *Lifeboat) bool {
|
||||||
|
if !value.Rollback() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return value.SetTraffic(TrafficStable, value.StableUpstream, value.CandidateUpstream)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.store.Load()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,22 +9,34 @@ const (
|
|||||||
ReleaseRolledBack ReleaseState = "ROLLED_BACK"
|
ReleaseRolledBack ReleaseState = "ROLLED_BACK"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TrafficMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TrafficStable TrafficMode = "STABLE"
|
||||||
|
TrafficCanaryHeader TrafficMode = "CANARY_HEADER"
|
||||||
|
TrafficCandidate TrafficMode = "CANDIDATE"
|
||||||
|
)
|
||||||
|
|
||||||
type Release struct {
|
type Release struct {
|
||||||
ID string
|
ID string `json:"id"`
|
||||||
Version string
|
Version string `json:"version"`
|
||||||
State ReleaseState
|
State ReleaseState `json:"state"`
|
||||||
LastKnownGood bool
|
LastKnownGood bool `json:"last_known_good"`
|
||||||
}
|
}
|
||||||
type Observation struct {
|
type Observation struct {
|
||||||
ReleaseID string
|
ReleaseID string `json:"release_id"`
|
||||||
Ready bool
|
Ready bool `json:"ready"`
|
||||||
Reason string
|
Reason string `json:"reason"`
|
||||||
ObservedAtUnixMillis int64
|
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||||
}
|
}
|
||||||
type Lifeboat struct {
|
type Lifeboat struct {
|
||||||
Current Release
|
Current Release `json:"current"`
|
||||||
LastKnownGood Release
|
LastKnownGood Release `json:"last_known_good"`
|
||||||
Observations []Observation
|
RollbackTarget Release `json:"rollback_target"`
|
||||||
|
Observations []Observation `json:"observations"`
|
||||||
|
TrafficMode TrafficMode `json:"traffic_mode"`
|
||||||
|
StableUpstream string `json:"stable_upstream"`
|
||||||
|
CandidateUpstream string `json:"candidate_upstream"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *Lifeboat) Start(candidate Release) bool {
|
func (l *Lifeboat) Start(candidate Release) bool {
|
||||||
@@ -32,9 +44,25 @@ func (l *Lifeboat) Start(candidate Release) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
candidate.State = ReleaseCandidate
|
candidate.State = ReleaseCandidate
|
||||||
|
if l.LastKnownGood.ID != "" {
|
||||||
|
l.RollbackTarget = l.LastKnownGood
|
||||||
|
}
|
||||||
l.Current = candidate
|
l.Current = candidate
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Lifeboat) EstablishBaseline(release Release) bool {
|
||||||
|
if release.ID == "" || release.Version == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
release.State = ReleaseHealthy
|
||||||
|
release.LastKnownGood = true
|
||||||
|
l.Current = release
|
||||||
|
l.LastKnownGood = release
|
||||||
|
l.RollbackTarget = Release{}
|
||||||
|
l.TrafficMode = TrafficStable
|
||||||
|
return true
|
||||||
|
}
|
||||||
func (l *Lifeboat) Record(o Observation) bool {
|
func (l *Lifeboat) Record(o Observation) bool {
|
||||||
if o.ReleaseID == "" {
|
if o.ReleaseID == "" {
|
||||||
return false
|
return false
|
||||||
@@ -53,10 +81,30 @@ func (l *Lifeboat) Record(o Observation) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
func (l *Lifeboat) Rollback() bool {
|
func (l *Lifeboat) Rollback() bool {
|
||||||
if l.LastKnownGood.ID == "" {
|
target := l.RollbackTarget
|
||||||
|
if target.ID == "" {
|
||||||
|
target = l.LastKnownGood
|
||||||
|
}
|
||||||
|
if target.ID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
l.Current = l.LastKnownGood
|
l.Current = target
|
||||||
l.Current.State = ReleaseRolledBack
|
l.Current.State = ReleaseRolledBack
|
||||||
|
l.Current.LastKnownGood = true
|
||||||
|
l.LastKnownGood = l.Current
|
||||||
|
l.RollbackTarget = Release{}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Lifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) bool {
|
||||||
|
if mode != TrafficStable && mode != TrafficCanaryHeader && mode != TrafficCandidate {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if stableUpstream == "" || (mode != TrafficStable && candidateUpstream == "") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.TrafficMode = mode
|
||||||
|
l.StableUpstream = stableUpstream
|
||||||
|
l.CandidateUpstream = candidateUpstream
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,3 +47,38 @@ func TestPersistentLifeboatRestoresLastKnownGoodAfterCandidateFailure(t *testing
|
|||||||
t.Fatalf("state=%+v", state)
|
t.Fatalf("state=%+v", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersistentSupervisorRestoresTrafficModeAfterRestart(t *testing.T) {
|
||||||
|
store, err := supervisor.NewDurableStore(filepath.Join(t.TempDir(), "control", "release-state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := supervisor.NewPersistentLifeboat(store)
|
||||||
|
if ok, err := first.Start(supervisor.Release{ID: "candidate", Version: "2"}); err != nil || !ok {
|
||||||
|
t.Fatalf("start: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := first.Record(supervisor.Observation{ReleaseID: "candidate", Ready: true, ObservedAtUnixMillis: 1}); err != nil || !ok {
|
||||||
|
t.Fatalf("observe: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
if ok, err := first.SetTraffic(supervisor.TrafficCanaryHeader, "http://stable:8084", "http://candidate:8084"); err != nil || !ok {
|
||||||
|
t.Fatalf("canary: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
second := supervisor.NewPersistentLifeboat(store)
|
||||||
|
state, err := second.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.TrafficMode != supervisor.TrafficCanaryHeader || state.CandidateUpstream != "http://candidate:8084" || state.LastKnownGood.ID != "candidate" {
|
||||||
|
t.Fatalf("state=%+v", state)
|
||||||
|
}
|
||||||
|
if ok, err := second.RollbackTraffic(); err != nil || !ok {
|
||||||
|
t.Fatalf("rollback traffic: %v %v", ok, err)
|
||||||
|
}
|
||||||
|
state, err = second.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if state.TrafficMode != supervisor.TrafficStable || state.Current.State != supervisor.ReleaseRolledBack {
|
||||||
|
t.Fatalf("rollback state=%+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user