Compare commits

..
Author SHA1 Message Date
m 54de3f44d0 chore: verify and finalize human-facing chat surface
CI / test (push) Successful in 19s
2026-08-18 12:42:51 +03:00
397 changed files with 1585 additions and 18094 deletions
-4
View File
@@ -15,8 +15,6 @@ jobs:
go-version: '1.26.6'
- name: Active task preflight
run: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json
- name: Independent governance verifier
run: go run ./cmd/devctl/independent
- name: Exact diff scope
run: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json
- name: Format check
@@ -27,5 +25,3 @@ jobs:
run: go test ./...
- name: Vet
run: go vet ./...
- name: Diff whitespace check
run: git diff --check
-61
View File
@@ -1,61 +0,0 @@
package agentbridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/work"
)
var ErrUnavailable = errors.New("agent execution bridge unavailable")
type HTTPExecutor struct {
URL string
Client *http.Client
}
func NewHTTPExecutor(url string, client *http.Client) (*HTTPExecutor, error) {
url = strings.TrimSpace(url)
if url == "" || !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return nil, ErrUnavailable
}
if client == nil {
client = http.DefaultClient
}
return &HTTPExecutor{URL: strings.TrimRight(url, "/"), Client: client}, nil
}
func (e *HTTPExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
body, err := json.Marshal(request)
if err != nil {
return work.AgentExecutionResult{}, err
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, e.URL+"/v1/execute", bytes.NewReader(body))
if err != nil {
return work.AgentExecutionResult{}, err
}
httpRequest.Header.Set("Content-Type", "application/json")
response, err := e.Client.Do(httpRequest)
if err != nil {
return work.AgentExecutionResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return work.AgentExecutionResult{}, fmt.Errorf("%w: status=%s", ErrUnavailable, response.Status)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, 1<<20))
var result work.AgentExecutionResult
if err := decoder.Decode(&result); err != nil {
return work.AgentExecutionResult{}, err
}
if result.State == "" || result.Summary == "" || result.ObservedAtUnixMillis < 1 {
return work.AgentExecutionResult{}, errors.New("agent execution bridge returned invalid result")
}
return result, nil
}
-43
View File
@@ -1,43 +0,0 @@
package agentbridge
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/work"
)
func TestHTTPExecutorCarriesTypedDispatchAndDecodesResult(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/execute" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var request work.AgentExecutionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if request.WorkerID != "worker-1" || request.FencingToken != 7 || request.Intent.DispatchID != "dispatch-1" {
t.Fatalf("request=%+v", request)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "completed", ObservedAtUnixMillis: 2})
}))
defer server.Close()
executor, err := NewHTTPExecutor(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}, WorkerID: "worker-1", FencingToken: 7})
if err != nil || result.State != work.AgentExecutionCompleted || result.Summary != "completed" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestHTTPExecutorRejectsUnavailableEndpoint(t *testing.T) {
if _, err := NewHTTPExecutor("", nil); err != ErrUnavailable {
t.Fatalf("expected unavailable, got %v", err)
}
}
+7 -32
View File
@@ -22,35 +22,17 @@ import (
type OpenAICompatibleLocalAdapter struct {
base *url.URL
client *http.Client
apiKey string
}
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
}
// NewOpenAICompatibleLocalAdapterWithAPIKey creates an adapter for a gateway
// whose bearer credential was resolved by the trusted runtime boundary.
func NewOpenAICompatibleLocalAdapterWithAPIKey(raw, apiKey string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
return NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey, transport, 10*time.Second)
}
// NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout creates a local gateway
// adapter with an explicit invocation deadline. The request context remains
// authoritative for per-call cancellation; this client deadline prevents a
// provider from holding a worker indefinitely.
func NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(raw, apiKey string, transport http.RoundTripper, timeout time.Duration) (*OpenAICompatibleLocalAdapter, error) {
base, err := url.Parse(raw)
if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil {
return nil, errors.New("invalid local compatible endpoint")
}
if timeout <= 0 {
return nil, errors.New("local compatible timeout must be positive")
}
if transport == nil {
transport = http.DefaultTransport
}
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, apiKey: strings.TrimSpace(apiKey)}, nil
return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
}
type compatibleModelsResponse struct {
@@ -96,15 +78,11 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
}
type compatibleChatRequest struct {
Model string `json:"model"`
Messages []compatibleMessage `json:"messages"`
Temperature int `json:"temperature"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
ChatTemplateKwargs compatibleTemplateKwargs `json:"chat_template_kwargs"`
}
type compatibleTemplateKwargs struct {
EnableThinking bool `json:"enable_thinking"`
Model string `json:"model"`
Messages []compatibleMessage `json:"messages"`
Temperature int `json:"temperature"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
}
type compatibleMessage struct {
Role string `json:"role"`
@@ -125,7 +103,7 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 {
return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory
}
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4}
var result compatibleChatResponse
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
if err != nil {
@@ -167,9 +145,6 @@ func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method,
return 0, err
}
request.Header.Set("Accept", "application/json")
if a.apiKey != "" {
request.Header.Set("Authorization", "Bearer "+a.apiKey)
}
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
@@ -1,94 +0,0 @@
package models
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
type captureRoundTripper struct {
authorization string
body []byte
}
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
if err != nil {
t.Fatal(err)
}
if adapter.client.Timeout != 47*time.Second {
t.Fatalf("unexpected client timeout: %s", adapter.client.Timeout)
}
}
func TestOpenAICompatibleLocalAdapterRejectsNonPositiveTimeout(t *testing.T) {
if _, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", nil, 0); err == nil {
t.Fatal("expected non-positive timeout to be rejected")
}
}
func (t *captureRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
t.authorization = request.Header.Get("Authorization")
if request.Body != nil {
t.body, _ = io.ReadAll(request.Body)
}
body := `{"data":[]}`
if strings.HasSuffix(request.URL.Path, "/chat/completions") {
body = `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header), Request: request}, nil
}
func TestOpenAICompatibleLocalAdapterUsesResolvedAPIKey(t *testing.T) {
transport := &captureRoundTripper{}
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKey("http://gateway/v1", "resolved-key", transport)
if err != nil {
t.Fatal(err)
}
_, _ = adapter.Discover(context.Background())
if transport.authorization != "Bearer resolved-key" {
t.Fatalf("unexpected authorization header: %q", transport.authorization)
}
}
func TestOpenAICompatibleLocalAdapterDoesNotSendAuthorizationWithoutKey(t *testing.T) {
transport := &captureRoundTripper{}
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
if err != nil {
t.Fatal(err)
}
_, _ = adapter.Discover(context.Background())
if transport.authorization != "" {
t.Fatalf("unexpected authorization header")
}
}
func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(t *testing.T) {
transport := &captureRoundTripper{}
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
if err != nil {
t.Fatal(err)
}
_, err = adapter.Advise(context.Background(), cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ActorID: "actor", PrincipalID: "principal", ScopeID: "scope", Message: "help", Locale: "ru-RU", MaxOutputBytes: 256, ProviderBindingID: "binding-1", ProviderBindingRevision: 1, ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1", ProviderModelID: capability.ProviderModelID("medium-model")})
if err != nil {
t.Fatal(err)
}
var payload struct {
ChatTemplateKwargs struct {
EnableThinking bool `json:"enable_thinking"`
} `json:"chat_template_kwargs"`
}
if err := json.Unmarshal(transport.body, &payload); err != nil {
t.Fatal(err)
}
if payload.ChatTemplateKwargs.EnableThinking {
t.Fatal("expected bounded advisory to disable template thinking")
}
}
+17 -171
View File
@@ -28,89 +28,6 @@ func OpenCapability(ctx context.Context, config ContinuityConfig) (*CapabilityRe
func (r *CapabilityRepository) Close() { r.pool.Close() }
func (r *CapabilityRepository) AdmitCognitive(ctx context.Context, leaseID string, policy capability.AdmissionPolicy, request capability.AdmissionRequest, nowUnixMillis int64) (capability.AdmissionDecision, capability.AdmissionLease, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
if leaseID == "" || nowUnixMillis < 1 {
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonInputBudget, PolicyID: policy.ID}, capability.AdmissionLease{}, nil
}
var existingState string
var existingPolicy string
err = tx.QueryRow(ctx, `SELECT state, policy_id FROM capability.cognitive_admission_leases WHERE lease_id=$1`, leaseID).Scan(&existingState, &existingPolicy)
if err == nil {
if existingState == "ACTIVE" {
return capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: existingPolicy}, capability.AdmissionLease{ID: leaseID, PolicyID: existingPolicy}, tx.Commit(ctx)
}
return capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: existingPolicy}, capability.AdmissionLease{}, tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
windowStart := nowUnixMillis - 60*60*1000
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_counters(policy_id,window_start_unix_millis,accepted_count,concurrent_count,updated_at_unix_millis) VALUES($1,$2,0,0,$3) ON CONFLICT(policy_id) DO NOTHING`, policy.ID, windowStart, nowUnixMillis)
if err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
var storedWindow int64
var accepted, concurrent int
if err = tx.QueryRow(ctx, `SELECT window_start_unix_millis,accepted_count,concurrent_count FROM capability.cognitive_admission_counters WHERE policy_id=$1 FOR UPDATE`, policy.ID).Scan(&storedWindow, &accepted, &concurrent); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
if nowUnixMillis-storedWindow >= 60*60*1000 {
storedWindow, accepted = windowStart, 0
}
var expired int64
if err = tx.QueryRow(ctx, `WITH expired AS (UPDATE capability.cognitive_admission_leases SET state='EXPIRED' WHERE policy_id=$1 AND state='ACTIVE' AND expires_at_unix_millis <= $2 RETURNING lease_id) SELECT count(*) FROM expired`, policy.ID, nowUnixMillis).Scan(&expired); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
concurrent -= int(expired)
if concurrent < 0 {
concurrent = 0
}
decision := capability.Admit(request, policy, capability.AdmissionCounters{Concurrent: concurrent, AcceptedLastHour: accepted}, nowUnixMillis)
if decision.State != capability.AdmissionAdmitted {
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted, concurrent, nowUnixMillis); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
return decision, capability.AdmissionLease{}, tx.Commit(ctx)
}
expires := nowUnixMillis + 60*1000
if request.DeadlineMillis > nowUnixMillis && request.DeadlineMillis < expires {
expires = request.DeadlineMillis
}
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET window_start_unix_millis=$2,accepted_count=$3,concurrent_count=$4,updated_at_unix_millis=$5 WHERE policy_id=$1`, policy.ID, storedWindow, accepted+1, concurrent+1, nowUnixMillis); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_admission_leases(lease_id,policy_id,state,expires_at_unix_millis,created_at_unix_millis) VALUES($1,$2,'ACTIVE',$3,$4)`, leaseID, policy.ID, expires, nowUnixMillis); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
return decision, capability.AdmissionLease{ID: leaseID, PolicyID: policy.ID}, tx.Commit(ctx)
}
func (r *CapabilityRepository) ReleaseCognitiveAdmission(ctx context.Context, lease capability.AdmissionLease, nowUnixMillis int64) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var policyID string
err = tx.QueryRow(ctx, `UPDATE capability.cognitive_admission_leases SET state='RELEASED' WHERE lease_id=$1 AND state='ACTIVE' RETURNING policy_id`, lease.ID).Scan(&policyID)
if errors.Is(err, pgx.ErrNoRows) {
return tx.Commit(ctx)
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `UPDATE capability.cognitive_admission_counters SET concurrent_count=GREATEST(0,concurrent_count-1),updated_at_unix_millis=$2 WHERE policy_id=$1`, policyID, nowUnixMillis)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *CapabilityRepository) SaveHealth(ctx context.Context, health capability.Health) error {
_, err := r.pool.Exec(ctx, `INSERT INTO capability.health (capability_id, state, reason, revision, semantic_version, observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (capability_id) DO UPDATE SET state=EXCLUDED.state, reason=EXCLUDED.reason, revision=EXCLUDED.revision, semantic_version=EXCLUDED.semantic_version, observed_at_unix_millis=EXCLUDED.observed_at_unix_millis WHERE capability.health.revision < EXCLUDED.revision`, health.CapabilityID, health.State, health.Reason, health.Revision, health.SemanticVersion, health.ObservedAtUnixMillis)
return err
@@ -405,18 +322,18 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability
func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error {
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances
(provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind,
value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.BillingMode, value.AuthMode, value.InputCostMicros, value.OutputCostMicros, value.RequestCostMicros)
(provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind,
value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis)
return err
}
func (r *CapabilityRepository) SaveModelDescriptor(ctx context.Context, value capability.ModelDescriptor) error {
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors
(model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, value.ID, value.Revision,
(model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.Revision,
value.ProviderInstanceID, value.ProviderModelID, value.Fingerprint, value.MaxContextBytes, value.Modalities,
value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis, value.IntelligenceScoreMillis)
value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis)
return err
}
@@ -430,36 +347,10 @@ func (r *CapabilityRepository) SaveCognitiveBinding(ctx context.Context, value c
}
func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) {
return r.loadCognitiveCandidates(ctx, capabilityID, owner, "")
}
func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, profileID capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error) {
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
}
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
var capabilityID capability.CapabilityID
var owner identity.PrincipalID
if err := r.pool.QueryRow(ctx, `SELECT capability_id,owner_principal_id FROM capability.cognitive_bindings WHERE binding_id=$1 AND revision=$2`, bindingID, revision).Scan(&capabilityID, &owner); err != nil {
return capability.ResolvedCognitiveBinding{}, err
}
candidates, err := r.loadCognitiveCandidates(ctx, capabilityID, owner, "")
if err != nil {
return capability.ResolvedCognitiveBinding{}, err
}
for _, candidate := range candidates {
if candidate.Binding.ID == bindingID && candidate.Binding.Revision == revision {
return candidate, nil
}
}
return capability.ResolvedCognitiveBinding{}, pgx.ErrNoRows
}
func (r *CapabilityRepository) loadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, profileID capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error) {
rows, err := r.pool.Query(ctx, `SELECT
b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref,
p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),p.billing_mode,p.auth_mode,p.input_cost_micros,p.output_cost_micros,p.request_cost_micros,
m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0)),m.intelligence_score_millis
p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)),
m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0))
FROM capability.cognitive_binding_revision_heads bh
JOIN capability.cognitive_bindings b ON b.binding_id=bh.binding_id AND b.revision=bh.revision
JOIN capability.cognitive_provider_instances p ON p.provider_instance_id=b.provider_instance_id AND p.revision=b.provider_revision
@@ -475,12 +366,12 @@ func (r *CapabilityRepository) loadCognitiveCandidates(ctx context.Context, capa
for rows.Next() {
var value capability.ResolvedCognitiveBinding
err = rows.Scan(&value.Binding.ID, &value.Binding.Revision, &value.Binding.CapabilityID, &value.Binding.OwnerPrincipalID, &value.Binding.ProviderInstanceID, &value.Binding.ProviderRevision, &value.Binding.ModelDescriptorID, &value.Binding.ModelRevision, &value.Binding.DataPolicy, &value.Binding.Qualification, &value.Binding.Activation, &value.Binding.Health, &value.Binding.LimitPolicyRef,
&value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis, &value.Provider.BillingMode, &value.Provider.AuthMode, &value.Provider.InputCostMicros, &value.Provider.OutputCostMicros, &value.Provider.RequestCostMicros,
&value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis, &value.Model.IntelligenceScoreMillis)
&value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis,
&value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis)
if err != nil {
return nil, err
}
if err = r.loadCognitiveEvidence(ctx, &value, profileID); err != nil {
if err = r.loadCognitiveEvidence(ctx, &value); err != nil {
return nil, err
}
result = append(result, value)
@@ -488,7 +379,7 @@ func (r *CapabilityRepository) loadCognitiveCandidates(ctx context.Context, capa
return result, rows.Err()
}
func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value *capability.ResolvedCognitiveBinding, profileID capability.TaskEvaluationProfileID) error {
func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value *capability.ResolvedCognitiveBinding) error {
var evaluation capability.EvaluatedCapability
err := r.pool.QueryRow(ctx, `SELECT evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_evaluated_capabilities WHERE binding_id=$1 AND binding_revision=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Binding.ID, value.Binding.Revision).Scan(&evaluation.ID, &evaluation.BindingID, &evaluation.BindingRevision, &evaluation.CapabilityID, &evaluation.Result, &evaluation.QualityScoreMillis, &evaluation.CriticalFailureMillis, &evaluation.BenchmarkVersion, &evaluation.ArtifactID, &evaluation.ObservedAtUnixMillis)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -505,16 +396,6 @@ func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value
if err == nil {
value.Performance = &performance
}
if profileID != "" {
var taskEvaluation capability.TaskEvaluation
err = r.pool.QueryRow(ctx, `SELECT evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_task_evaluations WHERE task_profile_id=$1 AND binding_id=$2 AND binding_revision=$3 ORDER BY observed_at_unix_millis DESC LIMIT 1`, profileID, value.Binding.ID, value.Binding.Revision).Scan(&taskEvaluation.ID, &taskEvaluation.ProfileID, &taskEvaluation.BindingID, &taskEvaluation.BindingRevision, &taskEvaluation.CapabilityID, &taskEvaluation.Result, &taskEvaluation.QualityScoreMillis, &taskEvaluation.ReliabilityScoreMillis, &taskEvaluation.LatencyScoreMillis, &taskEvaluation.OverallScoreMillis, &taskEvaluation.CriticalFailureMillis, &taskEvaluation.BenchmarkVersion, &taskEvaluation.ArtifactID, &taskEvaluation.ObservedAtUnixMillis)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if err == nil {
value.TaskEvaluation = &taskEvaluation
}
}
return nil
}
@@ -533,7 +414,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
return err
}
defer func() { _ = tx.Rollback(ctx) }()
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (provider_instance_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,billing_mode=EXCLUDED.billing_mode,auth_mode=EXCLUDED.auth_mode,input_cost_micros=EXCLUDED.input_cost_micros,output_cost_micros=EXCLUDED.output_cost_micros,request_cost_micros=EXCLUDED.request_cost_micros WHERE capability.cognitive_provider_instances.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis, value.Provider.BillingMode, value.Provider.AuthMode, value.Provider.InputCostMicros, value.Provider.OutputCostMicros, value.Provider.RequestCostMicros)
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis)
if err != nil {
return err
}
@@ -542,7 +423,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
return err
}
for index, model := range value.Models {
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (model_descriptor_id,revision) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,intelligence_score_millis=EXCLUDED.intelligence_score_millis WHERE capability.cognitive_model_descriptors.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis, model.IntelligenceScoreMillis)
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis)
if err != nil {
return err
}
@@ -552,11 +433,7 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
return err
}
binding := value.Bindings[index]
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (binding_id,revision) DO UPDATE SET health=EXCLUDED.health WHERE capability.cognitive_bindings.health <> EXCLUDED.health`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision)
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef)
if err != nil {
return err
}
@@ -576,10 +453,10 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context,
}
defer func() { _ = tx.Rollback(ctx) }()
p, m, b, e := value.Provider, value.Model, value.Binding, value.Evaluation
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis,billing_mode,auth_mode,input_cost_micros,output_cost_micros,request_cost_micros) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis, p.BillingMode, p.AuthMode, p.InputCostMicros, p.OutputCostMicros, p.RequestCostMicros); err != nil {
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, p.ID, p.Revision, p.Kind, p.EndpointRef, p.CredentialRef, p.Enabled, p.Qualification, p.Health, p.ObservedAtUnixMillis); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis,intelligence_score_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis, m.IntelligenceScoreMillis); err != nil {
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, m.ID, m.Revision, m.ProviderInstanceID, m.ProviderModelID, m.Fingerprint, m.MaxContextBytes, m.Modalities, m.Capabilities, m.Qualification, m.Health, m.ObservedAtUnixMillis); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil {
@@ -598,38 +475,7 @@ func (r *CapabilityRepository) SaveCognitiveCanaryPromotion(ctx context.Context,
return tx.Commit(ctx)
}
func (r *CapabilityRepository) SaveCognitiveActivationPromotion(ctx context.Context, value capability.CognitiveActivationPromotion) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
b, e := value.Binding, value.Evaluation
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, b.ID, b.Revision, b.CapabilityID, b.OwnerPrincipalID, b.ProviderInstanceID, b.ProviderRevision, b.ModelDescriptorID, b.ModelRevision, b.DataPolicy, b.Qualification, b.Activation, b.Health, b.LimitPolicyRef); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_evaluated_capabilities (evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, e.ID, e.BindingID, e.BindingRevision, e.CapabilityID, e.Result, e.QualityScoreMillis, e.CriticalFailureMillis, e.BenchmarkVersion, e.ArtifactID, e.ObservedAtUnixMillis); err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE capability.cognitive_bindings SET activation='DEPRECATED' WHERE capability_id=$1 AND owner_principal_id=$2 AND activation='ACTIVE' AND NOT (binding_id=$3 AND revision=$4)`, b.CapabilityID, b.OwnerPrincipalID, b.ID, b.Revision); err != nil {
return err
}
result, err := tx.Exec(ctx, `UPDATE capability.cognitive_binding_revision_heads SET revision=$1,updated_at=CURRENT_TIMESTAMP WHERE binding_id=$2 AND revision=$3`, b.Revision, b.ID, value.ExpectedBindingRevision)
if err != nil {
return err
}
if result.RowsAffected() != 1 {
return ErrCapabilityBindingConflict
}
return tx.Commit(ctx)
}
func (r *CapabilityRepository) SavePerformanceEvidence(ctx context.Context, value capability.PerformanceEvidence) error {
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_performance_evidence (evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.ModelDescriptorID, value.ModelRevision, value.HardwareProfile, value.Runtime, value.Quantization, value.ContextUnits, value.ThinkingEnabled, value.GenerationTPSMillis, value.PromptTPSMillis, value.TTFTMicros, value.LatencyMicros, value.MemoryBytes, value.ReliabilityMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
return err
}
func (r *CapabilityRepository) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_task_evaluations (evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.ProfileID, value.BindingID, value.BindingRevision, value.CapabilityID, value.Result, value.QualityScoreMillis, value.ReliabilityScoreMillis, value.LatencyScoreMillis, value.OverallScoreMillis, value.CriticalFailureMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
return err
}
+2 -80
View File
@@ -3,7 +3,6 @@ package postgres
import (
"context"
"fmt"
"time"
"git.cin.su/m/Assistant/contracts/epistemics"
"github.com/jackc/pgx/v5/pgxpool"
@@ -21,10 +20,6 @@ func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRe
func (r *EpistemicsRepository) Close() { r.pool.Close() }
func (r *EpistemicsRepository) Ping(ctx context.Context) error {
return r.pool.Ping(ctx)
}
func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value epistemics.SystemExpectation) error {
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.system_expectations (
expectation_id, original_intent_id, source_utterance_id, expected_outcome, expected_effect,
@@ -69,7 +64,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
}
rows, err := r.pool.Query(ctx, `SELECT
reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision
FROM epistemics.reflection_candidates ORDER BY recorded_at_unix_millis DESC, reflection_candidate_id DESC LIMIT $1`, limit)
if err != nil {
return nil, err
@@ -79,7 +74,7 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
for rows.Next() {
var value epistemics.ReflectionCandidate
if err := rows.Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID,
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis); err != nil {
&value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision); err != nil {
return nil, err
}
result = append(result, value)
@@ -87,61 +82,6 @@ func (r *EpistemicsRepository) RecentReflectionCandidates(ctx context.Context, l
return result, rows.Err()
}
func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, workerID string, nowUnixMillis, leaseMillis int64) (epistemics.ReflectionCandidate, error) {
if workerID == "" || leaseMillis < 1 {
return epistemics.ReflectionCandidate{}, fmt.Errorf("invalid reflection claim")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return epistemics.ReflectionCandidate{}, err
}
defer tx.Rollback(ctx)
var value epistemics.ReflectionCandidate
err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
FROM epistemics.reflection_candidates
WHERE state='OPEN' AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
ORDER BY recorded_at_unix_millis,reflection_candidate_id
FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis)
if err != nil {
return epistemics.ReflectionCandidate{}, err
}
value.State = epistemics.ReflectionCandidateEvaluating
value.ClaimedBy = workerID
value.ClaimExpiresAtUnixMillis = nowUnixMillis + leaseMillis
_, err = tx.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state=$1,claimed_by=$2,claim_expires_at_unix_millis=$3 WHERE reflection_candidate_id=$4 AND state='OPEN'`, value.State, value.ClaimedBy, value.ClaimExpiresAtUnixMillis, value.ID)
if err != nil {
return epistemics.ReflectionCandidate{}, err
}
if err := tx.Commit(ctx); err != nil {
return epistemics.ReflectionCandidate{}, err
}
return value, nil
}
func (r *EpistemicsRepository) RecoverReflectionClaims(ctx context.Context, nowUnixMillis int64) (int64, error) {
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='OPEN',claimed_by='',claim_expires_at_unix_millis=0 WHERE state='EVALUATING' AND claim_expires_at_unix_millis > 0 AND claim_expires_at_unix_millis < $1`, nowUnixMillis)
return result.RowsAffected(), err
}
func (r *EpistemicsRepository) ResolveReflectionCandidate(ctx context.Context, id epistemics.ReflectionCandidateID, workerID string, nowUnixMillis int64) error {
if id == "" || workerID == "" {
return fmt.Errorf("invalid reflection resolution")
}
result, err := r.pool.Exec(ctx, `UPDATE epistemics.reflection_candidates SET state='RESOLVED',claimed_by='',claim_expires_at_unix_millis=0,revision=revision+1 WHERE reflection_candidate_id=$1 AND state='EVALUATING' AND claimed_by=$2 AND claim_expires_at_unix_millis >= $3`, id, workerID, nowUnixMillis)
if err == nil && result.RowsAffected() != 1 {
return fmt.Errorf("reflection candidate claim lost")
}
return err
}
func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, interval time.Duration) error {
if interval <= 0 {
return fmt.Errorf("invalid reflection poll interval")
}
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
return err
}
func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error {
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records (
experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref,
@@ -165,21 +105,3 @@ func (r *EpistemicsRepository) SaveReflectionCandidate(ctx context.Context, valu
value.Summary, value.EvidenceRefs, value.RequiresCognition, value.RecordedAtUnixMillis, value.Revision)
return err
}
func (r *EpistemicsRepository) SaveTruthBundle(ctx context.Context, value epistemics.TruthBundle, recordedAtUnixMillis int64) error {
if value.Source.ID == "" || value.Snapshot.ID == "" || value.Excerpt.ID == "" || value.Claim.ID == "" || value.Belief.ID == "" || value.Citation.ID == "" || recordedAtUnixMillis < 1 {
return fmt.Errorf("invalid truth bundle")
}
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.truth_bundles (
bundle_id,source_id,source_kind,source_locator,source_title,source_publisher,snapshot_id,snapshot_source_id,snapshot_content_digest,snapshot_captured_at_unix_millis,snapshot_freshness,
excerpt_id,excerpt_snapshot_id,excerpt_text,excerpt_start,excerpt_end,observation_id,observation_observed_at_unix_millis,claim_id,claim_statement,claim_observation_id,claim_citation_id,claim_verification,
belief_id,belief_claim_id,belief_state,citation_id,citation_excerpt_id,citation_evidence,citation_coverage,citation_conflict,citation_resolved_at_unix_millis,recorded_at_unix_millis)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33)
ON CONFLICT (bundle_id) DO UPDATE SET recorded_at_unix_millis=EXCLUDED.recorded_at_unix_millis`,
"bundle:"+string(value.Citation.ID), value.Source.ID, value.Source.Kind, value.Source.Locator, value.Source.Title, value.Source.Publisher,
value.Snapshot.ID, value.Snapshot.SourceID, value.Snapshot.ContentDigest, value.Snapshot.CapturedAtUnixMillis, value.Snapshot.Freshness,
value.Excerpt.ID, value.Excerpt.SnapshotID, value.Excerpt.Text, value.Excerpt.Start, value.Excerpt.End,
value.Observation.ID, value.Observation.ObservedAt, value.Claim.ID, value.Claim.Statement, value.Claim.ObservationID, value.Claim.CitationID, value.Claim.Verification,
value.Belief.ID, value.Belief.ClaimID, value.Belief.State, value.Citation.ID, value.Citation.ExcerptID, value.Citation.Evidence, value.Citation.Coverage, value.Citation.Conflict, value.Citation.ResolvedAtUnixMillis, recordedAtUnixMillis)
return err
}
+1 -395
View File
@@ -2,29 +2,14 @@ package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type WorkRepository struct{ pool *pgxpool.Pool }
var (
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
ErrAgentCallbackRejected = errors.New("agent callback rejected")
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
ErrDevelopmentCandidateConflict = errors.New("development candidate conflict")
)
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
pool, err := openPool(ctx, config)
if err != nil {
@@ -32,8 +17,7 @@ func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, er
}
return &WorkRepository{pool}, nil
}
func (r *WorkRepository) Close() { r.pool.Close() }
func (r *WorkRepository) Ping(ctx context.Context) error { return r.pool.Ping(ctx) }
func (r *WorkRepository) Close() { r.pool.Close() }
func (r *WorkRepository) Create(ctx context.Context, target work.TaskTarget) error {
_, err := r.pool.Exec(ctx, `INSERT INTO work.tasks (task_id,goal_id,scope_id,revision) VALUES ($1,$2,$3,$4) ON CONFLICT (task_id) DO NOTHING`, target.TaskID, target.GoalID, target.ScopeID, target.Revision)
return err
@@ -55,355 +39,6 @@ func (r *WorkRepository) Load(ctx context.Context, id work.TaskID) (work.TaskTar
return target, nil
}
func (r *WorkRepository) LoadDevelopmentGate(ctx context.Context, id string) (work.DevelopmentGate, error) {
var gate work.DevelopmentGate
var evidence, producer, verifier []string
err := r.pool.QueryRow(ctx, `SELECT gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref FROM work.development_gates WHERE gate_id=$1`, id).Scan(
&gate.ID, &gate.Revision, &gate.UpdatedAtUnixMillis, &gate.Stage, &evidence, &gate.ProposalRef, &gate.TaskPacketRef, &gate.WorktreeRef, &producer, &verifier, &gate.VerificationEvidence, &gate.CanaryEvidence, &gate.PromotionRef, &gate.RollbackRef)
if err != nil {
return work.DevelopmentGate{}, fmt.Errorf("load development gate: %w", err)
}
gate.EvidenceRefs = append([]string(nil), evidence...)
for _, run := range producer {
gate.ProducerRunRefs = append(gate.ProducerRunRefs, work.AgentRunID(run))
}
for _, run := range verifier {
gate.VerifierRunRefs = append(gate.VerifierRunRefs, work.AgentRunID(run))
}
return gate, nil
}
func (r *WorkRepository) SaveDevelopmentGate(ctx context.Context, gate work.DevelopmentGate, expectedRevision int64) error {
if gate.ID == "" || expectedRevision < 0 || gate.Revision < 1 || gate.UpdatedAtUnixMillis < 1 {
return ErrDevelopmentGateConflict
}
producer := make([]string, 0, len(gate.ProducerRunRefs))
for _, run := range gate.ProducerRunRefs {
producer = append(producer, string(run))
}
verifier := make([]string, 0, len(gate.VerifierRunRefs))
for _, run := range gate.VerifierRunRefs {
verifier = append(verifier, string(run))
}
if expectedRevision == 0 {
command, err := r.pool.Exec(ctx, `INSERT INTO work.development_gates(gate_id,revision,updated_at_unix_millis,stage,evidence_refs,proposal_ref,task_packet_ref,worktree_ref,producer_run_refs,verifier_run_refs,verification_evidence,canary_evidence,promotion_ref,rollback_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT(gate_id) DO NOTHING`, gate.ID, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrDevelopmentGateConflict
}
return nil
}
command, err := r.pool.Exec(ctx, `UPDATE work.development_gates SET revision=$1,updated_at_unix_millis=$2,stage=$3,evidence_refs=$4,proposal_ref=$5,task_packet_ref=$6,worktree_ref=$7,producer_run_refs=$8,verifier_run_refs=$9,verification_evidence=$10,canary_evidence=$11,promotion_ref=$12,rollback_ref=$13 WHERE gate_id=$14 AND revision=$15`, gate.Revision, gate.UpdatedAtUnixMillis, gate.Stage, gate.EvidenceRefs, gate.ProposalRef, gate.TaskPacketRef, gate.WorktreeRef, producer, verifier, gate.VerificationEvidence, gate.CanaryEvidence, gate.PromotionRef, gate.RollbackRef, gate.ID, expectedRevision)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrDevelopmentGateConflict
}
return nil
}
func (r *WorkRepository) SaveExtensionProposal(ctx context.Context, value work.ExtensionProposal) error {
result, err := r.pool.Exec(ctx, `INSERT INTO work.extension_proposals(proposal_id,requested_by_principal_id,stage,evidence_refs,deduplicated_signal_keys,desired_outcome,smallest_change_layer,affected_owners,affected_contracts,risk_tier,evaluation_plan,alternatives,rollback_plan,assumptions,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(proposal_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.EvidenceRefs, value.DeduplicatedSignalKeys, value.DesiredOutcome, value.SmallestChangeLayer, value.AffectedOwners, value.AffectedContracts, value.RiskTier, value.EvaluationPlan, value.Alternatives, value.RollbackPlan, value.Assumptions, value.CreatedAtUnixMillis)
if err != nil {
return err
}
if result.RowsAffected() != 1 {
return ErrDevelopmentCandidateConflict
}
return nil
}
func (r *WorkRepository) SaveDevelopmentScaffold(ctx context.Context, value work.DevelopmentScaffold) error {
result, err := r.pool.Exec(ctx, `INSERT INTO work.development_scaffolds(scaffold_id,requested_by_principal_id,stage,proposal_ref,base_repo_revision,worktree_ref,task_intent,modules,allowed_paths,forbidden_paths,required_tests,artifact_lineage_refs,protected_main_access,production_access,secret_access,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(scaffold_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.ProposalRef, value.BaseRepoRevision, value.WorktreeRef, value.TaskIntent, value.Modules, value.AllowedPaths, value.ForbiddenPaths, value.RequiredTests, value.ArtifactLineageRefs, value.ProtectedMainAccess, value.ProductionAccess, value.SecretAccess, value.CreatedAtUnixMillis)
if err != nil {
return err
}
if result.RowsAffected() != 1 {
return ErrDevelopmentCandidateConflict
}
return nil
}
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
profileJSON, casesJSON, err := marshalTaskPayload(intent.TaskEvaluationProfile, intent.TaskEvaluationCases)
if err != nil {
return err
}
executionJSON, err := json.Marshal(intent.TaskEvaluationExecution)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis, intent.SpecialistPurpose, intent.TaskEvaluationProfileID, intent.TaskEvaluationBindingID, intent.TaskEvaluationCaseIDs, profileJSON, casesJSON, executionJSON)
if err != nil {
return fmt.Errorf("save dispatch intent: %w", err)
}
var existing work.AgentDispatchIntent
var profileJSONExisting, casesJSONExisting, executionJSONExisting []byte
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis, &existing.SpecialistPurpose, &existing.TaskEvaluationProfileID, &existing.TaskEvaluationBindingID, &existing.TaskEvaluationCaseIDs, &profileJSONExisting, &casesJSONExisting, &executionJSONExisting)
if err != nil {
return fmt.Errorf("read dispatch intent: %w", err)
}
if err := unmarshalTaskPayload(profileJSONExisting, casesJSONExisting, &existing.TaskEvaluationProfile, &existing.TaskEvaluationCases); err != nil {
return err
}
if len(executionJSONExisting) > 0 && string(executionJSONExisting) != "null" {
if err := json.Unmarshal(executionJSONExisting, &existing.TaskEvaluationExecution); err != nil {
return err
}
}
if !sameDispatchIntent(existing, intent) {
return ErrDispatchIntentConflict
}
return tx.Commit(ctx)
}
func sameDispatchIntent(left, right work.AgentDispatchIntent) bool {
return left.DispatchID == right.DispatchID && left.IdempotencyKey == right.IdempotencyKey && left.DelegationID == right.DelegationID && left.RunID == right.RunID && left.ImplementationID == right.ImplementationID && left.ImplementationRevision == right.ImplementationRevision && left.SourceRevision == right.SourceRevision && left.WorktreeID == right.WorktreeID && left.VerificationPlanRef == right.VerificationPlanRef && left.CreatedAtUnixMillis == right.CreatedAtUnixMillis && left.SpecialistPurpose == right.SpecialistPurpose && left.TaskEvaluationProfileID == right.TaskEvaluationProfileID && left.TaskEvaluationBindingID == right.TaskEvaluationBindingID && slices.Equal(left.TaskEvaluationCaseIDs, right.TaskEvaluationCaseIDs) && reflect.DeepEqual(left.TaskEvaluationProfile, right.TaskEvaluationProfile) && slices.Equal(left.TaskEvaluationCases, right.TaskEvaluationCases) && reflect.DeepEqual(left.TaskEvaluationExecution, right.TaskEvaluationExecution)
}
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
var intent work.AgentDispatchIntent
var profileJSON, casesJSON, executionJSON []byte
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis, &intent.SpecialistPurpose, &intent.TaskEvaluationProfileID, &intent.TaskEvaluationBindingID, &intent.TaskEvaluationCaseIDs, &profileJSON, &casesJSON, &executionJSON)
if err != nil {
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
}
if err := unmarshalTaskPayload(profileJSON, casesJSON, &intent.TaskEvaluationProfile, &intent.TaskEvaluationCases); err != nil {
return work.AgentDispatchIntent{}, err
}
if len(executionJSON) > 0 && string(executionJSON) != "null" {
if err := json.Unmarshal(executionJSON, &intent.TaskEvaluationExecution); err != nil {
return work.AgentDispatchIntent{}, err
}
}
return intent, nil
}
func (r *WorkRepository) EnqueueDispatch(ctx context.Context, item work.AgentDispatchQueueItem) error {
_, err := r.pool.Exec(ctx, `INSERT INTO work.agent_dispatch_queue(dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(dispatch_id) DO NOTHING`, item.DispatchID, item.State, item.AvailableAtUnixMillis, item.Attempts, item.ClaimedBy, item.UpdatedAtUnixMillis)
return err
}
func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string, nowUnixMillis int64) (work.AgentDispatchQueueItem, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return work.AgentDispatchQueueItem{}, err
}
defer tx.Rollback(ctx)
var item work.AgentDispatchQueueItem
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 AND attempts < retry_limit ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return work.AgentDispatchQueueItem{}, nil
}
return work.AgentDispatchQueueItem{}, err
}
item.State = work.AgentDispatchClaimed
item.Attempts++
item.ClaimedBy = workerID
item.UpdatedAtUnixMillis = nowUnixMillis
_, err = tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=$1,attempts=$2,claimed_by=$3,updated_at_unix_millis=$4 WHERE dispatch_id=$5 AND state='QUEUED'`, item.State, item.Attempts, item.ClaimedBy, item.UpdatedAtUnixMillis, item.DispatchID)
if err != nil {
return work.AgentDispatchQueueItem{}, err
}
if err := tx.Commit(ctx); err != nil {
return work.AgentDispatchQueueItem{}, err
}
return item, nil
}
func (r *WorkRepository) WaitForDispatch(ctx context.Context, interval time.Duration) error {
if interval <= 0 {
return fmt.Errorf("invalid worker poll interval")
}
_, err := r.pool.Exec(ctx, `SELECT pg_sleep($1)`, interval.Seconds())
return err
}
func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, workerID string, state work.AgentDispatchQueueState, nowUnixMillis int64) error {
if state != work.AgentDispatchCompleted && state != work.AgentDispatchFailed {
return fmt.Errorf("invalid terminal dispatch state: %s", state)
}
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=$1,updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, state, nowUnixMillis, dispatchID, workerID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrAgentCallbackRejected
}
return nil
}
func (r *WorkRepository) RetryDispatch(ctx context.Context, dispatchID, workerID string, nowUnixMillis int64, delay time.Duration) error {
if delay < 0 {
return fmt.Errorf("invalid retry delay")
}
availableAt := nowUnixMillis + delay.Milliseconds()
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, availableAt, nowUnixMillis, dispatchID, workerID)
if err != nil {
return err
}
if command.RowsAffected() != 1 {
return ErrAgentCallbackRejected
}
return nil
}
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef); err != nil {
return err
}
for _, node := range graph.Nodes {
profileJSON, casesJSON, err := marshalTaskPayload(node.TaskProfile, node.TaskCases)
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts, node.Purpose, node.TaskProfileID, node.BindingID, node.CaseIDs, profileJSON, casesJSON); err != nil {
return err
}
}
for _, edge := range graph.Edges {
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_edges(graph_id,revision,from_node_id,to_node_id) VALUES($1,$2,$3,$4) ON CONFLICT(graph_id,revision,from_node_id,to_node_id) DO NOTHING`, graph.ID, graph.Revision, edge.From, edge.To); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) {
var graph work.WorkGraph
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef)
if err != nil {
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
}
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
if err != nil {
return work.WorkGraph{}, err
}
for rows.Next() {
var node work.GraphNode
var profileJSON, casesJSON []byte
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs, &profileJSON, &casesJSON); err != nil {
rows.Close()
return work.WorkGraph{}, err
}
if err := unmarshalTaskPayload(profileJSON, casesJSON, &node.TaskProfile, &node.TaskCases); err != nil {
rows.Close()
return work.WorkGraph{}, err
}
graph.Nodes = append(graph.Nodes, node)
}
if err := rows.Err(); err != nil {
rows.Close()
return work.WorkGraph{}, err
}
rows.Close()
edges, err := r.pool.Query(ctx, `SELECT from_node_id,to_node_id FROM work.graph_edges WHERE graph_id=$1 AND revision=$2 ORDER BY from_node_id,to_node_id`, graphID, revision)
if err != nil {
return work.WorkGraph{}, err
}
for edges.Next() {
var edge work.GraphEdge
if err := edges.Scan(&edge.From, &edge.To); err != nil {
edges.Close()
return work.WorkGraph{}, err
}
graph.Edges = append(graph.Edges, edge)
}
if err := edges.Err(); err != nil {
edges.Close()
return work.WorkGraph{}, err
}
edges.Close()
return graph, nil
}
func (r *WorkRepository) SaveGraphSynthesis(ctx context.Context, conflict work.ConflictSet, candidates []work.ResultCandidate, synthesis work.GraphSynthesisCandidate) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, candidate := range candidates {
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_result_candidates(result_candidate_id,graph_id,graph_revision,node_id,author_run_id,artifact_refs,evidence_summary,summary) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(result_candidate_id) DO NOTHING`, candidate.ID, candidate.GraphID, candidate.GraphRevision, candidate.NodeID, candidate.AuthorRunID, candidate.ArtifactRefs, candidate.EvidenceSummary, candidate.Summary); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_conflicts(conflict_id,graph_id,graph_revision,node_id,candidate_ids,resolved) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(conflict_id) DO NOTHING`, conflict.ID, conflict.GraphID, conflict.GraphRevision, conflict.NodeID, conflict.CandidateIDs, conflict.Resolved); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_synthesis_candidates(synthesis_id,graph_id,graph_revision,node_id,candidate_ids,selected_candidate_id,dissent_candidate_ids,verifier_run_id,verifier_identity,evidence_refs,summary,state) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(synthesis_id) DO NOTHING`, synthesis.ID, synthesis.GraphID, synthesis.GraphRevision, synthesis.NodeID, synthesis.CandidateIDs, synthesis.SelectedCandidateID, synthesis.DissentCandidateIDs, synthesis.VerifierRunID, synthesis.VerifierIdentity, synthesis.EvidenceRefs, synthesis.Summary, synthesis.State); err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `UPDATE work.agent_leases AS l SET state='EXPIRED' FROM work.agent_dispatch_queue AS q JOIN work.agent_dispatch_intents AS i ON i.dispatch_id=q.dispatch_id WHERE q.state='CLAIMED' AND q.updated_at_unix_millis <= $1 AND l.run_id=i.run_id AND l.state='ACTIVE'`, nowUnixMillis-staleAfterMillis); err != nil {
return 0, err
}
command, err := tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE state='CLAIMED' AND updated_at_unix_millis <= $3`, nowUnixMillis, nowUnixMillis, nowUnixMillis-staleAfterMillis)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return command.RowsAffected(), nil
}
func (r *WorkRepository) SaveAgentLease(ctx context.Context, lease work.AgentLease) error {
err := r.pool.QueryRow(ctx, `INSERT INTO work.agent_leases(lease_id,run_id,worker_id,fencing_token,state,acquired_at_unix_millis,expires_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(run_id) DO UPDATE SET lease_id=EXCLUDED.lease_id,worker_id=EXCLUDED.worker_id,fencing_token=EXCLUDED.fencing_token,state=EXCLUDED.state,acquired_at_unix_millis=EXCLUDED.acquired_at_unix_millis,expires_at_unix_millis=EXCLUDED.expires_at_unix_millis WHERE work.agent_leases.state='EXPIRED' OR work.agent_leases.expires_at_unix_millis <= EXCLUDED.acquired_at_unix_millis RETURNING run_id`, lease.ID, lease.RunID, lease.WorkerID, lease.FencingToken, lease.State, lease.AcquiredAt.UnixMilli(), lease.ExpiresAt.UnixMilli()).Scan(new(string))
if err != nil {
return ErrAgentLeaseUnavailable
}
return nil
}
func (r *WorkRepository) RenewAgentLease(ctx context.Context, lease work.AgentLease, expiresAt time.Time) error {
command, err := r.pool.Exec(ctx, `UPDATE work.agent_leases SET expires_at_unix_millis=$1 WHERE run_id=$2 AND worker_id=$3 AND fencing_token=$4 AND state='ACTIVE' AND expires_at_unix_millis > $5`, expiresAt.UnixMilli(), lease.RunID, lease.WorkerID, lease.FencingToken, time.Now().UnixMilli())
if err != nil || command.RowsAffected() != 1 {
return ErrAgentLeaseUnavailable
}
return nil
}
func (r *WorkRepository) AcceptAgentCallback(ctx context.Context, callback work.AgentCallback) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var runID string
err = tx.QueryRow(ctx, `SELECT run_id FROM work.agent_leases WHERE run_id=$1 AND worker_id=$2 AND fencing_token=$3 AND state='ACTIVE' AND expires_at_unix_millis > $4 FOR UPDATE`, callback.RunID, callback.WorkerID, callback.FencingToken, callback.ObservedAt.UnixMilli()).Scan(&runID)
if err != nil {
return ErrAgentCallbackRejected
}
command, err := tx.Exec(ctx, `INSERT INTO work.agent_callback_heads(run_id,last_sequence,updated_at_unix_millis) VALUES($1,$2,$3) ON CONFLICT(run_id) DO UPDATE SET last_sequence=EXCLUDED.last_sequence,updated_at_unix_millis=EXCLUDED.updated_at_unix_millis WHERE work.agent_callback_heads.last_sequence < EXCLUDED.last_sequence`, runID, callback.Sequence, callback.ObservedAt.UnixMilli())
if err != nil || command.RowsAffected() != 1 {
return ErrAgentCallbackRejected
}
return tx.Commit(ctx)
}
func (r *WorkRepository) SavePlanningResult(ctx context.Context, result work.PlanningResult) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
@@ -657,32 +292,3 @@ func latestRowErr(err error) error {
}
return err
}
func marshalTaskPayload(profile *capability.TaskEvaluationProfile, cases []capability.TaskEvaluationCase) ([]byte, []byte, error) {
var profileJSON []byte
var err error
if profile != nil {
profileJSON, err = json.Marshal(profile)
if err != nil {
return nil, nil, err
}
}
casesJSON, err := json.Marshal(cases)
return profileJSON, casesJSON, err
}
func unmarshalTaskPayload(profileJSON, casesJSON []byte, profile **capability.TaskEvaluationProfile, cases *[]capability.TaskEvaluationCase) error {
if len(profileJSON) > 0 && string(profileJSON) != "null" {
var value capability.TaskEvaluationProfile
if err := json.Unmarshal(profileJSON, &value); err != nil {
return err
}
*profile = &value
}
if len(casesJSON) > 0 && string(casesJSON) != "null" {
if err := json.Unmarshal(casesJSON, cases); err != nil {
return err
}
}
return nil
}
-74
View File
@@ -1,74 +0,0 @@
package research
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
var ErrHTTPSourceRejected = errors.New("http research source rejected")
type HTTPConnector struct {
Client *http.Client
AllowedHosts map[string]struct{}
MaxBytes int64
}
func (c HTTPConnector) Collect(ctx context.Context, request epistemics.WebResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
if request.Plan.Validate() != nil || request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
}
u, err := url.Parse(request.URL)
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || !c.allowedHost(strings.ToLower(u.Hostname())) {
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
}
client := c.Client
if client == nil {
client = &http.Client{}
}
maxBytes := c.MaxBytes
if maxBytes <= 0 || maxBytes > 4<<20 {
maxBytes = 256 << 10
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
}
response, err := client.Do(req)
if err != nil {
return epistemics.ResearchCandidate{}, fmt.Errorf("read web source: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return epistemics.ResearchCandidate{}, fmt.Errorf("web source status: %s", response.Status)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
if err != nil || int64(len(body)) > maxBytes || len(body) == 0 {
return epistemics.ResearchCandidate{}, ErrHTTPSourceRejected
}
digest := sha256.Sum256(body)
digestText := "sha256:" + hex.EncodeToString(digest[:])
key := sha256.Sum256([]byte(u.String() + "\x00" + digestText))
keyText := hex.EncodeToString(key[:])
snapshotID := epistemics.SourceSnapshotID("snapshot:" + keyText)
return epistemics.ResearchCandidate{
PropositionKey: request.PropositionKey, Statement: request.Statement,
Source: epistemics.Source{ID: request.SourceID, Kind: "web", Locator: u.String(), Title: request.Title, Publisher: u.Hostname()},
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: snapshotID, Text: string(body), Start: 0, End: int64(len(body))},
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
}, nil
}
func (c HTTPConnector) allowedHost(host string) bool {
_, ok := c.AllowedHosts[host]
return ok
}
-63
View File
@@ -1,63 +0,0 @@
package research
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
var ErrRepositoryPathRejected = errors.New("repository source path rejected")
type RepositoryConnector struct{ Root string }
func (c RepositoryConnector) Collect(ctx context.Context, request epistemics.RepositoryResearchRequest, nowUnixMillis int64) (epistemics.ResearchCandidate, error) {
if err := request.Plan.Validate(); err != nil {
return epistemics.ResearchCandidate{}, err
}
if request.SourceID == "" || strings.TrimSpace(request.Statement) == "" || nowUnixMillis < 1 {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
root, err := filepath.Abs(c.Root)
if err != nil || root == "" {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
path, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(request.Path)))
if err != nil || !withinRoot(root, path) {
return epistemics.ResearchCandidate{}, ErrRepositoryPathRejected
}
select {
case <-ctx.Done():
return epistemics.ResearchCandidate{}, ctx.Err()
default:
}
content, err := os.ReadFile(path)
if err != nil || len(content) == 0 {
return epistemics.ResearchCandidate{}, fmt.Errorf("read repository source: %w", err)
}
digest := sha256.Sum256(content)
digestText := "sha256:" + hex.EncodeToString(digest[:])
key := sha256.Sum256([]byte(filepath.ToSlash(path) + "\x00" + digestText))
keyText := hex.EncodeToString(key[:])
return epistemics.ResearchCandidate{
PropositionKey: request.PropositionKey,
Statement: request.Statement,
Source: epistemics.Source{ID: request.SourceID, Kind: "repository", Locator: "file://" + filepath.ToSlash(path), Title: filepath.Base(path), Publisher: "local-repository"},
Snapshot: epistemics.SourceSnapshot{ID: epistemics.SourceSnapshotID("snapshot:" + keyText), SourceID: request.SourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
Excerpt: epistemics.SourceExcerpt{ID: epistemics.SourceExcerptID("excerpt:" + keyText), SnapshotID: epistemics.SourceSnapshotID("snapshot:" + keyText), Text: string(content), Start: 0, End: int64(len(content))},
Evidence: epistemics.EvidenceObserved,
Coverage: epistemics.CoverageComplete,
Conflict: epistemics.ConflictNone,
}, nil
}
func withinRoot(root, path string) bool {
rel, err := filepath.Rel(root, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
@@ -1,52 +0,0 @@
# ADR-0018 — External audit and exploration mode
Status: Accepted
Date: 2026-08-22
Accepted under: ARCH-000012
## Decision
1. Domovoy may operate in a test-only `Exploration` profile when an ordinary
local conversation or cognitive-provider recovery request is received.
Exploration permits contextual reasoning, allowlisted read-only diagnostics,
hypothesis generation, alternatives and a reversible next-step proposal.
2. Exploration is not an authority grant. It does not grant access to raw
secrets, executor credentials, a provider's unapproved data-egress path, or
direct mutation of canonical or external state.
3. Audit is an independently deployed, rebuildable projection of compact,
redacted action/proposal events. It is not a canonical owner and never
executes actions.
4. An attributed external audit verdict is an Observation. A later Commit
contract may require a current `ALLOW` Observation as an additional
precondition for explicitly configured action classes. `REVIEW_REQUIRED`,
`STOP`, missing or stale review do not authorize execution.
5. Loss of the audit deployment does not remove exploration or approved
read-only diagnostics. A review-governed external action remains a pending
proposal; it is neither executed nor silently converted to failure.
6. Audit payloads exclude raw credentials, secrets and private content. They
carry stable identifiers/references, classifications, hashes and redacted
summaries only.
7. Initial delivery is staged: first semantic local dialogue and provider
recovery, then capability-use telemetry, then audit-only projection. No
existing executor action is review-governed until a later task names its
class and recovery behavior.
## Consequences
- Local cognition remains useful during provider loss and can seek a recovery
plan instead of returning canned availability text.
- Higher-level external scrutiny is possible without creating a competing
executor or a second source of semantic truth.
- Commit/Executor, semantic ownership, append-only evidence and unknown
outcome semantics remain invariants, so external review has attributable
evidence to inspect.
- The first implementation task may change local semantic routing and
capability telemetry only; audit projection and Commit gating need their
own explicit contracts and failure/rebuild tests.
## Revisit triggers
- The first external audit projection is deployed.
- A concrete external action class is proposed for review gating.
- Audit needs a data class beyond redacted references.
- The Exploration profile needs a new privilege or canonical owner.
+1 -1
View File
@@ -4,4 +4,4 @@ Accepted architecture decisions live here. Normal implementation tasks may read
Use `ADR-XXXX-title.md` with: problem, evidence, alternatives, decision, rationale, consequences, validity conditions, revisit triggers, migration/rollback, status.
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary; `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class; and `ADR-0018-external-audit-exploration-mode.md`, the staged external-audit and test Exploration profile.
Current decisions include `ADR-0006-creator-advisory-boundary.md`, the fail-closed provider-neutral advisory boundary, and `ADR-0007-openai-advisory-binding.md`, the accepted but not yet production-enabled OpenAI binding class.
+1 -6
View File
@@ -65,12 +65,7 @@
"WorkPlan",
"PlanStep",
"CapabilityRequirement",
"DelegationCandidate",
"Delegation",
"AgentRun",
"AgentCheckpoint",
"AgentResult",
"DelegationVerification"
"DelegationCandidate"
],
"database_schema": "work",
"boundary_class": "remote_capable",
+6 -12
View File
@@ -25,17 +25,17 @@ modules:
situation:
id: mod.situation
owns: [Situation, FocusBinding, ProjectRegistry, ActiveProjectFocus]
owns: [Situation, FocusBinding]
database_schema: situation
boundary_class: remote_capable
may_depend_on: [contracts.identity, contracts.authority, contracts.work, contracts.capability]
may_depend_on: [contracts.identity, contracts.work, contracts.capability]
work:
id: mod.work
owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification]
owns: [Goal, Task, Decision]
database_schema: work
boundary_class: remote_capable
may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events]
may_depend_on: [contracts.identity, contracts.authority, platform.events]
epistemics:
id: mod.epistemics
@@ -53,7 +53,7 @@ modules:
capability:
id: mod.capability
owns: [Capability, CapabilityBinding, CapabilityHealth, CapabilityImplementation, CapabilityResolution, CapabilitySelection, CapabilityGap, AcquisitionStrategyCandidate, CapabilityUsageObservation, SkillProfile, SkillEvidence, FastPathDecision]
owns: [Capability, CapabilityBinding, CapabilityHealth]
database_schema: capability
boundary_class: remote_capable
may_depend_on: [contracts.identity, contracts.authority, contracts.artifact]
@@ -81,12 +81,11 @@ platform:
- platform.inbox
- platform.telemetry
- platform.errors
- platform.secrets
rule: "platform owns mechanisms, never domain meaning"
runtime_topology_v1:
core_runtime:
modules: [identity, situation, work, epistemics, capability, artifact, interaction]
modules: [identity, situation, work, epistemics, capability, artifact]
continuity_runtime:
modules: [continuity]
commit_runtime:
@@ -108,7 +107,6 @@ storage_topology_v1:
capability: capability_role
commit: commit_role
artifact: artifact_role
interaction: interaction_role
cross_owner_fk: forbidden_by_default
cross_owner_write: forbidden
cross_owner_application_join: forbidden
@@ -144,10 +142,6 @@ protected_paths:
- architecture/architecture-registry.yaml
- architecture/adrs/**
- ci/architecture/**
- .gitea/workflows/ci.yml
- cmd/devctl/**
- dev/ACTIVE_TASK.json
- ci/architecture/**
- security/**
default_feature_task_authority:
+1 -1
View File
@@ -27,7 +27,7 @@ modules:
work:
id: mod.work
owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate, Delegation, AgentRun, AgentCheckpoint, AgentResult, DelegationVerification]
owns: [Goal, HelpPath, GoalLifecycle, Task, Decision, PlanCandidate, WorkPlan, PlanStep, CapabilityRequirement, DelegationCandidate]
database_schema: work
boundary_class: remote_capable
may_depend_on: [contracts.identity, contracts.authority, contracts.epistemics, contracts.capability, platform.ids, platform.events]
-108
View File
@@ -1,108 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/contracts/work"
"git.cin.su/m/Assistant/modules/work/application"
"git.cin.su/m/Assistant/modules/work/ports"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
type memoryStore struct{ writes int }
func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchIntent) error {
s.writes++
return nil
}
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
func (s *memoryStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error { return nil }
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
s.writes++
return nil
}
func main() {
enabled := flag.Bool("enable-canary", false, "explicitly enable the manual canary")
implementation := flag.String("implementation", "fake-worker", "allowlisted deterministic implementation")
postgresMode := flag.Bool("postgres", false, "use the Work PostgreSQL repository instead of in-memory storage")
runID := flag.String("run-id", "", "optional unique canary run suffix; generated when omitted")
flag.Parse()
suffix := *runID
if suffix == "" {
suffix = fmt.Sprintf("%d", time.Now().UnixNano())
}
now := time.Now().UTC()
intent := work.AgentDispatchIntent{DispatchID: "manual-dispatch-" + suffix, IdempotencyKey: "manual-idempotency-" + suffix, DelegationID: work.DelegationID("manual-delegation-" + suffix), RunID: work.AgentRunID("manual-run-" + suffix), ImplementationID: *implementation, ImplementationRevision: 1, SourceRevision: "manual-source-" + suffix, WorktreeID: "manual-worktree-" + suffix, VerificationPlanRef: "manual-verification-" + suffix, CreatedAtUnixMillis: now.UnixMilli()}
var store ports.ExecutionStore = &memoryStore{}
var closeStore func()
if *postgresMode {
password, err := resolveDatabasePassword()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
store = repository
closeStore = repository.Close
}
if closeStore != nil {
defer closeStore()
}
result, err := application.ExecuteCanaryDryRun(context.Background(), store, application.CanaryConfig{Enabled: *enabled, ImplementationID: "fake-worker", MaxDispatches: 1}, 0, intent, "manual-worker", 1, now, 15*time.Minute)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
output := struct {
DispatchID string `json:"dispatch_id"`
RunID string `json:"run_id"`
CallbackCount int `json:"callback_count"`
StoreWrites int `json:"store_writes"`
}{result.Intent.DispatchID, string(result.Intent.RunID), result.CallbackCount(), 0}
if memory, ok := store.(*memoryStore); ok {
output.StoreWrites = memory.writes
}
if err := json.NewEncoder(os.Stdout).Encode(output); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func resolveDatabasePassword() (string, error) {
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
if path == "" || ref == "" {
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
cleanRoot := filepath.Clean(root)
cleanPath := filepath.Clean(path)
if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, cleanRoot+string(os.PathSeparator)) {
return "", fmt.Errorf("database password file escapes protected secret root")
}
resolver, err := platformsecrets.NewFileResolver(cleanRoot)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: cleanPath})
}
-23
View File
@@ -1,23 +0,0 @@
package main
import (
"path/filepath"
"testing"
)
func TestResolveDatabasePasswordRejectsDirectEnvironmentSecret(t *testing.T) {
t.Setenv("WORK_POSTGRES_PASSWORD", "literal-secret")
if _, err := resolveDatabasePassword(); err == nil {
t.Fatal("direct database password environment variable was accepted")
}
}
func TestResolveDatabasePasswordRejectsPathOutsideProtectedRoot(t *testing.T) {
root := t.TempDir()
t.Setenv("SECRET_BACKEND_ROOT", root)
t.Setenv("WORK_POSTGRES_PASSWORD_FILE", filepath.Join(root, "..", "outside"))
t.Setenv("WORK_POSTGRES_CREDENTIAL_REF", "credential://domovoy/work/db-password/v1")
if _, err := resolveDatabasePassword(); err == nil {
t.Fatal("database password path outside protected root was accepted")
}
}
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"context"
"time"
"git.cin.su/m/Assistant/contracts/work"
)
// localExecutor provides the bounded local execution capability. It never
// claims that an unsupported or external action happened. The work owner
// records the WAITING callback and can retry after a suitable executor is
// configured or a human chooses the next step.
type localExecutor struct {
now func() time.Time
}
func newLocalExecutor() localExecutor {
return localExecutor{now: time.Now}
}
func (e localExecutor) Execute(_ context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
now := time.Now()
if e.now != nil {
now = e.now()
}
return work.AgentExecutionResult{
State: work.AgentExecutionWaiting,
Summary: "Локальный исполнитель не поддерживает эту операцию; durable работа сохранена и ожидает подходящего исполнителя.",
ObservedAtUnixMillis: now.UnixMilli(),
}, nil
}
-24
View File
@@ -1,24 +0,0 @@
package main
import (
"context"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/work"
)
func TestLocalExecutorNeverClaimsUnsupportedExecution(t *testing.T) {
when := time.UnixMilli(1_700_000_000_000)
executor := localExecutor{now: func() time.Time { return when }}
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}})
if err != nil {
t.Fatal(err)
}
if result.State != work.AgentExecutionWaiting || result.ObservedAtUnixMillis != when.UnixMilli() {
t.Fatalf("unexpected fallback result: %+v", result)
}
if result.OutcomeRef != "" || result.Summary == "" {
t.Fatalf("local executor must not claim an outcome: %+v", result)
}
}
-137
View File
@@ -1,137 +0,0 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/agentbridge"
"git.cin.su/m/Assistant/adapters/postgres"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/contracts/work"
"git.cin.su/m/Assistant/modules/work/application"
"git.cin.su/m/Assistant/modules/work/ports"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
func main() {
workerID := flag.String("worker-id", "agent-worker-1", "durable worker identity")
statusAddr := flag.String("status-addr", ":8090", "internal status HTTP address")
staleAfter := flag.Duration("stale-after", 10*time.Minute, "age after which a claimed item is recovered")
leaseTTL := flag.Duration("lease-ttl", 15*time.Minute, "executor lease duration")
pollInterval := flag.Duration("poll-interval", 5*time.Second, "database-owned idle wake-up interval")
once := flag.Bool("once", false, "run one cycle and exit")
flag.Parse()
password, err := resolveDatabasePassword("WORK_POSTGRES_PASSWORD", "WORK_POSTGRES_CREDENTIAL_REF", secretcontracts.RuntimeConsumerWorkWorker)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
repository, err := postgres.OpenWork(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer repository.Close()
var executor ports.AgentExecutor
localFallback := false
if bridgeURL, bridgeConfigured := loadProtectedBridgeURL(); bridgeConfigured {
executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
} else {
// The local executor keeps supported task-qualification work alive, but
// it is not an external Agent Execution Bridge and must never be reported
// as one. Unsupported work remains durably WAITING.
localFallback = true
executor = newTaskEvaluationExecutor(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), envOr("TASK_VERIFIER_URL", "http://task-verifier:8091"), &http.Client{Timeout: *leaseTTL})
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; task evaluations use the local durable evaluator, other work remains in fallback")
}
go func() {
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback)); serveErr != nil {
fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr)
}
}()
ctx := context.Background()
for {
var result application.WorkerCycleResult
var cycleErr error
if executor == nil {
result, cycleErr = application.RecoverOnlyWorkerCycle(ctx, repository, time.Now().UTC(), *staleAfter)
} else {
result, cycleErr = application.ExecuteWorkerCycleWithExecutor(ctx, repository, executor, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
}
if cycleErr != nil {
fmt.Fprintln(os.Stderr, cycleErr)
if *once {
os.Exit(1)
}
}
if cycleErr == nil && (result.Completed || result.RecoveredClaims > 0) {
_ = json.NewEncoder(os.Stdout).Encode(result)
}
if *once {
return
}
if err := repository.WaitForDispatch(ctx, *pollInterval); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
}
func loadProtectedBridgeURL() (string, bool) {
return loadProtectedBridgeURLFrom("/run/secrets", "/run/secrets/agent_execution_bridge_url")
}
func loadProtectedBridgeURLFrom(root, path string) (string, bool) {
clean := filepath.Clean(path)
cleanRoot := filepath.Clean(root)
if clean != cleanRoot && !strings.HasPrefix(clean, cleanRoot+string(os.PathSeparator)) {
return "", false
}
payload, err := os.ReadFile(clean)
if err != nil {
return "", false
}
value := strings.TrimSpace(string(payload))
if value == "" || strings.ContainsAny(value, "\r\n") {
return "", false
}
return value, true
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
if _, ok := os.LookupEnv(name); ok {
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
}
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
if path == "" || ref == "" {
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
resolver, err := platformsecrets.NewFileResolver(root)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: consumer, Path: path})
}
-51
View File
@@ -1,51 +0,0 @@
package main
import (
"encoding/json"
"net/http"
)
type workerStatusResponse struct {
Runtime string `json:"runtime"`
ExecutorConfigured bool `json:"executor_configured"`
Mode string `json:"mode"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
LocalFallback bool `json:"local_fallback"`
LocalExecutor bool `json:"local_executor"`
Capabilities []string `json:"capabilities,omitempty"`
}
func workerStatusHandler(executorConfigured bool, localFallback ...bool) http.Handler {
local := len(localFallback) > 0 && localFallback[0]
status := workerStatusResponse{
Runtime: "agent-worker",
ExecutorConfigured: executorConfigured,
Mode: "recovery-only",
Ready: false,
Reason: "protected agent execution bridge URL is not configured",
LocalFallback: local,
LocalExecutor: local,
}
if local {
status.Mode = "local-executor"
status.Reason = "bounded local task-evaluation executor is active; unsupported work remains durably waiting"
status.Capabilities = []string{"task-evaluation"}
}
if executorConfigured {
status.Mode = "execution"
status.Ready = true
status.Reason = "typed agent execution bridge configured"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/readyz" && !status.Ready {
w.WriteHeader(http.StatusServiceUnavailable)
}
_ = json.NewEncoder(w).Encode(status)
})
}
-79
View File
@@ -1,79 +0,0 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestLoadProtectedBridgeURL(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "agent_execution_bridge_url")
if err := os.WriteFile(path, []byte("http://bridge.local\n"), 0o600); err != nil {
t.Fatal(err)
}
if value, configured := loadProtectedBridgeURLFrom(root, path); !configured || value != "http://bridge.local" {
t.Fatalf("protected bridge was not loaded: %q %v", value, configured)
}
if value, configured := loadProtectedBridgeURLFrom(root, filepath.Join(root, "missing")); configured || value != "" {
t.Fatalf("missing bridge was accepted: %q %v", value, configured)
}
if value, configured := loadProtectedBridgeURLFrom(root, "/tmp/not-protected"); configured || value != "" {
t.Fatalf("path outside protected root was accepted: %q %v", value, configured)
}
}
func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
response := httptest.NewRecorder()
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
var value workerStatusResponse
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || value.Ready || value.Mode != "recovery-only" || value.ExecutorConfigured {
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
}
}
func TestWorkerStatusReportsLocalExecutor(t *testing.T) {
response := httptest.NewRecorder()
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
var value workerStatusResponse
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || value.Ready || value.Mode != "local-executor" || !value.LocalFallback || !value.LocalExecutor {
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
}
if len(value.Capabilities) != 1 || value.Capabilities[0] != "task-evaluation" {
t.Fatalf("unexpected local capabilities: %+v", value.Capabilities)
}
}
func TestWorkerStatusDoesNotCallLocalFallbackAnExternalExecutor(t *testing.T) {
response := httptest.NewRecorder()
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
var value workerStatusResponse
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
t.Fatal(err)
}
if value.ExecutorConfigured || value.Ready || !value.LocalFallback || !value.LocalExecutor || value.Mode != "local-executor" {
t.Fatalf("local fallback was presented as external execution: %+v", value)
}
}
func TestWorkerReadyzRequiresExecutor(t *testing.T) {
response := httptest.NewRecorder()
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz status=%d", response.Code)
}
response = httptest.NewRecorder()
workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if response.Code != http.StatusOK {
t.Fatalf("configured readyz status=%d", response.Code)
}
}
@@ -1,109 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.cin.su/m/Assistant/contracts/artifact"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
)
type taskEvaluationExecutor struct {
cognitiveURL string
callbackURL string
verifierURL string
client *http.Client
fallback localExecutor
}
func newTaskEvaluationExecutor(cognitiveURL, callbackURL, verifierURL string, client *http.Client) taskEvaluationExecutor {
if client == nil {
client = http.DefaultClient
}
return taskEvaluationExecutor{cognitiveURL: strings.TrimRight(cognitiveURL, "/"), callbackURL: strings.TrimRight(callbackURL, "/"), verifierURL: strings.TrimRight(verifierURL, "/"), client: client, fallback: newLocalExecutor()}
}
func (e taskEvaluationExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
intent := request.Intent
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
return e.fallback.Execute(ctx, request)
}
execution := intent.TaskEvaluationExecution
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
return e.fallback.Execute(ctx, request)
}
if e.cognitiveURL == "" || e.callbackURL == "" || e.verifierURL == "" {
return e.fallback.Execute(ctx, request)
}
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, e.client, e.cognitiveURL+"/v1/task-evaluations/execute", payload)
if err != nil {
return work.AgentExecutionResult{}, err
}
if len(response.Responses) == 0 || response.ObservedAtUnixMillis < 1 {
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
}
if len(intent.TaskEvaluationCases) == 0 {
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation cases are missing")
}
caseByID := make(map[string]capability.TaskEvaluationCase, len(intent.TaskEvaluationCases))
for _, item := range intent.TaskEvaluationCases {
caseByID[item.CaseID] = item
}
observations := make([]capability.TaskCaseObservation, 0, len(response.Responses))
for _, item := range response.Responses {
caseValue, ok := caseByID[item.CaseID]
if !ok || caseValue.Verification.Mode == "" {
return e.fallback.Execute(ctx, request)
}
verified, verifyErr := postJSON[capability.TaskCaseVerificationResponse](ctx, e.client, e.verifierURL+"/v1/task-evaluations/verify", capability.TaskCaseVerificationRequest{CaseID: item.CaseID, ResponseText: item.ResponseText, LatencyMicros: item.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef, Verification: caseValue.Verification})
if verifyErr != nil {
return e.fallback.Execute(ctx, request)
}
observations = append(observations, capability.TaskCaseObservation{CaseID: verified.CaseID, Result: verified.Result, QualityScoreMillis: verified.QualityScoreMillis, LatencyMicros: verified.LatencyMicros, CriticalFailure: verified.CriticalFailure, EvidenceArtifactRef: verified.EvidenceArtifactRef, VerificationEvidenceRef: verified.VerificationEvidenceRef, VerifierIdentity: verified.VerifierIdentity})
}
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
if err != nil {
return work.AgentExecutionResult{}, err
}
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
return work.AgentExecutionResult{}, err
}
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation executed, aggregated and accepted by Work Graph callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
}
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
var result T
body, err := json.Marshal(payload)
if err != nil {
return result, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return result, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return result, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
}
if resp.StatusCode == http.StatusNoContent {
return result, nil
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, err
}
return result, nil
}
@@ -1,43 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/work"
)
func TestTaskEvaluationExecutorInvokesAggregatesAndCallbacks(t *testing.T) {
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
verification := capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/task-evaluations/execute" {
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", ResponseText: "answer", LatencyMicros: 2, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
return
}
if r.URL.Path == "/v1/task-evaluations/verify" {
_ = json.NewEncoder(w).Encode(capability.TaskCaseVerificationResponse{CaseID: "case-1", Result: capability.EvaluationSupported, QualityScoreMillis: 900, LatencyMicros: 2, EvidenceArtifactRef: "artifact-1", VerificationEvidenceRef: "verify-1", VerifierIdentity: "verifier-1"})
return
}
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
var callback work.TaskEvaluationEvidenceCallback
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.GraphID != "graph-1" || callback.NodeID != "node-1" || callback.Evaluation.Result != capability.EvaluationSupported {
http.Error(w, "invalid callback", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer server.Close()
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.URL, server.Client())
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1", Verification: verification}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+9 -10
View File
@@ -98,9 +98,8 @@ func main() {
manifestPath := flag.String("manifest", "", "benchmark manifest JSON")
outputPath := flag.String("output", "", "output artifact JSON")
timeout := flag.Duration("case-timeout", 30*time.Second, "deadline for each case")
maxOutputBytes := flag.Int64("max-output-bytes", 4096, "maximum advisory output bytes per case")
flag.Parse()
if *manifestPath == "" || *outputPath == "" || *timeout <= 0 || *maxOutputBytes < 1 {
if *manifestPath == "" || *outputPath == "" || *timeout <= 0 {
fatal(errors.New("manifest, output and positive case-timeout are required"))
}
input, err := os.ReadFile(*manifestPath)
@@ -111,7 +110,7 @@ func main() {
if err := json.Unmarshal(input, &plan); err != nil {
fatal(err)
}
artifact, err := run(context.Background(), plan, *timeout, *maxOutputBytes, time.Now().UTC())
artifact, err := run(context.Background(), plan, *timeout, time.Now().UTC())
if err != nil {
fatal(err)
}
@@ -126,7 +125,7 @@ func main() {
fmt.Printf("artifact=%s models=%d output=%s\n", artifact.ArtifactID, len(artifact.Results), *outputPath)
}
func run(ctx context.Context, plan manifest, timeout time.Duration, maxOutputBytes int64, observed time.Time) (benchmarkArtifact, error) {
func run(ctx context.Context, plan manifest, timeout time.Duration, observed time.Time) (benchmarkArtifact, error) {
if plan.ArtifactID == "" || len(plan.Providers) == 0 {
return benchmarkArtifact{}, errors.New("invalid benchmark manifest")
}
@@ -135,7 +134,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, maxOutputByt
artifact.Corpus = append(artifact.Corpus, corpusRecord{ID: item.id, Prompt: item.prompt, PromptSHA256: digest(item.prompt), Critical: item.critical})
}
for _, provider := range plan.Providers {
adapter, err := newProvider(provider, timeout)
adapter, err := newProvider(provider)
if err != nil {
return benchmarkArtifact{}, err
}
@@ -143,7 +142,7 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, maxOutputByt
return provider.Candidates[i].ProviderModelID < provider.Candidates[j].ProviderModelID
})
for _, candidate := range provider.Candidates {
result, err := runModel(ctx, adapter, provider.ProviderInstanceID, candidate, timeout, maxOutputBytes)
result, err := runModel(ctx, adapter, provider.ProviderInstanceID, candidate, timeout)
if err != nil {
return benchmarkArtifact{}, err
}
@@ -159,10 +158,10 @@ func run(ctx context.Context, plan manifest, timeout time.Duration, maxOutputByt
return artifact, nil
}
func newProvider(candidate providerCandidate, timeout time.Duration) (models.LocalProvider, error) {
func newProvider(candidate providerCandidate) (models.LocalProvider, error) {
switch candidate.ProviderKind {
case "openai-compatible-chat-v1":
return models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(candidate.Endpoint, "", nil, timeout)
return models.NewOpenAICompatibleLocalAdapter(candidate.Endpoint, nil)
case "ollama-v1":
return models.NewOllamaAdapter(candidate.Endpoint, nil)
default:
@@ -170,7 +169,7 @@ func newProvider(candidate providerCandidate, timeout time.Duration) (models.Loc
}
}
func runModel(ctx context.Context, adapter models.LocalProvider, providerID string, candidate modelCandidate, timeout time.Duration, maxOutputBytes int64) (modelResult, error) {
func runModel(ctx context.Context, adapter models.LocalProvider, providerID string, candidate modelCandidate, timeout time.Duration) (modelResult, error) {
if providerID == "" || candidate.ProviderModelID == "" || candidate.ModelDescriptorID == "" || candidate.ModelRevision < 1 || candidate.BindingID == "" || candidate.BindingRevision < 1 {
return modelResult{}, errors.New("invalid benchmark candidate")
}
@@ -179,7 +178,7 @@ func runModel(ctx context.Context, adapter models.LocalProvider, providerID stri
for index, item := range corpus {
caseCtx, cancel := context.WithTimeout(ctx, timeout)
started := time.Now()
response, err := adapter.Advise(caseCtx, cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("bench-%s-%d", digest(candidate.BindingID)[:12], index)), ActorID: "benchmark-actor", PrincipalID: "domovoy-creator-principal", ScopeID: "benchmark-public-synthetic", Message: item.prompt, Locale: "ru-RU", MaxOutputBytes: int(maxOutputBytes), ProviderBindingID: capability.CapabilityBindingID(candidate.BindingID), ProviderBindingRevision: candidate.BindingRevision, ProviderInstanceID: capability.ProviderInstanceID(providerID), ModelDescriptorID: capability.ModelDescriptorID(candidate.ModelDescriptorID), ProviderModelID: capability.ProviderModelID(candidate.ProviderModelID)})
response, err := adapter.Advise(caseCtx, cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("bench-%s-%d", digest(candidate.BindingID)[:12], index)), ActorID: "benchmark-actor", PrincipalID: "domovoy-creator-principal", ScopeID: "benchmark-public-synthetic", Message: item.prompt, Locale: "ru-RU", MaxOutputBytes: 4096, ProviderBindingID: capability.CapabilityBindingID(candidate.BindingID), ProviderBindingRevision: candidate.BindingRevision, ProviderInstanceID: capability.ProviderInstanceID(providerID), ModelDescriptorID: capability.ModelDescriptorID(candidate.ModelDescriptorID), ProviderModelID: capability.ProviderModelID(candidate.ProviderModelID)})
cancel()
caseResult := caseResult{CaseID: item.id, LatencyMillis: time.Since(started).Milliseconds()}
if err != nil {
-68
View File
@@ -1,68 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
type httpAdmissionClient struct {
baseURL string
client *http.Client
}
func newHTTPAdmissionClient(baseURL string, client *http.Client) *httpAdmissionClient {
return &httpAdmissionClient{baseURL: strings.TrimRight(baseURL, "/"), client: client}
}
func (c *httpAdmissionClient) Admit(ctx context.Context, leaseID string, request capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
payload, err := json.Marshal(struct {
LeaseID string `json:"lease_id"`
Request capability.AdmissionRequest `json:"request"`
}{leaseID, request})
if err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission", bytes.NewReader(payload))
if err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, fmt.Errorf("admission status %d", resp.StatusCode)
}
var result struct {
Decision capability.AdmissionDecision `json:"decision"`
Lease capability.AdmissionLease `json:"lease"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return capability.AdmissionDecision{}, capability.AdmissionLease{}, err
}
return result.Decision, result.Lease, nil
}
func (c *httpAdmissionClient) Release(ctx context.Context, lease capability.AdmissionLease) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/internal/cognitive/admission/"+lease.ID+"/release", nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("admission release status %d", resp.StatusCode)
}
return nil
}
-49
View File
@@ -1,49 +0,0 @@
package main
import (
"context"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
type admissionTestProvider struct{ calls int }
func (p *admissionTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
p.calls++
return cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted}, nil
}
type admissionTestClient struct {
decision capability.AdmissionDecision
releases int
}
func (c *admissionTestClient) Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error) {
return c.decision, capability.AdmissionLease{ID: "lease-1", PolicyID: "policy-1"}, nil
}
func (c *admissionTestClient) Release(context.Context, capability.AdmissionLease) error {
c.releases++
return nil
}
func TestInvokeAdmittedDoesNotCallProviderWhenRejected(t *testing.T) {
provider := &admissionTestProvider{}
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionRejected, Reason: capability.AdmissionReasonConcurrency, PolicyID: "policy-1"}}
gateway := cognitiveGateway{admission: client}
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
if err != nil || response.FinishReason != cognitive.FinishRateLimited || provider.calls != 0 || client.releases != 0 {
t.Fatalf("rejected admission leaked provider call: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
}
}
func TestInvokeAdmittedReleasesDurableLeaseAfterProvider(t *testing.T) {
provider := &admissionTestProvider{}
client := &admissionTestClient{decision: capability.AdmissionDecision{State: capability.AdmissionAdmitted, Reason: capability.AdmissionReasonNone, PolicyID: "policy-1"}}
gateway := cognitiveGateway{admission: client}
response, err := gateway.invokeAdmitted(context.Background(), "request-1", provider, cognitive.AdvisoryRequest{Message: "hello"}, 100)
if err != nil || response.FinishReason != cognitive.FinishCompleted || provider.calls != 1 || client.releases != 1 {
t.Fatalf("admitted lease was not released: response=%#v err=%v calls=%d releases=%d", response, err, provider.calls, client.releases)
}
}
+1 -45
View File
@@ -3,7 +3,6 @@ package main
import (
"encoding/json"
"net/http"
"sort"
"time"
"git.cin.su/m/Assistant/contracts/capability"
@@ -18,17 +17,13 @@ type discoveryService struct {
}
func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/v1/provider-discovery/status" {
s.statusHandler(w, r)
return
}
registration, ok := s.providers[r.PathValue("provider")]
if !ok || registration.Discovery == nil {
http.Error(w, "provider discovery not found", http.StatusNotFound)
return
}
discovered, probe := registration.Discovery.Discover(r.Context())
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, AuthMode: registration.AuthMode, BillingMode: registration.BillingMode, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: registration.DataPolicy, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()}
for _, model := range discovered {
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved})
}
@@ -40,42 +35,3 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
type discoveryStatus struct {
Provider string `json:"provider"`
ProviderKind capability.ProviderKind `json:"provider_kind"`
Health capability.HealthState `json:"health"`
FailureClass capability.CognitiveFailureType `json:"failure_class,omitempty"`
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
Models []discoveredModelStatus `json:"models"`
}
type discoveredModelStatus struct {
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
Fingerprint capability.ModelFingerprint `json:"fingerprint,omitempty"`
Capabilities []capability.ModelCapability `json:"capabilities,omitempty"`
}
func (s discoveryService) statusHandler(w http.ResponseWriter, r *http.Request) {
providers := make([]string, 0, len(s.providers))
for name := range s.providers {
providers = append(providers, name)
}
sort.Strings(providers)
result := make([]discoveryStatus, 0, len(providers))
for _, name := range providers {
registration := s.providers[name]
status := discoveryStatus{Provider: name, ProviderKind: registration.Kind, Health: capability.HealthUnknown, ObservedAtUnixMillis: s.now().UnixMilli(), Models: []discoveredModelStatus{}}
if registration.Discovery != nil {
models, probe := registration.Discovery.Discover(r.Context())
status.Health = probe.Health
status.FailureClass = capability.CognitiveFailureType(probe.Failure)
for _, model := range models {
status.Models = append(status.Models, discoveredModelStatus{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, Capabilities: model.Capabilities})
}
}
result = append(result, status)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
}
-52
View File
@@ -1,52 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.cin.su/m/Assistant/adapters/models"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
)
type discoveryTestProvider struct{}
func (discoveryTestProvider) Probe(context.Context) models.ProviderProbe {
return models.ProviderProbe{Health: capability.HealthAvailable}
}
func (discoveryTestProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
return []models.DiscoveredModel{{ProviderModelID: "local-model", Fingerprint: "fingerprint-1", Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}}}, models.ProviderProbe{Health: capability.HealthAvailable}
}
func (discoveryTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
return cognitive.AdvisoryResponse{}, nil
}
func TestDiscoveryStatusIsReadOnlyAndDoesNotExposeCredentialMetadata(t *testing.T) {
service := discoveryService{
providers: map[string]providerRegistration{"cpu": {ProviderInstanceID: "provider-1", Kind: "openai-compatible-chat-v1", CredentialRef: "credential://must-not-be-returned", Discovery: discoveryTestProvider{}}},
owner: identity.PrincipalID("owner-1"),
now: func() time.Time { return time.UnixMilli(1234) },
}
response := httptest.NewRecorder()
service.handler(response, httptest.NewRequest(http.MethodGet, "/v1/provider-discovery/status", nil))
if response.Code != http.StatusOK {
t.Fatalf("status=%d", response.Code)
}
body := response.Body.String()
var result []discoveryStatus
if err := json.NewDecoder(strings.NewReader(body)).Decode(&result); err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Provider != "cpu" || result[0].Health != capability.HealthAvailable || len(result[0].Models) != 1 || result[0].ObservedAtUnixMillis != 1234 {
t.Fatalf("unexpected discovery status: %+v", result)
}
if strings.Contains(body, "must-not-be-returned") || strings.Contains(body, "credential://") {
t.Fatalf("credential metadata leaked in discovery status: %s", body)
}
}
-79
View File
@@ -1,79 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"git.cin.su/m/Assistant/contracts/capability"
fleetdomain "git.cin.su/m/Assistant/modules/capability/domain"
)
type fleetEndpoint struct {
ProviderID string
ModelID string
Tier capability.FleetTier
URL string
Headers map[string]string
Client *http.Client
}
type fleetStatusResponse struct {
Readiness capability.FleetReadiness `json:"readiness"`
Preferred capability.FleetTier `json:"preferred_tier"`
Selected capability.FleetTier `json:"selected_tier,omitempty"`
Error string `json:"error,omitempty"`
}
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
members := make([]capability.FleetMember, 0, len(endpoints))
for _, endpoint := range endpoints {
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
if strings.TrimSpace(endpoint.URL) == "" {
members = append(members, member)
continue
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil)
if err == nil {
for name, value := range endpoint.Headers {
request.Header.Set(name, value)
}
probeClient := endpoint.Client
if probeClient == nil {
probeClient = client
}
response, requestErr := probeClient.Do(request)
if requestErr == nil {
response.Body.Close()
if response.StatusCode >= 200 && response.StatusCode < 300 {
member.Ready, member.Reason = true, "AVAILABLE"
} else {
member.Reason = fmt.Sprintf("status=%s", response.Status)
}
} else {
member.Reason = requestErr.Error()
}
}
members = append(members, member)
}
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
if err == nil {
response.Selected, err = fleetdomain.SelectFleetTier(readiness, preferred)
}
if err != nil {
response.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
if response.Selected == "" {
w.WriteHeader(http.StatusServiceUnavailable)
}
_ = json.NewEncoder(w).Encode(response)
})
}
func fleetContext(ctx context.Context) context.Context { return ctx }
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
)
func TestFleetReadinessSelectsBestAvailablePrimaryTierBeforeLifeboat(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
defer server.Close()
handler := fleetReadinessHandler(server.Client(), capability.FleetTierHeavy, []fleetEndpoint{
{ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL},
{ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium},
{ProviderID: "heavy", ModelID: "heavy-model", Tier: capability.FleetTierHeavy},
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: server.URL},
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"FAST"`) || !strings.Contains(recorder.Body.String(), `"tier":"MEDIUM"`) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer fleet-key" {
t.Fatalf("authorization=%q", r.Header.Get("Authorization"))
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
handler := fleetReadinessHandler(server.Client(), capability.FleetTierFast, []fleetEndpoint{{
ProviderID: "cpu", ModelID: "cpu-model", Tier: capability.FleetTierFast, URL: server.URL,
Headers: map[string]string{"Authorization": "Bearer fleet-key"},
}})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"ready":true`) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+7 -184
View File
@@ -8,13 +8,11 @@ import (
"io"
"net/http"
"net/url"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/models"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/interaction"
"git.cin.su/m/Assistant/platform/ids"
)
@@ -26,23 +24,11 @@ type bindingResolver interface {
RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error)
}
type candidateBindingResolver interface {
ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error)
}
type admissionClient interface {
Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error)
Release(context.Context, capability.AdmissionLease) error
}
type providerRegistration struct {
ProviderInstanceID capability.ProviderInstanceID
Kind capability.ProviderKind
EndpointRef capability.EndpointBindingRef
CredentialRef capability.CredentialRef
AuthMode capability.AuthMode
BillingMode capability.BillingMode
DataPolicy capability.DataPolicy
Provider advisoryProvider
Discovery models.LocalProvider
}
@@ -50,12 +36,9 @@ type providerRegistration struct {
type cognitiveGateway struct {
resolver bindingResolver
providers map[capability.ProviderKind][]providerRegistration
builtinLocal *providerRegistration
externalRecovery []providerRegistration
now func() time.Time
allowCanary bool
canaryMaxOutputBytes int
admission admissionClient
}
func (g cognitiveGateway) policyHandler(w http.ResponseWriter, r *http.Request) {
@@ -88,10 +71,10 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
if g.allowCanary && g.canaryMaxOutputBytes > 0 && maxOutputBytes > g.canaryMaxOutputBytes {
maxOutputBytes = g.canaryMaxOutputBytes
}
requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes), Objective: selectionObjectiveForMeaning(request.Meaning, request.Message)}
requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(maxOutputBytes)}
selected, err := g.resolver.Resolve(r.Context(), requirement)
if err != nil {
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
return
}
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
@@ -100,14 +83,14 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
}
provider := g.provider(selected)
if provider == nil {
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
return
}
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
started := g.now()
response, invokeErr := g.invokeAdmitted(r.Context(), string(request.RequestID), provider, invocation, maxOutputBytes)
response, invokeErr := provider.Advise(r.Context(), invocation)
if invokeErr != nil {
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
}
g.observe(r.Context(), selected, response, started)
if fallbackEligible(response.FinishReason) {
@@ -119,142 +102,17 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request
fallbackRequest.ProviderBindingID, fallbackRequest.ProviderBindingRevision = fallback.Binding.ID, fallback.Binding.Revision
fallbackRequest.ProviderInstanceID, fallbackRequest.ModelDescriptorID, fallbackRequest.ProviderModelID = fallback.Provider.ID, fallback.Model.ID, fallback.Model.ProviderModelID
fallbackStarted := g.now()
fallbackResponse, fallbackErr := g.invokeAdmitted(r.Context(), string(request.RequestID)+":fallback", fallbackProvider, fallbackRequest, maxOutputBytes)
fallbackResponse, fallbackErr := fallbackProvider.Advise(r.Context(), fallbackRequest)
if fallbackErr != nil {
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}
fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse}
}
g.observe(r.Context(), fallback, fallbackResponse, fallbackStarted)
response = fallbackResponse
}
}
if response.FinishReason != cognitive.FinishCompleted && fallbackEligible(response.FinishReason) {
writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes))
return
}
writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage})
}
func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, provider advisoryProvider, invocation cognitive.AdvisoryRequest, maxOutputBytes int) (cognitive.AdvisoryResponse, error) {
var lease capability.AdmissionLease
if g.admission != nil {
decision, admittedLease, err := g.admission.Admit(ctx, leaseID, capability.AdmissionRequest{InputBytes: len(invocation.Message), MaxOutputBytes: maxOutputBytes, DeadlineMillis: 0})
if err != nil {
return cognitive.AdvisoryResponse{}, err
}
if decision.State != capability.AdmissionAdmitted {
return cognitive.AdvisoryResponse{Version: invocation.Version, RequestID: invocation.RequestID, ProviderBindingID: invocation.ProviderBindingID, ProviderBindingRevision: invocation.ProviderBindingRevision, FinishReason: cognitive.FinishRateLimited}, nil
}
lease = admittedLease
defer func() { _ = g.admission.Release(ctx, lease) }()
}
return provider.Advise(ctx, invocation)
}
func selectionObjectiveForMessage(message string) capability.SelectionObjective {
lower := strings.ToLower(message)
if strings.Contains(lower, "дешев") || strings.Contains(lower, "бесплат") || strings.Contains(lower, "эконом") || strings.Contains(lower, "cost") || strings.Contains(lower, "cheap") {
return capability.SelectionEconomy
}
if strings.Contains(lower, "быстр") || strings.Contains(lower, "срочно") || strings.Contains(lower, "статус") || strings.Contains(lower, "quick") || strings.Contains(lower, "fast") {
return capability.SelectionSpeed
}
if strings.Contains(lower, "сложн") || strings.Contains(lower, "анализ") || strings.Contains(lower, "код") || strings.Contains(lower, "план") || strings.Contains(lower, "глубок") || strings.Contains(lower, "reason") || strings.Contains(lower, "code") {
return capability.SelectionQuality
}
return capability.SelectionBalanced
}
func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string) capability.SelectionObjective {
if meaning.CapabilityKind != "" || len(meaning.CapabilityHints) > 0 || meaning.SpeechAct != "" || meaning.RequestedOutcome != "" {
kinds := append([]capability.CapabilityKind{meaning.CapabilityKind}, meaning.CapabilityHints...)
for _, kind := range kinds {
switch kind {
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindVerifyFact, capability.CapabilityKindInspectProject, capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs:
return capability.SelectionQuality
case capability.CapabilityKindScheduleReminder:
return capability.SelectionSpeed
}
}
if meaning.RequestedOutcome != "" || meaning.SpeechAct == "REQUEST_TO_INVESTIGATE" || meaning.SpeechAct == "DIRECTIVE" {
return capability.SelectionQuality
}
if meaning.ConfidenceMillis > 0 {
return capability.SelectionBalanced
}
}
return selectionObjectiveForMessage(message)
}
func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse {
text := "Я рядом и продолжаю работу во встроенном режиме Домового. Внешний когнитивный маршрут сейчас недоступен, поэтому опираюсь на уже установленный смысл сообщения и локальные возможности."
switch request.Meaning.InformationRequestKind {
case interaction.InformationRequestCognitiveModelInventory:
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
case interaction.InformationRequestCapabilityInventory:
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
case interaction.InformationRequestCognitiveChannelStatus:
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
case interaction.InformationRequestCognitiveRecovery:
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
default:
switch request.Meaning.SpeechAct {
case "REQUEST_TO_INVESTIGATE":
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
case "QUESTION":
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
}
}
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
}
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
started := g.now()
response, err := g.builtinLocal.Provider.Advise(ctx, invocation)
if response.RequestID == "" {
response.RequestID = request.RequestID
}
if response.Version == "" {
response.Version = request.Version
}
if err != nil {
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse}
}
g.observeBuiltin(ctx, g.builtinLocal, response, started)
if err == nil && response.FinishReason == cognitive.FinishCompleted && strings.TrimSpace(response.AdvisoryText) != "" {
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}
}
}
if request.EgressAcknowledged {
if response, ok := g.recoverExternalAdvisory(ctx, request, maxOutputBytes); ok {
return response
}
}
return builtinAdvisory(request)
}
func (g cognitiveGateway) recoverExternalAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) (cognitive.GatewayAdvisoryResponse, bool) {
for _, candidate := range g.externalRecovery {
if candidate.Provider == nil || candidate.Discovery == nil {
continue
}
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
modelsFound, probe := candidate.Discovery.Discover(probeCtx)
cancel()
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
continue
}
model := modelsFound[0]
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "recovery-external-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: capability.ProviderInstanceID("recovery-" + string(candidate.ProviderInstanceID)), ModelDescriptorID: capability.ModelDescriptorID("recovery-model-" + string(model.ProviderModelID)), ProviderModelID: model.ProviderModelID}
response, err := candidate.Provider.Advise(ctx, invocation)
if err == nil && response.FinishReason == cognitive.FinishCompleted && strings.TrimSpace(response.AdvisoryText) != "" {
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}, true
}
}
return cognitive.GatewayAdvisoryResponse{}, false
}
func (g cognitiveGateway) observe(ctx context.Context, selected capability.ResolvedCognitiveBinding, response cognitive.AdvisoryResponse, started time.Time) {
usage := response.Usage
observationID, idErr := ids.New()
@@ -274,21 +132,6 @@ func (g cognitiveGateway) observe(ctx context.Context, selected capability.Resol
}
}
// observeBuiltin gives the always-available local provider the same evidence
// path as a resolved binding. The identity is stable and contains references
// only; no endpoint, credential, prompt, or response text is persisted here.
func (g cognitiveGateway) observeBuiltin(ctx context.Context, registration *providerRegistration, response cognitive.AdvisoryResponse, started time.Time) {
if registration == nil {
return
}
selected := capability.ResolvedCognitiveBinding{
Binding: capability.CognitiveBinding{ID: "builtin-local-binding-v1", Revision: 1},
Provider: capability.ProviderInstance{ID: "builtin-local-lifeboat-v1", Revision: 1, Kind: registration.Kind},
Model: capability.ModelDescriptor{ID: "builtin-local-model-v1", Revision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ProviderModelID: "domovoy-lifeboat"},
}
g.observe(ctx, selected, response, started)
}
func fallbackEligible(reason cognitive.FinishReason) bool {
switch reason {
case cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishTimeout, cognitive.FinishDegraded:
@@ -345,26 +188,6 @@ func (r *httpBindingResolver) Resolve(ctx context.Context, value capability.Cogn
return result, err
}
func (r *httpBindingResolver) ResolveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
var result capability.ResolvedCognitiveBinding
target := *r.base
target.Path = "/v1/cognitive-bindings/" + url.PathEscape(string(bindingID)) + "/revisions/" + fmt.Sprint(revision) + "/snapshot"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return result, err
}
response, err := r.client.Do(request)
if err != nil {
return result, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return result, fmt.Errorf("candidate status %d", response.StatusCode)
}
err = json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&result)
return result, err
}
func (r *httpBindingResolver) Observe(ctx context.Context, value capability.CognitiveUsageObservation) error {
return r.post(ctx, "/v1/cognitive-usage-observations", value, nil)
}
+2 -93
View File
@@ -12,7 +12,6 @@ import (
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/interaction"
)
type registryStub struct {
@@ -115,45 +114,7 @@ func TestCanaryOutputLimitIsClamped(t *testing.T) {
}
}
func TestSelectionObjectiveForMessage(t *testing.T) {
cases := []struct {
message string
objective capability.SelectionObjective
}{
{"Сделай глубокий анализ кода", capability.SelectionQuality},
{"Нужен быстрый статус", capability.SelectionSpeed},
{"Выбери бесплатную модель", capability.SelectionEconomy},
{"Помоги сформулировать ответ", capability.SelectionBalanced},
}
for _, test := range cases {
if actual := selectionObjectiveForMessage(test.message); actual != test.objective {
t.Errorf("message=%q objective=%q, want %q", test.message, actual, test.objective)
}
}
}
func TestSelectionObjectiveUsesTrustedMeaningBeforeText(t *testing.T) {
meaning := cognitive.TaskMeaning{CapabilityKind: capability.CapabilityKindModifyCode, SpeechAct: "DIRECTIVE"}
if actual := selectionObjectiveForMeaning(meaning, "сделай быстро и дёшево"); actual != capability.SelectionQuality {
t.Fatalf("objective=%q, want quality", actual)
}
if actual := selectionObjectiveForMeaning(cognitive.TaskMeaning{}, "сделай быстро"); actual != capability.SelectionSpeed {
t.Fatalf("fallback objective=%q, want speed", actual)
}
}
func TestSelectionObjectiveForInterpretationUsesSemanticSignals(t *testing.T) {
request := cognitive.GatewayInterpretationRequest{RequestedOutcome: "сравнить варианты", DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "QUESTION"}
if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionQuality {
t.Fatalf("objective=%q, want quality", actual)
}
request = cognitive.GatewayInterpretationRequest{DeterministicApplicability: cognitive.InterpretationApplicable, DeterministicConfidence: 900, DeterministicSpeechAct: "ASSERTION"}
if actual := selectionObjectiveForInterpretation(request); actual != capability.SelectionSpeed {
t.Fatalf("objective=%q, want speed", actual)
}
}
func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) {
registry := &registryStub{err: errors.New("no active binding")}
provider := &stubAdvisoryProvider{}
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"unused": {{Provider: provider}}}, now: time.Now}
@@ -164,63 +125,11 @@ func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishCompleted || result.AdvisoryText == "" || provider.called {
if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishProviderUnavailable || provider.called {
t.Fatalf("status=%d result=%+v provider_called=%t", response.Code, result, provider.called)
}
}
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
response := builtinAdvisory(request)
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
t.Fatalf("unexpected recovery response: %q", response.AdvisoryText)
}
}
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
registry := &registryStub{err: errors.New("no active binding")}
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
builtin := providerRegistration{ProviderInstanceID: "local-lifeboat", Kind: "openai-compatible-chat-v1", EndpointRef: "local-lifeboat-v1", Provider: provider}
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest()))
response := httptest.NewRecorder()
gateway.advisoryHandler(response, request)
if response.Code != http.StatusOK || !provider.called || !strings.Contains(response.Body.String(), "локальный ответ") {
t.Fatalf("status=%d provider_called=%t body=%s", response.Code, provider.called, response.Body.String())
}
if registry.observation == nil || registry.observation.BindingID != "builtin-local-binding-v1" || registry.observation.ProviderInstanceID != "builtin-local-lifeboat-v1" || registry.observation.ModelDescriptorID != "builtin-local-model-v1" || registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishCompleted) {
t.Fatalf("built-in usage observation=%+v", registry.observation)
}
}
func TestBuiltInProviderFailureIsObservedWithoutBlockingFallback(t *testing.T) {
registry := &registryStub{err: errors.New("no active binding")}
provider := &stubAdvisoryProvider{err: errors.New("local timeout")}
builtin := providerRegistration{Kind: "ollama-v1", Provider: provider}
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest()))
response := httptest.NewRecorder()
gateway.advisoryHandler(response, request)
if response.Code != http.StatusOK || registry.observation == nil {
t.Fatalf("status=%d observation=%+v body=%s", response.Code, registry.observation, response.Body.String())
}
if registry.observation.ResultClass != capability.CognitiveResultClass(cognitive.FinishInvalidProviderResponse) || registry.observation.FailureType != capability.CognitiveFailureType(cognitive.FinishInvalidProviderResponse) {
t.Fatalf("failure observation=%+v", registry.observation)
}
}
func TestBuiltInInterpretationIsObserved(t *testing.T) {
registry := &registryStub{err: errors.New("no active binding")}
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{FinishReason: cognitive.FinishCompleted, AdvisoryText: `{"primary_speech_act":"QUESTION","secondary_speech_act":"","confidence_millis":900,"applicability":"APPLICABLE","requested_outcome":"","needs_cognition":false,"clarification":"NOT_REQUIRED","selection_reason":"local","capability_hints":[]}`}}
builtin := providerRegistration{Kind: "openai-compatible-chat-v1", Provider: provider}
gateway := cognitiveGateway{resolver: registry, builtinLocal: &builtin, now: time.Now}
request := cognitive.GatewayInterpretationRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "interpretation-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "что доступно", MaxOutputBytes: 4096, DeterministicSpeechAct: "QUESTION", DeterministicApplicability: cognitive.InterpretationApplicable}
result := gateway.interpretWithBuiltin(context.Background(), request, request.MaxOutputBytes)
if result.FinishReason != cognitive.FinishCompleted || registry.observation == nil || registry.observation.RequestID != "interpretation-1" {
t.Fatalf("result=%+v observation=%+v", result, registry.observation)
}
}
func TestProviderLossBetweenRoutingAndInvocationFallsBackOnce(t *testing.T) {
primary := gatewaySelection()
primary.Binding.ID = "gpu-binding"
+2 -80
View File
@@ -1,7 +1,6 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -33,16 +32,15 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R
AllowCanary: g.allowCanary,
InputBytes: int64(len(request.Message)),
MaxOutputBytes: int64(maxOutputBytes),
Objective: selectionObjectiveForInterpretation(request),
}
selected, err := g.resolver.Resolve(r.Context(), requirement)
if err != nil {
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
return
}
provider := g.provider(selected)
if provider == nil {
writeInterpretationResponse(w, g.interpretWithBuiltin(r.Context(), request, maxOutputBytes))
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable})
return
}
invocation := cognitive.AdvisoryRequest{
@@ -78,82 +76,6 @@ func (g cognitiveGateway) interpretationHandler(w http.ResponseWriter, r *http.R
writeInterpretationResponse(w, cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage})
}
func (g cognitiveGateway) interpretWithBuiltin(ctx context.Context, request cognitive.GatewayInterpretationRequest, maxOutputBytes int) cognitive.GatewayInterpretationResponse {
if g.builtinLocal == nil || g.builtinLocal.Provider == nil {
return deterministicInterpretationResponse(request)
}
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: interpretationPrompt(request), Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
started := g.now()
response, err := g.builtinLocal.Provider.Advise(ctx, invocation)
if response.RequestID == "" {
response.RequestID = request.RequestID
}
if response.Version == "" {
response.Version = request.Version
}
if err != nil {
response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, FinishReason: cognitive.FinishInvalidProviderResponse}
}
g.observeBuiltin(ctx, g.builtinLocal, response, started)
if err != nil || response.FinishReason != cognitive.FinishCompleted {
return deterministicInterpretationResponse(request)
}
candidate, ok := parseInterpretationCandidate(response.AdvisoryText)
if !ok {
return deterministicInterpretationResponse(request)
}
return cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted, Usage: response.Usage}
}
// deterministicInterpretationResponse keeps the semantic route alive when the
// local model cannot produce strict JSON in time. It does not invent meaning:
// it carries forward the already-recorded deterministic interpretation and
// leaves the advisory answer to the built-in gateway path.
func deterministicInterpretationResponse(request cognitive.GatewayInterpretationRequest) cognitive.GatewayInterpretationResponse {
speechAct := request.DeterministicSpeechAct
if !validSpeechAct(speechAct) {
speechAct = "UNKNOWN"
}
applicability := request.DeterministicApplicability
switch applicability {
case cognitive.InterpretationApplicable, cognitive.InterpretationLowConfidence, cognitive.InterpretationMixedIntent, cognitive.InterpretationNeedsBroader, cognitive.InterpretationClarificationRequired:
default:
applicability = cognitive.InterpretationLowConfidence
}
confidence := request.DeterministicConfidence
if confidence < 0 {
confidence = 0
}
if confidence > 1000 {
confidence = 1000
}
candidate := cognitive.GatewayInterpretationCandidate{
PrimarySpeechAct: speechAct,
SecondarySpeechAct: request.DeterministicSecondary,
ConfidenceMillis: confidence,
Applicability: applicability,
RequestedOutcome: request.RequestedOutcome,
NeedsCognition: true,
Clarification: "NOT_REQUIRED",
SelectionReason: "deterministic interpretation retained; built-in structured interpretation was unavailable",
}
if applicability == cognitive.InterpretationClarificationRequired {
candidate.NeedsCognition = false
candidate.Clarification = "REQUIRED"
}
return cognitive.GatewayInterpretationResponse{Version: request.Version, RequestID: request.RequestID, Candidate: candidate, FinishReason: cognitive.FinishCompleted}
}
func selectionObjectiveForInterpretation(request cognitive.GatewayInterpretationRequest) capability.SelectionObjective {
if request.RequestedOutcome != "" || request.DeterministicApplicability == cognitive.InterpretationNeedsBroader || request.DeterministicApplicability == cognitive.InterpretationMixedIntent || request.DeterministicApplicability == cognitive.InterpretationLowConfidence {
return capability.SelectionQuality
}
if request.DeterministicConfidence >= 800 && request.DeterministicSpeechAct != "QUESTION" {
return capability.SelectionSpeed
}
return capability.SelectionBalanced
}
func interpretationPrompt(request cognitive.GatewayInterpretationRequest) string {
projects := "none"
if len(request.ProjectNames) > 0 {
@@ -1,26 +0,0 @@
package main
import (
"context"
"testing"
"git.cin.su/m/Assistant/contracts/cognitive"
)
func TestBuiltinInterpretationTimeoutRetainsDeterministicCandidate(t *testing.T) {
request := cognitive.GatewayInterpretationRequest{
Version: cognitive.CreatorAdvisoryV1,
RequestID: "request-1",
DeterministicSpeechAct: "QUESTION",
DeterministicConfidence: 720,
DeterministicApplicability: cognitive.InterpretationNeedsBroader,
RequestedOutcome: "вывести список настроек",
}
response := (cognitiveGateway{}).interpretWithBuiltin(context.Background(), request, 4096)
if response.FinishReason != cognitive.FinishCompleted {
t.Fatalf("finish reason=%q", response.FinishReason)
}
if response.Candidate.PrimarySpeechAct != "QUESTION" || response.Candidate.ConfidenceMillis != 720 || response.Candidate.RequestedOutcome != request.RequestedOutcome {
t.Fatalf("candidate did not retain deterministic meaning: %+v", response.Candidate)
}
}
+10 -257
View File
@@ -10,15 +10,11 @@ import (
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
@@ -44,262 +40,38 @@ func main() {
}
providers := map[capability.ProviderKind][]providerRegistration{}
discoveries := map[string]providerRegistration{}
var builtinLocal *providerRegistration
externalRecovery := []providerRegistration{}
cpuAPIKey, err := loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), nil)
if err != nil {
log.Fatal(err)
}
cpuAdapter, err := models.NewOpenAICompatibleLocalAdapterWithAPIKey(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), cpuAPIKey, nil)
if err != nil {
log.Fatal(err)
}
cpuAuthMode := capability.AuthNone
cpuCredentialRef := capability.CredentialRef("")
if cpuAPIKey != "" {
cpuAuthMode = capability.AuthKey
cpuCredentialRef = capability.CredentialRef(envOr("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1"))
}
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", CredentialRef: cpuCredentialRef, AuthMode: cpuAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter}
cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", Provider: cpuAdapter, Discovery: cpuAdapter}
providers[cpu.Kind] = append(providers[cpu.Kind], cpu)
discoveries["cpu"] = cpu
mediumURL := strings.TrimSpace(os.Getenv("LOCAL_MEDIUM_PROVIDER_URL"))
mediumAPIKey := ""
if mediumURL != "" {
mediumAPIKey, err = loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
if err != nil {
log.Fatal(err)
}
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
if adapterErr != nil {
log.Fatal(adapterErr)
}
mediumAuthMode := capability.AuthNone
mediumCredentialRef := capability.CredentialRef("")
if mediumAPIKey != "" {
mediumAuthMode = capability.AuthKey
mediumCredentialRef = capability.CredentialRef(envOr("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"))
}
medium := providerRegistration{ProviderInstanceID: "local-medium-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-medium-primary-v1", CredentialRef: mediumCredentialRef, AuthMode: mediumAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: mediumAdapter, Discovery: mediumAdapter}
providers[medium.Kind] = append(providers[medium.Kind], medium)
discoveries["medium"] = medium
}
if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil {
log.Fatal(lifeboatErr)
} else if enabled {
lifeboat.DataPolicy = capability.DataPolicyLocalOnly
providers[lifeboat.Kind] = append(providers[lifeboat.Kind], lifeboat)
discoveries["lifeboat"] = lifeboat
builtinLocal = &lifeboat
}
gpuURL, gpuKey, gpuConfigured, gpuErr := loadProtectedGPUConfiguration()
if gpuErr != nil {
log.Printf("optional GPU provider disabled: %v", gpuErr)
} else if gpuConfigured {
gpuAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKey(gpuURL, gpuKey, nil)
if adapterErr != nil {
log.Printf("optional GPU provider disabled: %v", adapterErr)
} else {
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "openai-compatible-chat-v1", EndpointRef: "local-gpu-opportunistic-v1", CredentialRef: "credential://domovoy/gpu/provider-api-key/v1", AuthMode: capability.AuthKey, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: gpuAdapter, Discovery: gpuAdapter}
providers[gpu.Kind] = append(providers[gpu.Kind], gpu)
discoveries["gpu"] = gpu
externalRecovery = append(externalRecovery, gpu)
}
gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil)
if err != nil {
log.Fatal(err)
}
gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", Provider: gpuAdapter, Discovery: gpuAdapter}
providers[gpu.Kind] = append(providers[gpu.Kind], gpu)
discoveries["gpu"] = gpu
if openAIProvider != nil {
kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1"))
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), AuthMode: capability.AuthKey, BillingMode: capability.BillingPerInput, DataPolicy: capability.DataPolicyExternalMessageOnly, Provider: openAIProvider})
providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), Provider: openAIProvider})
}
gateway := cognitiveGateway{resolver: resolver, providers: providers, builtinLocal: builtinLocal, externalRecovery: externalRecovery, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096, admission: newHTTPAdmissionClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second})}
gateway := cognitiveGateway{resolver: resolver, providers: providers, now: time.Now, allowCanary: os.Getenv("COGNITIVE_ALLOW_CANARY") == "true", canaryMaxOutputBytes: 4096}
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
readinessClient := &http.Client{Timeout: 2 * time.Second}
cpuReadinessClient := &http.Client{Timeout: 10 * time.Second}
mediumReadinessClient := &http.Client{Timeout: 10 * time.Second}
lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health"
fleetClient := &http.Client{Timeout: 2 * time.Second}
preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierFast)))
cpuFleetHeaders := map[string]string{}
if cpuAPIKey != "" {
cpuFleetHeaders["Authorization"] = "Bearer " + cpuAPIKey
}
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
cpuHealthProbe.Timeout = 10 * time.Second
mediumEndpoint := ""
mediumHeaders := map[string]string{}
mediumHealthProbe := health.Probe{}
if mediumURL != "" {
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
if mediumAPIKey != "" {
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
}
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
mediumHealthProbe.Timeout = 10 * time.Second
}
readinessProbes := []health.Probe{
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
cpuHealthProbe,
}
if mediumURL != "" {
readinessProbes = append(readinessProbes, mediumHealthProbe)
}
readinessProbes = append(readinessProbes, health.HTTPProbe(readinessClient, lifeboatURL))
if gpuConfigured {
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
gpuProbe.Timeout = 10 * time.Second
readinessProbes = append(readinessProbes, gpuProbe)
}
fleetEndpoints := []fleetEndpoint{
{ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}},
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient},
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
}
if gpuConfigured {
fleetEndpoints = append(fleetEndpoints, fleetEndpoint{ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(gpuURL, "/") + "/models", Headers: map[string]string{"Authorization": "Bearer " + gpuKey}})
}
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes})
mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, fleetEndpoints))
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler)
mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler)
mux.HandleFunc("POST /v1/interpretations", gateway.interpretationHandler)
mux.HandleFunc("POST /v1/task-evaluations/execute", gateway.taskEvaluationHandler)
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
mux.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
refreshProviderDiscoveries(resolver, discovery)
log.Printf("cognitive-worker listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
func durationEnv(name string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
log.Printf("invalid %s=%q; using %s", name, value, fallback)
return fallback
}
return parsed
}
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
for name := range service.providers {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
registration, ok := service.providers[name]
if !ok || registration.Discovery == nil {
cancel()
continue
}
discovered, probe := registration.Discovery.Discover(ctx)
cancel()
snapshot := capability.CognitiveDiscoverySnapshot{
ProviderInstanceID: registration.ProviderInstanceID,
ProviderKind: registration.Kind,
EndpointRef: registration.EndpointRef,
CredentialRef: registration.CredentialRef,
AuthMode: registration.AuthMode,
BillingMode: registration.BillingMode,
Enabled: true,
OwnerPrincipalID: service.owner,
CapabilityID: creatorAdvisoryCapabilityID,
DataPolicy: registration.DataPolicy,
LimitPolicyRef: "creator-advisory-local-v1",
Health: probe.Health,
FailureClass: capability.CognitiveFailureType(probe.Failure),
ObservedAtUnixMillis: time.Now().UnixMilli(),
}
for _, model := range discovered {
snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{
ProviderModelID: model.ProviderModelID,
Fingerprint: model.Fingerprint,
MaxContextBytes: model.MaxContextBytes,
Modalities: model.Modalities,
Capabilities: model.Capabilities,
Installed: capability.OperationalYes,
Loaded: capability.OperationalNotObserved,
Invokable: capability.OperationalNotObserved,
})
}
if _, err := resolver.RegisterDiscovery(context.Background(), snapshot); err != nil {
log.Printf("startup cognitive discovery refresh failed for %s: %v", name, err)
}
}
}
func loadProviderAPIKey(directEnvName, fileEnvName, refEnvName, defaultRef string) (string, error) {
if _, set := os.LookupEnv(directEnvName); set {
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s", directEnvName, fileEnvName)
}
fileName := strings.TrimSpace(os.Getenv(fileEnvName))
if fileName == "" {
return "", nil
}
ref := strings.TrimSpace(os.Getenv(refEnvName))
if ref == "" {
ref = defaultRef
}
if ref == "" {
return "", fmt.Errorf("%s is required when %s is configured", refEnvName, fileEnvName)
}
resolver, err := platformsecrets.NewFileResolver(envOr("SECRET_BACKEND_ROOT", "/run/secrets"))
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeProviderAPIKey, Consumer: secretcontracts.RuntimeConsumerCognitive, Path: fileName})
}
func loadLocalCPUProviderAPIKey() (string, error) {
return loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
}
const (
gpuProviderURLFile = "/run/secrets/llm_gpu_gateway_url"
gpuProviderKeyFile = "/run/secrets/llm_gpu_gateway_api_key"
)
func loadProtectedGPUConfiguration() (string, string, bool, error) {
urlValue, err := readProtectedRuntimeValue(gpuProviderURLFile)
if errors.Is(err, os.ErrNotExist) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
key, err := readProtectedRuntimeValue(gpuProviderKeyFile)
if errors.Is(err, os.ErrNotExist) {
return "", "", false, fmt.Errorf("GPU provider key is not configured")
}
if err != nil {
return "", "", false, err
}
return urlValue, key, true, nil
}
func readProtectedRuntimeValue(path string) (string, error) {
const root = "/run/secrets"
clean := filepath.Clean(path)
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
return "", fmt.Errorf("protected runtime path outside secret root")
}
payload, err := os.ReadFile(clean)
if err != nil {
return "", err
}
value := strings.TrimSpace(string(payload))
if value == "" || strings.ContainsAny(value, "\r\n") {
return "", fmt.Errorf("protected runtime value is empty or multiline")
}
return value, nil
}
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
request, ok := decodeAdvisoryRequest(w, r)
if !ok {
@@ -402,22 +174,3 @@ func envOr(name, fallback string) string {
}
return fallback
}
func newLifeboatProvider(rawURL string) (providerRegistration, bool, error) {
if rawURL == "" {
return providerRegistration{}, false, nil
}
adapter, err := models.NewOpenAICompatibleLocalAdapter(rawURL, nil)
if err != nil {
return providerRegistration{}, false, err
}
return providerRegistration{
ProviderInstanceID: "local-lifeboat",
Kind: "openai-compatible-chat-v1",
EndpointRef: "local-lifeboat-v1",
AuthMode: capability.AuthNone,
BillingMode: capability.BillingFree,
Provider: adapter,
Discovery: adapter,
}, true, nil
}
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
if _, enabled, err := newLifeboatProvider(""); err != nil || enabled {
t.Fatalf("empty lifeboat URL should disable provider: enabled=%v err=%v", enabled, err)
}
provider, enabled, err := newLifeboatProvider("http://lifeboat:8080/v1")
if err != nil || !enabled {
t.Fatalf("configured lifeboat should be enabled: enabled=%v err=%v", enabled, err)
}
if provider.ProviderInstanceID != "local-lifeboat" || provider.EndpointRef != "local-lifeboat-v1" || provider.Provider == nil {
t.Fatalf("unexpected lifeboat registration: %+v", provider)
}
}
func TestLoadLocalCPUProviderAPIKeyUsesProtectedFileReference(t *testing.T) {
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "")
_ = os.Unsetenv("LOCAL_CPU_PROVIDER_API_KEY")
root := t.TempDir()
path := filepath.Join(root, "local_cpu_provider_api_key")
if err := os.WriteFile(path, []byte("resolved-key\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", path)
t.Setenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1")
t.Setenv("SECRET_BACKEND_ROOT", root)
key, err := loadLocalCPUProviderAPIKey()
if err != nil || key != "resolved-key" {
t.Fatalf("unexpected key resolution result: err=%v", err)
}
}
func TestLoadLocalCPUProviderAPIKeyRejectsDirectEnvironmentSecret(t *testing.T) {
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "must-not-be-used")
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY_FILE", "")
if _, err := loadLocalCPUProviderAPIKey(); err == nil {
t.Fatal("expected direct environment secret to be rejected")
}
}
func TestLoadProviderAPIKeySupportsMediumCredentialRef(t *testing.T) {
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "")
_ = os.Unsetenv("LOCAL_MEDIUM_PROVIDER_API_KEY")
root := t.TempDir()
path := filepath.Join(root, "medium_provider_api_key")
if err := os.WriteFile(path, []byte("medium-key\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", path)
t.Setenv("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
t.Setenv("SECRET_BACKEND_ROOT", root)
key, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")
if err != nil || key != "medium-key" {
t.Fatalf("unexpected medium key resolution result: err=%v", err)
}
}
func TestLoadProviderAPIKeyRejectsMediumDirectEnvironmentSecret(t *testing.T) {
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "must-not-be-used")
t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "")
if _, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"); err == nil {
t.Fatal("expected direct medium environment secret to be rejected")
}
}
func TestReadProtectedRuntimeValueRejectsPathOutsideSecretRoot(t *testing.T) {
if _, err := readProtectedRuntimeValue("/tmp/not-a-domovoy-secret"); err == nil {
t.Fatal("expected protected path rejection")
}
}
-96
View File
@@ -1,96 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
)
func (g cognitiveGateway) taskEvaluationHandler(w http.ResponseWriter, r *http.Request) {
var request capability.TaskEvaluationExecutionRequest
if !decodeJSON(w, r, 256<<10, &request) {
return
}
if err := request.Profile.Validate(); err != nil || request.CapabilityID == "" || request.CapabilityID != request.Profile.CapabilityID || request.BindingID == "" || request.BindingRevision < 1 || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.MaxOutputBytes < 1 || len(request.Cases) != len(request.Profile.RequiredCaseIDs) {
http.Error(w, "invalid task evaluation execution request", http.StatusBadRequest)
return
}
resolver, ok := g.resolver.(candidateBindingResolver)
if !ok {
http.Error(w, "exact candidate resolver unavailable", http.StatusServiceUnavailable)
return
}
selected, err := resolver.ResolveCandidate(r.Context(), request.BindingID, request.BindingRevision)
if err != nil || selected.Binding.ID != request.BindingID || selected.Binding.Revision != request.BindingRevision || selected.Binding.CapabilityID != request.CapabilityID || selected.Binding.OwnerPrincipalID != request.PrincipalID {
http.Error(w, "cognitive binding snapshot unavailable", http.StatusConflict)
return
}
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
return
}
provider := g.provider(selected)
if provider == nil {
http.Error(w, "cognitive provider unavailable", http.StatusServiceUnavailable)
return
}
caseSet := make(map[string]struct{}, len(request.Profile.RequiredCaseIDs))
for _, id := range request.Profile.RequiredCaseIDs {
caseSet[id] = struct{}{}
}
observations := make([]capability.TaskCaseObservation, len(request.Cases))
responses := make([]capability.TaskCaseResponse, len(request.Cases))
var wg sync.WaitGroup
seenCases := make(map[string]struct{}, len(request.Cases))
for i, item := range request.Cases {
if item.CaseID == "" || item.Prompt == "" || item.EvidenceArtifactRef == "" {
http.Error(w, "invalid task evaluation case", http.StatusBadRequest)
return
}
if _, exists := caseSet[item.CaseID]; !exists {
http.Error(w, "task evaluation case is not in profile", http.StatusBadRequest)
return
}
if _, exists := seenCases[item.CaseID]; exists {
http.Error(w, "duplicate task evaluation case", http.StatusBadRequest)
return
}
seenCases[item.CaseID] = struct{}{}
wg.Add(1)
go func(index int, item capability.TaskEvaluationCase) {
defer wg.Done()
started := time.Now()
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
response, invokeErr := g.invokeAdmitted(r.Context(), string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
observation := capability.TaskCaseObservation{CaseID: item.CaseID, LatencyMicros: time.Since(started).Microseconds(), EvidenceArtifactRef: item.EvidenceArtifactRef}
responses[index] = capability.TaskCaseResponse{CaseID: item.CaseID, ResponseText: response.AdvisoryText, LatencyMicros: observation.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef}
if invokeErr == nil && response.FinishReason == cognitive.FinishCompleted && response.AdvisoryText != "" {
// Invocation success proves reachability only. Without an
// independent verifier and answer evidence, semantic support and
// quality must remain UNKNOWN rather than becoming false evidence.
observation.Result = capability.EvaluationUnknown
observation.QualityScoreMillis = 0
} else {
observation.Result = capability.EvaluationUnknown
observation.CriticalFailure = invokeErr != nil
}
observations[index] = observation
}(i, item)
}
if len(seenCases) != len(caseSet) {
http.Error(w, "task evaluation case set is incomplete", http.StatusBadRequest)
return
}
wg.Wait()
writeTaskEvaluationJSON(w, capability.TaskEvaluationExecutionResponse{ProfileID: request.Profile.ID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, Observations: observations, Responses: responses, ObservedAtUnixMillis: time.Now().UnixMilli()})
}
func writeTaskEvaluationJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
@@ -1,58 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/identity"
)
type candidateRegistryStub struct {
registryStub
selected capability.ResolvedCognitiveBinding
}
func (s *candidateRegistryStub) ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error) {
return s.selected, nil
}
func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
selected := gatewaySelection()
registry := &candidateRegistryStub{selected: selected}
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}
gateway := cognitiveGateway{
resolver: registry,
providers: map[capability.ProviderKind][]providerRegistration{
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}},
},
now: func() time.Time { return time.UnixMilli(1000) },
}
profile := capability.TaskEvaluationProfile{ID: "profile-general", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: selected.Binding.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}, {CaseID: "case-b", Prompt: "two", EvidenceArtifactRef: "artifact:b"}}}
payload, _ := json.Marshal(request)
recorder := httptest.NewRecorder()
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response capability.TaskEvaluationExecutionResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Observations) != 2 || response.Observations[0].Result != capability.EvaluationUnknown || response.Observations[1].Result != capability.EvaluationUnknown {
t.Fatalf("unexpected observations: %+v", response.Observations)
}
for _, observation := range response.Observations {
if observation.QualityScoreMillis != 0 || observation.CriticalFailure {
t.Fatalf("reachability-only evidence must not carry quality or failure claims: %+v", observation)
}
}
}
-7
View File
@@ -84,13 +84,6 @@ func main() {
dispatcher := operationDispatcher{continuityExecutor: continuityExecutor, continuityReconciler: continuityExecutor, bindings: creatorCore, secretRef: notificationSecretRef, secret: notificationSecret, client: creatorHTTPClient}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "commit-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
readinessClient := &http.Client{Timeout: 2 * time.Second}
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "commit-runtime", Probes: []health.Probe{
health.TCPProbe("commit-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
health.TCPProbe("authority-postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
health.HTTPProbe(readinessClient, strings.TrimRight(coreURL, "/")+"/healthz"),
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
}})
registerExecutionRoutes(mux, repository, dispatcher, dispatcher, executorToken)
registerCreatorCommandRoutes(mux, creatorCommandHandler{
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
-7
View File
@@ -7,7 +7,6 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
@@ -32,12 +31,6 @@ func main() {
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "continuity-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
readinessClient := &http.Client{Timeout: 2 * time.Second}
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "continuity-runtime", Probes: []health.Probe{
health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), "/")+"/healthz"),
}})
executorToken := os.Getenv("COMMIT_EXECUTOR_TOKEN")
if executorToken == "" {
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
@@ -61,13 +61,6 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
}
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
// The built-in advisory channel is the default conversational path. A
// simple question must not first wait for the slower, strict JSON
// interpretation route: that route exists for ambiguity and work routing,
// not as a prerequisite for talking to Domovoy.
if isSimpleLocalQuestion(result) {
return false
}
switch result.Interpretation.Applicability {
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
return true
@@ -76,19 +69,6 @@ func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult)
}
}
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
if result.Interpretation.SpeechAct != interaction.SpeechActQuestion || result.Interpretation.Clarification == interaction.ClarificationRequired {
return false
}
for _, hint := range result.CapabilityHints {
switch hint {
case "MODIFY_CODE", "RUN_TESTS", "SEND_EMAIL", "SCHEDULE_REMINDER", "READ_DATABASE", "QUERY_LOGS":
return false
}
}
return true
}
func buildGatewayInterpretationRequest(session identity.CreatorSession, utterance interaction.HumanUtterance, semantic interactiondomain.SemanticResult, projectNames []string) cognitive.GatewayInterpretationRequest {
requestID, _ := ids.New()
return cognitive.GatewayInterpretationRequest{
@@ -1,36 +0,0 @@
package main
import (
"testing"
"git.cin.su/m/Assistant/contracts/interaction"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActQuestion,
Applicability: interaction.SemanticNeedsBroaderInterpret,
Clarification: interaction.ClarificationNotRequired,
},
CapabilityHints: []string{"COGNITIVE_REASONING", "VERIFY_FACT"},
}
if shouldRequestBroaderInterpretation(result) {
t.Fatal("simple question must go directly to the built-in advisory path")
}
}
func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
result := interactiondomain.SemanticResult{
Interpretation: interaction.UtteranceInterpretation{
SpeechAct: interaction.SpeechActQuestion,
Applicability: interaction.SemanticNeedsBroaderInterpret,
Clarification: interaction.ClarificationNotRequired,
},
CapabilityHints: []string{"MODIFY_CODE", "RUN_TESTS"},
}
if !shouldRequestBroaderInterpretation(result) {
t.Fatal("operational question must retain strict interpretation routing")
}
}
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"errors"
"time"
"git.cin.su/m/Assistant/contracts/work"
workdomain "git.cin.su/m/Assistant/modules/work/domain"
)
var errDevelopmentGateCreate = errors.New("development gate must start at evidence collected with revision one")
func prepareDevelopmentGate(value work.DevelopmentGate, now time.Time) (work.DevelopmentGate, error) {
if value.Stage != work.DevelopmentEvidenceCollected || value.Revision != 1 {
return work.DevelopmentGate{}, errDevelopmentGateCreate
}
value.UpdatedAtUnixMillis = now.UnixMilli()
if err := workdomain.ValidateDevelopmentGate(value); err != nil {
return work.DevelopmentGate{}, err
}
return value, nil
}
func advanceDevelopmentGate(value work.DevelopmentGate, next work.DevelopmentStage, now time.Time) (work.DevelopmentGate, error) {
advanced, err := workdomain.AdvanceDevelopmentGate(value, next)
if err != nil {
return work.DevelopmentGate{}, err
}
advanced.Revision = value.Revision + 1
advanced.UpdatedAtUnixMillis = now.UnixMilli()
return advanced, nil
}
-41
View File
@@ -1,41 +0,0 @@
package main
import (
"testing"
"time"
"git.cin.su/m/Assistant/contracts/work"
)
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
now := time.UnixMilli(10)
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
if err != nil {
t.Fatal(err)
}
gate.ProposalRef = "proposal-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentProposalCreated, now)
if err != nil || gate.Revision != 2 {
t.Fatalf("proposal transition failed: %#v %v", gate, err)
}
gate.TaskPacketRef = "task-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentTaskAdmitted, now)
if err != nil {
t.Fatal(err)
}
gate.WorktreeRef = "isolated-worktree"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentIsolated, now)
if err != nil {
t.Fatal(err)
}
gate.ProducerRunRefs = []work.AgentRunID{"producer"}
gate.VerifierRunRefs = []work.AgentRunID{"verifier"}
gate.VerificationEvidence = "verification-1"
gate, err = advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
if err != nil {
t.Fatal(err)
}
if _, err := advanceDevelopmentGate(gate, work.DevelopmentPromoted, now); err == nil {
t.Fatal("promotion bypassed canary")
}
}
+5 -1
View File
@@ -145,6 +145,7 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction
OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID),
SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID),
HelpMethod: epistemics.HelpMethodClarification,
SkillRef: epistemics.SkillRef(reminderResponseSkillID),
ExpectedOutcome: epistemics.OutcomeSuccess,
ActualOutcome: epistemics.OutcomeUserCorrected,
OutcomeDetail: "human correction indicates prior interpretation or target resolution may be wrong",
@@ -153,7 +154,10 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction
ConfidenceMillis: 1000,
RecordedAtUnixMillis: input.At.UnixMilli(),
}
r.saveExperienceAndReflection(ctx, experience)
saved, ok := r.saveExperienceAndReflection(ctx, experience)
if ok {
r.updateReminderSkill(ctx, saved, capability.UsageFailed, true)
}
}
func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastPathDeclineLearningInput) {
-57
View File
@@ -1,57 +0,0 @@
package main
import (
"context"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/epistemics"
"git.cin.su/m/Assistant/contracts/interaction"
)
type learningEpistemicsStub struct {
experiences []epistemics.ExperienceRecord
}
func (s *learningEpistemicsStub) SaveSystemExpectation(context.Context, epistemics.SystemExpectation) error {
return nil
}
func (s *learningEpistemicsStub) SaveExperienceRecord(_ context.Context, value epistemics.ExperienceRecord) error {
s.experiences = append(s.experiences, value)
return nil
}
func (s *learningEpistemicsStub) SaveReflectionCandidate(context.Context, epistemics.ReflectionCandidate) error {
return nil
}
type learningCapabilityStub struct {
loadCalls int
saveCalls int
}
func (s *learningCapabilityStub) LoadSkillProfile(context.Context, capability.SkillProfileID) (capability.SkillProfile, error) {
s.loadCalls++
return capability.SkillProfile{}, nil
}
func (s *learningCapabilityStub) SaveSkillProfile(context.Context, capability.SkillProfile) error {
s.saveCalls++
return nil
}
func TestGenericCorrectionDoesNotDegradeReminderSkill(t *testing.T) {
epistemicsStore := &learningEpistemicsStub{}
capabilityStore := &learningCapabilityStub{}
recorder := learningRecorder{epistemics: epistemicsStore, capability: capabilityStore}
recorder.recordCorrection(context.Background(), correctionLearningInput{
Intent: interaction.OriginalHumanIntent{ID: "intent-1"},
Utterance: interaction.HumanUtterance{ID: "utterance-1"},
At: time.UnixMilli(1000),
})
if capabilityStore.loadCalls != 0 || capabilityStore.saveCalls != 0 {
t.Fatalf("generic correction must not mutate reminder skill: load=%d save=%d", capabilityStore.loadCalls, capabilityStore.saveCalls)
}
if len(epistemicsStore.experiences) != 1 || epistemicsStore.experiences[0].SkillRef != "" {
t.Fatalf("generic correction must remain evidence without a reminder skill reference: %#v", epistemicsStore.experiences)
}
}
+23 -406
View File
@@ -116,10 +116,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 12 * time.Second})
if err != nil {
log.Fatal(err)
}
learning := learningRecorder{epistemics: epistemicsRepository, capability: capabilityRepository}
mux := http.NewServeMux()
authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) {
@@ -139,40 +135,6 @@ func main() {
return situationdomain.NewResolver(bindings).Resolve(runtime)
}
mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
readinessClient := &http.Client{Timeout: 2 * time.Second}
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Probes: []health.Probe{
health.TCPProbe("postgres", envOr("POSTGRES_HOST", "postgres"), envOr("POSTGRES_PORT", "5432")),
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), "/")+"/healthz"),
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/healthz"),
}})
admissionPolicy := capability.AdmissionPolicy{ID: envOr("COGNITIVE_ADMISSION_POLICY_ID", "creator-advisory-local-v1"), MaxConcurrent: envInt("COGNITIVE_ADMISSION_MAX_CONCURRENT", 1), MaxAcceptedPerHour: envInt("COGNITIVE_ADMISSION_MAX_ACCEPTED_PER_HOUR", 60), MaxInputBytes: envInt("COGNITIVE_ADMISSION_MAX_INPUT_BYTES", 80<<10), MaxOutputBytes: envInt("COGNITIVE_ADMISSION_MAX_OUTPUT_BYTES", 16<<10)}
mux.HandleFunc("POST /v1/internal/cognitive/admission", func(w http.ResponseWriter, r *http.Request) {
var request struct {
LeaseID string `json:"lease_id"`
Request capability.AdmissionRequest `json:"request"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&request); err != nil || request.LeaseID == "" {
http.Error(w, "invalid admission request", http.StatusBadRequest)
return
}
decision, lease, err := capabilityRepository.AdmitCognitive(r.Context(), request.LeaseID, admissionPolicy, request.Request, time.Now().UnixMilli())
if err != nil {
http.Error(w, "admission unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Decision capability.AdmissionDecision `json:"decision"`
Lease capability.AdmissionLease `json:"lease"`
}{decision, lease})
})
mux.HandleFunc("POST /v1/internal/cognitive/admission/{lease}/release", func(w http.ResponseWriter, r *http.Request) {
if err := capabilityRepository.ReleaseCognitiveAdmission(r.Context(), capability.AdmissionLease{ID: r.PathValue("lease")}, time.Now().UnixMilli()); err != nil {
http.Error(w, "admission release unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
@@ -182,135 +144,6 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(session)
})
mux.HandleFunc("POST /v1/creator/research/web", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var request epistemics.WebResearchRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid web research request", http.StatusBadRequest)
return
}
result, err := researchClient.EvaluateWeb(r.Context(), request)
if err != nil {
http.Error(w, "web research unavailable", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
mux.HandleFunc("POST /v1/creator/development/proposals", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
var request struct {
Evidence []work.ExtensionEvidence `json:"evidence"`
Proposal work.ExtensionProposalRequest `json:"proposal"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid development proposal", http.StatusBadRequest)
return
}
proposal, err := workdomain.BuildExtensionProposal(request.Evidence, request.Proposal)
if err != nil {
http.Error(w, "development proposal rejected", http.StatusUnprocessableEntity)
return
}
proposal.RequestedByPrincipalID = string(session.PrincipalID)
if err := workRepository.SaveExtensionProposal(r.Context(), proposal); err != nil {
http.Error(w, "development proposal conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(proposal)
})
mux.HandleFunc("POST /v1/creator/development/scaffolds", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
var request work.DevelopmentScaffoldRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid development scaffold", http.StatusBadRequest)
return
}
scaffold, err := workdomain.BuildDevelopmentScaffold(request)
if err != nil {
http.Error(w, "development scaffold rejected", http.StatusUnprocessableEntity)
return
}
scaffold.RequestedByPrincipalID = string(session.PrincipalID)
if err := workRepository.SaveDevelopmentScaffold(r.Context(), scaffold); err != nil {
http.Error(w, "development scaffold conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(scaffold)
})
mux.HandleFunc("POST /v1/creator/development/gates", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var gate work.DevelopmentGate
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&gate); err != nil {
http.Error(w, "invalid development gate", http.StatusBadRequest)
return
}
gate, err := prepareDevelopmentGate(gate, time.Now())
if err != nil {
http.Error(w, "development gate rejected", http.StatusUnprocessableEntity)
return
}
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, 0); err != nil {
http.Error(w, "development gate conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(gate)
})
mux.HandleFunc("POST /v1/creator/development/gates/{id}/advance", func(w http.ResponseWriter, r *http.Request) {
if _, ok := authenticateCreator(w, r); !ok {
return
}
var request struct {
ExpectedRevision int64 `json:"expected_revision"`
NextStage work.DevelopmentStage `json:"next_stage"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.NextStage == "" {
http.Error(w, "invalid development gate transition", http.StatusBadRequest)
return
}
gate, err := workRepository.LoadDevelopmentGate(r.Context(), r.PathValue("id"))
if err != nil || gate.Revision != request.ExpectedRevision {
http.Error(w, "development gate revision conflict", http.StatusConflict)
return
}
gate, err = advanceDevelopmentGate(gate, request.NextStage, time.Now())
if err != nil {
http.Error(w, "development gate transition rejected", http.StatusUnprocessableEntity)
return
}
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, request.ExpectedRevision); err != nil {
http.Error(w, "development gate conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(gate)
})
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -460,10 +293,7 @@ func main() {
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
return
}
cognitiveAttempted := false
cognitiveAvailable := true
if shouldRequestBroaderInterpretation(semantic) {
cognitiveAttempted = true
projectNames := make([]string, 0, len(semantic.Projects))
for _, ref := range semantic.Projects {
if ref.ProjectDisplayName != "" {
@@ -473,11 +303,7 @@ func main() {
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil {
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus)
} else {
cognitiveAvailable = false
}
} else {
cognitiveAvailable = false
}
}
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
@@ -552,8 +378,7 @@ func main() {
if responseErr == nil && responseResult.Handled {
helpPath = work.HelpPath{}
}
switch semantic.InformationRequestKind {
case interaction.InformationRequestCapabilityInventory:
if isCapabilityQuestion(utterance.Content) {
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
if err == nil && len(kinds) > 0 {
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
@@ -561,23 +386,11 @@ func main() {
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
}
needsCognition = false
case interaction.InformationRequestCognitiveModelInventory:
candidates, inventoryErr := capabilityRepository.LoadCognitiveCandidates(r.Context(), capability.CapabilityID("creator-advisory"), session.PrincipalID)
planningReply = humanCognitiveModelInventory(candidates, inventoryErr)
needsCognition = false
case interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery:
health, healthErr := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID("cognitive-proposal"))
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
needsCognition = false
}
if helpPath.Reply != "" {
planningReply = helpPath.Reply
needsCognition = false
}
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
planningReply = humanCognitiveDegradedReply(utterance.Content)
needsCognition = false
}
var capabilityStatus capability.CapabilityResolutionStatus
var capabilityKind capability.CapabilityKind
var capabilityGapID capability.CapabilityGapID
@@ -655,28 +468,24 @@ func main() {
}
}
response := interaction.ChatMessageResponse{
ConversationID: conversation.ID,
UtteranceID: utterance.ID,
OriginalIntentID: intent.ID,
SpeechAct: semantic.Interpretation.SpeechAct,
InterpretationState: utterance.InterpretationState,
NeedsCognition: needsCognition,
FallbackText: semantic.Reply,
ActiveProjectName: activeFocusName,
ProjectProvenance: activeFocusProvenance,
Clarification: semantic.Interpretation.Clarification,
WorkPlanned: workPlanned,
PlanningReply: planningReply,
CapabilityStatus: capabilityStatus,
CapabilityKind: capabilityKind,
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
InformationRequestKind: semantic.InformationRequestKind,
InterpretationConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
CapabilityGapID: capabilityGapID,
DelegationID: string(delegationID),
DelegationState: string(delegationState),
Reminder: reminder,
ConversationID: conversation.ID,
UtteranceID: utterance.ID,
OriginalIntentID: intent.ID,
SpeechAct: semantic.Interpretation.SpeechAct,
InterpretationState: utterance.InterpretationState,
NeedsCognition: needsCognition,
FallbackText: semantic.Reply,
ActiveProjectName: activeFocusName,
ProjectProvenance: activeFocusProvenance,
Clarification: semantic.Interpretation.Clarification,
WorkPlanned: workPlanned,
PlanningReply: planningReply,
CapabilityStatus: capabilityStatus,
CapabilityKind: capabilityKind,
CapabilityGapID: capabilityGapID,
DelegationID: string(delegationID),
DelegationState: string(delegationState),
Reminder: reminder,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
@@ -967,20 +776,6 @@ func main() {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(binding)
})
mux.HandleFunc("GET /v1/cognitive-bindings/{id}/revisions/{revision}/snapshot", func(w http.ResponseWriter, r *http.Request) {
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
if err != nil || revision < 1 {
http.Error(w, "invalid cognitive binding revision", http.StatusBadRequest)
return
}
snapshot, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
if err != nil {
http.Error(w, "cognitive binding snapshot not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snapshot)
})
mux.HandleFunc("POST /v1/cognitive-bindings/resolve", func(w http.ResponseWriter, r *http.Request) {
var requirement capability.CognitiveRequirement
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1))
@@ -989,13 +784,7 @@ func main() {
http.Error(w, "invalid cognitive requirement", http.StatusBadRequest)
return
}
var candidates []capability.ResolvedCognitiveBinding
var err error
if requirement.TaskProfileID != "" {
candidates, err = capabilityRepository.LoadCognitiveCandidatesForProfile(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID, requirement.TaskProfileID)
} else {
candidates, err = capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID)
}
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID)
if err != nil {
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
return
@@ -1022,7 +811,6 @@ func main() {
return
}
if err := capabilityRepository.SaveCognitiveDiscovery(r.Context(), registration); err != nil {
log.Printf("cognitive discovery persistence failed: %v", err)
http.Error(w, "save cognitive discovery", http.StatusInternalServerError)
return
}
@@ -1044,20 +832,6 @@ func main() {
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("POST /v1/cognitive-registry/task-evaluations", func(w http.ResponseWriter, r *http.Request) {
var value capability.TaskEvaluation
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil || capabilitydomain.ValidateTaskEvaluation(value) != nil {
http.Error(w, "invalid task evaluation", http.StatusBadRequest)
return
}
if err := capabilityRepository.SaveTaskEvaluation(r.Context(), value); err != nil {
http.Error(w, "save task evaluation", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("POST /v1/creator/cognitive-canary", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -1097,45 +871,6 @@ func main() {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(promotion)
})
mux.HandleFunc("POST /v1/creator/cognitive-activate", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
var request capability.CognitiveActivationPromotionRequest
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid cognitive activation", http.StatusBadRequest)
return
}
request.RequestedByPrincipalID = session.PrincipalID
request.CapabilityID = "creator-advisory"
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
if err != nil {
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
return
}
var current capability.ResolvedCognitiveBinding
for _, candidate := range candidates {
if candidate.Binding.ID == request.BindingID && candidate.Binding.Revision == request.ExpectedBindingRevision {
current = candidate
break
}
}
promotion, err := capabilitydomain.BuildActivationPromotion(request, current)
if err != nil {
http.Error(w, "cognitive activation precondition failed", http.StatusConflict)
return
}
if err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion); err != nil {
http.Error(w, "cognitive activation conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(promotion)
})
mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) {
var value capability.PerformanceEvidence
decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10))
@@ -1354,127 +1089,9 @@ func envOr(name, fallback string) string {
return fallback
}
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil || value < 1 {
return fallback
}
return value
}
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
if inventoryErr != nil {
return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
}
if len(candidates) == 0 {
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
}
lines := make([]string, 0, len(candidates)+1)
lines = append(lines, "Подключённые модели из реестра:")
for _, candidate := range candidates {
modelName := string(candidate.Model.ProviderModelID)
if modelName == "" {
modelName = "имя модели не подтверждено"
}
status := cognitiveInventoryStatus(candidate)
pricing := cognitiveInventoryPricing(candidate.Provider)
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
if pricing != "" {
details = append(details, pricing)
}
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
}
lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")")
}
lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.")
return strings.Join(lines, "\n")
}
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
if candidate.Binding.Activation == capability.ActivationActive {
return "активна"
}
return "доступна, не активирована для автоматического выбора"
}
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
return "работает с ограничениями"
}
return "недоступна или не подтверждена"
}
func cognitiveInventoryPricing(provider capability.ProviderInstance) string {
switch provider.BillingMode {
case capability.BillingFree:
return "бесплатная"
case capability.BillingPerInput:
return "платная: стоимость входа указана в реестре"
case capability.BillingPerOutput:
return "платная: стоимость выхода указана в реестре"
case capability.BillingPerRequest:
return "платная: стоимость запроса указана в реестре"
default:
return "стоимость не подтверждена"
}
}
func humanAuthMode(mode capability.AuthMode) string {
switch mode {
case capability.AuthKey:
return "API-ключ"
case capability.AuthBearer:
return "Bearer-токен"
case capability.AuthUsernamePassword:
return "логин и пароль"
case capability.AuthOAuth:
return "OAuth"
case capability.AuthSecretFile:
return "защищённый файл секрета"
default:
return "защищённый способ"
}
}
func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act interaction.SpeechAct, health capability.Health, healthErr error) string {
recoveryRequested := kind == interaction.InformationRequestCognitiveRecovery || act == interaction.SpeechActRequestToInvestigate || act == interaction.SpeechActDirective
if healthErr != nil {
if recoveryRequested {
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
}
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
}
var observation string
switch health.State {
case capability.HealthAvailable:
observation = "Основной локальный канал сейчас доступен."
case capability.HealthDegraded:
observation = "Основной локальный канал сейчас работает с ограничениями."
case capability.HealthUnavailable:
observation = "Основной локальный канал сейчас недоступен."
default:
observation = "Состояние основного локального канала пока не подтверждено."
}
if recoveryRequested {
if health.State == capability.HealthUnavailable {
return observation + " Домовой жив: начну с проверки встроенного маршрута, затем зафиксирую варианты аварийного восстановления без передачи секретов."
}
return observation + " Я не меняю модель прямо из разговора: сначала покажу наблюдаемое состояние и подготовлю следующий проверяемый шаг восстановления."
}
if health.State == capability.HealthUnavailable {
if recoveryRequested {
return observation + " Домовой не остановился: начну с проверки встроенного маршрута, затем зафиксирую доступные варианты восстановления без передачи секретов."
}
return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал."
}
return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели."
}
func humanCognitiveDegradedReply(message string) string {
if strings.TrimSpace(message) == "" {
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
}
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
func isCapabilityQuestion(message string) bool {
lower := strings.ToLower(message)
return strings.Contains(lower, "что ты умеешь") || strings.Contains(lower, "что умеешь") || strings.Contains(lower, "какие возможности")
}
func humanCapabilityList(kinds []capability.CapabilityKind) string {
-56
View File
@@ -9,10 +9,6 @@ import (
"strings"
"testing"
"time"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/interaction"
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
)
func TestBearerTokenRequiresBearerScheme(t *testing.T) {
@@ -27,58 +23,6 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
}
}
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
t.Fatalf("unexpected safe reply: %q", reply)
}
}
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") {
t.Fatalf("unexpected configuration reply: %q", reply)
}
}
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
t.Fatalf("unexpected degraded reply: %q", reply)
}
}
func TestModelInventoryQuestionUsesReadOnlyFastPath(t *testing.T) {
for _, message := range []string{"какие модели доступны?", "напиши список настроек ИИ", "какие есть подключения ИИ"} {
result, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: message}, nil, nil, time.Now())
if err != nil {
t.Fatalf("interpretation failed for %q: %v", message, err)
}
if result.InformationRequestKind != interaction.InformationRequestCognitiveModelInventory {
t.Fatalf("semantic inventory request %q was not recognized: kind=%q act=%q hints=%v", message, result.InformationRequestKind, result.Interpretation.SpeechAct, result.CapabilityHints)
}
}
ordinary, err := interactiondomain.InterpretUtterance(interaction.HumanUtterance{Content: "настрой локальную модель"}, nil, nil, time.Now())
if err != nil {
t.Fatal(err)
}
if ordinary.InformationRequestKind == interaction.InformationRequestCognitiveModelInventory {
t.Fatal("configuration mutation must not use read-only model inventory path")
}
}
func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) {
cost := int64(25)
text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{{
Binding: capability.CognitiveBinding{DataPolicy: capability.DataPolicyExternalMessageOnly, Activation: capability.ActivationActive},
Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable},
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
}}, nil)
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") {
t.Fatalf("unsafe or incomplete inventory: %q", text)
}
}
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
-53
View File
@@ -1,53 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
type researchRuntimeClient struct {
baseURL string
client *http.Client
}
func newResearchRuntimeClient(rawURL string, client *http.Client) (researchRuntimeClient, error) {
base := strings.TrimRight(strings.TrimSpace(rawURL), "/")
if base == "" {
return researchRuntimeClient{}, fmt.Errorf("research runtime URL is empty")
}
if client == nil {
client = http.DefaultClient
}
return researchRuntimeClient{baseURL: base, client: client}, nil
}
func (c researchRuntimeClient) EvaluateWeb(ctx context.Context, request epistemics.WebResearchRequest) (epistemics.ResearchResult, error) {
payload, err := json.Marshal(request)
if err != nil {
return epistemics.ResearchResult{}, err
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/evaluate", bytes.NewReader(payload))
if err != nil {
return epistemics.ResearchResult{}, err
}
httpRequest.Header.Set("Content-Type", "application/json")
response, err := c.client.Do(httpRequest)
if err != nil {
return epistemics.ResearchResult{}, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return epistemics.ResearchResult{}, fmt.Errorf("research runtime status %d", response.StatusCode)
}
var result epistemics.ResearchResult
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
return epistemics.ResearchResult{}, err
}
return result, nil
}
-33
View File
@@ -1,33 +0,0 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/epistemics"
)
func TestResearchRuntimeClientForwardsBoundedWebRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/v1/research/web/evaluate" || r.Header.Get("Content-Type") != "application/json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var request epistemics.WebResearchRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.URL != "https://assistant.cin.su/healthz" {
t.Fatalf("unexpected request body: %#v %v", request, err)
}
_ = json.NewEncoder(w).Encode(epistemics.ResearchResult{PlanID: request.Plan.ID, Findings: []epistemics.ResearchFinding{{PropositionKey: request.PropositionKey, State: epistemics.FindingSupported}}})
}))
defer server.Close()
client, err := newResearchRuntimeClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
result, err := client.EvaluateWeb(context.Background(), epistemics.WebResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan-1"}, PropositionKey: "health", URL: "https://assistant.cin.su/healthz"})
if err != nil || result.PlanID != "plan-1" || len(result.Findings) != 1 || result.Findings[0].State != epistemics.FindingSupported {
t.Fatalf("unexpected result: %#v %v", result, err)
}
}
+2 -58
View File
@@ -15,8 +15,6 @@ import (
)
func TestChatPageServedWithoutInternalIDs(t *testing.T) {
t.Setenv("ASSISTANT_BUILD_REVISION", "0123456789abcdef")
t.Setenv("ASSISTANT_BUILD_TIME", "2026-08-22T03:04:05Z")
handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive")
if err != nil {
t.Fatal(err)
@@ -27,62 +25,11 @@ func TestChatPageServedWithoutInternalIDs(t *testing.T) {
t.Fatalf("expected /chat status 200, got %d", recorder.Code)
}
body := recorder.Body.String()
for _, required := range []string{"cognitive-status", "aria-live=\"polite\"", "cognitive-console-link", "href=\"/admin\"", "Проверь канал", "Изменить настройку", "ai-data-error", "Не вставляйте пароли", "build-meta", "версия ", "обновлено ", "datetime=\""} {
if !strings.Contains(body, required) {
t.Fatalf("/chat is missing human status marker %q", required)
}
}
for _, forbidden := range []string{"provider_id", "model_id", "binding_id", "workspace-id", "session-id", "__ASSISTANT_BUILD_REVISION__", "__ASSISTANT_BUILD_TIME__", "__ASSISTANT_BUILD_DATETIME__"} {
for _, forbidden := range []string{"provider_id", "model_id", "binding_id", "workspace-id", "session-id"} {
if strings.Contains(body, forbidden) {
t.Fatalf("/chat leaks internal field %q", forbidden)
}
}
if !strings.Contains(body, "версия 0123456789ab") || !strings.Contains(body, "обновлено 22.08.2026 03:04 UTC") || !strings.Contains(body, "datetime=\"2026-08-22T03:04:05Z\"") {
t.Fatalf("/chat does not render build metadata: %q", body)
}
for _, path := range []string{"/", "/chat.html"} {
alternate := httptest.NewRecorder()
handler.ServeHTTP(alternate, httptest.NewRequest(http.MethodGet, path, nil))
if alternate.Code != http.StatusOK || strings.Contains(alternate.Body.String(), "__ASSISTANT_BUILD_REVISION__") {
t.Fatalf("%s did not render build metadata: status=%d", path, alternate.Code)
}
}
}
func TestChatScriptExplainsSessionAndRuntimeFailures(t *testing.T) {
body, err := staticFiles.ReadFile("static/assets/chat.js")
if err != nil {
t.Fatal(err)
}
script := string(body)
for _, required := range []string{"class ChatApiError", "Сессия устарела", "основной runtime сейчас недоступен", "Проверьте текст и повторите попытку"} {
if !strings.Contains(script, required) {
t.Fatalf("chat script is missing recovery message %q", required)
}
}
}
func TestPublicSurfacesSeparateChatAndConsole(t *testing.T) {
handler, err := newHandler("http://core", "http://continuity", "http://commit", http.DefaultClient, "http://cognitive")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
path string
must string
mustNot string
}{
{path: "/", must: "Напишите Домовому обычное сообщение", mustNot: "context-preset"},
{path: "/admin", must: "Чат с Домовым", mustNot: "Напишите Домовому обычное сообщение"},
{path: "/chat", must: "Напишите Домовому обычное сообщение", mustNot: "context-preset"},
} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
body := recorder.Body.String()
if recorder.Code != http.StatusOK || !strings.Contains(body, test.must) || strings.Contains(body, test.mustNot) {
t.Fatalf("%s response=%d contains expected=%t forbidden=%t", test.path, recorder.Code, strings.Contains(body, test.must), strings.Contains(body, test.mustNot))
}
}
}
func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
@@ -100,7 +47,7 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
t.Fatalf("unexpected core chat request: %#v", request)
}
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, InterpretationState: interaction.InterpretationInterpreted, NeedsCognition: true, FallbackText: "fallback", InformationRequestKind: interaction.InformationRequestCognitiveChannelStatus})
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, InterpretationState: interaction.InterpretationInterpreted, NeedsCognition: true, FallbackText: "fallback"})
default:
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
}
@@ -146,9 +93,6 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
if cognitiveRequest.ActorID != "actor-1" || cognitiveRequest.PrincipalID != "principal-1" || cognitiveRequest.ScopeID != "domovoy-global-scope" || cognitiveRequest.Message != "Кто ты?" {
t.Fatalf("unexpected trusted gateway request: %#v", cognitiveRequest)
}
if cognitiveRequest.Meaning.InformationRequestKind != interaction.InformationRequestCognitiveChannelStatus {
t.Fatalf("information intent was not preserved: %#v", cognitiveRequest.Meaning)
}
}
func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) {
-1
View File
@@ -15,7 +15,6 @@ func main() {
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
&http.Client{Timeout: 10 * time.Second},
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
)
if err != nil {
log.Fatal(err)
+42 -211
View File
@@ -1,7 +1,6 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"fmt"
@@ -9,9 +8,7 @@ import (
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/capability"
@@ -33,19 +30,12 @@ const (
var staticFiles embed.FS
type gateway struct {
core *url.URL
continuity *url.URL
commit *url.URL
cognitive *url.URL
agentWorker *url.URL
client *http.Client
static http.Handler
build buildMetadata
}
type buildMetadata struct {
revision string
updated time.Time
core *url.URL
continuity *url.URL
commit *url.URL
cognitive *url.URL
client *http.Client
static http.Handler
}
type routeTarget uint8
@@ -77,14 +67,6 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
if err != nil {
return nil, err
}
agentWorkerURL := "http://agent-worker:8090"
if len(cognitiveURLs) > 1 {
agentWorkerURL = cognitiveURLs[1]
}
agentWorker, err := parseRuntimeURL(agentWorkerURL, "Agent worker")
if err != nil {
return nil, err
}
assets, err := fs.Sub(staticFiles, "static")
if err != nil {
return nil, fmt.Errorf("load embedded console: %w", err)
@@ -92,20 +74,7 @@ func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, c
if client == nil {
client = http.DefaultClient
}
updated := time.Now().UTC()
if raw := strings.TrimSpace(os.Getenv("ASSISTANT_BUILD_TIME")); raw != "" {
if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil {
updated = parsed.UTC()
}
}
revision := strings.TrimSpace(os.Getenv("ASSISTANT_BUILD_REVISION"))
if revision == "" {
revision = "development"
}
if len(revision) > 12 {
revision = revision[:12]
}
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, agentWorker: agentWorker, client: client, static: http.FileServer(http.FS(assets)), build: buildMetadata{revision: revision, updated: updated}}), nil
return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil
}
func parseRuntimeURL(rawURL, name string) (*url.URL, error) {
@@ -135,14 +104,6 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.handleChatMessage(w, r)
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/fleet/readiness" {
g.handleFleetReadiness(w, r)
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/agent-worker/status" {
g.handleAgentWorkerStatus(w, r)
return
}
g.forwardAPI(w, r)
return
}
@@ -150,31 +111,17 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/" || r.URL.Path == "/login" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
g.serveEmbeddedHTML(w, "chat.html")
return
if r.URL.Path == "/" {
w.Header().Set("Cache-Control", "no-store")
}
if r.URL.Path == "/admin" {
g.serveEmbeddedHTML(w, "index.html")
return
if r.URL.Path == "/chat" {
r = r.Clone(r.Context())
r.URL.Path = "/chat.html"
w.Header().Set("Cache-Control", "no-store")
}
g.static.ServeHTTP(w, r)
}
func (g *gateway) serveEmbeddedHTML(w http.ResponseWriter, name string) {
body, err := staticFiles.ReadFile("static/" + name)
if err != nil {
http.Error(w, "page unavailable", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_REVISION__"), []byte(g.build.revision))
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_TIME__"), []byte(g.build.updated.Format("02.01.2006 15:04 UTC")))
body = bytes.ReplaceAll(body, []byte("__ASSISTANT_BUILD_DATETIME__"), []byte(g.build.updated.Format(time.RFC3339)))
_, _ = w.Write(body)
}
type browserAdvisoryRequest struct {
Message string `json:"message"`
SessionID string `json:"session_id"`
@@ -196,23 +143,20 @@ type browserChatRequest struct {
}
type browserChatResponse struct {
ConversationID string `json:"conversation_id"`
UtteranceID string `json:"utterance_id"`
SpeechAct string `json:"speech_act"`
ReplyText string `json:"reply_text"`
Cognition string `json:"cognition"`
ActiveFocus string `json:"active_focus"`
ProjectProvenance string `json:"project_provenance"`
Clarification string `json:"clarification"`
WorkPlanned bool `json:"work_planned"`
CapabilityStatus string `json:"capability_status"`
CapabilityKind string `json:"capability_kind"`
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
RequestedOutcome string `json:"requested_outcome,omitempty"`
InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"`
DelegationID string `json:"delegation_id"`
DelegationState string `json:"delegation_state"`
ReminderScheduled bool `json:"reminder_scheduled"`
ConversationID string `json:"conversation_id"`
UtteranceID string `json:"utterance_id"`
SpeechAct string `json:"speech_act"`
ReplyText string `json:"reply_text"`
Cognition string `json:"cognition"`
ActiveFocus string `json:"active_focus"`
ProjectProvenance string `json:"project_provenance"`
Clarification string `json:"clarification"`
WorkPlanned bool `json:"work_planned"`
CapabilityStatus string `json:"capability_status"`
CapabilityKind string `json:"capability_kind"`
DelegationID string `json:"delegation_id"`
DelegationState string `json:"delegation_state"`
ReminderScheduled bool `json:"reminder_scheduled"`
}
func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
@@ -265,82 +209,6 @@ func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(policy)
}
func (g *gateway) handleFleetReadiness(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
if status == 0 {
status = http.StatusUnauthorized
}
http.Error(w, "creator session denied", status)
return
}
target := *g.cognitive
target.Path = "/v1/fleet/readiness"
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
return
}
response, err := g.client.Do(request)
if err != nil {
http.Error(w, "fleet readiness unavailable", http.StatusBadGateway)
return
}
defer response.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
}
func (g *gateway) handleAgentWorkerStatus(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "creator session denied", http.StatusUnauthorized)
return
}
var session identity.CreatorSession
status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusBadGateway)
return
}
if status != http.StatusOK || session.ActorID == "" || session.PrincipalID == "" {
if status == 0 {
status = http.StatusUnauthorized
}
http.Error(w, "creator session denied", status)
return
}
target := *g.agentWorker
target.Path = "/status"
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
return
}
response, err := g.client.Do(request)
if err != nil {
http.Error(w, "agent worker status unavailable", http.StatusBadGateway)
return
}
defer response.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, io.LimitReader(response.Body, maxResponseBytes))
}
func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) {
var browserRequest browserAdvisoryRequest
if err := decodeStrictJSON(r.Body, maxAdvisoryRequestBytes, &browserRequest); err != nil || browserRequest.Message == "" || len(browserRequest.Message) > 16*1024 || !validID(browserRequest.SessionID) || !validID(browserRequest.WorkspaceID) {
@@ -471,7 +339,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
cognition = "unavailable"
requestID, idErr := ids.New()
if idErr == nil {
trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: localChatContext(saved), Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false, Meaning: cognitive.TaskMeaning{SpeechAct: string(saved.SpeechAct), CapabilityKind: saved.CapabilityKind, CapabilityHints: saved.CapabilityHints, RequestedOutcome: saved.RequestedOutcome, InformationRequestKind: saved.InformationRequestKind, ConfidenceMillis: saved.InterpretationConfidenceMillis}}
trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: "domovoy-global-scope", Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyLocalOnly, EgressAcknowledged: false}
payload, err = json.Marshal(trusted)
if err == nil {
var advisory cognitive.GatewayAdvisoryResponse
@@ -488,50 +356,23 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(browserChatResponse{
ConversationID: string(saved.ConversationID),
UtteranceID: string(saved.UtteranceID),
SpeechAct: string(saved.SpeechAct),
ReplyText: reply,
Cognition: cognition,
ActiveFocus: saved.ActiveProjectName,
ProjectProvenance: string(saved.ProjectProvenance),
Clarification: string(saved.Clarification),
WorkPlanned: saved.WorkPlanned,
CapabilityStatus: string(saved.CapabilityStatus),
CapabilityKind: string(saved.CapabilityKind),
CapabilityHints: saved.CapabilityHints,
RequestedOutcome: saved.RequestedOutcome,
InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis,
DelegationID: saved.DelegationID,
DelegationState: saved.DelegationState,
ReminderScheduled: saved.Reminder != nil,
ConversationID: string(saved.ConversationID),
UtteranceID: string(saved.UtteranceID),
SpeechAct: string(saved.SpeechAct),
ReplyText: reply,
Cognition: cognition,
ActiveFocus: saved.ActiveProjectName,
ProjectProvenance: string(saved.ProjectProvenance),
Clarification: string(saved.Clarification),
WorkPlanned: saved.WorkPlanned,
CapabilityStatus: string(saved.CapabilityStatus),
CapabilityKind: string(saved.CapabilityKind),
DelegationID: saved.DelegationID,
DelegationState: saved.DelegationState,
ReminderScheduled: saved.Reminder != nil,
})
}
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
if strings.TrimSpace(saved.ActiveProjectName) == "" {
return []cognitive.ContextExcerpt{}
}
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
DataPolicy: capability.DataPolicyLocalOnly,
MaxBytes: 2048,
Candidates: []cognitive.ContextCandidate{{
Excerpt: cognitive.ContextExcerpt{
ID: cognitive.ContextExcerptID("active-project-focus"),
SourceKind: "situation-active-project",
SourceID: strings.TrimSpace(saved.ActiveProjectName),
Sensitivity: cognitive.SensitivityPrivateAllowed,
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
},
Relevance: 100,
}},
})
if err != nil {
return []cognitive.ContextExcerpt{}
}
return bundle.Excerpts
}
func asksAttention(message string) bool {
lower := strings.ToLower(message)
return strings.Contains(lower, "требует моего внимания") || strings.Contains(lower, "требует внимания") || strings.Contains(lower, "что сейчас важно")
@@ -714,16 +555,6 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
}
case method == http.MethodGet && path == "/api/learning/recent":
return targetCore, "/v1/creator/learning/recent", true
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/") && strings.HasSuffix(path, "/latest-checkpoint"):
id := strings.TrimSuffix(strings.TrimPrefix(path, "/api/delegations/"), "/latest-checkpoint")
if validID(id) {
return targetCore, "/v1/creator/delegations/" + id + "/latest-checkpoint", true
}
case method == http.MethodGet && strings.HasPrefix(path, "/api/delegations/"):
id := strings.TrimPrefix(path, "/api/delegations/")
if validID(id) {
return targetCore, "/v1/creator/delegations/" + id, true
}
case method == http.MethodGet && strings.HasPrefix(path, "/api/prospective-items/"):
id := strings.TrimPrefix(path, "/api/prospective-items/")
if validID(id) {
+1 -121
View File
@@ -10,105 +10,8 @@ import (
"testing"
"git.cin.su/m/Assistant/contracts/cognitive"
"git.cin.su/m/Assistant/contracts/interaction"
)
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" {
t.Fatalf("unexpected local context: %#v", context)
}
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
t.Fatalf("expected empty context without active project: %#v", empty)
}
}
func TestCreatorRouteAllowsOnlyBoundedDelegationReads(t *testing.T) {
tests := []struct {
path string
want string
}{
{"/api/delegations/delegation-1", "/v1/creator/delegations/delegation-1"},
{"/api/delegations/delegation-1/latest-checkpoint", "/v1/creator/delegations/delegation-1/latest-checkpoint"},
}
for _, test := range tests {
_, got, ok := creatorRoute(http.MethodGet, test.path)
if !ok || got != test.want {
t.Fatalf("creatorRoute(%q) = %q, %t; want %q, true", test.path, got, ok, test.want)
}
}
if _, _, ok := creatorRoute(http.MethodGet, "/api/delegations/../secrets"); ok {
t.Fatal("path traversal-like delegation route must be denied")
}
}
func TestAgentWorkerStatusProxyRequiresCreatorSession(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
}
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
}))
defer core.Close()
worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/status" {
t.Fatalf("unexpected worker path %s", r.URL.Path)
}
_, _ = io.WriteString(w, `{"runtime":"agent-worker","executor_configured":false,"mode":"recovery-only","ready":false,"reason":"bridge missing"}`)
}))
defer worker.Close()
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), "http://cognitive.invalid", worker.URL)
if err != nil {
t.Fatal(err)
}
denied := httptest.NewRecorder()
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", nil))
if denied.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", denied.Code)
}
request := httptest.NewRequest(http.MethodGet, "/api/agent-worker/status", 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(), `"mode":"recovery-only"`) || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
}
}
func TestFleetReadinessRequiresCreatorSessionAndProxiesCognitiveState(t *testing.T) {
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" {
t.Fatalf("unexpected session request path=%s auth=%q", r.URL.Path, r.Header.Get("Authorization"))
}
_, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`)
}))
defer core.Close()
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/fleet/readiness" {
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"readiness":{"members":[{"provider_instance_id":"lifeboat","model_descriptor_id":"qwen","tier":"LIFEBOAT","ready":true,"reason":"AVAILABLE"}],"ready_tiers":["LIFEBOAT"],"fallback_tier":"LIFEBOAT"},"preferred_tier":"HEAVY","selected_tier":"LIFEBOAT"}`)
}))
defer cognitiveRuntime.Close()
handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitiveRuntime.URL)
if err != nil {
t.Fatal(err)
}
denied := httptest.NewRecorder()
handler.ServeHTTP(denied, httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", nil))
if denied.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d", denied.Code)
}
request := httptest.NewRequest(http.MethodGet, "/api/fleet/readiness", 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(), `"selected_tier":"LIFEBOAT"`) || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response=%d cache=%q body=%s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
}
}
const legacyExternalMode = "legacy-external-test-mode"
func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, mode string, cognitiveURLs ...string) (http.Handler, error) {
@@ -326,21 +229,10 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "Домовой") ||
!strings.Contains(response.Body.String(), "context-preset") ||
!strings.Contains(response.Body.String(), "context-state") ||
!strings.Contains(response.Body.String(), "copy-context") ||
!strings.Contains(response.Body.String(), "К содержимому") ||
!strings.Contains(response.Body.String(), "aria-current=\"location\"") ||
!strings.Contains(response.Body.String(), "section-nav-link") ||
!strings.Contains(response.Body.String(), "data-page=\"overview\"") ||
!strings.Contains(response.Body.String(), "data-page=\"diagnostics\"") ||
!strings.Contains(response.Body.String(), "data-page=\"operations\"") ||
!strings.Contains(response.Body.String(), "data-page=\"learning\"") ||
!strings.Contains(response.Body.String(), "data-page=\"settings\"") ||
!strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") ||
!strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") ||
!strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") ||
@@ -354,18 +246,6 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
}
}
func TestLoginRouteServesChatShell(t *testing.T) {
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/login", nil))
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "id=\"username\"") || !strings.Contains(response.Body.String(), "id=\"password\"") {
t.Fatalf("login response = %d", response.Code)
}
}
func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) {
_, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected")
if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") {
+477 -114
View File
@@ -1,125 +1,488 @@
:root {
--ink: #e9eee7;
--muted: #93a096;
--base: #0b100e;
--panel: #101714;
--line: #2a352f;
--signal: #c9a85f;
--bad: #de816f;
--good: #78c6a3;
--radius: 3px;
--ink: #e7efe9;
--muted: #96a49d;
--faint: #5f6e66;
--surface: #0f1512;
--surface-alt: #151d18;
--line: #27322c;
--base: #090d0b;
--good: #7cc7a4;
--signal: #ceb27a;
--bad: #d88d78;
--radius-lg: 16px;
--radius: 10px;
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
--serif: Georgia, "Times New Roman", serif;
--mono: "Cascadia Mono", Consolas, monospace;
--shadow: 0 24px 56px rgba(0, 0, 0, .22);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: var(--base); font-family: var(--sans); }
body {
margin: 0;
min-height: 100vh;
min-width: 320px;
color: var(--ink);
background:
radial-gradient(circle at 10% 0%, rgba(120, 182, 160, .16), transparent 40%),
radial-gradient(circle at 90% 0%, rgba(201, 168, 95, .16), transparent 35%),
var(--base);
font-family: var(--sans);
}
button, input, textarea { font: inherit; }
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
.chat-shell { min-height: 100vh; display: grid; }
.login-panel { width: min(100% - 32px, 420px); margin: auto; padding: 46px 40px; border: 1px solid var(--line); background: var(--panel); }
.brand-mark { width: 46px; height: 46px; display: grid; place-items: center; color: var(--base); background: var(--signal); font: 700 24px/1 var(--serif); clip-path: polygon(50% 0, 92% 22%, 92% 76%, 50% 100%, 8% 76%, 8% 22%); }
h1 { margin: 30px 0; font: 400 58px/.95 var(--serif); letter-spacing: -.02em; }
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; }
input { height: 48px; padding: 0 12px; }
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; line-height: 1.45; }
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
.chat-view { height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
#active-focus { color: var(--good); }
.cognitive-status { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.chat-header .cognitive-status { width: auto; height: auto; padding: 0; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.chat-header .cognitive-status { max-width: min(30vw, 320px); margin-left: auto; overflow: hidden; text-overflow: ellipsis; }
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--signal); }
.cognitive-status[data-state="available"] { color: var(--good); }
.cognitive-status[data-state="available"]::before { background: var(--good); }
.cognitive-status[data-state="unavailable"] { color: var(--bad); }
.cognitive-status[data-state="unavailable"]::before { background: var(--bad); }
.cognitive-console-link { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .08em; text-align: right; text-transform: uppercase; white-space: nowrap; }
.build-meta time { color: #68776d; font-size: 8px; }
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
.human-nav { display: grid; gap: 8px; }
.nav-tab, .operator-link { height: 34px; padding: 0 12px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--muted); background: #0b110e; text-decoration: none; font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.chat-left .nav-tab { height: auto; min-height: 58px; padding: 10px 12px; display: grid; grid-template-columns: 28px 1fr; gap: 2px 8px; align-items: center; text-align: left; }
.chat-left .nav-tab b { grid-row: span 2; color: var(--signal); font: 11px/1 var(--mono); }
.chat-left .nav-tab span { color: var(--ink); font: 600 13px/1 var(--sans); text-transform: none; letter-spacing: 0; }
.chat-left .nav-tab small { color: var(--muted); font: 10px/1 var(--mono); text-transform: none; letter-spacing: 0; }
.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); }
.operator-link { color: var(--good); }
.chat-header > .operator-link { flex: 0 0 auto; }
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) minmax(250px, 320px); }
.chat-side { min-width: 0; min-height: 0; padding: 28px 20px; overflow-y: auto; background: #0e1512; }
.chat-left { border-right: 1px solid var(--line); }
.chat-right { border-left: 1px solid var(--line); }
.side-intro h1, .side-intro h2 { margin: 12px 0; font: 400 clamp(28px, 2.5vw, 40px)/.95 var(--serif); letter-spacing: -.03em; }
.side-intro p:not(.side-index) { color: var(--muted); line-height: 1.5; }
.side-index, .side-focus span, .side-note span, .ai-info-card span { color: var(--signal); font: 10px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.side-focus, .side-note, .ai-info-card { margin-top: 28px; padding: 14px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.side-focus strong, .side-note strong, .ai-info-card strong { display: block; margin-top: 9px; color: var(--ink); font: 600 14px/1.3 var(--sans); }
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 9px; color: var(--muted); line-height: 1.45; }
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; }
.surface-panel { min-height: 0; position: relative; overflow-y: auto; }
.active-panel { display: grid; grid-template-rows: minmax(0, 1fr) auto auto; min-height: 0; }
.messages { width: min(100%, 920px); min-height: 0; margin: 0 auto; padding: 28px 18px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; }
.message { max-width: min(720px, 92%); padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius); line-height: 1.45; white-space: pre-wrap; }
.message.user { align-self: flex-end; background: #151e1a; }
.message.assistant { align-self: flex-start; background: #0f1613; }
.message small { display: block; margin-top: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; }
.composer { width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 10px; display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 12px; background: linear-gradient(transparent, var(--base) 20%); }
.composer button { height: 48px; align-self: end; }
.composer button:disabled { cursor: wait; filter: saturate(.3); }
.conversation-starters { width: min(100%, 920px); margin: -4px auto 0; padding: 0 18px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.conversation-starters button { min-height: 32px; padding: 0 10px; border: 1px solid var(--line); color: var(--ink); background: #101814; font: 10px/1 var(--mono); }
.conversation-starters button:hover, .conversation-starters button:focus-visible { border-color: var(--signal); color: var(--signal); outline: none; }
.ai-data-form { margin-top: 24px; }
.ai-data-form textarea { min-height: 150px; resize: vertical; }
.ai-data-form button { width: 100%; min-height: 42px; margin-top: 12px; border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
.ai-data-error { margin: 10px 0 0; color: var(--bad); font-size: 12px; line-height: 1.4; }
.side-console-link { display: flex; justify-content: space-between; margin-top: 26px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--signal) !important; font: 11px/1.3 var(--mono) !important; text-transform: uppercase; letter-spacing: .06em; }
.attention-surface, .history-surface { width: min(100%, 920px); margin: 0 auto; padding: 34px 18px; align-content: start; gap: 18px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 16px; }
.surface-heading p { margin: 0; font: 400 36px/.95 var(--serif); }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .1em; }
.surface-heading button { height: 36px; padding: 0 12px; border: 1px solid var(--line); background: transparent; }
.attention-list, .history-list { display: grid; gap: 12px; }
.attention-card, .history-item { border: 1px solid var(--line); background: #0f1613; padding: 16px; }
.attention-card time { color: var(--signal); font: 12px/1 var(--mono); }
.attention-card h2 { margin: 10px 0 8px; font: 400 24px/1.1 var(--serif); }
.attention-card p, .history-item p { margin: 0 0 10px; color: var(--ink); line-height: 1.45; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.attention-actions button { height: 34px; padding: 0 12px; border: 1px solid var(--line); background: #151e1a; color: var(--ink); font: 11px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.attention-actions button:first-child { border-color: var(--good); color: var(--good); }
.history-item.user { border-color: #34443c; background: #151e1a; }
.history-item strong { display: block; margin-bottom: 8px; color: var(--signal); }
.empty-state { margin: 24px 0 0; color: var(--muted); }
button { color: inherit; border: 0; border-radius: var(--radius); }
p { margin: 0; }
.assistant-shell,
.chat-view { min-height: 100vh; }
.auth-view {
min-height: 100vh;
display: grid;
place-items: center;
padding: 36px 20px;
}
.auth-card {
width: min(100%, 440px);
padding: 30px;
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: rgba(15, 21, 18, .92);
box-shadow: var(--shadow);
}
.brand-block,
.header-brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--base);
background: linear-gradient(145deg, var(--good), #7aa7bd);
font: 700 24px/1 var(--serif);
box-shadow: 0 0 0 4px rgba(124, 199, 164, .12);
}
h1 {
margin: 0;
font: 500 42px/1.05 var(--serif);
}
.brand-kicker {
margin: 0 0 4px;
color: var(--faint);
text-transform: uppercase;
letter-spacing: .1em;
font-size: 11px;
font-family: var(--mono);
}
.auth-copy {
margin: 18px 0 22px;
color: var(--muted);
line-height: 1.45;
}
label {
display: block;
margin: 0 0 6px;
color: var(--faint);
font-size: 12px;
letter-spacing: .03em;
}
input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
background: #080e0c;
}
input { height: 46px; padding: 0 14px; }
input:focus,
textarea:focus,
.primary-button:focus,
.ghost-button:focus,
.nav-tab:focus-visible,
button:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
textarea {
min-height: 96px;
max-height: 220px;
padding: 14px;
line-height: 1.45;
resize: vertical;
}
.input-with-action {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.icon-button,
.ghost-button,
.primary-button {
height: 44px;
border-radius: var(--radius);
}
.icon-button,
.ghost-button {
background: rgba(255, 255, 255, .05);
border: 1px solid var(--line);
color: var(--muted);
}
.primary-button {
background: var(--signal);
color: #111810;
font-weight: 700;
}
#login-button,
.primary-button { width: 100%; }
#login-button,
#send { margin-top: 14px; }
.login-form,
.composer {
display: grid;
gap: 12px;
}
#login-error {
min-height: 20px;
color: var(--bad);
}
.chat-view {
width: min(100%, 980px);
margin: 0 auto;
padding: 14px 16px 24px;
display: grid;
gap: 12px;
}
.assistant-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
}
.assistant-header h1 {
font-size: clamp(30px, 4.4vw, 38px);
letter-spacing: -.02em;
}
.session-state {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
padding: 9px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: #0f1512;
font-size: 12px;
}
.human-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px;
width: fit-content;
background: #0c1210;
}
.nav-tab {
height: 34px;
padding: 0 14px;
border-radius: 999px;
background: transparent;
color: var(--muted);
}
.nav-tab.active,
.nav-tab:hover {
background: var(--signal);
color: #11150f;
}
.surface-panel {
display: grid;
align-content: start;
min-height: 0;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, rgba(21, 29, 24, .86), rgba(15, 21, 18, .95));
box-shadow: var(--shadow);
overflow: hidden;
}
.surface-panel.active-panel {
display: grid;
}
.panel-toolbar {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: #0c1210;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-toolbar h2 {
margin: 0;
font: 500 26px/1.12 var(--serif);
}
.panel-toolbar p {
margin-top: 6px;
color: var(--muted);
line-height: 1.45;
}
.focus-chip {
margin: 8px 0 0;
font-size: 12px;
color: var(--good);
padding: 6px 10px;
border: 1px solid rgba(124, 199, 164, .45);
border-radius: 999px;
background: rgba(124, 199, 164, .12);
}
.messages {
padding: 24px 18px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
min-height: calc(100vh - 280px);
max-height: calc(100vh - 280px);
}
.message {
width: min(92%, 760px);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 14px;
line-height: 1.45;
white-space: pre-wrap;
background: #0f1512;
}
.message.assistant { align-self: flex-start; background: #121a16; }
.message.user { align-self: flex-end; background: #19231c; border-color: rgba(120, 191, 162, .4); }
.message-author { color: var(--faint); font-size: 11px; margin-bottom: 6px; font-weight: 700; }
.message.user .message-author { color: #b6ddb8; }
.message-time { margin-top: 8px; font-size: 11px; color: var(--faint); font-family: var(--mono); }
.message small { display: block; margin-top: 6px; color: var(--faint); }
.composer {
position: sticky;
bottom: 0;
padding: 14px 16px 16px;
border-top: 1px solid var(--line);
background: linear-gradient(transparent, rgba(11, 16, 14, .88));
grid-template-columns: minmax(0, 1fr) auto;
}
.composer-actions { display: grid; align-items: end; }
.composer button:disabled {
cursor: wait;
opacity: .7;
}
.surface-panel:not(.active-panel) { display: none; }
.attention-surface,
.history-surface { padding: 0 0 12px; }
.attention-list,
.history-list {
display: grid;
gap: 12px;
padding: 18px;
}
.attention-card,
.history-item {
border: 1px solid var(--line);
border-radius: 12px;
background: #0b110f;
padding: 14px;
}
.attention-card h3,
.history-item strong { margin: 0 0 8px; font: 600 15px/1.2 var(--serif); }
.attention-card time,
.history-item time,
.attention-card .muted,
.history-item .muted {
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.attention-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.attention-actions .ghost-button,
.action-row button {
height: 34px;
padding: 0 11px;
background: rgba(255, 255, 255, .06);
color: var(--ink);
}
.attention-actions .ghost-button {
border: 1px solid rgba(194, 168, 118, .48);
color: #f2e8d0;
}
.attention-actions .ghost-button.primary { border-color: transparent; background: var(--good); color: #11211b; }
.history-item {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.history-item p {
margin: 0 0 8px;
color: var(--ink);
white-space: pre-wrap;
}
.history-item .history-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
color: var(--faint);
font-family: var(--mono);
font-size: 11px;
}
.history-item .message-kind {
text-transform: uppercase;
letter-spacing: .08em;
color: var(--signal);
}
.status-bar {
margin: 8px auto 0;
max-width: 980px;
color: var(--faint);
padding: 10px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(15, 21, 18, .8);
min-height: 18px;
visibility: hidden;
}
.status-bar.visible { visibility: visible; }
.composer-error {
position: fixed;
right: 16px;
bottom: 16px;
max-width: min(420px, calc(100vw - 32px));
border-radius: 12px;
border: 1px solid rgba(216, 141, 120, .36);
background: #241715;
color: #ffd8ca;
padding: 10px 12px;
transform: translateY(16px);
opacity: 0;
transition: .2s ease;
}
.composer-error.show {
transform: translateY(0);
opacity: 1;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
.empty-state {
color: var(--muted);
padding: 18px;
}
.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
.composer { grid-template-columns: 1fr; }
.composer button { width: 100%; }
.conversation-starters { align-items: stretch; }
.conversation-starters span { width: 100%; }
.chat-header { gap: 10px; padding: 0 12px; }
.chat-header .cognitive-status { display: inline-flex; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
.build-meta { font-size: 8px; letter-spacing: .04em; }
.build-meta time { font-size: 7px; }
.chat-layout { height: auto; display: block; }
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
.chat-main { height: 70vh; min-height: 520px; }
.chat-left { padding-bottom: 20px; }
.chat-right { padding-top: 20px; }
.operator-link, .nav-tab { padding: 0 9px; }
}
@media (min-width: 641px) and (max-width: 1100px) {
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
.chat-side { padding-inline: 14px; }
.side-intro h1, .side-intro h2 { font-size: 30px; }
@media (max-width: 720px) {
.chat-view {
padding: 10px 10px 18px;
gap: 10px;
}
.assistant-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.session-state { width: 100%; justify-content: space-between; }
.messages {
min-height: calc(100vh - 320px);
max-height: calc(100vh - 320px);
}
.composer {
grid-template-columns: 1fr;
}
.panel-toolbar {
flex-direction: column;
}
.attention-list,
.history-list {
padding: 12px;
}
}
+416 -222
View File
@@ -2,316 +2,518 @@ const loginView = document.querySelector("#login-view");
const chatView = document.querySelector("#chat-view");
const loginForm = document.querySelector("#login-form");
const loginError = document.querySelector("#login-error");
const togglePassword = document.querySelector("#toggle-password");
const usernameInput = document.querySelector("#username");
const passwordInput = document.querySelector("#password");
const messages = document.querySelector("#messages");
const chatForm = document.querySelector("#chat-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const logoutButton = document.querySelector("#logout");
const clearHistoryButton = document.querySelector("#clear-history");
const activeFocus = document.querySelector("#active-focus");
const cognitiveStatus = document.querySelector("#cognitive-status");
const cognitiveConsoleLink = document.querySelector("#cognitive-console-link");
const todayList = document.querySelector("#today-list");
const todaySubtitle = document.querySelector("#today-subtitle");
const refreshToday = document.querySelector("#refresh-today");
const historyList = document.querySelector("#history-list");
const navTabs = Array.from(document.querySelectorAll(".nav-tab"));
const conversationStarters = Array.from(document.querySelectorAll("[data-starter]"));
const aiDataForm = document.querySelector("#ai-data-form");
const aiDataInput = document.querySelector("#ai-data");
const aiDataError = document.querySelector("#ai-data-error");
const statusPill = document.querySelector("#connection-state");
const statusBar = document.querySelector("#status-bar");
const composerError = document.querySelector("#composer-error");
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
const SESSION_KEY = "domovoy.chat.token";
const CONVERSATION_KEY = "domovoy.chat.conversation";
const SECTION_KEY = "domovoy.chat.section";
let token = sessionStorage.getItem(SESSION_KEY) || "";
let conversationID = sessionStorage.getItem(CONVERSATION_KEY) || "";
let localHistory = [];
let cognitiveStatusTimer = null;
let todayRequestInFlight = false;
class ChatApiError extends Error {
constructor(status, detail = "") {
super(detail || `chat api returned ${status}`);
this.name = "ChatApiError";
this.status = status;
function setConnectionState(text, tone = "neutral") {
if (!statusPill) return;
statusPill.textContent = text;
if (tone === "bad") {
statusPill.style.borderColor = "rgba(216, 141, 120, .6)";
statusPill.style.color = "var(--bad)";
} else {
statusPill.style.borderColor = "var(--line)";
statusPill.style.color = "var(--muted)";
}
}
function containsSecretShapedText(value) {
return /(-----BEGIN [A-Z ]+PRIVATE KEY-----|\b(?:password|passwd|пароль|token|токен|api[_ -]?key|секрет|ключ)\s*[:=]|\bsk-[a-zA-Z0-9_-]{12,})/i.test(value);
function showToast(message) {
if (!statusBar) return;
statusBar.textContent = message;
statusBar.classList.add("visible");
clearTimeout(showToast._timer);
showToast._timer = setTimeout(() => {
statusBar.classList.remove("visible");
}, 2800);
}
function showAIDataError(message = "") {
if (!aiDataError) return;
aiDataError.textContent = message;
aiDataError.hidden = !message;
function showComposerError(message) {
if (!composerError) return;
composerError.textContent = message;
composerError.classList.add("show");
clearTimeout(showComposerError._timer);
showComposerError._timer = setTimeout(() => {
composerError.classList.remove("show");
}, 2600);
}
function renderCognitiveStatus(state) {
if (!cognitiveStatus) return;
const normalized = String(state || "UNKNOWN").toUpperCase();
let displayState = "degraded";
if (normalized === "AVAILABLE") displayState = "available";
if (normalized === "UNAVAILABLE") displayState = "unavailable";
const labels = {
available: "канал мышления доступен",
degraded: "канал мышления работает с ограничениями",
unavailable: "Домовой рядом · основной канал недоступен",
};
cognitiveStatus.dataset.state = displayState;
cognitiveStatus.textContent = labels[displayState];
cognitiveStatus.title = displayState === "available" ? "Канал доступен. Нажмите, чтобы проверить ещё раз" : "Проверить канал ещё раз";
if (cognitiveConsoleLink) cognitiveConsoleLink.hidden = displayState === "available";
}
async function loadCognitiveStatus() {
if (!cognitiveStatus || !token) return;
cognitiveStatus.dataset.state = "checking";
cognitiveStatus.textContent = "проверяю канал мышления";
try {
const result = await api("/api/capabilities/cognitive-proposal/health", {method: "GET"});
renderCognitiveStatus(result.State || result.state || "UNKNOWN");
} catch (error) {
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
return;
}
renderCognitiveStatus("UNAVAILABLE");
}
}
function startCognitiveStatusPolling() {
if (cognitiveStatusTimer) return;
loadCognitiveStatus();
cognitiveStatusTimer = window.setInterval(loadCognitiveStatus, 30000);
}
function stopCognitiveStatusPolling() {
if (!cognitiveStatusTimer) return;
window.clearInterval(cognitiveStatusTimer);
cognitiveStatusTimer = null;
function formatTime(value = new Date()) {
return new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(value);
}
function showChat() {
setConnectionState("Сессия активна");
loginView.hidden = true;
chatView.hidden = false;
if (!messages.children.length) {
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
appendMessage("assistant", "Я рядом. Пишите просто и по-человечески, я всё пойму.");
}
startCognitiveStatusPolling();
const storedSection = localStorage.getItem(SECTION_KEY);
activateTab(storedSection === "today-panel" || storedSection === "history-panel" ? storedSection : "chat-panel");
messageInput.focus();
}
function showLogin() {
stopCognitiveStatusPolling();
setConnectionState("Вход не выполнен", "bad");
chatView.hidden = true;
loginView.hidden = false;
usernameInput?.focus();
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("div");
node.className = `message ${kind}`;
node.textContent = text;
if (meta) {
const small = document.createElement("small");
small.textContent = meta;
node.appendChild(small);
}
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({kind, text, meta, at: new Date()});
renderHistory();
}
function updateActiveFocus(value, provenance = "") {
if (!activeFocus) return;
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
activeFocus.hidden = !value;
}
async function api(path, options = {}) {
const headers = {"Content-Type": "application/json", ...(options.headers || {})};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(path, {...options, headers});
if (!response.ok) throw new ChatApiError(response.status, await response.text());
return response.json();
}
function expireSession(message = "") {
function endSession(message = "Сессия завершена") {
token = "";
conversationID = "";
sessionStorage.removeItem("domovoy.chat.token");
sessionStorage.removeItem("domovoy.chat.conversation");
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(CONVERSATION_KEY);
messages.replaceChildren();
localHistory = [];
updateActiveFocus("");
if (message) loginError.textContent = message;
setConnectionState("Вход не выполнен", "bad");
showToast(message);
showLogin();
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (token) headers.set("Authorization", `Bearer ${token}`);
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(path, { ...options, headers });
if (response.status === 401 && path !== "/api/session") {
endSession("Сессия просрочена, войдите снова");
throw new Error("Требуется авторизация");
}
if (!response.ok) {
const detail = (await response.text()).trim();
throw new Error(detail || `Ошибка ${response.status}`);
}
return response.json();
}
function showError(message) {
showComposerError(message || "Произошла ошибка");
}
function appendMessage(kind, text, meta = "") {
const node = document.createElement("article");
node.className = `message ${kind}`;
const who = document.createElement("strong");
who.className = "message-author";
who.textContent = kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = text;
node.append(who, body);
if (meta) {
const metaNode = document.createElement("small");
metaNode.textContent = meta;
node.appendChild(metaNode);
}
const when = document.createElement("time");
when.className = "message-time";
when.textContent = formatTime();
node.appendChild(when);
messages.appendChild(node);
messages.scrollTop = messages.scrollHeight;
localHistory.push({
kind,
text,
meta,
at: new Date(),
});
if (localHistory.length > 120) {
localHistory = localHistory.slice(-120);
}
renderHistory();
}
function updateActiveFocus(value) {
if (!activeFocus) return;
if (value) {
activeFocus.textContent = `Сейчас обсуждаем: ${value}`;
activeFocus.hidden = false;
} else {
activeFocus.hidden = true;
activeFocus.textContent = "";
}
}
function humanMeta(result) {
if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам.";
if (result.delegation_id && result.delegation_state) return `Передал в coding-agent: ${humanDelegationState(result.delegation_state)}.`;
if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена.";
if (result.work_planned) return "Я разложил это как работу.";
if (result.reminder_scheduled) {
return "Я зафиксировал напоминание и сообщу, когда наступит время.";
}
if (result.delegation_id && result.delegation_state) {
return `Передал дальше: ${humanDelegationState(result.delegation_state)}.`;
}
if (result.capability_status && result.capability_status !== "AVAILABLE") {
return "Эта задача сейчас проверяется отдельно.";
}
if (result.work_planned) {
return "Я взял задачу в работу и отслежу статус.";
}
return "";
}
function humanDelegationState(state) {
switch (state) {
case "RUNNING":
return "работа запущена";
return "выполняется";
case "WAITING":
return "ожидаю следующий шаг";
return "ожидает следующего шага";
case "BLOCKED":
return "нужно решение человека";
return "нужен человеческий выбор";
case "VERIFYING":
return "результат проверяется";
return "результат на проверке";
case "COMPLETED":
return "работа завершена";
return "готово";
case "FAILED":
return "run завершился ошибкой";
return "завершилось ошибкой";
default:
return state.toLowerCase();
return String(state || "обрабатывается").toLowerCase();
}
}
function renderHistory() {
if (!historyList) return;
historyList.replaceChildren();
const items = localHistory.slice(-30).reverse();
const items = localHistory.slice(-50).slice().reverse();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "История появится после сообщений в этом окне.";
empty.textContent = "История появится после первых сообщений в этом окне.";
historyList.appendChild(empty);
return;
}
for (const item of items) {
items.forEach((item) => {
const row = document.createElement("article");
row.className = `history-item ${item.kind}`;
const title = document.createElement("strong");
title.textContent = item.kind === "user" ? "Вы" : "Домовой";
row.className = "history-item";
const who = document.createElement("strong");
who.className = "message-kind";
who.textContent = item.kind === "user" ? "Ты" : "Домовой";
const body = document.createElement("p");
body.textContent = item.text;
const time = document.createElement("span");
time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
row.append(title, body, time);
const footer = document.createElement("div");
footer.className = "history-meta";
const time = document.createElement("time");
time.textContent = formatTime(item.at);
const meta = document.createElement("span");
meta.textContent = item.meta || "";
footer.append(time, meta);
row.append(who, body, footer);
historyList.appendChild(row);
});
}
function createTodayCard(item) {
const card = document.createElement("article");
card.className = "attention-card";
const title = document.createElement("h3");
const when = document.createElement("time");
const state = document.createElement("p");
const source = document.createElement("p");
const hint = document.createElement("p");
const actions = document.createElement("div");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp
? new Date(stamp).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "текущий момент";
when.className = "muted";
title.textContent = item.Title || item.title || "Событие";
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
state.className = "muted";
const sourceText = [
item.SourceDescription || item.source_description,
item.DeliveryState || item.delivery_state,
]
.filter(Boolean)
.join(" · ");
source.textContent = sourceText || "Источник не задан";
source.className = "muted";
const hintText = item.ResponseHint || item.response_hint || "";
if (hintText) {
hint.textContent = hintText;
}
actions.className = "attention-actions";
const quick = [
{ label: "Готово", text: "Готово", tone: "primary" },
{ label: "Позже", text: "Отложи на 10 минут" },
{ label: "Отменить", text: "Отмени" },
];
quick.forEach((action) => {
const actionButton = document.createElement("button");
actionButton.type = "button";
actionButton.className = `ghost-button ${action.tone || ""}`.trim();
actionButton.textContent = action.label;
actionButton.addEventListener("click", async () => {
const previous = actionButton.textContent;
actionButton.disabled = true;
actionButton.textContent = "Отправляю…";
try {
await sendChatMessage(action.text, { silentUser: false });
await loadToday();
activateTab("chat-panel");
} finally {
actionButton.disabled = false;
actionButton.textContent = previous;
}
});
actions.appendChild(actionButton);
});
card.append(title, when, state, source);
if (hintText) card.append(hint);
card.append(actions);
return card;
}
function renderTodayEmpty(message) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = message;
todayList.appendChild(empty);
}
async function loadToday() {
if (!todayList || !token) return;
if (!todayList || todayRequestInFlight) return;
todayRequestInFlight = true;
todayList.replaceChildren();
const loading = document.createElement("p");
loading.className = "empty-state";
loading.textContent = "Смотрю, что требует внимания...";
todayList.appendChild(loading);
renderTodayEmpty("Собираю актуальные напоминания…");
try {
const result = await api("/api/today", {method: "GET"});
todayList.replaceChildren();
const result = await api("/api/today", { method: "GET" });
const items = result.Items || result.items || [];
todayList.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "Сейчас ничего срочного не требует внимания.";
todayList.appendChild(empty);
todaySubtitle.textContent = "Сейчас спокойный период. Нечего тревожного";
renderTodayEmpty("Сейчас ничего срочного не требует внимания.");
return;
}
todaySubtitle.textContent = `Найдено записей: ${items.length}`;
for (const item of items) {
const card = document.createElement("article");
card.className = "attention-card";
const when = document.createElement("time");
const stamp = item.WhenUnixMillis || item.when_unix_millis || 0;
when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас";
const title = document.createElement("h2");
title.textContent = item.Title || item.title || "Событие Домового";
const state = document.createElement("p");
state.textContent = item.HumanState || item.human_state || "ожидает внимания";
const source = document.createElement("small");
source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · ");
const hintText = item.ResponseHint || item.response_hint || "";
const actions = document.createElement("div");
actions.className = "attention-actions";
for (const [label, text] of [["Готово", "Готово"], ["Отложить", "Отложи на 10 минут"], ["Отменить", "Отмени"]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.addEventListener("click", async () => {
await sendChatMessage(text, {silentUser: false});
await loadToday();
activateTab("chat-panel");
});
actions.appendChild(button);
}
if (hintText) {
const hint = document.createElement("small");
hint.textContent = hintText;
card.append(when, title, state, source, hint, actions);
} else {
card.append(when, title, state, source, actions);
}
todayList.appendChild(card);
todayList.appendChild(createTodayCard(item));
}
} catch {
} catch (error) {
todayList.replaceChildren();
const error = document.createElement("p");
error.className = "empty-state error";
error.textContent = "Внимание сейчас недоступно. Чат можно продолжать.";
todayList.appendChild(error);
const err = document.createElement("p");
err.className = "empty-state error";
err.textContent = error.message || "Сегодня недоступен на мгновение.";
todayList.appendChild(err);
todaySubtitle.textContent = "Не удалось обновить список.";
} finally {
todayRequestInFlight = false;
}
}
async function sendChatMessage(text, options = {}) {
const clean = text.trim();
if (!clean) return;
if (!options.silentUser) appendMessage("user", clean);
if (!options.silentUser) {
appendMessage("user", clean);
}
sendButton.disabled = true;
messageInput.disabled = true;
const originalLabel = sendButton.textContent;
sendButton.textContent = "Отправляю…";
try {
const result = await api("/api/chat/messages", {method: "POST", body: JSON.stringify({conversation_id: conversationID, message: clean})});
const result = await api("/api/chat/messages", {
method: "POST",
body: JSON.stringify({
conversation_id: conversationID,
message: clean,
}),
});
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
if (conversationID) {
sessionStorage.setItem(CONVERSATION_KEY, conversationID);
}
updateActiveFocus(result.active_focus);
appendMessage("assistant", result.reply_text || "Принял тебя. Подготовлю ответ.", humanMeta(result));
if (/что сейчас требует моего внимания/i.test(clean)) {
await loadToday();
activateTab("today-panel");
}
showToast("Сообщение отправлено");
} catch (error) {
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
} else if (error instanceof ChatApiError && error.status >= 500) {
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
} else {
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
}
appendMessage("assistant", "Я не могу отправить сообщение в этот момент. Проверьте сеть и авторизацию.");
showError(error.message);
} finally {
sendButton.disabled = false;
messageInput.focus();
sendButton.textContent = originalLabel;
messageInput.disabled = false;
if (!options.silentUser) {
messageInput.focus();
}
}
}
function activateTab(panelID) {
for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID);
for (const panel of document.querySelectorAll(".surface-panel")) {
let activeSet = false;
navTabs.forEach((tab) => {
const isActive = tab.dataset.tab === panelID;
tab.classList.toggle("active", isActive);
tab.setAttribute("aria-selected", String(isActive));
tab.tabIndex = isActive ? 0 : -1;
if (isActive) activeSet = true;
});
document.querySelectorAll(".surface-panel").forEach((panel) => {
const active = panel.id === panelID;
panel.hidden = !active;
panel.classList.toggle("active-panel", active);
});
if (!activeSet) return;
localStorage.setItem(SECTION_KEY, panelID);
if (panelID === "today-panel") {
loadToday();
}
if (panelID === "history-panel") {
renderHistory();
}
}
function navigateTabs(event) {
const current = navTabs.findIndex((tab) => tab.classList.contains("active"));
if (current === -1) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
const index = (current - 1 + navTabs.length) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (event.key === "ArrowRight") {
event.preventDefault();
const index = (current + 1) % navTabs.length;
activateTab(navTabs[index].dataset.tab);
navTabs[index].focus();
}
if (panelID === "today-panel") loadToday();
if (panelID === "history-panel") renderHistory();
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const loginButton = document.querySelector("#login-button");
loginError.textContent = "";
const username = document.querySelector("#username").value;
const password = document.querySelector("#password").value;
loginButton.disabled = true;
loginButton.textContent = "Проверяю…";
try {
const result = await api("/api/session", {method: "POST", body: JSON.stringify({username, password})});
token = result.Token || result.token;
sessionStorage.setItem("domovoy.chat.token", token);
const result = await api("/api/session", {
method: "POST",
body: JSON.stringify({ username, password }),
});
token = result.Token || result.token || "";
if (!token) {
throw new Error("Пустой токен");
}
sessionStorage.setItem(SESSION_KEY, token);
conversationID = "";
sessionStorage.removeItem(CONVERSATION_KEY);
showChat();
} catch {
loginError.textContent = "Вход не выполнен";
loginError.textContent = "Не удалось войти. Проверьте логин и пароль.";
showToast("Не удалось войти");
} finally {
loginButton.disabled = false;
loginButton.textContent = "Открыть чат";
}
});
togglePassword?.addEventListener("click", () => {
const isPassword = passwordInput.type === "password";
passwordInput.type = isPassword ? "text" : "password";
togglePassword.textContent = isPassword ? "скрыть" : "показать";
togglePassword.setAttribute("aria-label", isPassword ? "Скрыть пароль" : "Показать пароль");
});
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const text = messageInput.value.trim();
@@ -320,39 +522,31 @@ chatForm.addEventListener("submit", async (event) => {
await sendChatMessage(text);
});
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
}
for (const starter of conversationStarters) {
starter.addEventListener("click", async () => {
const text = starter.dataset.starter || "";
if (!text || sendButton.disabled) return;
messageInput.value = "";
await sendChatMessage(text);
});
}
if (refreshToday) refreshToday.addEventListener("click", loadToday);
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
if (aiDataForm) {
aiDataForm.addEventListener("submit", async (event) => {
messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
const details = aiDataInput.value.trim();
if (!details || sendButton.disabled) return;
if (containsSecretShapedText(details)) {
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
return;
}
showAIDataError();
aiDataInput.value = "";
await sendChatMessage(`Передаю сведения о локальном ИИ для текущего разговора:\n${details}`);
});
}
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
chatForm.requestSubmit();
}
});
logoutButton.addEventListener("click", () => {
expireSession();
for (const tab of navTabs) {
tab.addEventListener("click", () => {
activateTab(tab.dataset.tab);
});
tab.addEventListener("keydown", navigateTabs);
}
refreshToday?.addEventListener("click", loadToday);
clearHistoryButton?.addEventListener("click", () => {
localHistory = [];
messages.replaceChildren();
renderHistory();
showToast("История очищена");
});
logoutButton?.addEventListener("click", () => {
endSession("Вы вышли из сеанса");
});
if (token) {
+14 -153
View File
@@ -1,12 +1,12 @@
:root {
--ink: #eef2ec;
--muted: #a7b2aa;
--faint: #718078;
--base: #0f1512;
--panel: #17201b;
--panel-high: #202b25;
--line: #34423a;
--line-soft: #26332c;
--ink: #e9eee7;
--muted: #8c9a91;
--faint: #58645d;
--base: #0b100e;
--panel: #101714;
--panel-high: #151e1a;
--line: #2a352f;
--line-soft: #1d2822;
--signal: #c9a85f;
--signal-bright: #e7c97b;
--good: #78c6a3;
@@ -21,15 +21,13 @@
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { background: var(--base); color: var(--ink); font-family: var(--sans); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 73% -10%, rgba(83, 123, 101, .13), transparent 38%), var(--base); }
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, textarea { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--signal-bright); outline-offset: 3px; }
.skip-link { position: fixed; top: 10px; left: 10px; z-index: 30; padding: 10px 13px; color: #11150f; background: var(--signal-bright); font: 600 10px/1 var(--mono); text-decoration: none; transform: translateY(-160%); transition: transform .18s; }
.skip-link:focus { transform: translateY(0); }
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: .045; 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"); }
.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; }
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px 20px; position: relative; overflow: hidden; }
@@ -65,10 +63,6 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
.wordmark strong { font: 400 20px/1 var(--serif); }
.wordmark small { margin-top: 4px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.chat-return-link { color: var(--signal); text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; }
.topbar-nav { display: flex; align-items: center; gap: 4px; margin-right: 8px; }
.topbar-nav a { padding: 9px 10px; color: var(--muted); text-decoration: none; font: 600 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.topbar-nav a:hover, .topbar-nav a:focus-visible { color: var(--ink); background: var(--panel-high); outline: 0; }
.connection { margin-right: 12px; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.connection i { width: 7px; height: 7px; display: inline-block; margin-right: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 14px rgba(120, 198, 163, .6); }
.connection.offline i { background: var(--bad); box-shadow: none; }
@@ -79,55 +73,17 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
.context-rail h2 { margin: 0; font: 400 29px/1.1 var(--serif); }
.rail-copy { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.context-form { display: grid; }
.context-status-row { margin-bottom: 16px; padding: 10px 11px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--line-soft); background: rgba(120,198,163,.04); }
.context-status-label { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; }
.context-status-row strong { color: var(--warn); font: 600 9px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.context-status-row strong[data-state="ready"] { color: var(--good); }
.context-presets { display: grid; gap: 7px; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--line-soft); }
.context-presets label { margin: 0; }
.context-presets select { width: 100%; min-height: 38px; padding: 0 9px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 10px/1 var(--mono); }
.context-presets small { color: var(--faint); font-size: 10px; line-height: 1.35; }
.context-field { display: grid; gap: 7px; }
.context-field + .context-field { margin-top: 13px; }
.context-field label { margin: 0; }
.context-field label span { color: var(--signal); font-size: 8px; letter-spacing: .06em; }
.context-input-row { display: grid; grid-template-columns: minmax(0, 1fr) 34px; gap: 5px; }
.context-input-row input { min-width: 0; }
.copy-context { width: 34px; height: 40px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; }
.copy-context:hover { color: var(--ink); border-color: var(--faint); background: var(--panel-high); }
.id-actions { min-height: 31px; display: flex; align-items: center; gap: 7px; margin-top: 12px; }
.id-action { min-height: 30px; padding: 0 9px; border: 1px solid var(--line-soft); border-radius: var(--radius); color: var(--muted); background: transparent; cursor: pointer; font: 9px/1 var(--mono); letter-spacing: .04em; }
.id-action:hover { color: var(--ink); border-color: var(--signal); background: rgba(201,168,95,.07); }
.icon-id-action { width: 30px; padding: 0; color: var(--faint); }
.context-form label { margin-top: 15px; }
.context-form label:first-child { margin-top: 0; }
.context-form input { height: 40px; padding: 0 10px; font: 11px/1 var(--mono); }
.context-hint { margin: 12px 0 0; color: var(--faint); font-size: 10px; line-height: 1.45; }
.advanced-context { margin-top: 17px; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.advanced-context summary { padding: 12px 0; color: var(--muted); cursor: pointer; list-style: none; font: 600 9px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.advanced-context summary::before { content: "+"; display: inline-block; width: 18px; color: var(--signal); font-size: 13px; }
.advanced-context[open] summary::before { content: "−"; }
.advanced-context > div { padding: 0 0 10px; }
.advanced-context label { margin-top: 13px; }
.context-actions { display: grid; gap: 8px; }
.context-actions .secondary-button { margin-top: 18px; }
.context-actions .text-button { margin: 0; justify-self: start; padding-inline: 0; }
.secondary-button { height: 42px; margin-top: 20px; color: #11150f; background: var(--signal); font-size: 12px; }
.section-nav { display: grid; gap: 3px; margin-top: 4px; }
.section-nav-link { display: grid; grid-template-columns: 28px 1fr; gap: 2px 10px; padding: 11px 10px; border-left: 2px solid transparent; color: var(--muted); text-decoration: none; transition: background .18s, border-color .18s, color .18s; }
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="location"] { border-left-color: var(--signal); color: var(--ink); background: rgba(201,168,95,.07); outline: 0; }
.section-nav-link span { grid-row: 1 / span 2; color: var(--signal); font: 9px/1.4 var(--mono); }
.section-nav-link b { font: 600 11px/1.2 var(--sans); }
.section-nav-link small { color: var(--faint); font: 9px/1.2 var(--mono); }
.session-stamp { margin-top: auto; padding-top: 20px; display: grid; gap: 6px; border-top: 1px solid var(--line); }
.session-stamp span { margin-top: 8px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .14em; }
.session-stamp strong { overflow: hidden; color: var(--muted); font: 10px/1.4 var(--mono); text-overflow: ellipsis; }
.workspace { grid-column: 2; grid-row: 2; width: min(100%, 1740px); justify-self: start; padding: 42px clamp(24px, 3vw, 48px) 64px; min-width: 0; }
.workspace > [data-page][hidden] { display: none !important; }
.workspace > section { scroll-margin-top: 94px; }
.workspace { grid-column: 2; grid-row: 2; padding: clamp(28px, 4vw, 58px); min-width: 0; }
.workspace-head { display: flex; align-items: end; justify-content: space-between; gap: 24px; margin-bottom: 34px; }
.workspace-head h1 { margin: 0; font: 400 clamp(34px, 4vw, 50px)/1 var(--serif); letter-spacing: -.035em; }
.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(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); }
@@ -138,46 +94,6 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
.signal-strip strong[data-state="error"] { color: var(--bad); }
.signal-strip strong[data-state="loading"] { color: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.diagnostics-panel, .settings-panel { margin-bottom: 18px; border: 1px solid var(--line); background: rgba(16,23,20,.82); }
.diagnostics-panel { border-color: rgba(120,198,163,.32); box-shadow: inset 3px 0 0 rgba(120,198,163,.45); }
.panel-heading { min-height: 68px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.panel-heading p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
.panel-heading h2 { margin: 0; font: 400 23px/1 var(--serif); }
.panel-heading-tools { display: flex; align-items: center; gap: 14px; }
.panel-heading-tools > span, .settings-saved { color: var(--muted); font: 9px/1.3 var(--mono); letter-spacing: .06em; text-transform: uppercase; }
.compact-button { width: auto; min-width: 142px; height: 38px; margin: 0; padding: 0 13px; }
.diagnostic-intro { padding: 13px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line-soft); color: var(--muted); }
.diagnostic-intro p { margin: 0; font-size: 11px; line-height: 1.45; }
.diagnostic-pulse { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: var(--good); box-shadow: 0 0 12px rgba(120,198,163,.7); }
.diagnostic-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.diagnostic-list[aria-busy="true"] { opacity: .7; }
.diagnostic-item { min-width: 0; min-height: 92px; padding: 14px 16px; display: grid; grid-template-columns: 9px 1fr auto; gap: 9px; align-items: start; border-right: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
.diagnostic-item:nth-child(4n) { border-right: 0; }
.diagnostic-item > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--faint); }
.diagnostic-item[data-state="ok"] > i { background: var(--good); box-shadow: 0 0 9px rgba(120,198,163,.6); }
.diagnostic-item[data-state="error"] > i { background: var(--bad); }
.diagnostic-item[data-state="loading"] > i { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.diagnostic-item strong { display: block; color: var(--ink); font: 600 10px/1.3 var(--mono); }
.diagnostic-item span { display: block; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.35; }
.diagnostic-item em { color: var(--faint); font: normal 9px/1.2 var(--mono); text-transform: uppercase; }
.diagnostic-item[data-state="ok"] em { color: var(--good); }
.diagnostic-item[data-state="error"] em { color: var(--bad); }
.settings-grid { padding: 18px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 20px; }
.setting-field, .setting-check { min-width: 0; display: grid; gap: 7px; color: var(--ink); }
.setting-field > span, .setting-check b { font: 600 10px/1.2 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.setting-field small, .setting-check small { color: var(--muted); font-size: 11px; line-height: 1.4; }
.setting-field select { width: 100%; min-height: 40px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: #0b110e; font: 11px/1 var(--mono); }
.setting-check { grid-template-columns: 18px 1fr; align-items: start; gap: 10px; padding: 10px 12px; border: 1px solid var(--line-soft); }
.setting-check input { width: 16px; height: 16px; margin: 2px 0 0; accent-color: var(--signal); }
.setting-check span { display: grid; gap: 7px; }
.settings-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; grid-column: 1 / -1; padding-top: 2px; }
.settings-actions .text-button { margin: 0; }
html[data-density="compact"] .data-card { padding: 18px; }
html[data-density="compact"] .data-card dl div { padding-block: 10px; }
html[data-density="compact"] .activity-row { min-height: 58px; padding-block: 8px; }
html[data-density="compact"] .learning-row { min-height: 54px; padding-block: 9px; }
html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before, html[data-reduced-motion="true"] *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
.advisor-panel { margin-bottom: 18px; padding: 24px; display: grid; grid-template-columns: minmax(220px, .72fr) minmax(320px, 1.28fr); gap: 18px 34px; border: 1px solid rgba(201,168,95,.42); background: linear-gradient(125deg, rgba(201,168,95,.1), rgba(16,23,20,.88) 44%, rgba(12,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.58); }
.advisor-intro { align-self: center; }
.advisor-intro > p { margin: 0 0 10px; color: var(--signal); font: 600 9px/1 var(--mono); letter-spacing: .15em; }
@@ -253,40 +169,6 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
.activity-reconcile:disabled { opacity: .55; cursor: wait; }
.activity-error { color: var(--bad); }
.fleet-panel { margin-bottom: 18px; border: 1px solid rgba(201,168,95,.32); background: linear-gradient(135deg, rgba(31,27,18,.92), rgba(13,20,17,.96)); box-shadow: inset 3px 0 0 rgba(201,168,95,.45); }
.fleet-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.fleet-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
.fleet-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
.fleet-overview { padding: 16px 18px; display: flex; align-items: end; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line-soft); }
.fleet-overview span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.fleet-overview strong { display: block; margin-top: 6px; color: var(--signal); font: 600 16px/1 var(--mono); }
.fleet-overview p { max-width: 620px; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
.fleet-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.fleet-list[aria-busy="true"] { opacity: .72; }
.fleet-row { min-width: 0; padding: 14px 18px; display: grid; gap: 7px; border-right: 1px solid var(--line-soft); }
.fleet-row:last-child { border-right: 0; }
.fleet-row strong, .fleet-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fleet-row strong { color: var(--ink); font: 600 11px/1.3 var(--mono); }
.fleet-row span { color: var(--muted); font: 9px/1.4 var(--mono); }
.fleet-row .learning-state[data-state="READY"] { color: var(--good); }
.fleet-row .learning-state[data-state="DEGRADED"] { color: var(--warn); }
.agent-panel { margin-bottom: 18px; border: 1px solid rgba(120,198,163,.3); background: rgba(13,20,17,.92); box-shadow: inset 3px 0 0 rgba(120,198,163,.38); }
.agent-panel > header { min-height: 66px; padding: 13px 18px; display: flex; align-items: center; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--line); }
.agent-panel header p { margin: 0 0 6px; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .13em; text-transform: uppercase; }
.agent-panel h2 { margin: 0; font: 400 22px/1 var(--serif); }
.agent-empty { padding: 20px 18px; color: var(--muted); font: 10px/1.5 var(--mono); }
.agent-details { padding: 16px 18px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
.agent-details div { min-width: 0; }
.agent-details span { display: block; color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.agent-details strong { display: block; margin-top: 7px; overflow: hidden; color: var(--ink); font: 600 10px/1.4 var(--mono); text-overflow: ellipsis; white-space: nowrap; }
.agent-details p { grid-column: 1 / -1; margin: 0; padding-top: 12px; border-top: 1px solid var(--line-soft); color: var(--muted); font: 10px/1.5 var(--mono); }
.agent-worker-status { margin: 0 18px 16px; padding-top: 13px; display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; border-top: 1px solid var(--line-soft); }
.agent-worker-status span { color: var(--faint); font: 9px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.agent-worker-status strong { color: var(--warn); font: 600 10px/1.3 var(--mono); }
.agent-worker-status strong[data-state="ready"] { color: var(--good); }
.agent-worker-status p { grid-column: 1 / -1; margin: 0; color: var(--muted); font: 10px/1.45 var(--mono); }
.learning-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
.learning-grid article { min-width: 0; border-right: 1px solid var(--line-soft); }
.learning-grid article:last-child { border-right: 0; }
@@ -396,9 +278,6 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
.advisor-panel { grid-template-columns: 1fr; }
.capability-card { grid-column: auto; }
.prospective-card { grid-column: auto; }
.diagnostic-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.diagnostic-item:nth-child(4n) { border-right: 1px solid var(--line-soft); }
.diagnostic-item:nth-child(2n) { border-right: 0; }
}
@media (max-width: 680px) {
@@ -406,26 +285,14 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
.console { display: block; }
.topbar { height: 66px; padding: 0 14px; }
.wordmark small, .connection, .text-button { display: none; }
.chat-return-link { display: inline-block; }
.topbar-nav { display: none; }
.context-rail { padding: 24px 18px; border-right: 0; border-bottom: 1px solid var(--line); }
.rail-copy { max-width: 48ch; }
.session-stamp { margin-top: 8px; grid-template-columns: auto 1fr; align-items: center; }
.session-stamp span { margin: 0; }
.workspace { padding: 30px 16px 50px; }
.section-nav { display: flex; gap: 6px; margin: 10px -18px -24px; padding: 10px 18px; overflow-x: auto; border-top: 1px solid var(--line-soft); background: #0e1512; scrollbar-width: thin; }
.section-nav-link { min-width: 142px; padding: 10px; border-left: 0; border-bottom: 2px solid transparent; }
.section-nav-link:hover, .section-nav-link:focus-visible, .section-nav-link.active, .section-nav-link[aria-current="location"] { border-left-color: transparent; border-bottom-color: var(--signal); }
.workspace-head { align-items: start; flex-direction: column; }
.last-update { text-align: left; }
.signal-strip { grid-template-columns: 1fr; }
.panel-heading { align-items: flex-start; flex-direction: column; }
.panel-heading-tools { width: 100%; justify-content: space-between; }
.diagnostic-list { grid-template-columns: 1fr; }
.diagnostic-item, .diagnostic-item:nth-child(2n), .diagnostic-item:nth-child(4n) { border-right: 0; }
.settings-grid { grid-template-columns: 1fr; padding: 16px; }
.settings-actions { grid-column: auto; align-items: stretch; flex-direction: column; }
.settings-actions .primary-button { width: 100%; }
.signal-strip div { border-right: 0; border-bottom: 1px solid var(--line); }
.advisor-panel { padding: 20px 16px; gap: 20px; }
.advisor-controls { align-items: stretch; flex-direction: column; }
@@ -438,12 +305,6 @@ html[data-reduced-motion="true"] *, html[data-reduced-motion="true"] *::before,
.learning-grid { grid-template-columns: 1fr; }
.learning-grid article { border-right: 0; border-bottom: 1px solid var(--line-soft); }
.learning-grid article:last-child { border-bottom: 0; }
.fleet-overview { align-items: flex-start; flex-direction: column; }
.fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fleet-row:nth-child(2n) { border-right: 0; }
.fleet-row { border-bottom: 1px solid var(--line-soft); }
.agent-details { grid-template-columns: 1fr; gap: 14px; }
.agent-details p { grid-column: auto; }
.activity-row { padding: 14px 16px; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 12px; }
.activity-state { grid-column: 2; grid-row: 1; }
.activity-operation { grid-column: 1; grid-row: 1; }
+17 -366
View File
@@ -6,43 +6,15 @@ const contextForm = byId("context-form");
const advisoryForm = byId("advisory-form");
const loginError = byId("login-error");
const toast = byId("toast");
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id", "delegation-id"];
const settingsStorageKey = "domovoy.creator.settings";
const contextPresetsStorageKey = "domovoy.creator.context.presets";
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
let toastTimer;
let refreshTimer;
let currentTask = null;
let currentProspective = null;
let pendingCommand = null;
let pendingNotification = null;
let lastAuthorizedActionID = "";
let advisoryPolicy = {Mode: "deterministic", ExternalEgress: false, AcknowledgementRequired: false};
let uiSettings = {density: "comfortable", refresh: "off", motion: false};
const pageNavLinks = Array.from(document.querySelectorAll(".section-nav-link, .topbar-nav a"));
const pageViews = Array.from(document.querySelectorAll("[data-page]"));
const cabinetPages = ["overview", "diagnostics", "operations", "learning", "settings"];
function setActivePage(id, updateHistory = false) {
const page = cabinetPages.includes(id) ? id : "overview";
pageViews.forEach((view) => { view.hidden = view.dataset.page !== page; });
document.body.dataset.page = page;
pageNavLinks.forEach((link) => {
const active = link.getAttribute("href") === `#${page}`;
link.classList.toggle("active", active);
if (active) link.setAttribute("aria-current", "location");
else link.removeAttribute("aria-current");
});
if (updateHistory && window.location.hash !== `#${page}`) window.history.pushState({}, "", `#${page}`);
}
pageNavLinks.forEach((link) => link.addEventListener("click", (event) => {
event.preventDefault();
setActivePage(link.getAttribute("href").slice(1), true);
}));
window.addEventListener("popstate", () => setActivePage(window.location.hash.slice(1)));
setActivePage(window.location.hash.slice(1));
function setText(id, value, fallback = "—") {
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
@@ -67,18 +39,6 @@ function notify(message) {
toastTimer = setTimeout(() => { toast.hidden = true; }, 4200);
}
function safeClientError(error) {
const message = error && typeof error.message === "string" ? error.message : "";
switch (message) {
case "creator authentication denied":
return "Логин или пароль не принят.";
case "Сессия отклонена":
return "Сессия истекла. Войдите снова.";
default:
return "Операция недоступна. Попробуйте ещё раз.";
}
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
@@ -90,9 +50,7 @@ async function request(path, options = {}) {
}
if (!response.ok) {
const detail = (await response.text()).trim();
const error = new Error(detail || `Ошибка ${response.status}`);
error.status = response.status;
throw error;
throw new Error(detail || `Ошибка ${response.status}`);
}
return response.json();
}
@@ -106,57 +64,15 @@ function restoreContext() {
} catch (_) {
localStorage.removeItem("domovoy.creator.context");
}
renderContextState();
renderContextPresets();
}
function saveContext() {
const saved = {};
contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); });
localStorage.setItem("domovoy.creator.context", JSON.stringify(saved));
renderContextState();
return saved;
}
function readContextPresets() {
try {
const value = JSON.parse(localStorage.getItem(contextPresetsStorageKey) || "[]");
return Array.isArray(value) ? value.filter((item) => item && item.session && item.workspace) : [];
} catch (_) {
localStorage.removeItem(contextPresetsStorageKey);
return [];
}
}
function renderContextState() {
const state = byId("context-state");
if (!state) return;
const ready = Boolean(byId("session-id").value.trim() && byId("workspace-id").value.trim());
state.textContent = ready ? "готов к проверке" : "не выбран";
state.dataset.state = ready ? "ready" : "empty";
}
function renderContextPresets() {
const select = byId("context-preset");
if (!select) return;
const presets = readContextPresets();
select.replaceChildren(new Option("Выберите сохранённый набор…", ""));
presets.forEach((preset, index) => {
const session = preset.session.length > 18 ? `${preset.session.slice(0, 8)}…${preset.session.slice(-6)}` : preset.session;
const workspace = preset.workspace.length > 18 ? `${preset.workspace.slice(0, 8)}…${preset.workspace.slice(-6)}` : preset.workspace;
select.appendChild(new Option(`${session} · ${workspace}`, String(index)));
});
}
function rememberContextPreset(context) {
if (!context["session-id"] || !context["workspace-id"]) return;
const next = {session: context["session-id"], workspace: context["workspace-id"], savedAt: Date.now()};
const presets = readContextPresets().filter((item) => item.session !== next.session || item.workspace !== next.workspace);
presets.unshift(next);
localStorage.setItem(contextPresetsStorageKey, JSON.stringify(presets.slice(0, 5)));
renderContextPresets();
}
async function showConsole() {
const session = await request("/api/session/current");
setText("session-actor", session.ActorID);
@@ -164,7 +80,6 @@ async function showConsole() {
setText("session-expiry", expiry);
loginView.hidden = true;
consoleView.hidden = false;
restoreSettings();
restoreContext();
await loadAdvisoryPolicy();
const context = saveContext();
@@ -172,17 +87,11 @@ async function showConsole() {
else {
loadActionHistory();
loadLearning();
loadFleetReadiness();
loadAgentDelegation();
loadAgentWorkerStatus();
}
runDiagnostics();
}
function endSession(message = "Сессия завершена.") {
sessionToken = "";
clearInterval(refreshTimer);
refreshTimer = null;
sessionStorage.removeItem("domovoy.creator.session");
consoleView.hidden = true;
loginView.hidden = false;
@@ -219,7 +128,7 @@ loginForm.addEventListener("submit", async (event) => {
byId("creator-password").value = "";
await showConsole();
} catch (error) {
loginError.textContent = safeClientError(error);
loginError.textContent = error.message === "creator authentication denied" ? "Логин или пароль не принят." : error.message;
} finally {
button.disabled = false;
button.firstElementChild.textContent = "Открыть консоль";
@@ -235,131 +144,14 @@ byId("toggle-secret").addEventListener("click", () => {
contextForm.addEventListener("submit", (event) => {
event.preventDefault();
const context = saveContext();
rememberContextPreset(context);
saveContext();
loadDashboard();
});
for (const input of [byId("session-id"), byId("workspace-id")]) {
input.addEventListener("input", renderContextState);
}
byId("context-preset").addEventListener("change", (event) => {
const index = Number(event.target.value);
if (!Number.isInteger(index)) return;
const preset = readContextPresets()[index];
if (!preset) return;
byId("session-id").value = preset.session;
byId("workspace-id").value = preset.workspace;
renderContextState();
loadDashboard();
notify("Контекст из недавнего набора загружен.");
});
for (const button of document.querySelectorAll("[data-copy-context]")) {
button.addEventListener("click", async () => {
const value = byId(button.dataset.copyContext).value.trim();
if (!value) {
notify("Сначала укажите идентификатор.");
return;
}
try {
await navigator.clipboard.writeText(value);
notify("Идентификатор скопирован.");
} catch (_) {
notify("Не удалось скопировать идентификатор.");
}
});
}
for (const button of document.querySelectorAll("[data-copy-value], [data-use-value]")) {
button.addEventListener("click", async () => {
const source = byId(button.dataset.copyValue || button.dataset.useValue);
const value = source?.textContent.trim() || "";
if (!value || value === "—") {
notify("Сначала загрузите значение.");
return;
}
if (button.dataset.useValue) {
const target = byId(button.dataset.targetId);
if (!target) return;
target.value = value;
document.querySelector(".advanced-context").open = true;
saveContext();
notify("ID подставлен в расширенную проверку.");
return;
}
try {
await navigator.clipboard.writeText(value);
notify("ID скопирован.");
} catch (_) {
notify("Не удалось скопировать ID.");
}
});
}
byId("reset-context").addEventListener("click", () => {
contextFields.forEach((id) => { byId(id).value = id === "capability-id" ? "cognitive-proposal" : ""; });
localStorage.removeItem("domovoy.creator.context");
loadDashboard();
notify("Рабочий контекст сброшен.");
});
function restoreSettings() {
try {
const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}");
uiSettings = {
density: ["comfortable", "compact"].includes(saved.density) ? saved.density : "comfortable",
refresh: ["off", "30", "60", "300"].includes(saved.refresh) ? saved.refresh : "off",
motion: saved.motion === true,
};
} catch (_) {
uiSettings = {density: "comfortable", refresh: "off", motion: false};
}
byId("setting-density").value = uiSettings.density;
byId("setting-refresh").value = uiSettings.refresh;
byId("setting-motion").checked = uiSettings.motion;
applySettings();
}
function applySettings() {
document.documentElement.dataset.density = uiSettings.density;
document.documentElement.dataset.reducedMotion = uiSettings.motion ? "true" : "false";
clearInterval(refreshTimer);
refreshTimer = null;
if (sessionToken && uiSettings.refresh !== "off") {
refreshTimer = setInterval(() => loadDashboard(), Number(uiSettings.refresh) * 1000);
}
}
byId("settings-form").addEventListener("submit", (event) => {
event.preventDefault();
uiSettings = {
density: byId("setting-density").value,
refresh: byId("setting-refresh").value,
motion: byId("setting-motion").checked,
};
localStorage.setItem(settingsStorageKey, JSON.stringify(uiSettings));
applySettings();
setText("settings-saved", "Сохранено только в этом браузере");
notify("Настройки кабинета сохранены.");
});
byId("reset-settings").addEventListener("click", () => {
uiSettings = {density: "comfortable", refresh: "off", motion: false};
localStorage.removeItem(settingsStorageKey);
restoreSettings();
setText("settings-saved", "Восстановлены значения по умолчанию");
notify("Настройки сброшены.");
});
byId("refresh-all").addEventListener("click", loadDashboard);
byId("refresh-actions").addEventListener("click", loadActionHistory);
byId("refresh-learning").addEventListener("click", loadLearning);
byId("refresh-fleet").addEventListener("click", loadFleetReadiness);
byId("refresh-agent").addEventListener("click", loadAgentDelegation);
byId("logout").addEventListener("click", () => endSession());
byId("run-diagnostics").addEventListener("click", runDiagnostics);
advisoryForm.addEventListener("submit", async (event) => {
event.preventDefault();
@@ -393,7 +185,7 @@ advisoryForm.addEventListener("submit", async (event) => {
byId("advisory-result").hidden = false;
} catch (error) {
setText("advisory-state", "ОШИБКА");
setText("advisory-text", safeClientError(error));
setText("advisory-text", error.message);
byId("advisory-result").hidden = false;
} finally {
byId("advisory-egress-ack").checked = false;
@@ -447,9 +239,6 @@ async function loadDashboard() {
work.push(loadProspectiveRegister(context["prospective-id"]));
work.push(loadActionHistory());
work.push(loadLearning());
work.push(loadFleetReadiness());
work.push(loadAgentDelegation());
work.push(loadAgentWorkerStatus());
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
else setSignal("capability", "не выбрана", "idle");
@@ -457,144 +246,6 @@ async function loadDashboard() {
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
}
async function runDiagnostics() {
const list = byId("diagnostic-list");
const summary = byId("diagnostic-summary");
const checks = [
["Сессия", "identity", () => request("/api/session/current")],
["Политика совета", "advisory", () => request("/api/advisory-policy")],
["Когнитивная возможность", "capability", () => request("/api/capabilities/cognitive-proposal/health")],
["Сегодня", "today", () => request("/api/today")],
["Внимание", "attention", () => request("/api/attention")],
["Действия", "actions", () => request("/api/actions?limit=1")],
["Learning evidence", "learning", () => request("/api/learning/recent?limit=1")],
];
list.setAttribute("aria-busy", "true");
list.replaceChildren();
checks.forEach(([label, key]) => list.append(createDiagnosticItem(label, key, "loading")));
setText("diagnostic-summary", "Проверяю…");
const results = await Promise.all(checks.map(async ([label, key, run]) => {
const started = performance.now();
try {
await run();
return {label, key, state: "ok", detail: `Ответ получен · ${Math.round(performance.now() - started)} мс`};
} catch (error) {
const detail = error?.status === 400 ? "Нужны параметры рабочего контекста" : error?.status === 401 ? "Сессия истекла" : error?.status >= 500 ? "Сервис временно недоступен" : "Проверка не пройдена";
return {label, key, state: "error", detail};
}
}));
list.replaceChildren(...results.map((result) => createDiagnosticItem(result.label, result.key, result.state, result.detail)));
list.setAttribute("aria-busy", "false");
const passed = results.filter((result) => result.state === "ok").length;
setText("diagnostic-summary", `${passed}/${results.length} проверок пройдено`);
}
function createDiagnosticItem(label, key, state, detail = "Проверяю…") {
const item = document.createElement("article");
item.className = "diagnostic-item";
item.dataset.state = state;
item.dataset.check = key;
const marker = document.createElement("i");
marker.setAttribute("aria-hidden", "true");
const copy = document.createElement("div");
const title = document.createElement("strong");
title.textContent = label;
const description = document.createElement("span");
description.textContent = detail;
copy.append(title, description);
const status = document.createElement("em");
status.textContent = state === "ok" ? "OK" : state === "error" ? "ошибка" : "…";
item.append(marker, copy, status);
return item;
}
async function loadFleetReadiness() {
const list = byId("fleet-list");
list.setAttribute("aria-busy", "true");
list.replaceChildren();
const loading = document.createElement("p");
loading.className = "register-empty";
loading.textContent = "Проверяю доступность model fleet…";
list.append(loading);
try {
const result = await request("/api/fleet/readiness");
const readiness = result.readiness || {};
const members = Array.isArray(readiness.members) ? readiness.members : [];
setText("fleet-selected", result.selected_tier || "не выбран");
setText("fleet-summary", `${members.filter((member) => member.ready).length}/${members.length} ГОТОВЫ`);
setText("fleet-explanation", result.selected_tier ? `Активный маршрут: ${result.selected_tier}. Остальные tier остаются видимыми с их фактической причиной.` : "Ни один tier не выбран: проверьте конфигурацию и health провайдеров.");
list.replaceChildren(...members.map(createFleetRow));
} catch (error) {
setText("fleet-selected", "недоступен");
setText("fleet-summary", "ОШИБКА");
setText("fleet-explanation", safeClientError(error));
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = safeClientError(error);
list.replaceChildren(failed);
} finally {
list.setAttribute("aria-busy", "false");
}
}
function createFleetRow(member) {
const row = document.createElement("article");
row.className = "fleet-row";
const state = document.createElement("em");
state.className = "learning-state";
state.dataset.state = member.ready ? "READY" : "DEGRADED";
state.textContent = member.ready ? "READY" : "DEGRADED";
const title = document.createElement("strong");
title.textContent = member.tier || "UNKNOWN TIER";
const detail = document.createElement("span");
detail.textContent = `${member.model_descriptor_id || "model"} · ${member.reason || "причина не указана"}`;
row.append(state, title, detail);
return row;
}
async function loadAgentDelegation() {
const delegationID = byId("delegation-id").value.trim();
const empty = byId("agent-empty");
const details = byId("agent-details");
if (!delegationID) {
empty.hidden = false;
details.hidden = true;
setText("agent-summary", "DELEGATION НЕ ВЫБРАНА");
return;
}
empty.hidden = false;
empty.textContent = "Читаю durable delegation…";
details.hidden = true;
try {
const delegation = await request(`/api/delegations/${encodeURIComponent(delegationID)}`);
const checkpoint = await request(`/api/delegations/${encodeURIComponent(delegationID)}/latest-checkpoint`).catch(() => ({}));
setText("agent-summary", delegation.State || "СОСТОЯНИЕ НЕИЗВЕСТНО");
setText("agent-delegation-state", delegation.State);
setText("agent-run-state", `${delegation.LatestRunID || "run не выбран"} · ${delegation.SelectedImplementationID || "implementation не указана"}`);
setText("agent-blocker", checkpoint.BlockerClass || "нет checkpoint");
setText("agent-next-step", checkpoint.NextStep || checkpoint.RemainingSummary || delegation.DesiredOutcome || "Следующий шаг не записан.");
empty.hidden = true;
details.hidden = false;
} catch (error) {
setText("agent-summary", "ОШИБКА");
empty.textContent = safeClientError(error);
empty.hidden = false;
}
}
async function loadAgentWorkerStatus() {
try {
const status = await request("/api/agent-worker/status");
setText("agent-worker-state", status.mode || "UNKNOWN");
setText("agent-worker-message", status.reason || "Причина не указана.");
byId("agent-worker-state").dataset.state = status.ready === true ? "ready" : "degraded";
} catch (error) {
setText("agent-worker-state", "НЕДОСТУПЕН");
setText("agent-worker-message", safeClientError(error));
byId("agent-worker-state").dataset.state = "degraded";
}
}
async function loadLearning() {
const experienceList = byId("experience-list");
const reflectionList = byId("reflection-list");
@@ -621,7 +272,7 @@ async function loadLearning() {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = safeClientError(error);
failed.textContent = error.message;
list.append(failed);
});
setText("learning-count", "ОШИБКА");
@@ -720,7 +371,7 @@ async function loadActionHistory() {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty activity-error";
failed.textContent = safeClientError(error);
failed.textContent = error.message;
list.append(failed);
setText("action-count", "ОШИБКА");
} finally {
@@ -779,7 +430,7 @@ async function reconcileHistoryAction(actionID, button) {
await loadActionHistory();
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(safeClientError(error));
notify(error.message);
button.disabled = false;
button.textContent = "Сверить";
}
@@ -799,7 +450,7 @@ async function loadFocus(query) {
} catch (error) {
setSignal("focus", "ошибка", "error");
setBadge("focus", "НЕДОСТУПЕН", "bad");
setText("focus-message", safeClientError(error));
setText("focus-message", error.message);
byId("connection-state").classList.add("offline");
}
}
@@ -821,7 +472,7 @@ async function loadTask(taskID, query) {
currentTask = null;
setSignal("task", "отклонена", "error");
setBadge("task", "ВНЕ ФОКУСА", "bad");
setText("task-message", safeClientError(error));
setText("task-message", error.message);
updateActionReadiness();
}
}
@@ -860,12 +511,12 @@ async function loadProspectiveRegister(selectedID) {
list.replaceChildren();
const failed = document.createElement("p");
failed.className = "register-empty";
failed.textContent = safeClientError(error);
failed.textContent = error.message;
list.append(failed);
setText("prospective-count", "ОШИБКА");
setSignal("prospective", "ошибка", "error");
setBadge("prospective", "НЕДОСТУПНА", "bad");
setText("prospective-message", safeClientError(error));
setText("prospective-message", error.message);
}
}
@@ -921,7 +572,7 @@ async function loadProspective(prospectiveID) {
setSignal("prospective", "ошибка", "error");
setBadge("prospective", "НЕДОСТУПНА", "bad");
setText("prospective-state", "UNKNOWN");
setText("prospective-message", safeClientError(error));
setText("prospective-message", error.message);
updateActionReadiness();
}
}
@@ -943,7 +594,7 @@ async function loadCapability(capabilityID) {
setSignal("capability", "ошибка", "error");
setBadge("capability", "НЕДОСТУПНА", "bad");
setText("capability-state", "UNKNOWN");
setText("capability-reason", safeClientError(error));
setText("capability-reason", error.message);
}
}
@@ -1049,7 +700,7 @@ byId("confirm-notification").addEventListener("click", async () => {
await loadActionHistory();
notify(result.State === "SUCCEEDED" ? "Receiver принял уведомление." : "Исход отправки требует сверки.");
} catch (error) {
setText("notification-confirmation-error", safeClientError(error));
setText("notification-confirmation-error", error.message);
} finally {
button.disabled = false;
button.textContent = "Подтверждаю отправку";
@@ -1079,7 +730,7 @@ byId("confirm-satisfaction").addEventListener("click", async () => {
notify(result.State === "SUCCEEDED" ? "Исполнение зафиксировано через Commit." : "Исход требует проверки.");
await loadProspectiveRegister(command.ProspectiveItemID);
} catch (error) {
setText("confirmation-error", safeClientError(error));
setText("confirmation-error", error.message);
} finally {
button.disabled = false;
button.textContent = "Подтверждаю переход";
@@ -1113,7 +764,7 @@ byId("reconcile-action").addEventListener("click", async () => {
notify(result.State === "SUCCEEDED" ? "Исход подтверждён." : "Исход пока неизвестен.");
if (currentProspective) await loadProspectiveRegister(currentProspective.ID);
} catch (error) {
notify(safeClientError(error));
notify(error.message);
} finally {
button.disabled = false;
button.textContent = "Проверить исход";
+83 -74
View File
@@ -5,92 +5,101 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#101714">
<title>Домовой - чат</title>
<title>Домовой — твой помощник</title>
<link rel="stylesheet" href="/assets/chat.css">
</head>
<body>
<main class="chat-shell">
<section id="login-view" class="login-panel">
<div class="brand-mark" aria-hidden="true">Д</div>
<h1>Домовой</h1>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<input id="password" type="password" autocomplete="current-password" required>
<button type="submit">Войти</button>
<p id="login-error" role="alert"></p>
</form>
<main class="assistant-shell">
<section id="login-view" class="auth-view">
<div class="auth-card" aria-live="polite">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">личный диалог</p>
<h1>Домовой</h1>
</div>
</div>
<p class="auth-copy">Войдите, чтобы открыть спокойный чат с вашим помощником.</p>
<form id="login-form" class="login-form">
<label for="username">Логин</label>
<input id="username" autocomplete="username" required autofocus>
<label for="password">Пароль</label>
<div class="input-with-action">
<input id="password" type="password" autocomplete="current-password" required>
<button id="toggle-password" class="icon-button" type="button" aria-label="Показать пароль">показать</button>
</div>
<button id="login-button" type="submit">Открыть чат</button>
<p id="login-error" role="alert"></p>
</form>
</div>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header">
<a href="/" aria-label="Домовой">Домовой</a>
<button id="cognitive-status" class="cognitive-status" data-state="checking" type="button" role="status" aria-live="polite" title="Проверить канал ещё раз">проверяю канал мышления</button>
<a id="cognitive-console-link" class="cognitive-console-link" href="/admin" hidden>настроить в консоли</a>
<div class="build-meta" aria-label="Версия и время обновления">
<span>версия __ASSISTANT_BUILD_REVISION__</span>
<time datetime="__ASSISTANT_BUILD_DATETIME__">обновлено __ASSISTANT_BUILD_TIME__</time>
<header class="assistant-header">
<div class="header-brand">
<span class="brand-mark" aria-hidden="true">Д</span>
<div>
<p class="brand-kicker">твой спокойный ассистент</p>
<h1>Домовой</h1>
</div>
</div>
<div class="session-state" aria-live="polite">
<span id="connection-state" class="status-pill"></span>
<button id="logout" class="ghost-button" type="button" aria-label="Выйти">Выйти</button>
</div>
<a href="/admin" class="operator-link">Консоль</a>
<button id="logout" type="button" aria-label="Выйти">↗</button>
</header>
<div class="chat-layout">
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
<div class="side-intro">
<p class="side-index">01 / РАЗГОВОР</p>
<h1>Домовой рядом</h1>
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
<nav class="human-nav" aria-label="Разделы Домового" role="tablist">
<button class="nav-tab active" data-tab="chat-panel" role="tab" aria-selected="true" aria-controls="chat-panel" id="tab-chat" type="button">Чат</button>
<button class="nav-tab" data-tab="today-panel" role="tab" aria-selected="false" aria-controls="today-panel" id="tab-today" type="button">Сегодня</button>
<button class="nav-tab" data-tab="history-panel" role="tab" aria-selected="false" aria-controls="history-panel" id="tab-history" type="button">История</button>
</nav>
<section class="surface-panel active-panel" id="chat-panel" role="tabpanel" aria-labelledby="tab-chat">
<div class="panel-toolbar">
<div>
<h2>Чат</h2>
<p>Пишите обычные фразы. Я отвечу спокойно и по делу.</p>
</div>
<div class="side-focus">
<span>ТЕКУЩИЙ ФОКУС</span>
<strong id="active-focus">не выбран</strong>
<p id="active-focus" class="focus-chip" hidden></p>
</div>
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="composer">
<label class="visually-hidden" for="message">Ваше сообщение</label>
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите, чем помочь" required></textarea>
<div class="composer-actions">
<button id="send" class="primary-button" type="submit">Отправить</button>
</div>
<nav class="human-nav" aria-label="Разделы Домового">
<button class="nav-tab active" data-tab="chat-panel" type="button"><b>01</b><span>Чат</span><small>живой разговор</small></button>
<button class="nav-tab" data-tab="today-panel" type="button"><b>02</b><span>Сегодня</span><small>что требует внимания</small></button>
<button class="nav-tab" data-tab="history-panel" type="button"><b>03</b><span>История</span><small>это окно разговора</small></button>
</nav>
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
</aside>
<main class="chat-main">
<section id="chat-panel" class="surface-panel active-panel">
<div id="messages" class="messages" aria-live="polite"></div>
<form id="chat-form" class="composer">
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<button id="send" type="submit">Отправить</button>
</form>
<div class="conversation-starters" aria-label="Частые вопросы">
<span>Можно начать так:</span>
<button type="button" data-starter="Проверь локальный канал мышления">Проверь канал</button>
<button type="button" data-starter="Что сейчас доступно?">Что доступно?</button>
<button type="button" data-starter="Хочу изменить настройку локальной модели">Изменить настройку</button>
</div>
</section>
<section id="today-panel" class="surface-panel attention-surface" hidden>
<div class="surface-heading"><p>Сегодня / внимание</p><button id="refresh-today" type="button">Обновить</button></div>
<div id="today-list" class="attention-list"></div>
</section>
<section id="history-panel" class="surface-panel history-surface" hidden>
<div class="surface-heading"><p>История</p><span>Последние сообщения текущего окна</span></div>
<div id="history-list" class="history-list"></div>
</section>
</main>
<aside class="chat-side chat-right" aria-label="Состояние и сведения об ИИ">
<div class="side-intro"><p class="side-index">02 / ИНФОРМАЦИЯ</p><h2>Канал мышления</h2><p>Домовой видит подтверждённое состояние канала. Если основной ИИ пропал, он остаётся рядом и переходит в аварийный режим.</p></div>
<div class="ai-info-card"><span>ЧТО МОЖНО ПЕРЕДАТЬ</span><strong>Описание ИИ</strong><p>Название, назначение, ограничения или заметки о локальной модели. Не вставляйте пароли, токены и ключи.</p></div>
<form id="ai-data-form" class="ai-data-form">
<label for="ai-data">Данные для текущего разговора</label>
<textarea id="ai-data" rows="8" maxlength="12000" placeholder="Например: локальная модель нужна для коротких ответов на русском; контекст ограничен; внешняя сеть запрещена."></textarea>
<button type="submit">Передать Домовому</button>
<p id="ai-data-error" class="ai-data-error" role="alert" hidden></p>
<small>Это отправится как обычное сообщение в чат.</small>
</form>
<a class="side-console-link" href="/admin">Открыть консоль настройки <span>→</span></a>
</aside>
</div>
</form>
</section>
<section id="today-panel" class="surface-panel attention-surface" hidden role="tabpanel" aria-labelledby="tab-today">
<div class="panel-toolbar">
<div>
<h2>Сегодня</h2>
<p id="today-subtitle">Что сейчас требует твоего внимания</p>
</div>
<button id="refresh-today" class="ghost-button" type="button">Обновить</button>
</div>
<div id="today-list" class="attention-list"></div>
</section>
<section id="history-panel" class="surface-panel history-surface" hidden role="tabpanel" aria-labelledby="tab-history">
<div class="panel-toolbar">
<div>
<h2>История</h2>
<p>Сообщения текущего браузерного сеанса</p>
</div>
<button id="clear-history" class="ghost-button" type="button">Очистить</button>
</div>
<div id="history-list" class="history-list"></div>
</section>
</section>
<p id="status-bar" class="status-bar" aria-live="polite"></p>
</main>
<div id="composer-error" class="composer-error" role="status" aria-live="polite"></div>
<script src="/assets/chat.js"></script>
</body>
</html>
+20 -113
View File
@@ -6,10 +6,9 @@
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#101714">
<title>Домовой — консоль создателя</title>
<link rel="stylesheet" href="/assets/console.css?v=20260821-context">
<link rel="stylesheet" href="/assets/console.css">
</head>
<body>
<a class="skip-link" href="#overview">К содержимому</a>
<div class="grain" aria-hidden="true"></div>
<main id="login-view" class="login-shell">
@@ -44,15 +43,7 @@
<span><strong>Домовой</strong><small>консоль создателя</small></span>
</a>
<div class="topbar-actions">
<a class="chat-return-link" href="/">← Чат с Домовым</a>
<span id="connection-state" class="connection"><i></i>система доступна</span>
<nav class="topbar-nav" aria-label="Разделы кабинета">
<a href="#overview">Обзор</a>
<a href="#diagnostics">Проверки</a>
<a href="#operations">Операции</a>
<a href="#learning">Опыт</a>
<a href="#settings">Настройки</a>
</nav>
<button id="refresh-all" class="text-button" type="button">Обновить</button>
<button id="logout" class="icon-button" type="button" aria-label="Завершить сессию" title="Выйти">↗</button>
</div>
@@ -65,48 +56,18 @@
<p class="rail-copy">Укажите рабочий контекст. Он сохраняется только в этом браузере и не даёт дополнительных полномочий.</p>
</div>
<form id="context-form" class="context-form">
<div class="context-status-row">
<span class="context-status-label">ТЕКУЩИЙ КОНТЕКСТ</span>
<strong id="context-state" data-state="empty">не выбран</strong>
</div>
<div class="context-presets">
<label for="context-preset">Недавние наборы</label>
<select id="context-preset">
<option value="">Выберите сохранённый набор…</option>
</select>
<small>Сохраняются только в этом браузере.</small>
</div>
<div class="context-field">
<label for="session-id">Идентификатор сессии <span>обязательно</span></label>
<div class="context-input-row"><input id="session-id" autocomplete="off" spellcheck="false" placeholder="Вставьте session-id"><button class="copy-context" data-copy-context="session-id" type="button" aria-label="Скопировать идентификатор сессии" title="Скопировать">⧉</button></div>
</div>
<div class="context-field">
<label for="workspace-id">Рабочая область <span>обязательно</span></label>
<div class="context-input-row"><input id="workspace-id" autocomplete="off" spellcheck="false" placeholder="Вставьте workspace-id"><button class="copy-context" data-copy-context="workspace-id" type="button" aria-label="Скопировать рабочую область" title="Скопировать">⧉</button></div>
</div>
<p class="context-hint">Для «Состояния» нужны только эти два значения. Задача, ожидание и возможность находятся ниже в расширенных полях.</p>
<details class="advanced-context">
<summary>Расширенные идентификаторы</summary>
<div>
<label for="task-id">Задача</label>
<input id="task-id" autocomplete="off" placeholder="task-id">
<label for="prospective-id">Выбранное ожидание</label>
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
<label for="capability-id">Возможность</label>
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
<label for="delegation-id">Delegation / агентская работа</label>
<input id="delegation-id" autocomplete="off" placeholder="delegation-id из чата">
</div>
</details>
<div class="context-actions"><button class="secondary-button" type="submit">Загрузить контекст</button><button id="reset-context" class="text-button" type="button">Сбросить</button></div>
<label for="session-id">Идентификатор сессии</label>
<input id="session-id" autocomplete="off" placeholder="session-id">
<label for="workspace-id">Рабочая область</label>
<input id="workspace-id" autocomplete="off" placeholder="workspace-id">
<label for="task-id">Задача</label>
<input id="task-id" autocomplete="off" placeholder="task-id">
<label for="prospective-id">Выбранное ожидание</label>
<input id="prospective-id" autocomplete="off" placeholder="prospective-item-id">
<label for="capability-id">Возможность</label>
<input id="capability-id" autocomplete="off" value="cognitive-proposal">
<button class="secondary-button" type="submit">Загрузить контекст</button>
</form>
<nav class="section-nav" aria-label="Навигация по кабинету">
<a class="section-nav-link active" aria-current="location" href="#overview"><span>01</span><b>Состояние</b><small>фокус и сигналы</small></a>
<a class="section-nav-link" href="#diagnostics"><span>02</span><b>Проверки</b><small>доступность и границы</small></a>
<a class="section-nav-link" href="#operations"><span>03</span><b>Операции</b><small>ожидания и действия</small></a>
<a class="section-nav-link" href="#learning"><span>04</span><b>Опыт</b><small>reflection и навыки</small></a>
<a class="section-nav-link" href="#settings"><span>05</span><b>Настройки</b><small>интерфейс и обновление</small></a>
</nav>
<div class="session-stamp">
<span>ACTOR</span>
<strong id="session-actor">—</strong>
@@ -116,7 +77,7 @@
</aside>
<main class="workspace">
<section id="overview" class="workspace-head" data-page="overview">
<section class="workspace-head">
<div>
<p class="eyebrow">операционная картина</p>
<h1>Состояние сейчас</h1>
@@ -124,28 +85,14 @@
<p id="last-update" class="last-update">Данные ещё не запрошены</p>
</section>
<section class="signal-strip" aria-label="Сводка состояния" data-page="overview">
<section class="signal-strip" aria-label="Сводка состояния">
<div><span>ФОКУС</span><strong id="focus-signal" data-state="idle">ожидает</strong></div>
<div><span>ЗАДАЧА</span><strong id="task-signal" data-state="idle">ожидает</strong></div>
<div><span>CONTINUITY</span><strong id="prospective-signal" data-state="idle">ожидает</strong></div>
<div><span>ВОЗМОЖНОСТЬ</span><strong id="capability-signal" data-state="idle">ожидает</strong></div>
</section>
<section id="diagnostics" class="diagnostics-panel" aria-labelledby="diagnostics-title" data-page="diagnostics">
<header class="panel-heading">
<div><p>Проверяемая система</p><h2 id="diagnostics-title">Диагностика кабинета</h2></div>
<div class="panel-heading-tools"><span id="diagnostic-summary">Проверка ещё не запускалась</span><button id="run-diagnostics" class="secondary-button compact-button" type="button">Проверить всё</button></div>
</header>
<div class="diagnostic-intro">
<span class="diagnostic-pulse" aria-hidden="true"></span>
<p>Здесь видны только ответы текущей авторизованной сессии. Проверка не меняет данные и помогает отличить отсутствие контекста от недоступности сервиса.</p>
</div>
<div id="diagnostic-list" class="diagnostic-list" aria-live="polite" aria-busy="false">
<p class="register-empty">Нажмите «Проверить всё», чтобы получить актуальный снимок.</p>
</div>
</section>
<section class="advisor-panel" aria-labelledby="advisor-title" data-page="diagnostics">
<section class="advisor-panel" aria-labelledby="advisor-title">
<div class="advisor-intro">
<p>COGNITIVE / ADVISORY V1</p>
<h2 id="advisor-title">Спросить Домового</h2>
@@ -173,7 +120,7 @@
</div>
</section>
<section id="operations" class="register-panel" aria-labelledby="register-title" data-page="operations">
<section class="register-panel" aria-labelledby="register-title">
<header>
<div><p>Continuity / ближайшие</p><h2 id="register-title">Реестр ожиданий</h2></div>
<span id="prospective-count">0 ЗАПИСЕЙ</span>
@@ -183,31 +130,7 @@
</div>
</section>
<section class="fleet-panel" aria-labelledby="fleet-title" data-page="operations">
<header>
<div><p>Cognitive / capability health</p><h2 id="fleet-title">Модели и fallback</h2></div>
<div class="activity-tools"><span id="fleet-summary">СОСТОЯНИЕ НЕ ЗАГРУЖЕНО</span><button id="refresh-fleet" class="icon-button" type="button" aria-label="Обновить состояние моделей" title="Обновить состояние моделей">↻</button></div>
</header>
<div class="fleet-overview"><div><span>Выбранный tier</span><strong id="fleet-selected">—</strong></div><p id="fleet-explanation">Проверка доступности провайдеров ещё не выполнялась.</p></div>
<div id="fleet-list" class="fleet-list" aria-live="polite" aria-busy="false"><p class="register-empty">Состояние fleet ещё не загружено.</p></div>
</section>
<section class="agent-panel" aria-labelledby="agent-title" data-page="operations">
<header>
<div><p>Work / durable execution</p><h2 id="agent-title">Агентская работа</h2></div>
<div class="activity-tools"><span id="agent-summary">DELEGATION НЕ ВЫБРАНА</span><button id="refresh-agent" class="icon-button" type="button" aria-label="Обновить агентскую работу" title="Обновить агентскую работу">↻</button></div>
</header>
<div id="agent-empty" class="agent-empty">Укажите delegation ID в расширенных идентификаторах, чтобы прочитать durable state.</div>
<div id="agent-details" class="agent-details" hidden>
<div><span>Состояние delegation</span><strong id="agent-delegation-state">—</strong></div>
<div><span>Run / implementation</span><strong id="agent-run-state">—</strong></div>
<div><span>Последний blocker</span><strong id="agent-blocker">—</strong></div>
<p id="agent-next-step">—</p>
</div>
<div class="agent-worker-status"><span>Executor capability</span><strong id="agent-worker-state">ПРОВЕРКА НЕ ВЫПОЛНЯЛАСЬ</strong><p id="agent-worker-message">Статус worker виден только в авторизованной диагностике.</p></div>
</section>
<section class="activity-panel" aria-labelledby="activity-title" data-page="operations">
<section class="activity-panel" aria-labelledby="activity-title">
<header>
<div><p>Commit / владелец</p><h2 id="activity-title">Журнал действий</h2></div>
<div class="activity-tools">
@@ -220,7 +143,7 @@
</div>
</section>
<section id="learning" class="learning-panel" aria-labelledby="learning-title" data-page="learning">
<section class="learning-panel" aria-labelledby="learning-title">
<header>
<div><p>Learning / evidence</p><h2 id="learning-title">Опыт и reflection</h2></div>
<div class="activity-tools">
@@ -244,20 +167,7 @@
</div>
</section>
<section id="settings" class="settings-panel" aria-labelledby="settings-title" data-page="settings">
<header class="panel-heading">
<div><p>Локальные предпочтения</p><h2 id="settings-title">Настройки рабочего кабинета</h2></div>
<span id="settings-saved" class="settings-saved">Сохраняются в этом браузере</span>
</header>
<form id="settings-form" class="settings-grid">
<label class="setting-field"><span>Плотность данных</span><small>Выберите, сколько строк помещается на экране.</small><select id="setting-density"><option value="comfortable">Комфортная</option><option value="compact">Компактная</option></select></label>
<label class="setting-field"><span>Автообновление</span><small>Повторная проверка состояния без изменения данных.</small><select id="setting-refresh"><option value="off">Выключено</option><option value="30">Каждые 30 секунд</option><option value="60">Каждую минуту</option><option value="300">Каждые 5 минут</option></select></label>
<label class="setting-check"><input id="setting-motion" type="checkbox"><span><b>Сократить анимацию</b><small>Оставить спокойный режим для длительной работы.</small></span></label>
<div class="settings-actions"><button id="save-settings" class="primary-button compact-button" type="submit"><span>Сохранить настройки</span><span aria-hidden="true">→</span></button><button id="reset-settings" class="text-button" type="button">Сбросить</button></div>
</form>
</section>
<section class="data-grid" data-page="overview">
<section class="data-grid">
<article class="data-card focus-card">
<header><span class="card-number">01</span><div><p>Situation</p><h2>Активный фокус</h2></div><span id="focus-badge" class="status-badge neutral">НЕ ЗАГРУЖЕН</span></header>
<dl>
@@ -278,7 +188,6 @@
<div><dt>Ревизия</dt><dd id="task-revision">—</dd></div>
</dl>
<p id="task-message" class="card-message">Укажите задачу для проверки относительно активного фокуса.</p>
<div class="id-actions"><button class="id-action" data-use-value="task-value" data-target-id="task-id" type="button">Использовать ID задачи</button><button class="id-action icon-id-action" data-copy-value="task-value" type="button" aria-label="Скопировать ID задачи" title="Скопировать ID">⧉</button></div>
</article>
<article class="data-card prospective-card">
@@ -296,7 +205,6 @@
<div><dt>Ревизия</dt><dd id="prospective-revision">—</dd></div>
</dl>
<p id="prospective-message" class="card-message">Укажите идентификатор ProspectiveItem для проверки владельца и состояния.</p>
<div class="id-actions"><button class="id-action" data-use-value="prospective-value" data-target-id="prospective-id" type="button">Использовать ID ожидания</button><button class="id-action icon-id-action" data-copy-value="prospective-value" type="button" aria-label="Скопировать ID ожидания" title="Скопировать ID">⧉</button></div>
<section class="commit-zone" aria-labelledby="commit-zone-title">
<div class="commit-zone-copy">
<p>Commit / контролируемый переход</p>
@@ -332,7 +240,6 @@
<div><dt>Ревизия</dt><dd id="capability-revision">—</dd></div>
<div><dt>Наблюдалось</dt><dd id="capability-observed">—</dd></div>
</dl>
<div class="id-actions"><button class="id-action" data-use-value="capability-value" data-target-id="capability-id" type="button">Использовать ID возможности</button><button class="id-action icon-id-action" data-copy-value="capability-value" type="button" aria-label="Скопировать ID возможности" title="Скопировать ID">⧉</button></div>
</article>
</section>
</main>
@@ -379,6 +286,6 @@
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script src="/assets/console.js?v=20260821-context" defer></script>
<script src="/assets/console.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -166,7 +166,7 @@ func loadSelectedTask(root string, selection taskSelection) (TaskPacket, string,
func loadTaskPacket(root, taskPath string) (TaskPacket, error) {
var taskPacket TaskPacket
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, true); err != nil {
if err := decodeJSONFile(filepath.Join(root, filepath.FromSlash(taskPath)), &taskPacket, false); err != nil {
return TaskPacket{}, fmt.Errorf("task packet: %w", err)
}
return taskPacket, nil
-64
View File
@@ -1,64 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
var required = []string{
"task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent",
"requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta",
"contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges",
"persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios",
"required_tests", "terminal_gap_states",
}
func main() {
activePath := filepath.FromSlash("dev/ACTIVE_TASK.json")
var active struct {
TaskFile string `json:"task_file"`
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
}
if err := decode(activePath, &active); err != nil {
fail("active pointer: %v", err)
}
if active.TaskFile == "" || active.TaskID == "" || active.TaskRevision < 1 {
fail("active pointer is incomplete")
}
var raw map[string]json.RawMessage
if err := decode(filepath.FromSlash(active.TaskFile), &raw); err != nil {
fail("task packet: %v", err)
}
for _, key := range required {
if _, ok := raw[key]; !ok {
fail("task packet missing required field %q", key)
}
}
var packet struct {
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
}
if err := decode(filepath.FromSlash(active.TaskFile), &packet); err != nil {
fail("task identity: %v", err)
}
if packet.TaskID != active.TaskID || packet.TaskRevision != active.TaskRevision {
fail("active pointer and packet identity differ")
}
fmt.Printf("independent governance verification: OK task=%s revision=%d\n", packet.TaskID, packet.TaskRevision)
}
func decode(path string, target any) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(b, target)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "independent verifier: "+format+"\n", args...)
os.Exit(1)
}
+11 -32
View File
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"go/parser"
"go/token"
@@ -23,41 +24,19 @@ type Registry struct {
BoundaryClass string `json:"boundary_class"`
MayDependOn []string `json:"may_depend_on"`
Forbidden []string `json:"forbidden"`
Privileged bool `json:"privileged"`
} `json:"modules"`
ProtectedPaths []string `json:"protected_paths"`
}
type TaskPacket struct {
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
ArchitectureRevision string `json:"architecture_revision"`
BaseRepoRevision string `json:"base_repo_revision"`
RiskTier string `json:"risk_tier"`
Intent string `json:"intent"`
RequirementIDs []string `json:"requirement_ids"`
AllowedChangeScope struct {
Modules []string `json:"modules"`
Paths []string `json:"paths"`
} `json:"allowed_change_scope"`
ForbiddenChangeScope struct {
TaskID string `json:"task_id"`
TaskRevision int `json:"task_revision"`
BaseRepoRevision string `json:"base_repo_revision"`
RiskTier string `json:"risk_tier"`
Intent string `json:"intent"`
AllowedChangeScope struct {
Paths []string `json:"paths"`
} `json:"forbidden_change_scope"`
AllowedArchitectureDelta map[string]int `json:"allowed_architecture_delta"`
Contracts struct {
Consume []string `json:"consume"`
MayChange []string `json:"may_change"`
} `json:"contracts"`
SemanticOwner string `json:"semantic_owner"`
CanonicalState []string `json:"canonical_state"`
StateTransitions []string `json:"state_transitions"`
SideEffectsAndPrivileges []string `json:"side_effects_and_privileges"`
PersistenceAndMigrations []string `json:"persistence_and_migrations"`
FailureRetryRestart []string `json:"failure_retry_restart"`
Assumptions []string `json:"assumptions"`
AcceptanceScenarios []string `json:"acceptance_scenarios"`
RequiredTests []string `json:"required_tests"`
TerminalGapStates []string `json:"terminal_gap_states"`
} `json:"allowed_change_scope"`
}
func main() {
@@ -228,12 +207,12 @@ func preflight(args []string) error {
if err != nil {
return err
}
taskPacket, taskPath, _, err := loadSelectedTask(root, selection)
taskPacket, _, _, err := loadSelectedTask(root, selection)
if err != nil {
return err
}
if err := validateTaskPacket(root, taskPacket, taskPath); err != nil {
return err
if taskPacket.TaskID == "" || taskPacket.Intent == "" || taskPacket.RiskTier == "" {
return errors.New("task packet missing task_id, intent, or risk_tier")
}
registry, err := loadRegistry()
if err != nil {
-215
View File
@@ -1,215 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
var requiredPacketKeys = []string{
"task_id", "task_revision", "architecture_revision", "base_repo_revision", "risk_tier", "intent",
"requirement_ids", "allowed_change_scope", "forbidden_change_scope", "allowed_architecture_delta",
"contracts", "semantic_owner", "canonical_state", "state_transitions", "side_effects_and_privileges",
"persistence_and_migrations", "failure_retry_restart", "assumptions", "acceptance_scenarios",
"required_tests", "terminal_gap_states",
}
var requiredDeltaKeys = []string{
"new_cross_module_dependencies", "new_canonical_owners", "new_privileges",
"new_third_party_dependencies", "new_database_owner_roles",
}
func validateTaskPacket(root string, task TaskPacket, taskPath string) error {
if task.TaskID == "" || task.TaskRevision < 1 || task.ArchitectureRevision == "" || task.BaseRepoRevision == "" || task.RiskTier == "" || task.Intent == "" {
return fmt.Errorf("task %s is incomplete: identity, revision, architecture_revision, base_repo_revision, risk_tier and intent are required", task.TaskID)
}
if taskPath == "" || !matchesAny(taskPath, mustCompile([]string{"dev/tasks/*.json"})) {
return fmt.Errorf("task %s must be selected from dev/tasks", task.TaskID)
}
if task.RiskTier < "A" || task.RiskTier > "E" {
return fmt.Errorf("task %s has invalid risk_tier %q", task.TaskID, task.RiskTier)
}
if len(task.RequirementIDs) == 0 || len(task.AcceptanceScenarios) == 0 || len(task.RequiredTests) == 0 || len(task.TerminalGapStates) == 0 {
return fmt.Errorf("task %s must declare requirements, acceptance_scenarios, required_tests and terminal_gap_states", task.TaskID)
}
if len(task.AllowedChangeScope.Paths) == 0 {
return fmt.Errorf("task %s must declare allowed_change_scope.paths", task.TaskID)
}
if err := validateScopeOverlap(task.AllowedChangeScope.Paths, task.ForbiddenChangeScope.Paths); err != nil {
return fmt.Errorf("task %s scope: %w", task.TaskID, err)
}
if err := validateDelta(task.AllowedArchitectureDelta); err != nil {
return fmt.Errorf("task %s architecture delta: %w", task.TaskID, err)
}
if err := validateRequiredTests(task.RequiredTests); err != nil {
return fmt.Errorf("task %s required_tests: %w", task.TaskID, err)
}
if err := validateArchitectureRevision(root, task.ArchitectureRevision); err != nil {
return fmt.Errorf("task %s architecture_revision: %w", task.TaskID, err)
}
if _, err := resolveBaseCommit(root, task.BaseRepoRevision); err != nil {
return fmt.Errorf("task %s base_repo_revision: %w", task.TaskID, err)
}
if err := validateObservedDelta(root, task); err != nil {
return fmt.Errorf("task %s observed architecture delta: %w", task.TaskID, err)
}
return nil
}
func validateRequiredTests(tests []string) error {
joined := strings.Join(tests, "\n")
for _, required := range []string{"go test", "go vet", "preflight", "check-diff", "git diff --check", "independent"} {
if !strings.Contains(joined, required) {
return fmt.Errorf("missing required governance evidence %q", required)
}
}
return nil
}
func validateObservedDelta(root string, task TaskPacket) error {
changed, err := changedPaths(root, task.BaseRepoRevision)
if err != nil {
return err
}
if err := validateRegistryDelta(root, task); err != nil {
return err
}
for _, changedPath := range changed {
if (strings.HasPrefix(changedPath, "security/") || strings.HasPrefix(changedPath, "deploy/")) && task.AllowedArchitectureDelta["new_privileges"] == 0 {
return fmt.Errorf("privileged surface changed with new_privileges=0: %s", changedPath)
}
if strings.HasPrefix(changedPath, "database/") && task.AllowedArchitectureDelta["new_database_owner_roles"] == 0 {
return fmt.Errorf("database surface changed with new_database_owner_roles=0: %s", changedPath)
}
if (changedPath == "go.mod" || changedPath == "go.sum") && task.AllowedArchitectureDelta["new_third_party_dependencies"] == 0 {
return fmt.Errorf("dependency manifest changed with new_third_party_dependencies=0: %s", changedPath)
}
if strings.HasPrefix(changedPath, "contracts/") && len(task.Contracts.MayChange) == 0 {
return fmt.Errorf("contract surface changed but contracts.may_change is empty: %s", changedPath)
}
}
return nil
}
func validateRegistryDelta(root string, task TaskPacket) error {
baseBytes, err := runGit(root, "show", task.BaseRepoRevision+":architecture/architecture-registry.json")
if err != nil {
return fmt.Errorf("read base architecture registry: %w", err)
}
currentBytes, err := os.ReadFile(filepath.Join(root, "architecture", "architecture-registry.json"))
if err != nil {
return err
}
var base, current Registry
if err := json.Unmarshal(baseBytes, &base); err != nil {
return fmt.Errorf("parse base architecture registry: %w", err)
}
if err := json.Unmarshal(currentBytes, &current); err != nil {
return fmt.Errorf("parse current architecture registry: %w", err)
}
if task.AllowedArchitectureDelta["new_canonical_owners"] == 0 {
for name := range current.Modules {
if _, ok := base.Modules[name]; !ok {
return fmt.Errorf("new canonical module owner %q with new_canonical_owners=0", name)
}
}
}
if task.AllowedArchitectureDelta["new_cross_module_dependencies"] == 0 {
for name, module := range current.Modules {
previous, ok := base.Modules[name]
if !ok {
continue
}
old := map[string]bool{}
for _, dep := range previous.MayDependOn {
old[dep] = true
}
for _, dep := range module.MayDependOn {
if !old[dep] {
return fmt.Errorf("new dependency %q in module %q with new_cross_module_dependencies=0", dep, name)
}
}
}
}
if task.AllowedArchitectureDelta["new_privileges"] == 0 {
for name, module := range current.Modules {
if previous, ok := base.Modules[name]; ok && module.Privileged && !previous.Privileged {
return fmt.Errorf("new privilege in module %q with new_privileges=0", name)
}
}
}
return nil
}
func validateDelta(delta map[string]int) error {
if len(delta) != len(requiredDeltaKeys) {
return fmt.Errorf("must contain exactly %s", strings.Join(requiredDeltaKeys, ", "))
}
for _, key := range requiredDeltaKeys {
value, ok := delta[key]
if !ok || value < 0 {
return fmt.Errorf("%s must be a non-negative integer", key)
}
}
return nil
}
func validateArchitectureRevision(root, revision string) error {
if strings.HasPrefix(revision, "ADR-") {
matches, err := filepath.Glob(filepath.Join(root, "architecture", "adrs", revision+"-*.md"))
if err != nil || len(matches) != 1 {
return fmt.Errorf("accepted ADR %q is missing or ambiguous", revision)
}
return nil
}
if strings.HasPrefix(revision, "ARCH-") {
matches, err := filepath.Glob(filepath.Join(root, "dev", "tasks", revision+"*.json"))
if err != nil || len(matches) == 0 {
return fmt.Errorf("architecture task %q is missing", revision)
}
return nil
}
return fmt.Errorf("must reference an accepted ADR-NNNN or architecture task")
}
func validateScopeOverlap(allowed, forbidden []string) error {
allowedPatterns, err := compileScopePatterns(allowed)
if err != nil {
return err
}
forbiddenPatterns, err := compileScopePatterns(forbidden)
if err != nil {
return fmt.Errorf("forbidden_change_scope: %w", err)
}
for _, a := range allowedPatterns {
for _, f := range forbiddenPatterns {
if scopePatternsOverlap(a, f) {
return fmt.Errorf("allowed path %q overlaps forbidden path %q", a.raw, f.raw)
}
}
}
return nil
}
func scopePatternsOverlap(a, b scopePattern) bool {
if a.subtree != "" && b.subtree != "" {
return a.subtree == b.subtree || strings.HasPrefix(a.subtree, b.subtree+"/") || strings.HasPrefix(b.subtree, a.subtree+"/")
}
if a.subtree != "" {
return b.raw == a.subtree || strings.HasPrefix(b.raw, a.subtree+"/")
}
if b.subtree != "" {
return a.raw == b.subtree || strings.HasPrefix(a.raw, b.subtree+"/")
}
return a.raw == b.raw
}
func mustCompile(values []string) []scopePattern {
patterns, err := compileScopePatterns(values)
if err != nil {
panic(err)
}
return patterns
}
-27
View File
@@ -1,27 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestValidateScopeOverlapRejectsAllowedForbiddenConflict(t *testing.T) {
err := validateScopeOverlap([]string{"cmd/creator-web/server.go"}, []string{"cmd/creator-web/**"})
if err == nil || !strings.Contains(err.Error(), "overlaps forbidden") {
t.Fatalf("overlap error = %v", err)
}
}
func TestValidateRequiredTestsRejectsIncompleteEvidence(t *testing.T) {
err := validateRequiredTests([]string{"go test ./..."})
if err == nil || !strings.Contains(err.Error(), "missing required governance evidence") {
t.Fatalf("required test error = %v", err)
}
}
func TestValidateDeltaRejectsUnknownOrMissingFields(t *testing.T) {
err := validateDelta(map[string]int{"new_privileges": 0})
if err == nil || !strings.Contains(err.Error(), "exactly") {
t.Fatalf("delta error = %v", err)
}
}
-74
View File
@@ -1,74 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
func main() {
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
pollInterval := flag.Duration("poll-interval", 15*time.Second, "database-owned idle wake-up interval")
once := flag.Bool("once", false, "run one cycle and exit")
flag.Parse()
ctx := context.Background()
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if err != nil {
panic(err)
}
config := postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password}
repository, err := postgres.OpenEpistemics(ctx, config)
if err != nil {
panic(err)
}
defer repository.Close()
for {
now := time.Now().UnixMilli()
if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil {
panic(err)
}
candidate, claimErr := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds())
if claimErr == nil {
// This worker only closes the durable evaluation loop. Any cognitive
// proposal remains a candidate and must be admitted by a separate governed flow.
if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil {
panic(err)
}
fmt.Printf("resolved reflection candidate %s\n", candidate.ID)
} else if *once {
fmt.Println("no reflection candidate")
}
if *once {
return
}
if err := repository.WaitForReflectionCandidate(ctx, *pollInterval); err != nil {
panic(err)
}
}
}
func resolveDatabasePassword(name, refName string) (string, error) {
if _, ok := os.LookupEnv(name); ok {
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
}
path, ref := os.Getenv(name+"_FILE"), os.Getenv(refName)
if path == "" || ref == "" {
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
resolver, err := platformsecrets.NewFileResolver(root)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
}
-126
View File
@@ -1,126 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
researchadapter "git.cin.su/m/Assistant/adapters/research"
"git.cin.su/m/Assistant/contracts/epistemics"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/modules/epistemics/application"
"git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
)
func main() {
ctx := context.Background()
password, err := resolveDatabasePassword()
if err != nil {
log.Fatal(err)
}
repository, err := postgres.OpenEpistemics(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("EPISTEMICS_POSTGRES_USER"), Password: password})
if err != nil {
log.Fatal(err)
}
defer repository.Close()
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "research-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "research-runtime", Probes: []health.Probe{{Name: "epistemics-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
mux.HandleFunc("POST /v1/research/evaluate", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.ResearchEvaluationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&request); err != nil {
http.Error(w, "invalid research evidence", http.StatusBadRequest)
return
}
result, err := application.EvaluateAndPersist(r.Context(), request, repository, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
connector := researchadapter.RepositoryConnector{Root: os.Getenv("RESEARCH_SOURCE_ROOT")}
mux.HandleFunc("POST /v1/research/repository/evaluate", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.RepositoryResearchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
http.Error(w, "invalid repository research request", http.StatusBadRequest)
return
}
candidate, err := connector.Collect(r.Context(), request, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
webConnector := researchadapter.HTTPConnector{AllowedHosts: allowedHosts(os.Getenv("RESEARCH_HTTP_ALLOWED_HOSTS")), Client: &http.Client{Timeout: 5 * time.Second}}
mux.HandleFunc("POST /v1/research/web/evaluate", func(w http.ResponseWriter, r *http.Request) {
var request epistemics.WebResearchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10)).Decode(&request); err != nil {
http.Error(w, "invalid web research request", http.StatusBadRequest)
return
}
candidate, err := webConnector.Collect(r.Context(), request, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: []epistemics.ResearchCandidate{candidate}}, repository, time.Now().UnixMilli())
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(result)
})
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8087"
}
log.Printf("research-runtime listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func allowedHosts(value string) map[string]struct{} {
result := map[string]struct{}{}
for _, host := range strings.Split(value, ",") {
if host = strings.ToLower(strings.TrimSpace(host)); host != "" {
result[host] = struct{}{}
}
}
return result
}
func resolveDatabasePassword() (string, error) {
if _, ok := os.LookupEnv("EPISTEMICS_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv("EPISTEMICS_POSTGRES_PASSWORD_FILE"), os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if path == "" || ref == "" {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_PASSWORD_FILE and EPISTEMICS_POSTGRES_CREDENTIAL_REF are required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
resolver, err := secrets.NewFileResolver(root)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
}
-76
View File
@@ -1,76 +0,0 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
const verifierIdentity = "deterministic-task-verifier-v1"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"runtime":"task-verifier","status":"ok"}`))
})
mux.HandleFunc("POST /v1/task-evaluations/verify", verifyHandler)
addr := ":8091"
if value := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); value != "" {
addr = value
}
if err := http.ListenAndServe(addr, mux); err != nil {
panic(err)
}
}
func verifyHandler(w http.ResponseWriter, r *http.Request) {
var request capability.TaskCaseVerificationRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.CaseID == "" || request.ResponseText == "" || request.EvidenceArtifactRef == "" || request.LatencyMicros < 0 || (request.Verification.Mode != capability.TaskCaseVerificationExactText && request.Verification.Mode != capability.TaskCaseVerificationJSONFieldEquals) || (request.Verification.Mode == capability.TaskCaseVerificationJSONFieldEquals && request.Verification.FieldPath == "") || request.Verification.ExpectedText == "" || request.Verification.MinimumQualityMillis < 0 || request.Verification.MinimumQualityMillis > 1000 {
http.Error(w, "invalid task verification request", http.StatusBadRequest)
return
}
matched := matches(request)
quality := int64(0)
result := capability.EvaluationUnsupported
if matched {
quality = request.Verification.MinimumQualityMillis
if quality == 0 {
quality = 1000
}
result = capability.EvaluationSupported
}
evidence := verificationEvidence(request, matched)
writeJSON(w, capability.TaskCaseVerificationResponse{CaseID: request.CaseID, Result: result, QualityScoreMillis: quality, LatencyMicros: request.LatencyMicros, EvidenceArtifactRef: request.EvidenceArtifactRef, VerificationEvidenceRef: evidence, VerifierIdentity: verifierIdentity})
}
func verificationEvidence(request capability.TaskCaseVerificationRequest, matched bool) string {
payload := fmt.Sprintf("%s|%s|%s|%s|%s|%t", verifierIdentity, request.CaseID, request.EvidenceArtifactRef, request.Verification.Mode, request.Verification.FieldPath, matched)
digest := sha256.Sum256([]byte(payload))
return "verification:" + hex.EncodeToString(digest[:])
}
func matches(request capability.TaskCaseVerificationRequest) bool {
if request.Verification.Mode == capability.TaskCaseVerificationExactText {
return strings.TrimSpace(request.ResponseText) == strings.TrimSpace(request.Verification.ExpectedText)
}
var document map[string]json.RawMessage
if json.Unmarshal([]byte(request.ResponseText), &document) != nil {
return false
}
value, ok := document[request.Verification.FieldPath]
return ok && strings.TrimSpace(string(value)) == strings.TrimSpace(request.Verification.ExpectedText)
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
-41
View File
@@ -1,41 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.cin.su/m/Assistant/contracts/capability"
)
func TestVerifyExactTextProducesIndependentEvidence(t *testing.T) {
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: " answer ", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
recorder := httptest.NewRecorder()
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
var response capability.TaskCaseVerificationResponse
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 900 || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" {
t.Fatalf("unexpected verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
}
}
func TestVerifyMismatchIsUnsupportedWithEvidence(t *testing.T) {
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: "wrong", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
recorder := httptest.NewRecorder()
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
var response capability.TaskCaseVerificationResponse
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationUnsupported || response.QualityScoreMillis != 0 || response.VerificationEvidenceRef == "" {
t.Fatalf("unexpected mismatch response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
}
}
func TestVerifyJSONFieldEquals(t *testing.T) {
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "json-1", ResponseText: `{"answer":42}`, EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationJSONFieldEquals, FieldPath: "answer", ExpectedText: "42", MinimumQualityMillis: 850}})
recorder := httptest.NewRecorder()
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
var response capability.TaskCaseVerificationResponse
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 850 {
t.Fatalf("unexpected JSON verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
}
}
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"git.cin.su/m/Assistant/adapters/postgres"
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
"git.cin.su/m/Assistant/contracts/work"
"git.cin.su/m/Assistant/modules/work/application"
"git.cin.su/m/Assistant/platform/secrets"
"git.cin.su/m/Assistant/runtime/health"
)
func main() {
ctx := context.Background()
password, err := resolveDatabasePassword()
if err != nil {
log.Fatal(err)
}
repository, err := postgres.OpenWork(ctx, postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("WORK_POSTGRES_USER"), Password: password})
if err != nil {
log.Fatal(err)
}
defer repository.Close()
evidenceSink, err := newCoreTaskEvaluationSink(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 12 * time.Second})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/healthz", health.Handler{Runtime: "work-graph-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "work-graph-runtime", Probes: []health.Probe{{Name: "work-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
mux.HandleFunc("POST /v1/work-graphs/schedule", func(w http.ResponseWriter, r *http.Request) {
var request application.GraphScheduleRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
http.Error(w, "invalid graph schedule request", http.StatusBadRequest)
return
}
graph, ready, err := application.AdmitAndSchedule(r.Context(), repository, request)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
})
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/schedule", func(w http.ResponseWriter, r *http.Request) {
var request application.TaskEvaluationScheduleRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
http.Error(w, "invalid task evaluation graph request", http.StatusBadRequest)
return
}
graph, ready, err := application.AdmitAndScheduleTaskEvaluation(r.Context(), repository, request)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
})
mux.HandleFunc("POST /v1/work-graphs/orchestration/next", func(w http.ResponseWriter, r *http.Request) {
var request work.SolutionSearchInput
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&request); err != nil {
http.Error(w, "invalid orchestration input", http.StatusBadRequest)
return
}
decision, err := application.DecideSolutionSearch(request)
if err != nil {
http.Error(w, "invalid orchestration input", http.StatusUnprocessableEntity)
return
}
writeJSON(w, decision)
})
mux.HandleFunc("POST /v1/work-graphs/orchestration/wave", func(w http.ResponseWriter, r *http.Request) {
var request work.SolutionSearchWaveRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
http.Error(w, "invalid orchestration wave request", http.StatusBadRequest)
return
}
result, err := application.OrchestrateSolutionSearch(r.Context(), repository, request)
if err != nil {
http.Error(w, "invalid orchestration wave request", http.StatusUnprocessableEntity)
return
}
writeJSON(w, result)
})
mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) {
var request application.GraphCompleteRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
http.Error(w, "invalid graph completion request", http.StatusBadRequest)
return
}
graph, err := application.CompleteGraphNode(r.Context(), repository, request.GraphID, request.Revision, request.NodeID, request.Succeeded)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, graph)
})
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/callback", func(w http.ResponseWriter, r *http.Request) {
var request work.TaskEvaluationEvidenceCallback
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
http.Error(w, "invalid task evaluation callback", http.StatusBadRequest)
return
}
graph, err := application.CompleteTaskEvaluationNode(r.Context(), repository, evidenceSink, request)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, graph)
})
mux.HandleFunc("POST /v1/work-graphs/synthesis", func(w http.ResponseWriter, r *http.Request) {
var request application.GraphSynthesisRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
http.Error(w, "invalid graph synthesis request", http.StatusBadRequest)
return
}
conflict, synthesis, err := application.SynthesizeGraph(r.Context(), repository, request)
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis})
})
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8088"
}
log.Printf("work-graph-runtime listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
func resolveDatabasePassword() (string, error) {
if _, ok := os.LookupEnv("WORK_POSTGRES_PASSWORD"); ok {
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD direct environment secret is forbidden")
}
path, ref := os.Getenv("WORK_POSTGRES_PASSWORD_FILE"), os.Getenv("WORK_POSTGRES_CREDENTIAL_REF")
if path == "" || ref == "" {
return "", fmt.Errorf("WORK_POSTGRES_PASSWORD_FILE and WORK_POSTGRES_CREDENTIAL_REF are required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
root = "/run/secrets"
}
resolver, err := secrets.NewFileResolver(root)
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerWorkWorker, Path: path})
}
@@ -1,49 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
type coreTaskEvaluationSink struct {
baseURL string
client *http.Client
}
func newCoreTaskEvaluationSink(baseURL string, client *http.Client) (*coreTaskEvaluationSink, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("core runtime URL is required")
}
if client == nil {
client = http.DefaultClient
}
return &coreTaskEvaluationSink{baseURL: baseURL, client: client}, nil
}
func (s *coreTaskEvaluationSink) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
payload, err := json.Marshal(value)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/v1/cognitive-registry/task-evaluations", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := s.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("save task evaluation: status %s", response.Status)
}
return nil
}
-19
View File
@@ -2,23 +2,4 @@ package artifact
import "git.cin.su/m/Assistant/platform/ids"
import "errors"
type ArtifactID ids.ID
type AgentArtifactAttestation struct {
ArtifactID ArtifactID
ProducerRunID string
SourceRevision string
WorktreeID string
ContentDigest string
TestEvidenceRef string
CreatedAtUnixMillis int64
}
func (a AgentArtifactAttestation) Validate() error {
if a.ArtifactID == "" || a.ProducerRunID == "" || a.SourceRevision == "" || a.WorktreeID == "" || a.ContentDigest == "" || a.TestEvidenceRef == "" || a.CreatedAtUnixMillis < 1 {
return errors.New("agent artifact attestation is incomplete")
}
return nil
}
-73
View File
@@ -1,73 +0,0 @@
package capability
type AdmissionComplexity string
type AdmissionDecisionState string
type AdmissionReason string
const (
AdmissionSimple AdmissionComplexity = "SIMPLE"
AdmissionNormal AdmissionComplexity = "NORMAL"
AdmissionHeavy AdmissionComplexity = "HEAVY"
AdmissionAdmitted AdmissionDecisionState = "ADMITTED"
AdmissionRejected AdmissionDecisionState = "REJECTED"
AdmissionReasonNone AdmissionReason = "NONE"
AdmissionReasonConcurrency AdmissionReason = "CONCURRENCY_LIMIT"
AdmissionReasonAcceptedHour AdmissionReason = "ACCEPTED_PER_HOUR_LIMIT"
AdmissionReasonDeadline AdmissionReason = "DEADLINE"
AdmissionReasonInputBudget AdmissionReason = "INPUT_BUDGET"
AdmissionReasonOutputBudget AdmissionReason = "OUTPUT_BUDGET"
)
type AdmissionPolicy struct {
ID string
MaxConcurrent int
MaxAcceptedPerHour int
MaxInputBytes int
MaxOutputBytes int
}
type AdmissionRequest struct {
Complexity AdmissionComplexity `json:"complexity"`
InputBytes int `json:"input_bytes"`
MaxOutputBytes int `json:"max_output_bytes"`
DeadlineMillis int64 `json:"deadline_millis"`
}
type AdmissionCounters struct {
Concurrent int
AcceptedLastHour int
}
type AdmissionDecision struct {
State AdmissionDecisionState `json:"state"`
Reason AdmissionReason `json:"reason"`
PolicyID string `json:"policy_id"`
}
type AdmissionLease struct {
ID string `json:"id"`
PolicyID string `json:"policy_id"`
}
func Admit(request AdmissionRequest, policy AdmissionPolicy, counters AdmissionCounters, nowMillis int64) AdmissionDecision {
decision := AdmissionDecision{State: AdmissionAdmitted, Reason: AdmissionReasonNone, PolicyID: policy.ID}
if policy.ID == "" || policy.MaxConcurrent < 1 || policy.MaxAcceptedPerHour < 1 || policy.MaxInputBytes < 1 || policy.MaxOutputBytes < 1 {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID}
}
if counters.Concurrent >= policy.MaxConcurrent {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonConcurrency, PolicyID: policy.ID}
}
if counters.AcceptedLastHour >= policy.MaxAcceptedPerHour {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonAcceptedHour, PolicyID: policy.ID}
}
if request.InputBytes < 0 || request.InputBytes > policy.MaxInputBytes {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonInputBudget, PolicyID: policy.ID}
}
if request.MaxOutputBytes < 1 || request.MaxOutputBytes > policy.MaxOutputBytes {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonOutputBudget, PolicyID: policy.ID}
}
if request.DeadlineMillis > 0 && request.DeadlineMillis <= nowMillis {
return AdmissionDecision{State: AdmissionRejected, Reason: AdmissionReasonDeadline, PolicyID: policy.ID}
}
return decision
}
+24 -98
View File
@@ -23,10 +23,6 @@ type OperationalState string
type EvaluationResult string
type EvaluationID ids.ID
type PerformanceEvidenceID ids.ID
type BillingMode string
type AuthMode string
type SelectionObjective string
type ModelRatingCategory string
const (
QualificationDiscovered QualificationState = "DISCOVERED"
@@ -56,29 +52,6 @@ const (
EvaluationUnknown EvaluationResult = "UNKNOWN"
EvaluationSupported EvaluationResult = "SUPPORTED"
EvaluationUnsupported EvaluationResult = "UNSUPPORTED"
BillingUnknown BillingMode = "UNKNOWN"
BillingFree BillingMode = "FREE"
BillingPerInput BillingMode = "PER_INPUT_UNIT"
BillingPerOutput BillingMode = "PER_OUTPUT_UNIT"
BillingPerRequest BillingMode = "PER_REQUEST"
AuthNone AuthMode = "NONE"
AuthKey AuthMode = "API" + "_KEY"
AuthBearer AuthMode = "BEARER"
AuthUsernamePassword AuthMode = "USERNAME_PASSWORD"
AuthOAuth AuthMode = "OAUTH"
AuthSecretFile AuthMode = "SECRET_FILE"
SelectionBalanced SelectionObjective = "BALANCED"
SelectionQuality SelectionObjective = "QUALITY"
SelectionSpeed SelectionObjective = "SPEED"
SelectionEconomy SelectionObjective = "ECONOMY"
ModelRatingUnrated ModelRatingCategory = "UNRATED"
ModelRatingFast ModelRatingCategory = "FAST"
ModelRatingBalanced ModelRatingCategory = "BALANCED"
ModelRatingQuality ModelRatingCategory = "QUALITY"
)
// ProviderInstance contains only opaque references to endpoint and credentials.
@@ -93,26 +66,20 @@ type ProviderInstance struct {
Qualification QualificationState
Health HealthState
ObservedAtUnixMillis int64
BillingMode BillingMode
AuthMode AuthMode
InputCostMicros *int64
OutputCostMicros *int64
RequestCostMicros *int64
}
type ModelDescriptor struct {
ID ModelDescriptorID
Revision int64
ProviderInstanceID ProviderInstanceID
ProviderModelID ProviderModelID
Fingerprint ModelFingerprint
MaxContextBytes int64
Modalities []ModelModality
Capabilities []ModelCapability
Qualification QualificationState
Health HealthState
ObservedAtUnixMillis int64
IntelligenceScoreMillis *int64
ID ModelDescriptorID
Revision int64
ProviderInstanceID ProviderInstanceID
ProviderModelID ProviderModelID
Fingerprint ModelFingerprint
MaxContextBytes int64
Modalities []ModelModality
Capabilities []ModelCapability
Qualification QualificationState
Health HealthState
ObservedAtUnixMillis int64
}
type ProviderHealthObservation struct {
@@ -158,34 +125,16 @@ type CognitiveRequirement struct {
InputBytes int64
MaxOutputBytes int64
ExcludedBindingIDs []CapabilityBindingID
Objective SelectionObjective
MaxCostMicros *int64
TaskProfileID TaskEvaluationProfileID
}
// ResolvedCognitiveBinding is a provider-neutral invocation snapshot selected
// by Capability. It contains references and declared metadata, never secrets.
type ResolvedCognitiveBinding struct {
Binding CognitiveBinding
Provider ProviderInstance
Model ModelDescriptor
Evaluation *EvaluatedCapability
Performance *PerformanceEvidence
TaskEvaluation *TaskEvaluation
Rating *ModelRating
}
// ModelRating is a derived, provider-neutral explanation of why an eligible
// model is routed. It never grants qualification or activation authority.
type ModelRating struct {
TaskProfileID TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
TaskScoreMillis int64 `json:"task_score_millis,omitempty"`
Category ModelRatingCategory `json:"category"`
OverallScoreMillis int64 `json:"overall_score_millis"`
QualityScoreMillis int64 `json:"quality_score_millis"`
ReliabilityScoreMillis int64 `json:"reliability_score_millis"`
LatencyScoreMillis int64 `json:"latency_score_millis"`
EvidenceScoreMillis int64 `json:"evidence_score_millis"`
Binding CognitiveBinding
Provider ProviderInstance
Model ModelDescriptor
Evaluation *EvaluatedCapability
Performance *PerformanceEvidence
}
type CognitiveResultClass string
@@ -257,23 +206,6 @@ type CognitiveCanaryPromotion struct {
Evaluation EvaluatedCapability
}
type CognitiveActivationPromotionRequest struct {
PromotionID ids.ID
RequestedByPrincipalID identity.PrincipalID
CapabilityID CapabilityID
BindingID CapabilityBindingID
ExpectedBindingRevision int64
EvaluationID EvaluationID
ObservedAtUnixMillis int64
}
type CognitiveActivationPromotion struct {
PromotionID ids.ID
ExpectedBindingRevision int64
Binding CognitiveBinding
Evaluation EvaluatedCapability
}
// CognitiveDiscoverySnapshot is provider-neutral observed inventory submitted
// to the Capability owner. The owner assigns immutable revisions and always
// registers discovered bindings as DISCOVERED/INACTIVE.
@@ -290,24 +222,18 @@ type CognitiveDiscoverySnapshot struct {
Health HealthState
FailureClass CognitiveFailureType
ObservedAtUnixMillis int64
BillingMode BillingMode
AuthMode AuthMode
InputCostMicros *int64
OutputCostMicros *int64
RequestCostMicros *int64
Models []DiscoveredModelSnapshot
}
type DiscoveredModelSnapshot struct {
ProviderModelID ProviderModelID
Fingerprint ModelFingerprint
MaxContextBytes int64
Modalities []ModelModality
Capabilities []ModelCapability
Installed OperationalState
Loaded OperationalState
Invokable OperationalState
IntelligenceScoreMillis *int64
ProviderModelID ProviderModelID
Fingerprint ModelFingerprint
MaxContextBytes int64
Modalities []ModelModality
Capabilities []ModelCapability
Installed OperationalState
Loaded OperationalState
Invokable OperationalState
}
type CognitiveUsageObservation struct {
-25
View File
@@ -1,25 +0,0 @@
package capability
type FleetTier string
const (
FleetTierFast FleetTier = "FAST"
FleetTierMedium FleetTier = "MEDIUM"
FleetTierHeavy FleetTier = "HEAVY"
FleetTierLifeboat FleetTier = "LIFEBOAT"
)
type FleetMember struct {
ProviderInstanceID ProviderInstanceID `json:"provider_instance_id"`
ModelDescriptorID ModelDescriptorID `json:"model_descriptor_id"`
Tier FleetTier `json:"tier"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
}
type FleetReadiness struct {
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
Members []FleetMember `json:"members"`
ReadyTiers []FleetTier `json:"ready_tiers"`
FallbackTier FleetTier `json:"fallback_tier"`
}
-72
View File
@@ -1,72 +0,0 @@
package capability
import "errors"
type QualificationManifestID string
type QualificationDimension string
const (
DimensionDialogue QualificationDimension = "DIALOGUE"
DimensionInterpretation QualificationDimension = "INTERPRETATION"
DimensionCoding QualificationDimension = "CODING"
DimensionResearch QualificationDimension = "RESEARCH"
DimensionUncertainty QualificationDimension = "UNCERTAINTY"
DimensionContext QualificationDimension = "CONTEXT"
DimensionLatency QualificationDimension = "LATENCY"
DimensionResources QualificationDimension = "RESOURCES"
DimensionRestart QualificationDimension = "RESTART"
)
type QualificationMeasurement struct {
Dimension QualificationDimension
Result EvaluationResult
Artifact string
}
type QualificationManifest struct {
ID QualificationManifestID
ModelDescriptorID ModelDescriptorID
ModelRevision int64
ModelFingerprint ModelFingerprint
BenchmarkVersion string
HoldoutCorpusDigest string
LicenseEvidence string
DataPolicyEvidence string
Measurements []QualificationMeasurement
ObservedAtUnixMillis int64
}
func (m QualificationManifest) Validate() error {
if m.ID == "" || m.ModelDescriptorID == "" || m.ModelRevision < 1 || m.ModelFingerprint == "" || m.BenchmarkVersion == "" || m.HoldoutCorpusDigest == "" || m.LicenseEvidence == "" || m.DataPolicyEvidence == "" || m.ObservedAtUnixMillis < 1 {
return errors.New("qualification manifest identity and evidence are incomplete")
}
required := map[QualificationDimension]bool{DimensionDialogue: false, DimensionInterpretation: false, DimensionCoding: false, DimensionResearch: false, DimensionUncertainty: false, DimensionContext: false, DimensionLatency: false, DimensionResources: false, DimensionRestart: false}
for _, measurement := range m.Measurements {
if measurement.Dimension == "" || measurement.Artifact == "" || measurement.Result == EvaluationUnknown {
return errors.New("qualification measurement is incomplete")
}
if _, ok := required[measurement.Dimension]; ok {
required[measurement.Dimension] = true
}
}
for _, present := range required {
if !present {
return errors.New("qualification manifest lacks required measurement")
}
}
return nil
}
// QualificationState projects evidence into a qualification result. It does
// not activate a binding or mutate the model registry.
func (m QualificationManifest) QualificationState() QualificationState {
if m.Validate() != nil {
return QualificationRejected
}
for _, measurement := range m.Measurements {
if measurement.Result != EvaluationSupported {
return QualificationRejected
}
}
return QualificationValidated
}
-122
View File
@@ -1,122 +0,0 @@
package capability
import (
"errors"
"git.cin.su/m/Assistant/contracts/artifact"
"git.cin.su/m/Assistant/platform/ids"
)
type TaskEvaluationProfileID ids.ID
type TaskEvaluationID ids.ID
type TaskEvaluationProfileStage string
const (
TaskEvaluationGeneral TaskEvaluationProfileStage = "GENERAL"
TaskEvaluationAdditional TaskEvaluationProfileStage = "ADDITIONAL"
)
// TaskEvaluationProfile is the extensible, provider-neutral description of a
// task-specific qualification run. Case payloads live in Artifact; the
// profile stores only stable case references and scoring policy.
type TaskEvaluationProfile struct {
ID TaskEvaluationProfileID
Revision int64
CapabilityID CapabilityID
Stage TaskEvaluationProfileStage
ParentProfileID TaskEvaluationProfileID
TriggerEvidenceRef string
BenchmarkVersion string
RequiredCaseIDs []string
QualityWeightMillis int64
ReliabilityWeightMillis int64
LatencyWeightMillis int64
}
func (p TaskEvaluationProfile) Validate() error {
if p.ID == "" || p.Revision < 1 || p.CapabilityID == "" || (p.Stage != TaskEvaluationGeneral && p.Stage != TaskEvaluationAdditional) || p.BenchmarkVersion == "" || len(p.RequiredCaseIDs) == 0 {
return errors.New("task evaluation profile identity and cases are incomplete")
}
if p.Stage == TaskEvaluationGeneral && (p.ParentProfileID != "" || p.TriggerEvidenceRef != "") {
return errors.New("general task evaluation profile cannot have an expansion parent")
}
if p.Stage == TaskEvaluationAdditional && (p.ParentProfileID == "" || p.TriggerEvidenceRef == "") {
return errors.New("additional task evaluation profile requires parent and trigger evidence")
}
if p.QualityWeightMillis < 0 || p.ReliabilityWeightMillis < 0 || p.LatencyWeightMillis < 0 || p.QualityWeightMillis+p.ReliabilityWeightMillis+p.LatencyWeightMillis != 1000 {
return errors.New("task evaluation profile weights must be non-negative and sum to 1000")
}
seen := make(map[string]struct{}, len(p.RequiredCaseIDs))
for _, caseID := range p.RequiredCaseIDs {
if caseID == "" {
return errors.New("task evaluation profile contains an empty case")
}
if _, ok := seen[caseID]; ok {
return errors.New("task evaluation profile contains duplicate cases")
}
seen[caseID] = struct{}{}
}
return nil
}
// TaskEvaluationExpansionRequest is emitted after a task remains unresolved
// with the current best eligible candidate. It creates only a bounded new
// profile; it never silently rewrites the general benchmark.
type TaskEvaluationExpansionRequest struct {
ParentProfileID TaskEvaluationProfileID
CapabilityID CapabilityID
TriggerEvidenceRef string
NewCaseIDs []string
BenchmarkVersion string
}
type TaskCaseVerificationMode string
const (
TaskCaseVerificationExactText TaskCaseVerificationMode = "EXACT_TEXT"
TaskCaseVerificationJSONFieldEquals TaskCaseVerificationMode = "JSON_FIELD_EQUALS"
)
// TaskCaseVerification is a bounded, task-owned rubric. The expected text is
// test data, not model output, and is never sent to the cognitive provider.
type TaskCaseVerification struct {
Mode TaskCaseVerificationMode
FieldPath string
ExpectedText string
MinimumQualityMillis int64
}
// TaskCaseObservation is produced by the bounded evaluator and verifier. A
// score without a case result and evidence reference is not accepted as
// qualification evidence.
type TaskCaseObservation struct {
CaseID string
Result EvaluationResult
QualityScoreMillis int64
LatencyMicros int64
CriticalFailure bool
EvidenceArtifactRef string
// VerificationEvidenceRef and VerifierIdentity are required whenever an
// observation claims semantic support or a non-zero quality score.
VerificationEvidenceRef string
VerifierIdentity string
}
// TaskEvaluation is durable evidence for one model/binding revision under one
// task profile. It is not activation authority.
type TaskEvaluation struct {
ID TaskEvaluationID
ProfileID TaskEvaluationProfileID
BindingID CapabilityBindingID
BindingRevision int64
CapabilityID CapabilityID
Result EvaluationResult
QualityScoreMillis int64
ReliabilityScoreMillis int64
LatencyScoreMillis int64
OverallScoreMillis int64
CriticalFailureMillis int64
BenchmarkVersion string
ArtifactID artifact.ArtifactID
ObservedAtUnixMillis int64
}
@@ -1,63 +0,0 @@
package capability
import (
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/identity"
)
// TaskEvaluationCase carries one bounded prompt selected by the evaluator.
// The prompt is an input artifact, not a model-produced fact.
type TaskEvaluationCase struct {
CaseID string
Prompt string
EvidenceArtifactRef string
Verification TaskCaseVerification
}
type TaskCaseResponse struct {
CaseID string
ResponseText string
LatencyMicros int64
EvidenceArtifactRef string
}
type TaskCaseVerificationRequest struct {
CaseID string
ResponseText string
LatencyMicros int64
EvidenceArtifactRef string
Verification TaskCaseVerification
}
type TaskCaseVerificationResponse struct {
CaseID string
Result EvaluationResult
QualityScoreMillis int64
LatencyMicros int64
CriticalFailure bool
EvidenceArtifactRef string
VerificationEvidenceRef string
VerifierIdentity string
}
type TaskEvaluationExecutionRequest struct {
Profile TaskEvaluationProfile
CapabilityID CapabilityID
BindingID CapabilityBindingID
BindingRevision int64
ActorID identity.ActorID
PrincipalID identity.PrincipalID
ScopeID authority.ScopeID
Cases []TaskEvaluationCase
MaxOutputBytes int64
EgressAcknowledged bool
}
type TaskEvaluationExecutionResponse struct {
ProfileID TaskEvaluationProfileID
BindingID CapabilityBindingID
BindingRevision int64
Observations []TaskCaseObservation
Responses []TaskCaseResponse
ObservedAtUnixMillis int64
}
-91
View File
@@ -1,91 +0,0 @@
package cognitive
import (
"errors"
"sort"
"strings"
"git.cin.su/m/Assistant/contracts/capability"
)
var ErrInvalidContextBudget = errors.New("context budget must be positive")
type ContextCandidate struct {
Excerpt ContextExcerpt
Relevance int
}
type ContextCompileInput struct {
Candidates []ContextCandidate
DataPolicy capability.DataPolicy
MaxBytes int
}
type ContextOmission struct {
ID ContextExcerptID
Reason string
}
type ContextBundle struct {
Excerpts []ContextExcerpt
Omissions []ContextOmission
Bytes int
}
// CompileContext deterministically selects permitted, relevant excerpts for a
// gateway request. It carries no provider identity and performs no I/O.
func CompileContext(input ContextCompileInput) (ContextBundle, error) {
if input.MaxBytes <= 0 {
return ContextBundle{}, ErrInvalidContextBudget
}
candidates := append([]ContextCandidate(nil), input.Candidates...)
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].Relevance != candidates[j].Relevance {
return candidates[i].Relevance > candidates[j].Relevance
}
return string(candidates[i].Excerpt.ID) < string(candidates[j].Excerpt.ID)
})
bundle := ContextBundle{Excerpts: []ContextExcerpt{}, Omissions: []ContextOmission{}}
seen := make(map[ContextExcerptID]struct{}, len(candidates))
for _, candidate := range candidates {
excerpt := candidate.Excerpt
if _, ok := seen[excerpt.ID]; ok {
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "duplicate"})
continue
}
seen[excerpt.ID] = struct{}{}
reason := permittedContextReason(excerpt, input.DataPolicy)
if reason != "" {
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: reason})
continue
}
if strings.TrimSpace(excerpt.Text) == "" {
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "empty"})
continue
}
bytes := len([]byte(excerpt.Text))
if bundle.Bytes+bytes > input.MaxBytes {
bundle.Omissions = append(bundle.Omissions, ContextOmission{ID: excerpt.ID, Reason: "budget"})
continue
}
bundle.Excerpts = append(bundle.Excerpts, excerpt)
bundle.Bytes += bytes
}
return bundle, nil
}
func permittedContextReason(excerpt ContextExcerpt, policy capability.DataPolicy) string {
if excerpt.Sensitivity == SensitivitySecret {
return "secret"
}
if excerpt.Sensitivity == SensitivityUnknown {
return "unknown_sensitivity"
}
if policy == capability.DataPolicyExternalMessageOnly {
return "policy_message_only"
}
if policy == capability.DataPolicyExternalMinimizedContext && excerpt.Sensitivity != SensitivityPublic {
return "policy_public_only"
}
return ""
}
-14
View File
@@ -4,7 +4,6 @@ import (
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/interaction"
"git.cin.su/m/Assistant/platform/ids"
)
@@ -80,19 +79,6 @@ type GatewayAdvisoryRequest struct {
MaxOutputBytes int
DataPolicy capability.DataPolicy
EgressAcknowledged bool
Meaning TaskMeaning
}
// TaskMeaning is the trusted semantic summary produced by Core. It is not a
// browser-controlled provider selector; the Capability owner still resolves
// the actual binding.
type TaskMeaning struct {
SpeechAct string
CapabilityKind capability.CapabilityKind
CapabilityHints []capability.CapabilityKind
RequestedOutcome string
InformationRequestKind interaction.InformationRequestKind
ConfidenceMillis int64
}
type GatewayAdvisoryPolicyRequest struct {
-170
View File
@@ -1,170 +0,0 @@
package epistemics
import (
"errors"
"strings"
)
var ErrCitationEvidenceMismatch = errors.New("citation evidence does not match source chain")
type ResearchPlanID string
type SourceID string
type SourceSnapshotID string
type SourceExcerptID string
type CitationID string
type FreshnessState string
type CoverageState string
type ConflictState string
type EvidenceKind string
type ResearchFindingState string
const (
FreshnessCurrent FreshnessState = "CURRENT"
FreshnessStale FreshnessState = "STALE"
FreshnessUnknown FreshnessState = "UNKNOWN"
CoverageComplete CoverageState = "COMPLETE"
CoveragePartial CoverageState = "PARTIAL"
CoverageUnknown CoverageState = "UNKNOWN"
ConflictNone ConflictState = "NONE"
ConflictPresent ConflictState = "PRESENT"
ConflictUnknown ConflictState = "UNKNOWN"
EvidenceObserved EvidenceKind = "OBSERVED"
EvidenceAssumed EvidenceKind = "ASSUMED"
FindingSupported ResearchFindingState = "SUPPORTED"
FindingStale ResearchFindingState = "STALE"
FindingConflicted ResearchFindingState = "CONFLICTED"
FindingUnknown ResearchFindingState = "UNKNOWN"
)
type ResearchPlan struct {
ID ResearchPlanID
Question string
SourceClasses []string
FreshnessPolicy string
CreatedAtUnixMillis int64
}
type Source struct {
ID SourceID
Kind string
Locator string
Title string
Publisher string
}
type SourceSnapshot struct {
ID SourceSnapshotID
SourceID SourceID
ContentDigest string
CapturedAtUnixMillis int64
Freshness FreshnessState
}
type SourceExcerpt struct {
ID SourceExcerptID
SnapshotID SourceSnapshotID
Text string
Start int64
End int64
}
type Citation struct {
ID CitationID
ExcerptID SourceExcerptID
Evidence EvidenceKind
Coverage CoverageState
Conflict ConflictState
ResolvedAtUnixMillis int64
}
// ResearchCandidate is read-only evidence returned by a connector. The
// connector supplies the immutable snapshot and exact excerpt; the evaluator
// never fetches, rewrites or invents either one.
type ResearchCandidate struct {
PropositionKey string
Statement string
Source Source
Snapshot SourceSnapshot
Excerpt SourceExcerpt
Evidence EvidenceKind
Coverage CoverageState
Conflict ConflictState
}
type ResearchFinding struct {
PropositionKey string
Statement string
Claim Claim
Citations []Citation
State ResearchFindingState
Reason string
}
type ResearchResult struct {
PlanID ResearchPlanID
Findings []ResearchFinding
EvaluatedAtUnixMillis int64
}
// ResearchEvaluationRequest carries immutable evidence from a read-only
// connector to the evaluator. The runtime never fetches or mutates a source
// from this contract.
type ResearchEvaluationRequest struct {
Plan ResearchPlan
Candidates []ResearchCandidate
}
type RepositoryResearchRequest struct {
Plan ResearchPlan
PropositionKey string
SourceID SourceID
Path string
Statement string
}
type WebResearchRequest struct {
Plan ResearchPlan
PropositionKey string
SourceID SourceID
URL string
Title string
Statement string
}
// ResolveCitation creates evidence only when all identifiers form one exact
// source chain. It deliberately does not fetch, infer or rewrite source text.
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
if source.ID == "" || strings.TrimSpace(source.Locator) == "" || snapshot.SourceID != source.ID || excerpt.SnapshotID != snapshot.ID || excerpt.Validate() != nil || resolvedAtUnixMillis < 1 || evidence == "" || coverage == "" || conflict == "" {
return Citation{}, ErrCitationEvidenceMismatch
}
citation := Citation{ID: CitationID("citation:" + string(excerpt.ID)), ExcerptID: excerpt.ID, Evidence: evidence, Coverage: coverage, Conflict: conflict, ResolvedAtUnixMillis: resolvedAtUnixMillis}
return citation, citation.Validate()
}
func (p ResearchPlan) Validate() error {
if p.ID == "" || strings.TrimSpace(p.Question) == "" || len(p.SourceClasses) == 0 || strings.TrimSpace(p.FreshnessPolicy) == "" {
return errors.New("research plan requires question, source classes and freshness policy")
}
return nil
}
func (s SourceSnapshot) Validate() error {
if s.ID == "" || s.SourceID == "" || strings.TrimSpace(s.ContentDigest) == "" || s.CapturedAtUnixMillis < 1 || s.Freshness == "" {
return errors.New("source snapshot requires source, digest, capture time and freshness")
}
return nil
}
func (e SourceExcerpt) Validate() error {
if e.ID == "" || e.SnapshotID == "" || strings.TrimSpace(e.Text) == "" || e.Start < 0 || e.End < e.Start {
return errors.New("source excerpt requires snapshot, text and valid offsets")
}
return nil
}
func (c Citation) Validate() error {
if c.ID == "" || c.ExcerptID == "" || c.Evidence == "" || c.Coverage == "" || c.Conflict == "" || c.ResolvedAtUnixMillis < 1 {
return errors.New("citation requires exact excerpt, evidence, coverage, conflict and resolution time")
}
return nil
}
-41
View File
@@ -1,41 +0,0 @@
package epistemics
type BeliefState string
const (
BeliefProvisional BeliefState = "PROVISIONAL"
BeliefConflicted BeliefState = "CONFLICTED"
BeliefVerified BeliefState = "VERIFIED"
)
type Observation struct {
ID ObservationID
SourceSnapshot SourceSnapshotID
Excerpt SourceExcerptID
Text string
ObservedAt int64
}
type Claim struct {
ID ClaimID
Statement string
ObservationID ObservationID
CitationID CitationID
Verification VerificationState
}
type Belief struct {
ID BeliefID
ClaimID ClaimID
State BeliefState
}
type TruthBundle struct {
Source Source
Snapshot SourceSnapshot
Excerpt SourceExcerpt
Observation Observation
Claim Claim
Belief Belief
Citation Citation
}
+11 -13
View File
@@ -120,17 +120,15 @@ type ExperienceRecord struct {
// Cognitive reflection, if used, must go through Cognitive Gateway/Router and
// produce candidates only; it cannot directly rewrite canonical state.
type ReflectionCandidate struct {
ID ReflectionCandidateID
Trigger ReflectionTriggerKind
State ReflectionCandidateState
SourceExperienceID ExperienceRecordID
OriginalIntentID OriginalIntentRef
SourceUtteranceID SourceUtteranceRef
Summary string
EvidenceRefs []EvidenceRef
RequiresCognition bool
RecordedAtUnixMillis int64
Revision int64
ClaimedBy string
ClaimExpiresAtUnixMillis int64
ID ReflectionCandidateID
Trigger ReflectionTriggerKind
State ReflectionCandidateState
SourceExperienceID ExperienceRecordID
OriginalIntentID OriginalIntentRef
SourceUtteranceID SourceUtteranceRef
Summary string
EvidenceRefs []EvidenceRef
RequiresCognition bool
RecordedAtUnixMillis int64
Revision int64
}
+57 -66
View File
@@ -24,52 +24,47 @@ type ClarificationState string
type SemanticApplicability string
type SemanticCandidateSource string
type SemanticCandidateSelectionState string
type InformationRequestKind string
const (
ChannelWebChat Channel = "WEB_CHAT"
ConversationOpen ConversationState = "OPEN"
ConversationArchived ConversationState = "ARCHIVED"
SpeechActDirective SpeechAct = "DIRECTIVE"
SpeechActAssertion SpeechAct = "ASSERTION"
SpeechActDecision SpeechAct = "DECISION"
SpeechActPreference SpeechAct = "PREFERENCE"
SpeechActQuestion SpeechAct = "QUESTION"
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
SpeechActCorrection SpeechAct = "CORRECTION"
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
SpeechActConfirmation SpeechAct = "CONFIRMATION"
SpeechActRejection SpeechAct = "REJECTION"
SpeechActCancellation SpeechAct = "CANCELLATION"
SpeechActConversational SpeechAct = "CONVERSATIONAL"
SpeechActUnknown SpeechAct = "UNKNOWN"
InterpretationRecorded InterpretationState = "RECORDED"
InterpretationInterpreted InterpretationState = "INTERPRETED"
RelationCorrects InteractionRelationKind = "CORRECTS"
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
RelationClarifies InteractionRelationKind = "CLARIFIES"
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
ProjectRoleRelated ProjectReferenceRole = "RELATED"
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
ClarificationRequired ClarificationState = "REQUIRED"
SemanticApplicable SemanticApplicability = "APPLICABLE"
SemanticLowConfidence SemanticApplicability = "LOW_CONFIDENCE"
SemanticMixedIntent SemanticApplicability = "MIXED_INTENT"
SemanticNeedsBroaderInterpret SemanticApplicability = "NEEDS_BROADER_INTERPRETATION"
SemanticCandidateClarify SemanticApplicability = "CLARIFICATION_REQUIRED"
CandidateSourceDeterministic SemanticCandidateSource = "DETERMINISTIC_V2"
CandidateSourceCognitive SemanticCandidateSource = "COGNITIVE_V1"
CandidateSelected SemanticCandidateSelectionState = "SELECTED"
CandidateObserved SemanticCandidateSelectionState = "OBSERVED"
CandidateRejected SemanticCandidateSelectionState = "REJECTED"
InformationRequestCapabilityInventory InformationRequestKind = "CAPABILITY_INVENTORY"
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
ChannelWebChat Channel = "WEB_CHAT"
ConversationOpen ConversationState = "OPEN"
ConversationArchived ConversationState = "ARCHIVED"
SpeechActDirective SpeechAct = "DIRECTIVE"
SpeechActAssertion SpeechAct = "ASSERTION"
SpeechActDecision SpeechAct = "DECISION"
SpeechActPreference SpeechAct = "PREFERENCE"
SpeechActQuestion SpeechAct = "QUESTION"
SpeechActHypothesis SpeechAct = "HYPOTHESIS"
SpeechActCorrection SpeechAct = "CORRECTION"
SpeechActRequestToInvestigate SpeechAct = "REQUEST_TO_INVESTIGATE"
SpeechActConfirmation SpeechAct = "CONFIRMATION"
SpeechActRejection SpeechAct = "REJECTION"
SpeechActCancellation SpeechAct = "CANCELLATION"
SpeechActConversational SpeechAct = "CONVERSATIONAL"
SpeechActUnknown SpeechAct = "UNKNOWN"
InterpretationRecorded InterpretationState = "RECORDED"
InterpretationInterpreted InterpretationState = "INTERPRETED"
RelationCorrects InteractionRelationKind = "CORRECTS"
RelationRespondsTo InteractionRelationKind = "RESPONDS_TO"
RelationRequestsCognition InteractionRelationKind = "REQUESTS_COGNITION"
RelationCognitionFallback InteractionRelationKind = "COGNITION_FALLBACK"
RelationSupersedes InteractionRelationKind = "SUPERSEDES"
RelationClarifies InteractionRelationKind = "CLARIFIES"
ProjectRolePrimary ProjectReferenceRole = "PRIMARY"
ProjectRoleRelated ProjectReferenceRole = "RELATED"
ProjectRoleReferenceSource ProjectReferenceRole = "REFERENCE_SOURCE"
ClarificationNotRequired ClarificationState = "NOT_REQUIRED"
ClarificationRequired ClarificationState = "REQUIRED"
SemanticApplicable SemanticApplicability = "APPLICABLE"
SemanticLowConfidence SemanticApplicability = "LOW_CONFIDENCE"
SemanticMixedIntent SemanticApplicability = "MIXED_INTENT"
SemanticNeedsBroaderInterpret SemanticApplicability = "NEEDS_BROADER_INTERPRETATION"
SemanticCandidateClarify SemanticApplicability = "CLARIFICATION_REQUIRED"
CandidateSourceDeterministic SemanticCandidateSource = "DETERMINISTIC_V2"
CandidateSourceCognitive SemanticCandidateSource = "COGNITIVE_V1"
CandidateSelected SemanticCandidateSelectionState = "SELECTED"
CandidateObserved SemanticCandidateSelectionState = "OBSERVED"
CandidateRejected SemanticCandidateSelectionState = "REJECTED"
)
type Conversation struct {
@@ -161,26 +156,22 @@ type ChatMessageRequest struct {
}
type ChatMessageResponse struct {
ConversationID ConversationID
UtteranceID HumanUtteranceID
OriginalIntentID OriginalHumanIntentID
SpeechAct SpeechAct
InterpretationState InterpretationState
NeedsCognition bool
FallbackText string
ActiveProjectName string
ProjectProvenance situation.ProjectResolutionProvenance
Clarification ClarificationState
WorkPlanned bool
PlanningReply string
CapabilityStatus capability.CapabilityResolutionStatus
CapabilityKind capability.CapabilityKind
CapabilityHints []capability.CapabilityKind
RequestedOutcome string
InformationRequestKind InformationRequestKind
InterpretationConfidenceMillis int64
CapabilityGapID capability.CapabilityGapID
DelegationID string
DelegationState string
Reminder *continuity.ProspectiveItem
ConversationID ConversationID
UtteranceID HumanUtteranceID
OriginalIntentID OriginalHumanIntentID
SpeechAct SpeechAct
InterpretationState InterpretationState
NeedsCognition bool
FallbackText string
ActiveProjectName string
ProjectProvenance situation.ProjectResolutionProvenance
Clarification ClarificationState
WorkPlanned bool
PlanningReply string
CapabilityStatus capability.CapabilityResolutionStatus
CapabilityKind capability.CapabilityKind
CapabilityGapID capability.CapabilityGapID
DelegationID string
DelegationState string
Reminder *continuity.ProspectiveItem
}
+1 -5
View File
@@ -9,17 +9,13 @@ type SecretPurpose string
const (
SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD"
SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY"
)
// RuntimeConsumer identifies a trusted runtime allowed to resolve a credential.
type RuntimeConsumer string
const (
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker"
RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker"
RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker"
RuntimeConsumerCore RuntimeConsumer = "core-runtime"
)
// SecretFileRef is bootstrap delivery metadata for the V1 protected local
-32
View File
@@ -1,32 +0,0 @@
package work
import "time"
// AgentExecutionRequest is the serializable hand-off from the durable worker
// to an executor. The bridge receives the already-authorized dispatch context;
// it does not receive database credentials or ambient authority.
type AgentExecutionRequest struct {
Intent AgentDispatchIntent `json:"intent"`
WorkerID string `json:"worker_id"`
FencingToken AgentFencingToken `json:"fencing_token"`
LeaseExpiresAt time.Time `json:"lease_expires_at"`
}
type AgentExecutionState string
const (
AgentExecutionCompleted AgentExecutionState = "COMPLETED"
AgentExecutionWaiting AgentExecutionState = "WAITING"
AgentExecutionFailed AgentExecutionState = "FAILED"
)
// AgentExecutionResult is an acknowledgement of the external execution
// attempt. The worker persists it through the work owner before completing a
// dispatch, so a bridge timeout never becomes an unrecorded success.
type AgentExecutionResult struct {
State AgentExecutionState `json:"state"`
Summary string `json:"summary"`
OutcomeRef string `json:"outcome_ref"`
ArtifactRefs []string `json:"artifact_refs"`
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
}
-19
View File
@@ -1,19 +0,0 @@
package work
type AgentDispatchQueueState string
const (
AgentDispatchQueued AgentDispatchQueueState = "QUEUED"
AgentDispatchClaimed AgentDispatchQueueState = "CLAIMED"
AgentDispatchCompleted AgentDispatchQueueState = "COMPLETED"
AgentDispatchFailed AgentDispatchQueueState = "FAILED"
)
type AgentDispatchQueueItem struct {
DispatchID string `json:"dispatch_id"`
State AgentDispatchQueueState `json:"state"`
AvailableAtUnixMillis int64 `json:"available_at_unix_millis"`
Attempts int `json:"attempts"`
ClaimedBy string `json:"claimed_by"`
UpdatedAtUnixMillis int64 `json:"updated_at_unix_millis"`
}
-18
View File
@@ -1,18 +0,0 @@
package work
import "time"
type DryRunDispatch struct {
Intent AgentDispatchIntent
Lease AgentLease
Callbacks []AgentCallback
}
func (value DryRunDispatch) CallbackCount() int { return len(value.Callbacks) }
func (value DryRunDispatch) LastObservedAt() time.Time {
if len(value.Callbacks) == 0 {
return time.Time{}
}
return value.Callbacks[len(value.Callbacks)-1].ObservedAt
}
-142
View File
@@ -1,142 +0,0 @@
package work
import "git.cin.su/m/Assistant/contracts/capability"
type GraphNodeID string
type GraphState string
type GraphNodeState string
type GraphNodePurpose string
const (
GraphNodePurposeTaskEvaluation GraphNodePurpose = "TASK_EVALUATION_SPECIALIST"
GraphNodePurposeGeneric GraphNodePurpose = "GENERIC"
GraphDraft GraphState = "DRAFT"
GraphAdmitted GraphState = "ADMITTED"
GraphRunning GraphState = "RUNNING"
GraphPartial GraphState = "PARTIAL"
GraphVerifying GraphState = "VERIFYING"
GraphCompleted GraphState = "COMPLETED"
GraphFailed GraphState = "FAILED"
GraphUnknown GraphState = "UNKNOWN"
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
GraphNodeReady GraphNodeState = "READY"
GraphNodeLeased GraphNodeState = "LEASED"
GraphNodeRunning GraphNodeState = "RUNNING"
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
GraphNodeFailed GraphNodeState = "FAILED"
GraphNodeCancel GraphNodeState = "CANCELLED"
GraphNodeUnknown GraphNodeState = "UNKNOWN"
)
type GraphBudget struct {
MaxParallel int `json:"max_parallel"`
MaxAttempts int `json:"max_attempts"`
}
type GraphNode struct {
ID GraphNodeID `json:"id"`
DispatchID string `json:"dispatch_id"`
IdempotencyKey string `json:"idempotency_key"`
State GraphNodeState `json:"state"`
Attempts int `json:"attempts"`
Purpose GraphNodePurpose `json:"purpose,omitempty"`
TaskProfileID capability.TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
BindingID capability.CapabilityBindingID `json:"binding_id,omitempty"`
CaseIDs []string `json:"case_ids,omitempty"`
TaskProfile *capability.TaskEvaluationProfile `json:"task_profile,omitempty"`
TaskCases []capability.TaskEvaluationCase `json:"task_cases,omitempty"`
}
type GraphEdge struct {
From GraphNodeID `json:"from"`
To GraphNodeID `json:"to"`
}
type WorkGraph struct {
ID string `json:"id"`
Revision int64 `json:"revision"`
State GraphState `json:"state"`
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
Budget GraphBudget `json:"budget"`
ParentGraphID string `json:"parent_graph_id,omitempty"`
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
}
// TaskEvaluationGraphRequest describes a durable fan-out. The graph contains
// one specialist node per binding; each node receives the profile's bounded
// cases and reports evidence back to Capability for fan-in aggregation.
type TaskEvaluationGraphRequest struct {
GraphID string `json:"graph_id"`
Profile capability.TaskEvaluationProfile `json:"profile"`
BindingIDs []capability.CapabilityBindingID `json:"binding_ids"`
DispatchIDs []string `json:"dispatch_ids"`
IdempotencyPrefix string `json:"idempotency_prefix"`
Budget GraphBudget `json:"budget"`
ParentGraphID string `json:"parent_graph_id,omitempty"`
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
ParentGraphState GraphState `json:"parent_graph_state,omitempty"`
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
Cases []capability.TaskEvaluationCase `json:"cases,omitempty"`
BindingRevision int64 `json:"binding_revision,omitempty"`
ActorID string `json:"actor_id,omitempty"`
PrincipalID string `json:"principal_id,omitempty"`
ScopeID string `json:"scope_id,omitempty"`
}
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
// completed the bounded case set. Capability remains the evidence owner;
// Work only advances its matching graph node after the evidence is accepted.
type TaskEvaluationEvidenceCallback struct {
GraphID string `json:"graph_id"`
Revision int64 `json:"revision"`
NodeID GraphNodeID `json:"node_id"`
Evaluation capability.TaskEvaluation `json:"evaluation"`
Succeeded bool `json:"succeeded"`
}
type ResultCandidate struct {
ID string `json:"id"`
GraphID string `json:"graph_id"`
GraphRevision int64 `json:"graph_revision"`
NodeID GraphNodeID `json:"node_id"`
AuthorRunID AgentRunID `json:"author_run_id"`
ArtifactRefs []EvidenceRef `json:"artifact_refs"`
EvidenceSummary string `json:"evidence_summary"`
Summary string `json:"summary"`
}
type ConflictSet struct {
ID string `json:"id"`
GraphID string `json:"graph_id"`
GraphRevision int64 `json:"graph_revision"`
NodeID GraphNodeID `json:"node_id"`
CandidateIDs []string `json:"candidate_ids"`
Resolved bool `json:"resolved"`
}
type GraphSynthesisState string
const GraphSynthesisVerified GraphSynthesisState = "VERIFIED"
// GraphSynthesisCandidate retains every competing result while recording the
// independently verified selection. It is a candidate, not an external action
// or authority grant.
type GraphSynthesisCandidate struct {
ID string `json:"id"`
GraphID string `json:"graph_id"`
GraphRevision int64 `json:"graph_revision"`
NodeID GraphNodeID `json:"node_id"`
CandidateIDs []string `json:"candidate_ids"`
SelectedCandidateID string `json:"selected_candidate_id"`
DissentCandidateIDs []string `json:"dissent_candidate_ids"`
VerifierRunID AgentRunID `json:"verifier_run_id"`
VerifierIdentity string `json:"verifier_identity"`
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
Summary string `json:"summary"`
State GraphSynthesisState `json:"state"`
}
-86
View File
@@ -1,86 +0,0 @@
package work
import (
"time"
"git.cin.su/m/Assistant/contracts/authority"
"git.cin.su/m/Assistant/contracts/capability"
"git.cin.su/m/Assistant/contracts/identity"
)
type AgentLeaseID string
type AgentFencingToken uint64
type AgentLeaseState string
const (
AgentLeaseActive AgentLeaseState = "ACTIVE"
AgentLeaseExpired AgentLeaseState = "EXPIRED"
)
type AgentLease struct {
ID AgentLeaseID
RunID AgentRunID
WorkerID string
FencingToken AgentFencingToken
State AgentLeaseState
AcquiredAt time.Time
ExpiresAt time.Time
}
type AgentCallbackKind string
const (
AgentCallbackHeartbeat AgentCallbackKind = "HEARTBEAT"
AgentCallbackCheckpoint AgentCallbackKind = "CHECKPOINT"
AgentCallbackResult AgentCallbackKind = "RESULT"
AgentCallbackCancelAcknowledged AgentCallbackKind = "CANCEL_ACKNOWLEDGED"
)
type AgentCallback struct {
RunID AgentRunID
WorkerID string
FencingToken AgentFencingToken
Sequence uint64
Kind AgentCallbackKind
ObservedAt time.Time
}
type IndependentVerification struct {
ProducerRunID AgentRunID
VerifierRunID AgentRunID
VerifierIdentity string
ArtifactID string
TestEvidenceRef string
Verified bool
Summary string
}
type AgentDispatchIntent struct {
DispatchID string `json:"dispatch_id"`
IdempotencyKey string `json:"idempotency_key"`
DelegationID DelegationID `json:"delegation_id"`
RunID AgentRunID `json:"run_id"`
ImplementationID string `json:"implementation_id"`
ImplementationRevision int64 `json:"implementation_revision"`
SourceRevision string `json:"source_revision"`
WorktreeID string `json:"worktree_id"`
VerificationPlanRef string `json:"verification_plan_ref"`
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
SpecialistPurpose string `json:"specialist_purpose,omitempty"`
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
TaskEvaluationProfile *capability.TaskEvaluationProfile `json:"task_evaluation_profile,omitempty"`
TaskEvaluationCases []capability.TaskEvaluationCase `json:"task_evaluation_cases,omitempty"`
TaskEvaluationExecution *TaskEvaluationDispatchContext `json:"task_evaluation_execution,omitempty"`
}
type TaskEvaluationDispatchContext struct {
GraphID string `json:"graph_id"`
GraphRevision int64 `json:"graph_revision"`
NodeID GraphNodeID `json:"node_id"`
BindingRevision int64 `json:"binding_revision"`
ActorID identity.ActorID `json:"actor_id"`
PrincipalID identity.PrincipalID `json:"principal_id"`
ScopeID authority.ScopeID `json:"scope_id"`
}
-66
View File
@@ -1,66 +0,0 @@
package work
type OrchestrationState string
type SearchOptionKind string
const (
OrchestrationSearching OrchestrationState = "SEARCHING"
OrchestrationExecuting OrchestrationState = "EXECUTING"
OrchestrationVerifying OrchestrationState = "VERIFYING"
OrchestrationWaiting OrchestrationState = "WAITING"
OrchestrationCompleted OrchestrationState = "COMPLETED"
OrchestrationCancelled OrchestrationState = "CANCELLED"
SearchOptionAgents SearchOptionKind = "AGENT_TEAM"
SearchOptionCapability SearchOptionKind = "CAPABILITY_DISCOVERY"
SearchOptionResearch SearchOptionKind = "TRUTH_RESEARCH"
SearchOptionReflection SearchOptionKind = "REFLECTION_MEMORY"
SearchOptionHuman SearchOptionKind = "HUMAN_ATTENTION"
)
// SolutionSearchInput is the durable orchestration snapshot. The scheduler
// stores the graph/goal separately; this value only decides the next bounded
// search wave and never grants authority by itself.
type SolutionSearchInput struct {
GoalID GoalID
GoalState GoalState
SearchRound int
MaxRound int
CancelRequested bool
ActiveOptionCount int
VerificationReady bool
Unresolved bool
}
type SolutionSearchDecision struct {
GoalID GoalID
State OrchestrationState
ContinueSearching bool
NextSearchRound int
Options []SearchOptionKind
Reason string
}
type SolutionSearchWaveRequest struct {
Input SolutionSearchInput `json:"input"`
GraphID string `json:"graph_id"`
IdempotencyPrefix string `json:"idempotency_prefix"`
Dispatches []SolutionSearchDispatch `json:"dispatches"`
Budget GraphBudget `json:"budget"`
ParentGraphID string `json:"parent_graph_id,omitempty"`
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
NowUnixMillis int64 `json:"now_unix_millis"`
}
type SolutionSearchDispatch struct {
Option SearchOptionKind `json:"option"`
Intent AgentDispatchIntent `json:"intent"`
}
type SolutionSearchWaveResult struct {
Decision SolutionSearchDecision `json:"decision"`
Graph WorkGraph `json:"graph"`
ScheduledNodeIDs []GraphNodeID `json:"scheduled_node_ids,omitempty"`
GraphMaterialized bool `json:"graph_materialized"`
}
-133
View File
@@ -1,133 +0,0 @@
package work
import "time"
type DevelopmentStage string
const (
DevelopmentEvidenceCollected DevelopmentStage = "EVIDENCE_COLLECTED"
DevelopmentProposalCreated DevelopmentStage = "PROPOSAL_CREATED"
DevelopmentTaskAdmitted DevelopmentStage = "TASK_ADMITTED"
DevelopmentIsolated DevelopmentStage = "ISOLATED"
DevelopmentVerified DevelopmentStage = "VERIFIED"
DevelopmentCanary DevelopmentStage = "CANARY"
DevelopmentPromoted DevelopmentStage = "PROMOTED"
DevelopmentRolledBack DevelopmentStage = "ROLLED_BACK"
)
type DevelopmentGate struct {
ID string
Revision int64
UpdatedAtUnixMillis int64
Stage DevelopmentStage
EvidenceRefs []string
ProposalRef string
TaskPacketRef string
WorktreeRef string
ProducerRunRefs []AgentRunID
VerifierRunRefs []AgentRunID
VerificationEvidence string
CanaryEvidence string
PromotionRef string
RollbackRef string
}
// ExtensionProposalStage is deliberately proposal-only in this contract. A
// proposal does not authorize source changes, provider calls, deployment or
// promotion; those stages require the existing governed development gate.
type ExtensionProposalStage string
const ExtensionProposalProposed ExtensionProposalStage = "PROPOSED"
// ExtensionEvidence is an immutable reference to an observation that points
// at a repeated or corroborated improvement signal. The payload is owned by
// its source module; Work stores only the typed reference and stable signal
// key.
type ExtensionEvidence struct {
Ref string
SignalKey string
}
// ExtensionProposalRequest contains the operator/governance information
// needed to decide whether an evidence pattern is worth reviewing. Empty
// fields are rejected by the proposal generator rather than inferred.
type ExtensionProposalRequest struct {
ID string
DesiredOutcome string
SmallestChangeLayer string
AffectedOwners []string
AffectedContracts []string
RiskTier string
EvaluationPlan []string
Alternatives []string
RollbackPlan string
Assumptions []string
CreatedAt time.Time
}
// ExtensionProposal is a bounded, evidence-bearing candidate for governed
// development. It intentionally contains no executable command, credential,
// authority grant, implementation body or promotion decision.
type ExtensionProposal struct {
ID string
RequestedByPrincipalID string
Stage ExtensionProposalStage
EvidenceRefs []string
DeduplicatedSignalKeys []string
DesiredOutcome string
SmallestChangeLayer string
AffectedOwners []string
AffectedContracts []string
RiskTier string
EvaluationPlan []string
Alternatives []string
RollbackPlan string
Assumptions []string
CreatedAtUnixMillis int64
}
type DevelopmentScaffoldStage string
const DevelopmentScaffoldSandboxed DevelopmentScaffoldStage = "SANDBOXED"
// DevelopmentScaffoldRequest describes the boundary a later control plane
// would use to create an isolated worktree. This value object itself performs
// no checkout or filesystem operation.
type DevelopmentScaffoldRequest struct {
ID string
ProposalRef string
BaseRepoRevision string
WorktreeRef string
TaskIntent string
Modules []string
AllowedPaths []string
ForbiddenPaths []string
ProtectedPaths []string
RequiredTests []string
ArtifactLineageRefs []string
ProtectedMainAccess string
ProductionAccess string
SecretAccess string
CreatedAt time.Time
}
// DevelopmentScaffold is a provenance-linked task-packet candidate. It has
// no executable instruction, credential, checkout handle or authority grant.
type DevelopmentScaffold struct {
ID string
RequestedByPrincipalID string
Stage DevelopmentScaffoldStage
ProposalRef string
BaseRepoRevision string
WorktreeRef string
TaskIntent string
Modules []string
AllowedPaths []string
ForbiddenPaths []string
RequiredTests []string
ArtifactLineageRefs []string
ProtectedMainAccess string
ProductionAccess string
SecretAccess string
CreatedAtUnixMillis int64
}
@@ -1,26 +0,0 @@
BEGIN;
SET ROLE capability_role;
ALTER TABLE capability.cognitive_provider_instances
ADD COLUMN IF NOT EXISTS billing_mode TEXT NOT NULL DEFAULT 'UNKNOWN',
ADD COLUMN IF NOT EXISTS input_cost_micros BIGINT,
ADD COLUMN IF NOT EXISTS output_cost_micros BIGINT,
ADD COLUMN IF NOT EXISTS request_cost_micros BIGINT;
ALTER TABLE capability.cognitive_provider_instances
ADD COLUMN IF NOT EXISTS auth_mode TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE capability.cognitive_provider_instances
DROP CONSTRAINT IF EXISTS cognitive_provider_instances_billing_mode_check,
ADD CONSTRAINT cognitive_provider_instances_billing_mode_check CHECK (billing_mode IN ('UNKNOWN','FREE','PER_INPUT_UNIT','PER_OUTPUT_UNIT','PER_REQUEST')),
DROP CONSTRAINT IF EXISTS cognitive_provider_instances_auth_mode_check,
ADD CONSTRAINT cognitive_provider_instances_auth_mode_check CHECK (auth_mode IN ('NONE','API_KEY','BEARER','USERNAME_PASSWORD','OAUTH','SECRET_FILE')),
DROP CONSTRAINT IF EXISTS cognitive_provider_instances_cost_check,
ADD CONSTRAINT cognitive_provider_instances_cost_check CHECK ((input_cost_micros IS NULL OR input_cost_micros >= 0) AND (output_cost_micros IS NULL OR output_cost_micros >= 0) AND (request_cost_micros IS NULL OR request_cost_micros >= 0));
ALTER TABLE capability.cognitive_model_descriptors
ADD COLUMN IF NOT EXISTS intelligence_score_millis BIGINT;
ALTER TABLE capability.cognitive_model_descriptors
DROP CONSTRAINT IF EXISTS cognitive_model_descriptors_intelligence_score_check,
ADD CONSTRAINT cognitive_model_descriptors_intelligence_score_check CHECK (intelligence_score_millis IS NULL OR (intelligence_score_millis >= 0 AND intelligence_score_millis <= 1000));
RESET ROLE;
COMMIT;
@@ -1,43 +0,0 @@
BEGIN;
SET ROLE work_role;
CREATE TABLE IF NOT EXISTS work.agent_dispatch_intents (
dispatch_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
delegation_id TEXT NOT NULL,
run_id TEXT NOT NULL,
implementation_id TEXT NOT NULL,
implementation_revision BIGINT NOT NULL,
source_revision TEXT NOT NULL,
worktree_id TEXT NOT NULL,
verification_plan_ref TEXT NOT NULL,
created_at_unix_millis BIGINT NOT NULL,
CHECK (dispatch_id <> ''), CHECK (idempotency_key <> ''), CHECK (delegation_id <> ''),
CHECK (run_id <> ''), CHECK (implementation_id <> ''), CHECK (implementation_revision > 0),
CHECK (source_revision <> ''), CHECK (worktree_id <> ''), CHECK (verification_plan_ref <> ''),
CHECK (created_at_unix_millis > 0)
);
CREATE TABLE IF NOT EXISTS work.agent_leases (
lease_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL UNIQUE,
worker_id TEXT NOT NULL,
fencing_token BIGINT NOT NULL,
state TEXT NOT NULL,
acquired_at_unix_millis BIGINT NOT NULL,
expires_at_unix_millis BIGINT NOT NULL,
CHECK (lease_id <> ''), CHECK (run_id <> ''), CHECK (worker_id <> ''),
CHECK (fencing_token > 0), CHECK (state IN ('ACTIVE','EXPIRED')),
CHECK (acquired_at_unix_millis > 0), CHECK (expires_at_unix_millis > acquired_at_unix_millis)
);
CREATE TABLE IF NOT EXISTS work.agent_callback_heads (
run_id TEXT PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at_unix_millis BIGINT NOT NULL,
CHECK (run_id <> ''), CHECK (last_sequence >= 0), CHECK (updated_at_unix_millis > 0)
);
RESET ROLE;
COMMIT;
@@ -1,22 +0,0 @@
BEGIN;
SET ROLE work_role;
CREATE TABLE IF NOT EXISTS work.agent_dispatch_queue (
dispatch_id TEXT PRIMARY KEY REFERENCES work.agent_dispatch_intents(dispatch_id),
state TEXT NOT NULL,
available_at_unix_millis BIGINT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
claimed_by TEXT NOT NULL DEFAULT '',
updated_at_unix_millis BIGINT NOT NULL,
CHECK (state IN ('QUEUED','CLAIMED','COMPLETED','FAILED')),
CHECK (available_at_unix_millis > 0),
CHECK (attempts >= 0),
CHECK (updated_at_unix_millis > 0)
);
CREATE INDEX IF NOT EXISTS agent_dispatch_queue_claim_idx
ON work.agent_dispatch_queue(state, available_at_unix_millis, dispatch_id);
RESET ROLE;
COMMIT;
@@ -1,41 +0,0 @@
BEGIN;
SET ROLE work_role;
CREATE TABLE IF NOT EXISTS work.graphs (
graph_id TEXT NOT NULL,
revision BIGINT NOT NULL,
state TEXT NOT NULL,
max_parallel INTEGER NOT NULL,
max_attempts INTEGER NOT NULL,
PRIMARY KEY (graph_id, revision),
CHECK (graph_id <> ''), CHECK (revision > 0),
CHECK (state IN ('DRAFT','ADMITTED','RUNNING','PAUSED','CANCELLING','PARTIAL','VERIFYING','COMPLETED','FAILED','UNKNOWN','RECOVERING')),
CHECK (max_parallel > 0), CHECK (max_attempts > 0)
);
CREATE TABLE IF NOT EXISTS work.graph_nodes (
graph_id TEXT NOT NULL,
revision BIGINT NOT NULL,
node_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
state TEXT NOT NULL,
attempts INTEGER NOT NULL,
PRIMARY KEY (graph_id, revision, node_id),
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
CHECK (node_id <> ''), CHECK (idempotency_key <> ''), CHECK (attempts >= 0),
CHECK (state IN ('BLOCKED_PREREQUISITE','READY','LEASED','RUNNING','SUCCEEDED','FAILED','CANCELLED','UNKNOWN','RECOVERING'))
);
CREATE TABLE IF NOT EXISTS work.graph_edges (
graph_id TEXT NOT NULL,
revision BIGINT NOT NULL,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL,
PRIMARY KEY (graph_id, revision, from_node_id, to_node_id),
FOREIGN KEY (graph_id, revision) REFERENCES work.graphs(graph_id, revision),
CHECK (from_node_id <> ''), CHECK (to_node_id <> ''), CHECK (from_node_id <> to_node_id)
);
RESET ROLE;
COMMIT;
@@ -1,19 +0,0 @@
BEGIN;
SET ROLE epistemics_role;
ALTER TABLE epistemics.reflection_candidates
ADD COLUMN IF NOT EXISTS claimed_by TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS claim_expires_at_unix_millis BIGINT NOT NULL DEFAULT 0;
ALTER TABLE epistemics.reflection_candidates
DROP CONSTRAINT IF EXISTS reflection_candidates_claim_expiry_check;
ALTER TABLE epistemics.reflection_candidates
ADD CONSTRAINT reflection_candidates_claim_expiry_check
CHECK (claim_expires_at_unix_millis >= 0);
CREATE INDEX IF NOT EXISTS reflection_candidates_claimable_idx
ON epistemics.reflection_candidates (state, claim_expires_at_unix_millis, recorded_at_unix_millis, reflection_candidate_id);
RESET ROLE;
COMMIT;
@@ -1,30 +0,0 @@
BEGIN;
SET ROLE capability_role;
CREATE TABLE IF NOT EXISTS capability.cognitive_admission_counters (
policy_id TEXT PRIMARY KEY,
window_start_unix_millis BIGINT NOT NULL,
accepted_count INTEGER NOT NULL DEFAULT 0,
concurrent_count INTEGER NOT NULL DEFAULT 0,
updated_at_unix_millis BIGINT NOT NULL,
CHECK (policy_id <> ''), CHECK (window_start_unix_millis > 0),
CHECK (accepted_count >= 0), CHECK (concurrent_count >= 0),
CHECK (updated_at_unix_millis > 0)
);
CREATE TABLE IF NOT EXISTS capability.cognitive_admission_leases (
lease_id TEXT PRIMARY KEY,
policy_id TEXT NOT NULL REFERENCES capability.cognitive_admission_counters(policy_id),
state TEXT NOT NULL,
expires_at_unix_millis BIGINT NOT NULL,
created_at_unix_millis BIGINT NOT NULL,
CHECK (lease_id <> ''), CHECK (state IN ('ACTIVE','RELEASED','EXPIRED')),
CHECK (expires_at_unix_millis > 0), CHECK (created_at_unix_millis > 0)
);
CREATE INDEX IF NOT EXISTS cognitive_admission_lease_expiry_idx
ON capability.cognitive_admission_leases(policy_id, state, expires_at_unix_millis);
RESET ROLE;
COMMIT;
@@ -1,49 +0,0 @@
BEGIN;
SET ROLE epistemics_role;
CREATE TABLE IF NOT EXISTS epistemics.truth_bundles (
bundle_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_locator TEXT NOT NULL,
source_title TEXT NOT NULL,
source_publisher TEXT NOT NULL,
snapshot_id TEXT NOT NULL,
snapshot_source_id TEXT NOT NULL,
snapshot_content_digest TEXT NOT NULL,
snapshot_captured_at_unix_millis BIGINT NOT NULL,
snapshot_freshness TEXT NOT NULL,
excerpt_id TEXT NOT NULL,
excerpt_snapshot_id TEXT NOT NULL,
excerpt_text TEXT NOT NULL,
excerpt_start BIGINT NOT NULL,
excerpt_end BIGINT NOT NULL,
observation_id TEXT NOT NULL,
observation_observed_at_unix_millis BIGINT NOT NULL,
claim_id TEXT NOT NULL,
claim_statement TEXT NOT NULL,
claim_observation_id TEXT NOT NULL,
claim_citation_id TEXT NOT NULL,
claim_verification TEXT NOT NULL,
belief_id TEXT NOT NULL,
belief_claim_id TEXT NOT NULL,
belief_state TEXT NOT NULL,
citation_id TEXT NOT NULL,
citation_excerpt_id TEXT NOT NULL,
citation_evidence TEXT NOT NULL,
citation_coverage TEXT NOT NULL,
citation_conflict TEXT NOT NULL,
citation_resolved_at_unix_millis BIGINT NOT NULL,
recorded_at_unix_millis BIGINT NOT NULL,
UNIQUE (snapshot_id, excerpt_id, claim_id),
CHECK (bundle_id <> ''), CHECK (source_id <> ''), CHECK (source_locator <> ''),
CHECK (excerpt_start >= 0), CHECK (excerpt_end >= excerpt_start),
CHECK (recorded_at_unix_millis > 0)
);
CREATE INDEX IF NOT EXISTS truth_bundles_source_time_idx
ON epistemics.truth_bundles (source_id, recorded_at_unix_millis DESC);
RESET ROLE;
COMMIT;

Some files were not shown because too many files have changed in this diff Show More