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 @@
Commit / контролируемый переход
+——