feat: add creator satisfaction confirmation flow
CI / test (push) Successful in 11s

This commit is contained in:
m
2026-08-16 16:30:17 +03:00
parent 7fc140da4a
commit c230f5c530
10 changed files with 339 additions and 13 deletions
+1
View File
@@ -12,6 +12,7 @@ func main() {
handler, err := newHandler(
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
&http.Client{Timeout: 10 * time.Second},
)
if err != nil {
+17 -2
View File
@@ -22,6 +22,7 @@ var staticFiles embed.FS
type gateway struct {
core *url.URL
continuity *url.URL
commit *url.URL
client *http.Client
static http.Handler
}
@@ -31,9 +32,10 @@ type routeTarget uint8
const (
targetCore routeTarget = iota
targetContinuity
targetCommit
)
func newHandler(coreURL, continuityURL string, client *http.Client) (http.Handler, error) {
func newHandler(coreURL, continuityURL, commitURL string, client *http.Client) (http.Handler, error) {
core, err := parseRuntimeURL(coreURL, "Core")
if err != nil {
return nil, err
@@ -42,6 +44,10 @@ func newHandler(coreURL, continuityURL string, client *http.Client) (http.Handle
if err != nil {
return nil, err
}
commit, err := parseRuntimeURL(commitURL, "Commit")
if err != nil {
return nil, err
}
assets, err := fs.Sub(staticFiles, "static")
if err != nil {
return nil, fmt.Errorf("load embedded console: %w", err)
@@ -49,7 +55,7 @@ func newHandler(coreURL, continuityURL string, client *http.Client) (http.Handle
if client == nil {
client = http.DefaultClient
}
return securityHeaders(&gateway{core: core, continuity: continuity, client: client, static: http.FileServer(http.FS(assets))}), nil
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, client: client, static: http.FileServer(http.FS(assets))}), nil
}
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
@@ -98,6 +104,8 @@ func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
upstream := g.core
if routeTarget == targetContinuity {
upstream = g.continuity
} else if routeTarget == targetCommit {
upstream = g.commit
}
target := *upstream
target.Path = upstreamPath
@@ -147,6 +155,13 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
}
case method == http.MethodGet && path == "/api/prospective-items":
return targetContinuity, "/v1/creator/prospective-items", true
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
return targetCommit, "/v1/creator/continuity/satisfy-delivered", true
case method == http.MethodPost && strings.HasPrefix(path, "/api/actions/") && strings.HasSuffix(path, "/reconcile"):
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/actions/"), "/reconcile")
if validID(id) {
return targetCommit, "/v1/creator/authorized-actions/" + id + "/reconcile", true
}
}
return targetCore, "", false
}
+39 -6
View File
@@ -20,7 +20,7 @@ func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) {
_, _ = io.WriteString(w, `{"TaskID":"task-1"}`)
}))
defer upstream.Close()
handler, err := newHandler(upstream.URL, upstream.URL, upstream.Client())
handler, err := newHandler(upstream.URL, upstream.URL, upstream.URL, upstream.Client())
if err != nil {
t.Fatal(err)
}
@@ -34,7 +34,7 @@ func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) {
}
func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", http.DefaultClient)
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
@@ -44,6 +44,8 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
{http.MethodPost, "/api/focus-task-commits"},
{http.MethodPost, "/api/prospective-items/item-1"},
{http.MethodGet, "/api/tasks/../../healthz"},
{http.MethodPost, "/api/actions/arbitrary"},
{http.MethodGet, "/api/actions/satisfy-delivered"},
} {
request := httptest.NewRequest(test.method, test.path, nil)
response := httptest.NewRecorder()
@@ -65,7 +67,7 @@ func TestGatewayRoutesProspectiveItemToContinuity(t *testing.T) {
_, _ = io.WriteString(w, `{"ID":"item-1"}`)
}))
defer continuity.Close()
handler, err := newHandler("http://core-runtime.invalid", continuity.URL, continuity.Client())
handler, err := newHandler("http://core-runtime.invalid", continuity.URL, "http://commit-runtime.invalid", continuity.Client())
if err != nil {
t.Fatal(err)
}
@@ -86,7 +88,7 @@ func TestGatewayRoutesProspectiveListToContinuity(t *testing.T) {
_, _ = io.WriteString(w, `{"Items":[]}`)
}))
defer continuity.Close()
handler, err := newHandler("http://core-runtime.invalid", continuity.URL, continuity.Client())
handler, err := newHandler("http://core-runtime.invalid", continuity.URL, "http://commit-runtime.invalid", continuity.Client())
if err != nil {
t.Fatal(err)
}
@@ -98,15 +100,46 @@ func TestGatewayRoutesProspectiveListToContinuity(t *testing.T) {
}
}
func TestGatewayRoutesOnlyBoundedCreatorCommandsToCommit(t *testing.T) {
commitRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/continuity/satisfy-delivered" || r.Method != http.MethodPost {
t.Fatalf("unexpected Commit target %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatal("authorization header was not forwarded")
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "prospective-1") || strings.Contains(string(body), "GrantID") || strings.Contains(string(body), "CapabilityID") {
t.Fatalf("unexpected command body %s", body)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"State":"SUCCEEDED"}`)
}))
defer commitRuntime.Close()
handler, err := newHandler("http://core-runtime.invalid", "http://continuity-runtime.invalid", commitRuntime.URL, commitRuntime.Client())
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/api/actions/satisfy-delivered", strings.NewReader(`{"ProspectiveItemID":"prospective-1"}`))
request.Header.Set("Authorization", "Bearer bounded-session")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusCreated || !strings.Contains(response.Body.String(), "SUCCEEDED") {
t.Fatalf("response = %d %s", response.Code, response.Body.String())
}
}
func TestConsoleAndSecurityHeaders(t *testing.T) {
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", http.DefaultClient)
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") {
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") {
t.Fatalf("console response = %d", response.Code)
}
if response.Header().Get("Content-Security-Policy") == "" {
+43 -2
View File
@@ -23,9 +23,9 @@
[hidden] { display: none !important; }
html { background: var(--base); color: var(--ink); font-family: var(--sans); }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .15), transparent 38%), var(--base); }
button, input { font: inherit; }
button, input, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
.grain { position: fixed; inset: 0; pointer-events: none; opacity: .18; z-index: 10; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 160 160' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); }
.eyebrow, .rail-index { margin: 0 0 12px; color: var(--signal); font: 600 11px/1 var(--mono); letter-spacing: .18em; text-transform: uppercase; }
@@ -152,6 +152,37 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.radar i { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 16px var(--good); }
.compact-list { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
.compact-list div { padding-right: 18px !important; }
.commit-zone { margin-top: 24px; padding: 22px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .75fr); gap: 14px 24px; border: 1px solid rgba(201, 168, 95, .42); background: linear-gradient(120deg, rgba(201, 168, 95, .08), rgba(11, 16, 14, .58)); }
.commit-zone-copy { grid-column: 1; grid-row: 1 / span 3; align-self: center; }
.commit-zone-copy p { margin: 0 0 8px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; }
.commit-zone-copy h3 { margin: 0; font: 400 24px/1.1 var(--serif); }
.commit-zone-copy span { display: block; max-width: 52ch; margin-top: 11px; color: var(--muted); font-size: 11px; line-height: 1.5; }
.commit-zone > label { color: var(--muted); font: 600 9px/1 var(--mono); letter-spacing: .11em; text-transform: uppercase; }
.commit-zone textarea { width: 100%; min-height: 62px; padding: 10px 12px; resize: vertical; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 11px/1.45 var(--mono); }
.commit-button { min-height: 42px; padding: 0 15px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border: 1px solid var(--signal-bright); border-radius: var(--radius); color: #11150f; background: var(--signal); cursor: pointer; font-size: 11px; font-weight: 700; transition: filter .18s, transform .18s; }
.commit-button:hover:not(:disabled) { filter: brightness(1.12); transform: translateY(-1px); }
.commit-button:disabled { border-color: var(--line); color: var(--faint); background: var(--panel-high); cursor: not-allowed; transform: none; }
.action-result { grid-column: 1 / -1; margin-top: 6px; padding-top: 16px; display: grid; grid-template-columns: .55fr 1fr 1fr auto; gap: 16px; align-items: end; border-top: 1px solid var(--line); }
.action-result div { min-width: 0; }
.action-result span { display: block; margin-bottom: 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .12em; }
.action-result strong, .action-result code { display: block; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
.action-result strong[data-state="SUCCEEDED"] { color: var(--good); }
.action-result strong[data-state="FAILED"] { color: var(--bad); }
.action-result strong[data-state="UNKNOWN_OUTCOME"] { color: var(--warn); }
.action-result .secondary-button { min-width: 154px; margin: 0; padding: 0 14px; }
.confirmation-dialog { width: min(560px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #111915; box-shadow: var(--shadow); }
.confirmation-dialog::backdrop { background: rgba(3, 6, 5, .78); backdrop-filter: blur(5px); }
.confirmation-dialog form { padding: 30px; }
.confirmation-dialog h2 { margin: 0; font: 400 32px/1.05 var(--serif); }
.confirmation-copy { margin: 16px 0 24px; color: var(--muted); font-size: 12px; line-height: 1.55; }
.confirmation-grid { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--line); }
.confirmation-grid div { min-width: 0; padding: 14px 0; border-bottom: 1px solid var(--line-soft); }
.confirmation-grid div:nth-child(odd) { padding-right: 18px; }
.confirmation-grid .wide { grid-column: 1 / -1; padding-right: 0; }
.confirmation-grid dt { margin-bottom: 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.confirmation-grid dd { margin: 0; overflow-wrap: anywhere; color: var(--ink); font: 11px/1.45 var(--mono); }
.confirmation-actions { margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px; }
.confirmation-actions .commit-button { min-width: 190px; }
.toast { position: fixed; right: 22px; bottom: 22px; z-index: 20; max-width: min(390px, calc(100vw - 44px)); padding: 14px 16px; color: var(--ink); background: #222e28; border-left: 3px solid var(--signal); box-shadow: var(--shadow); font-size: 12px; }
@keyframes reveal { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@@ -198,4 +229,14 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.prospective-core > div:first-child { padding-right: 0; border-right: 0; border-bottom: 1px solid var(--line-soft); }
.prospective-core > div:last-child { padding-left: 0; }
.time-marker i { left: 0; }
.commit-zone { grid-template-columns: 1fr; padding: 18px 16px; }
.commit-zone-copy { grid-column: auto; grid-row: auto; margin-bottom: 6px; }
.action-result { grid-template-columns: 1fr; align-items: stretch; }
.action-result .secondary-button { width: 100%; }
.confirmation-dialog form { padding: 24px 20px; }
.confirmation-grid { grid-template-columns: 1fr; }
.confirmation-grid .wide { grid-column: auto; }
.confirmation-grid div:nth-child(odd) { padding-right: 0; }
.confirmation-actions { align-items: stretch; flex-direction: column-reverse; }
.confirmation-actions .commit-button { width: 100%; }
}
+128
View File
@@ -8,6 +8,10 @@ const toast = byId("toast");
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
let toastTimer;
let currentTask = null;
let currentProspective = null;
let pendingCommand = null;
let lastAuthorizedActionID = "";
function setText(id, value, fallback = "—") {
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
@@ -85,6 +89,10 @@ function endSession(message = "Сессия завершена.") {
loginView.hidden = false;
loginForm.reset();
loginError.textContent = "";
currentTask = null;
currentProspective = null;
pendingCommand = null;
lastAuthorizedActionID = "";
notify(message);
}
@@ -141,9 +149,11 @@ async function loadDashboard() {
}
if (sessionID && workspaceID && context["task-id"]) work.push(loadTask(context["task-id"], query));
else {
currentTask = null;
setSignal("task", "нет задачи", "idle");
setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral");
setText("task-message", "Укажите задачу для проверки относительно активного фокуса.");
updateActionReadiness();
}
work.push(loadProspectiveRegister(context["prospective-id"]));
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
@@ -176,6 +186,7 @@ async function loadTask(taskID, query) {
setSignal("task", "загрузка", "loading");
try {
const task = await request(`/api/tasks/${encodeURIComponent(taskID)}?${query}`);
currentTask = task;
setText("task-value", task.TaskID);
setText("task-goal", task.GoalID);
setText("task-scope", task.ScopeID);
@@ -183,10 +194,13 @@ async function loadTask(taskID, query) {
setText("task-message", "Цель и область задачи совпадают с активным фокусом.");
setSignal("task", "в фокусе", "ok");
setBadge("task", "ПРОВЕРЕНА", "good");
updateActionReadiness();
} catch (error) {
currentTask = null;
setSignal("task", "отклонена", "error");
setBadge("task", "ВНЕ ФОКУСА", "bad");
setText("task-message", error.message);
updateActionReadiness();
}
}
@@ -263,6 +277,7 @@ async function loadProspective(prospectiveID) {
setSignal("prospective", "загрузка", "loading");
try {
const item = await request(`/api/prospective-items/${encodeURIComponent(prospectiveID)}`);
currentProspective = item;
const state = item.State || "UNKNOWN";
const goodStates = ["REGISTERED", "ARMED", "SATISFIED"];
const warningStates = ["TRIGGERED", "REVALIDATING", "DELIVERED"];
@@ -278,11 +293,14 @@ async function loadProspective(prospectiveID) {
setText("prospective-message", "Запись принадлежит текущему Principal и прочитана из канонического Continuity state.");
setSignal("prospective", state.toLowerCase(), tone === "bad" ? "error" : "ok");
setBadge("prospective", state, tone);
updateActionReadiness();
} catch (error) {
currentProspective = null;
setSignal("prospective", "ошибка", "error");
setBadge("prospective", "НЕДОСТУПНА", "bad");
setText("prospective-state", "UNKNOWN");
setText("prospective-message", error.message);
updateActionReadiness();
}
}
@@ -307,6 +325,116 @@ async function loadCapability(capabilityID) {
}
}
function updateActionReadiness() {
const button = byId("prepare-satisfaction");
if (!button) return;
const evidence = byId("satisfaction-evidence").value.trim();
let message = "Доступно только для доставленного ожидания с проверенной Work-задачей.";
let ready = false;
if (!currentProspective) message = "Сначала выберите и загрузите ожидание.";
else if (currentProspective.State !== "DELIVERED") message = `Состояние ${currentProspective.State || "UNKNOWN"}: переход доступен только из DELIVERED.`;
else if (!currentTask) message = "Загрузите проверенную Work-задачу как обоснование.";
else if (currentTask.GoalID !== currentProspective.GoalID || currentTask.ScopeID !== currentProspective.ScopeID) message = "Цель или область Work-задачи не совпадает с ожиданием.";
else if (!evidence) message = "Добавьте ссылку на подтверждение результата.";
else {
ready = true;
message = `Готово: ${currentProspective.ID} · rev ${currentProspective.Revision} будет связано с ${currentTask.TaskID} · rev ${currentTask.Revision}.`;
}
button.disabled = !ready;
setText("action-readiness", message);
}
byId("satisfaction-evidence").addEventListener("input", updateActionReadiness);
byId("prepare-satisfaction").addEventListener("click", () => {
updateActionReadiness();
if (byId("prepare-satisfaction").disabled || !currentTask || !currentProspective) return;
pendingCommand = {
Target: {
TaskID: currentTask.TaskID,
GoalID: currentTask.GoalID,
ScopeID: currentTask.ScopeID,
Revision: currentTask.Revision,
},
ProspectiveItemID: currentProspective.ID,
ExpectedRevision: currentProspective.Revision,
EvidenceRef: byId("satisfaction-evidence").value.trim(),
};
setText("confirm-prospective", pendingCommand.ProspectiveItemID);
setText("confirm-prospective-revision", pendingCommand.ExpectedRevision);
setText("confirm-task", pendingCommand.Target.TaskID);
setText("confirm-task-revision", pendingCommand.Target.Revision);
setText("confirm-evidence", pendingCommand.EvidenceRef);
setText("confirmation-error", "", "");
byId("satisfaction-dialog").showModal();
});
byId("cancel-satisfaction").addEventListener("click", () => {
pendingCommand = null;
byId("satisfaction-dialog").close();
});
byId("confirm-satisfaction").addEventListener("click", async () => {
if (!pendingCommand) return;
const command = pendingCommand;
const button = byId("confirm-satisfaction");
const prepare = byId("prepare-satisfaction");
button.disabled = true;
prepare.disabled = true;
button.textContent = "Фиксирую…";
setText("confirmation-error", "", "");
try {
const result = await request("/api/actions/satisfy-delivered", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(command),
});
pendingCommand = null;
byId("satisfaction-dialog").close();
renderActionResult(result);
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
await loadProspectiveRegister(command.ProspectiveItemID);
} catch (error) {
setText("confirmation-error", error.message);
} finally {
button.disabled = false;
button.textContent = "Подтверждаю переход";
updateActionReadiness();
}
});
function renderActionResult(result) {
lastAuthorizedActionID = result.AuthorizedActionID || "";
setText("action-state", result.State);
byId("action-state").dataset.state = result.State || "UNKNOWN";
setText("action-id", result.AuthorizedActionID);
setText("attempt-id", result.ActionAttemptID);
byId("action-result").hidden = false;
byId("reconcile-action").hidden = result.State !== "UNKNOWN_OUTCOME" || !lastAuthorizedActionID;
}
byId("reconcile-action").addEventListener("click", async () => {
if (!lastAuthorizedActionID) return;
const button = byId("reconcile-action");
button.disabled = true;
button.textContent = "Проверяю…";
try {
const result = await request(`/api/actions/${encodeURIComponent(lastAuthorizedActionID)}/reconcile`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: "{}",
});
renderActionResult(result);
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(error.message);
} finally {
button.disabled = false;
button.textContent = "Проверить исход";
}
});
if (sessionToken) {
showConsole().catch(() => endSession("Сохранённая сессия больше не действует."));
}
+38
View File
@@ -138,6 +138,24 @@
<div><dt>Ревизия</dt><dd id="prospective-revision">—</dd></div>
</dl>
<p id="prospective-message" class="card-message">Укажите идентификатор ProspectiveItem для проверки владельца и состояния.</p>
<section class="commit-zone" aria-labelledby="commit-zone-title">
<div class="commit-zone-copy">
<p>Commit / контролируемый переход</p>
<h3 id="commit-zone-title">Подтвердить исполнение</h3>
<span id="action-readiness">Доступно только для доставленного ожидания с проверенной Work-задачей.</span>
</div>
<label for="satisfaction-evidence">Ссылка на подтверждение</label>
<textarea id="satisfaction-evidence" rows="2" maxlength="512" placeholder="artifact://… или краткая ссылка на результат"></textarea>
<button id="prepare-satisfaction" class="commit-button" type="button" disabled>
<span>Отметить исполненным</span><span aria-hidden="true">→</span>
</button>
<div id="action-result" class="action-result" hidden>
<div><span>ИСХОД</span><strong id="action-state">—</strong></div>
<div><span>ACTION</span><code id="action-id">—</code></div>
<div><span>ATTEMPT</span><code id="attempt-id">—</code></div>
<button id="reconcile-action" class="secondary-button" type="button" hidden>Проверить исход</button>
</div>
</section>
</article>
<article class="data-card capability-card">
@@ -156,6 +174,26 @@
</main>
</div>
<dialog id="satisfaction-dialog" class="confirmation-dialog" aria-labelledby="confirmation-title">
<form method="dialog">
<p class="eyebrow">commit / явное подтверждение</p>
<h2 id="confirmation-title">Зафиксировать исполнение?</h2>
<p class="confirmation-copy">Домовой создаст авторизованное действие и передаст его Continuity Executor. Этот переход меняет каноническое состояние.</p>
<dl class="confirmation-grid">
<div><dt>Ожидание</dt><dd id="confirm-prospective">—</dd></div>
<div><dt>Ревизия</dt><dd id="confirm-prospective-revision">—</dd></div>
<div><dt>Work-задача</dt><dd id="confirm-task">—</dd></div>
<div><dt>Ревизия Work</dt><dd id="confirm-task-revision">—</dd></div>
<div class="wide"><dt>Подтверждение</dt><dd id="confirm-evidence">—</dd></div>
</dl>
<p id="confirmation-error" class="form-error" role="alert"></p>
<div class="confirmation-actions">
<button id="cancel-satisfaction" class="text-button" type="button">Отмена</button>
<button id="confirm-satisfaction" class="commit-button" type="button">Подтверждаю переход</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/assets/console.js" defer></script>
</body>
+2 -1
View File
@@ -84,9 +84,10 @@ services:
AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD:-assistant_authority_dev_only}
CORE_RUNTIME_URL: http://core-runtime:8080
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
COMMIT_RUNTIME_URL: http://commit-runtime:8082
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN:-assistant_executor_dev_only_replace_me}
ports: ["19082:8082"]
depends_on: [core-runtime, continuity-runtime]
depends_on: [core-runtime, continuity-runtime, commit-runtime]
cognitive-worker:
build:
+2 -1
View File
@@ -77,9 +77,10 @@ services:
AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD}
CORE_RUNTIME_URL: http://core-runtime:8080
CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081
COMMIT_RUNTIME_URL: http://commit-runtime:8082
COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN}
# External mutation credentials belong here only via Portainer/Docker secrets/env injection.
depends_on: [core-runtime, continuity-runtime]
depends_on: [core-runtime, continuity-runtime, commit-runtime]
cognitive-worker:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker } }
@@ -0,0 +1,33 @@
{
"task_id": "DEV-000032",
"task_revision": 1,
"architecture_revision": "ADR-0003",
"base_repo_revision": "7fc140d",
"risk_tier": "C",
"intent": "Expose only the authenticated Creator satisfaction and reconciliation routes through the narrow gateway and add an explicit-confirmation operator flow to the Domovoy console.",
"requirement_ids": ["INV-AUTH-001", "INV-OWN-001"],
"allowed_change_scope": {
"modules": [],
"paths": ["cmd/creator-web/**", "deploy/docker-compose.dev.yml", "deploy/docker-compose.prod.yml", "tests/creator_web/**", "dev/tasks/DEV-000032-creator-satisfaction-console.json"]
},
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/continuity-runtime/**", "cmd/commit-runtime/**"]},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0},
"contracts": {"consume": ["DEV-000031 Creator satisfaction command", "Creator session", "Creator Continuity reads"], "may_change": []},
"semantic_owner": "none-interface-adapter",
"canonical_state": ["none in gateway or browser"],
"state_transitions": ["confirmed UI command invokes the existing Commit path"],
"side_effects_and_privileges": ["gateway forwards Creator bearer session only; executor credential remains isolated in Commit Runtime"],
"persistence_and_migrations": ["none"],
"failure_retry_restart": ["UNKNOWN_OUTCOME remains visible and offers explicit reconciliation", "button is disabled during submission", "no automatic retry"],
"assumptions": ["A selected DELIVERED item, loaded Work target, and non-empty evidence reference are required before confirmation"],
"acceptance_scenarios": [
"gateway allowlists only the exact satisfaction and reconciliation routes",
"UI never sends Actor, Principal, grant, capability, operation, or action IDs for a new command",
"confirmation shows item, expected revision, Work justification, and evidence",
"success refreshes canonical item and shows committed identities",
"UNKNOWN_OUTCOME is distinct from failure and requires an explicit reconcile action",
"desktop and mobile layouts are usable and keyboard accessible"
],
"required_tests": ["unit", "gateway allowlist", "runtime HTTP", "responsive browser", "browser console", "architecture", "remote Docker smoke"],
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_CONFLICT"]
}
+36 -1
View File
@@ -22,6 +22,21 @@ function Post-ContinuityJson([string]$url, [object]$body) {
}
}
function Post-CoreJson([string]$url, [object]$body) {
$json = $body | ConvertTo-Json -Depth 8 -Compress
$local = New-TemporaryFile
$remote = "/tmp/domovoy-$([guid]::NewGuid().ToString('N')).json"
try {
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
docker --context $DockerContext cp $local "${coreContainer}:$remote" | Out-Null
docker --context $DockerContext exec $coreContainer wget -qO- --header "Content-Type: application/json" --post-file $remote $url
if ($LASTEXITCODE -ne 0) { throw "Core fixture creation failed" }
} finally {
docker --context $DockerContext exec --user root $coreContainer rm -f $remote 2>$null
Remove-Item -LiteralPath $local -Force
}
}
$health = Invoke-RestMethod -Uri "$BaseURL/healthz" -Method Get
if ($health.status -ne "ok" -or $health.runtime -ne "creator-web") { throw "Creator web health response is invalid" }
@@ -61,4 +76,24 @@ $prospectiveList = Invoke-RestMethod -Uri "$BaseURL/api/prospective-items?limit=
if (-not $prospectiveList.Items -or $prospectiveList.Items.Count -gt 25) { throw "ProspectiveItem register response is invalid" }
if ($prospectiveList.Items | Where-Object OwnerPrincipalID -ne "domovoy-creator-principal") { throw "ProspectiveItem register leaked another Principal" }
Write-Output "DEV-000029 Creator Continuity register: PASS"
$actionGoal = "web-action-goal-$suffix"
$actionTask = "web-action-task-$suffix"
$actionItem = "web-action-item-$suffix"
Post-CoreJson "http://localhost:8080/v1/tasks" @{TaskID=$actionTask;GoalID=$actionGoal;ScopeID="domovoy-global-scope";Revision=1}
Post-ContinuityJson "http://localhost:8081/v1/prospective-items" @{
ID=$actionItem;CommitmentID="web-action-commitment-$suffix";OwnerPrincipalID="domovoy-creator-principal"
ScopeID="domovoy-global-scope";GoalID=$actionGoal;TriggerAtUnixMillis=1800000000000
State="DELIVERED";Revision=1
}
$actionCommand = @{
Target=@{TaskID=$actionTask;GoalID=$actionGoal;ScopeID="domovoy-global-scope";Revision=1}
ProspectiveItemID=$actionItem;ExpectedRevision=1;EvidenceRef="artifact://creator-web/$suffix"
} | ConvertTo-Json -Depth 8
$actionResult = Invoke-RestMethod -Uri "$BaseURL/api/actions/satisfy-delivered" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $($issued.Token)"} -Body $actionCommand
if ($actionResult.State -ne "SUCCEEDED" -or -not $actionResult.AuthorizedActionID -or -not $actionResult.ActionAttemptID) { throw "Creator action gateway response is invalid" }
$staleAction = Invoke-WebRequest -Uri "$BaseURL/api/actions/satisfy-delivered" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $($issued.Token)"} -Body $actionCommand -SkipHttpErrorCheck
if ($staleAction.StatusCode -ne 409) { throw "Stale Creator action was not rejected" }
$arbitraryAction = Invoke-WebRequest -Uri "$BaseURL/api/actions/arbitrary" -Method Post -ContentType "application/json" -Headers @{Authorization="Bearer $($issued.Token)"} -Body '{}' -SkipHttpErrorCheck
if ($arbitraryAction.StatusCode -ne 404) { throw "Arbitrary action route was exposed" }
Write-Output "DEV-000032 Creator satisfaction console gateway: PASS"