Continuity
Будущая значимость
-
+
- Ожидание
- —
- Обязательство
- —
- Цель
- —
- Область
- —
- Principal
- —
- Ревизия
- —
diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go
index 31287f0..589a89b 100644
--- a/cmd/creator-web/main.go
+++ b/cmd/creator-web/main.go
@@ -9,7 +9,11 @@ import (
func main() {
addr := envOr("LISTEN_ADDR", ":8084")
- handler, err := newHandler(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 10 * time.Second})
+ handler, err := newHandler(
+ envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
+ envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
+ &http.Client{Timeout: 10 * time.Second},
+ )
if err != nil {
log.Fatal(err)
}
diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go
index b1cd8c3..4df28bf 100644
--- a/cmd/creator-web/server.go
+++ b/cmd/creator-web/server.go
@@ -20,15 +20,27 @@ const (
var staticFiles embed.FS
type gateway struct {
- upstream *url.URL
- client *http.Client
- static http.Handler
+ core *url.URL
+ continuity *url.URL
+ client *http.Client
+ static http.Handler
}
-func newHandler(upstream string, client *http.Client) (http.Handler, error) {
- parsed, err := url.Parse(upstream)
- if err != nil || parsed.Scheme == "" || parsed.Host == "" {
- return nil, fmt.Errorf("invalid core runtime URL")
+type routeTarget uint8
+
+const (
+ targetCore routeTarget = iota
+ targetContinuity
+)
+
+func newHandler(coreURL, continuityURL string, client *http.Client) (http.Handler, error) {
+ core, err := parseRuntimeURL(coreURL, "Core")
+ if err != nil {
+ return nil, err
+ }
+ continuity, err := parseRuntimeURL(continuityURL, "Continuity")
+ if err != nil {
+ return nil, err
}
assets, err := fs.Sub(staticFiles, "static")
if err != nil {
@@ -37,7 +49,15 @@ func newHandler(upstream string, client *http.Client) (http.Handler, error) {
if client == nil {
client = http.DefaultClient
}
- return securityHeaders(&gateway{upstream: parsed, client: client, static: http.FileServer(http.FS(assets))}), nil
+ return securityHeaders(&gateway{core: core, continuity: continuity, client: client, static: http.FileServer(http.FS(assets))}), nil
+}
+
+func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
+ parsed, err := url.Parse(rawURL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return nil, fmt.Errorf("invalid %s Runtime URL", name)
+ }
+ return parsed, nil
}
func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -61,7 +81,7 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
- upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path)
+ routeTarget, upstreamPath, allowed := creatorRoute(r.Method, r.URL.Path)
if !allowed {
http.Error(w, "creator route denied", http.StatusNotFound)
return
@@ -75,7 +95,11 @@ func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
- target := *g.upstream
+ upstream := g.core
+ if routeTarget == targetContinuity {
+ upstream = g.continuity
+ }
+ target := *upstream
target.Path = upstreamPath
target.RawQuery = r.URL.RawQuery
request, err := http.NewRequestWithContext(r.Context(), r.Method, target.String(), strings.NewReader(string(body)))
@@ -98,26 +122,31 @@ func (g *gateway) forwardAPI(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
}
-func creatorRoute(method, path string) (string, bool) {
+func creatorRoute(method, path string) (routeTarget, string, bool) {
switch {
case method == http.MethodPost && path == "/api/session":
- return "/v1/creator/sessions", true
+ return targetCore, "/v1/creator/sessions", true
case method == http.MethodGet && path == "/api/session/current":
- return "/v1/creator/sessions/current", true
+ return targetCore, "/v1/creator/sessions/current", true
case method == http.MethodGet && path == "/api/focus":
- return "/v1/creator/focus", true
+ return targetCore, "/v1/creator/focus", true
case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"):
id := strings.TrimPrefix(path, "/api/tasks/")
if validID(id) {
- return "/v1/creator/tasks/" + id, true
+ return targetCore, "/v1/creator/tasks/" + id, true
}
case method == http.MethodGet && strings.HasPrefix(path, "/api/capabilities/") && strings.HasSuffix(path, "/health"):
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/capabilities/"), "/health")
if validID(id) {
- return "/v1/creator/capabilities/" + id + "/health", true
+ return targetCore, "/v1/creator/capabilities/" + id + "/health", true
+ }
+ case method == http.MethodGet && strings.HasPrefix(path, "/api/prospective-items/"):
+ id := strings.TrimPrefix(path, "/api/prospective-items/")
+ if validID(id) {
+ return targetContinuity, "/v1/creator/prospective-items/" + id, true
}
}
- return "", false
+ return targetCore, "", false
}
func validID(value string) bool {
diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go
index e0ceb1d..5a0215f 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.Client())
+ handler, err := newHandler(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.DefaultClient)
+ handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
@@ -42,6 +42,7 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
{http.MethodPost, "/api/tasks/task-1"},
{http.MethodGet, "/api/principal-bindings/actor/principal"},
{http.MethodPost, "/api/focus-task-commits"},
+ {http.MethodPost, "/api/prospective-items/item-1"},
{http.MethodGet, "/api/tasks/../../healthz"},
} {
request := httptest.NewRequest(test.method, test.path, nil)
@@ -53,8 +54,32 @@ func TestGatewayDeniesInternalAndMutationRoutes(t *testing.T) {
}
}
+func TestGatewayRoutesProspectiveItemToContinuity(t *testing.T) {
+ continuity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/creator/prospective-items/item-1" {
+ t.Fatalf("unexpected Continuity target %s", r.URL.Path)
+ }
+ if r.Header.Get("Authorization") != "Bearer bounded-session" {
+ t.Fatal("authorization header was not forwarded")
+ }
+ _, _ = io.WriteString(w, `{"ID":"item-1"}`)
+ }))
+ defer continuity.Close()
+ handler, err := newHandler("http://core-runtime.invalid", continuity.URL, continuity.Client())
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/prospective-items/item-1", nil)
+ request.Header.Set("Authorization", "Bearer bounded-session")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "item-1") {
+ t.Fatalf("response = %d %s", response.Code, response.Body.String())
+ }
+}
+
func TestConsoleAndSecurityHeaders(t *testing.T) {
- handler, err := newHandler("http://core-runtime:8080", http.DefaultClient)
+ handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
diff --git a/cmd/creator-web/static/assets/console.css b/cmd/creator-web/static/assets/console.css
index f181c73..2cc7f8d 100644
--- a/cmd/creator-web/static/assets/console.css
+++ b/cmd/creator-web/static/assets/console.css
@@ -84,7 +84,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
.workspace-head h1 { margin: 0; font: 400 clamp(36px, 5vw, 58px)/1 var(--serif); letter-spacing: -.035em; }
.last-update { color: var(--faint); font: 10px/1.4 var(--mono); text-align: right; }
-.signal-strip { display: grid; grid-template-columns: repeat(3, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); }
+.signal-strip { display: grid; grid-template-columns: repeat(4, 1fr); margin-bottom: 18px; border: 1px solid var(--line); background: var(--panel); }
.signal-strip div { padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-right: 1px solid var(--line); }
.signal-strip div:last-child { border: 0; }
.signal-strip span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; }
@@ -96,7 +96,8 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.data-card { min-width: 0; padding: 24px; border: 1px solid var(--line); background: linear-gradient(145deg, rgba(22, 31, 27, .94), rgba(13, 20, 17, .98)); box-shadow: 0 10px 40px rgba(0,0,0,.12); animation: reveal .55s both; }
.task-card { animation-delay: .08s; }
-.capability-card { grid-column: 1 / -1; animation-delay: .16s; }
+.prospective-card { grid-column: 1 / -1; animation-delay: .16s; }
+.capability-card { grid-column: 1 / -1; animation-delay: .24s; }
.data-card header { display: grid; grid-template-columns: 30px 1fr auto; gap: 12px; align-items: start; margin-bottom: 26px; }
.card-number { color: var(--signal); font: 10px/1.5 var(--mono); }
.data-card header p { margin: 0 0 5px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; }
@@ -112,6 +113,15 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.data-card dt { margin-bottom: 7px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.data-card dd { margin: 0; overflow: hidden; color: var(--ink); font: 11px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
.card-message { min-height: 18px; margin: 16px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
+.prospective-core { margin-bottom: 12px; display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
+.prospective-core > div { min-width: 0; padding: 22px 0; }
+.prospective-core > div:first-child { padding-right: 24px; border-right: 1px solid var(--line-soft); }
+.prospective-core > div:last-child { padding-left: 24px; }
+.prospective-core span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; }
+.prospective-core strong { display: block; margin-top: 8px; font: 400 clamp(30px, 5vw, 45px)/1 var(--serif); }
+.time-marker { position: relative; }
+.time-marker i { position: absolute; left: 24px; right: 0; bottom: -1px; height: 1px; background: linear-gradient(90deg, var(--signal), transparent); }
+.time-marker time { display: block; margin-top: 10px; color: var(--ink); font: 13px/1.4 var(--mono); }
.capability-core { display: grid; grid-template-columns: 110px 1fr; gap: 28px; align-items: center; padding: 8px 0 26px; }
.capability-core > div:last-child > span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; }
.capability-core strong { display: block; margin: 8px 0; font: 400 clamp(30px, 5vw, 46px)/1 var(--serif); }
@@ -137,6 +147,7 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.workspace { padding: 32px 24px; }
.data-grid { grid-template-columns: 1fr; }
.capability-card { grid-column: auto; }
+ .prospective-card { grid-column: auto; }
}
@media (max-width: 680px) {
@@ -160,4 +171,8 @@ button:focus-visible, input:focus-visible { outline: 2px solid var(--signal-brig
.data-card dl div { padding-right: 0 !important; }
.capability-core { grid-template-columns: 76px 1fr; gap: 18px; }
.radar { width: 72px; height: 72px; }
+ .prospective-core { grid-template-columns: 1fr; }
+ .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; }
}
diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js
index 215013b..d543396 100644
--- a/cmd/creator-web/static/assets/console.js
+++ b/cmd/creator-web/static/assets/console.js
@@ -5,7 +5,7 @@ const loginForm = byId("login-form");
const contextForm = byId("context-form");
const loginError = byId("login-error");
const toast = byId("toast");
-const contextFields = ["session-id", "workspace-id", "task-id", "capability-id"];
+const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
let toastTimer;
@@ -145,6 +145,12 @@ async function loadDashboard() {
setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral");
setText("task-message", "Укажите задачу для проверки относительно активного фокуса.");
}
+ if (context["prospective-id"]) work.push(loadProspective(context["prospective-id"]));
+ else {
+ setSignal("prospective", "не выбрано", "idle");
+ setBadge("prospective", "НЕ ЗАГРУЖЕНА", "neutral");
+ setText("prospective-message", "Укажите идентификатор ProspectiveItem для проверки владельца и состояния.");
+ }
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
else setSignal("capability", "не выбрана", "idle");
@@ -189,6 +195,33 @@ async function loadTask(taskID, query) {
}
}
+async function loadProspective(prospectiveID) {
+ setSignal("prospective", "загрузка", "loading");
+ try {
+ const item = await request(`/api/prospective-items/${encodeURIComponent(prospectiveID)}`);
+ const state = item.State || "UNKNOWN";
+ const goodStates = ["REGISTERED", "ARMED", "SATISFIED"];
+ const warningStates = ["TRIGGERED", "REVALIDATING", "DELIVERED"];
+ const tone = goodStates.includes(state) ? "good" : warningStates.includes(state) ? "warn" : "bad";
+ setText("prospective-state", state);
+ setText("prospective-trigger", item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "не указано");
+ setText("prospective-value", item.ID);
+ setText("prospective-commitment", item.CommitmentID);
+ setText("prospective-goal", item.GoalID);
+ setText("prospective-scope", item.ScopeID);
+ setText("prospective-owner", item.OwnerPrincipalID);
+ setText("prospective-revision", item.Revision);
+ setText("prospective-message", "Запись принадлежит текущему Principal и прочитана из канонического Continuity state.");
+ setSignal("prospective", state.toLowerCase(), tone === "bad" ? "error" : "ok");
+ setBadge("prospective", state, tone);
+ } catch (error) {
+ setSignal("prospective", "ошибка", "error");
+ setBadge("prospective", "НЕДОСТУПНА", "bad");
+ setText("prospective-state", "UNKNOWN");
+ setText("prospective-message", error.message);
+ }
+}
+
async function loadCapability(capabilityID) {
setSignal("capability", "загрузка", "loading");
try {
diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html
index fb9b38c..7c250cd 100644
--- a/cmd/creator-web/static/index.html
+++ b/cmd/creator-web/static/index.html
@@ -60,6 +60,8 @@
+
+
@@ -84,6 +86,7 @@
Continuity
Capability
Capability