From c230f5c53032ee98795adca608d5d81784ba2dec Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 16 Aug 2026 16:30:17 +0300 Subject: [PATCH] feat: add creator satisfaction confirmation flow --- cmd/creator-web/main.go | 1 + cmd/creator-web/server.go | 19 ++- cmd/creator-web/server_test.go | 45 +++++- cmd/creator-web/static/assets/console.css | 45 +++++- cmd/creator-web/static/assets/console.js | 128 ++++++++++++++++++ cmd/creator-web/static/index.html | 38 ++++++ deploy/docker-compose.dev.yml | 3 +- deploy/docker-compose.prod.yml | 3 +- ...V-000032-creator-satisfaction-console.json | 33 +++++ tests/creator_web/verify-remote.ps1 | 37 ++++- 10 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 dev/tasks/DEV-000032-creator-satisfaction-console.json diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go index 589a89b..ae1a6f3 100644 --- a/cmd/creator-web/main.go +++ b/cmd/creator-web/main.go @@ -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 { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index 5492fae..cd38114 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -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 } diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 179398d..a20025a 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -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") == "" { diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css index a9ebdcf..2a81a58 100644 --- a/cmd/creator-web/static/assets/console.css +++ b/cmd/creator-web/static/assets/console.css @@ -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%; } } diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 9c05036..6b38bbc 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -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("Сохранённая сессия больше не действует.")); } diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 19e9ae7..12c0ea4 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -138,6 +138,24 @@
Ревизия
—

Укажите идентификатор ProspectiveItem для проверки владельца и состояния.

+
+
+

Commit / контролируемый переход

+

Подтвердить исполнение

+ Доступно только для доставленного ожидания с проверенной Work-задачей. +
+ + + + +
@@ -156,6 +174,26 @@ + +
+

commit / явное подтверждение

+

Зафиксировать исполнение?

+

Домовой создаст авторизованное действие и передаст его Continuity Executor. Этот переход меняет каноническое состояние.

+
+
Ожидание
—
+
Ревизия
—
+
Work-задача
—
+
Ревизия Work
—
+
Подтверждение
—
+
+ +
+ + +
+
+
+ diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 41fec4a..4301b26 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -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: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 0f9eb60..cd9f6b8 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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 } } diff --git a/dev/tasks/DEV-000032-creator-satisfaction-console.json b/dev/tasks/DEV-000032-creator-satisfaction-console.json new file mode 100644 index 0000000..3a8e9ad --- /dev/null +++ b/dev/tasks/DEV-000032-creator-satisfaction-console.json @@ -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"] +} diff --git a/tests/creator_web/verify-remote.ps1 b/tests/creator_web/verify-remote.ps1 index 6afe080..3ba048c 100644 --- a/tests/creator_web/verify-remote.ps1 +++ b/tests/creator_web/verify-remote.ps1 @@ -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"