Compare commits

...
Author SHA1 Message Date
m df8a275eb1 record final core m5 production evidence
CI / test (push) Failing after 29s
2026-08-30 02:35:23 +03:00
m 7b62781087 separate medium health and model readiness
CI / test (push) Failing after 31s
2026-08-30 02:30:39 +03:00
m 8caa49f429 allow bounded medium invocation recovery
CI / test (push) Failing after 31s
2026-08-30 02:10:55 +03:00
m f7ffaee10d extend local medium readiness budget
CI / test (push) Failing after 29s
2026-08-30 01:59:42 +03:00
m e8b4c4fdab bound cognitive provider readiness and fleet probes
CI / test (push) Failing after 30s
2026-08-30 01:51:18 +03:00
m 39f6fdee7b record core M5 acceptance amendment
CI / test (push) Failing after 27s
2026-08-30 01:45:21 +03:00
m 711a734fee fix continuity recovery harness request forwarding
CI / test (push) Failing after 33s
2026-08-30 01:09:02 +03:00
m 1e3f29b791 short circuit cognitive work for retrieved memory
CI / test (push) Failing after 31s
2026-08-30 00:54:08 +03:00
m 2a52306f90 skip review for deterministic memory replies
CI / test (push) Failing after 28s
2026-08-30 00:48:25 +03:00
m 49b3165caa normalize marked memory subjects
CI / test (push) Failing after 28s
2026-08-30 00:43:07 +03:00
m 31dd628519 prioritize memory retrieval over stale travel continuation
CI / test (push) Failing after 29s
2026-08-30 00:38:35 +03:00
m 29217ca935 classify stable acknowledgements before continuation
CI / test (push) Failing after 30s
2026-08-30 00:34:30 +03:00
m 70ece6ed5c avoid redundant review for stable chat
CI / test (push) Failing after 32s
2026-08-30 00:30:00 +03:00
m cf68786d0c skip cognitive interpretation for stable chat
CI / test (push) Failing after 30s
2026-08-30 00:26:31 +03:00
m 8571e3fe59 bound deterministic chat response path
CI / test (push) Failing after 33s
2026-08-30 00:22:20 +03:00
m 0af6a9b9a5 merge conversational context fix into gen2 integration
CI / test (push) Failing after 34s
2026-08-29 23:42:57 +03:00
m 6b9d5be299 preserve stable conversational replies across stale context
CI / test (push) Failing after 26s
2026-08-29 23:42:52 +03:00
m 8b3d6f9176 merge cognitive readiness budget fix
CI / test (push) Failing after 27s
2026-08-29 23:31:29 +03:00
m b4ab280950 align cognitive readiness probe budgets
CI / test (push) Failing after 30s
2026-08-29 23:31:25 +03:00
m 9cb69d1663 merge Gen2 household acceptance fixes
CI / test (push) Failing after 38s
2026-08-29 23:28:15 +03:00
m 5387010a15 harden Gen2 household introspection and readiness
CI / test (push) Failing after 33s
2026-08-29 23:28:10 +03:00
m 66fd4bfc75 merge memory recall routing fix
CI / test (push) Failing after 36s
2026-08-29 23:14:19 +03:00
m 8ac4525569 route recalled memory through local cognitive review
CI / test (push) Failing after 27s
2026-08-29 23:14:15 +03:00
m 40c610945f merge stable local answer routing fix
CI / test (push) Failing after 34s
2026-08-29 23:10:11 +03:00
m 99458a6ced avoid redundant review of stable local answers
CI / test (push) Failing after 29s
2026-08-29 23:10:07 +03:00
m 25c62e38fc merge bounded cognitive response budgets into integration
CI / test (push) Failing after 36s
2026-08-29 23:05:57 +03:00
m 5c0c6b3e42 bound local cognitive household response budgets
CI / test (push) Failing after 29s
2026-08-29 23:05:43 +03:00
m 788137b4ba merge bounded Gen2 memory projection
CI / test (push) Failing after 33s
2026-08-29 22:59:43 +03:00
m 2ef639eba9 bound cognitive personal-memory projection
CI / test (push) Failing after 36s
2026-08-29 22:59:38 +03:00
m d4ed85dec2 merge cognitive head persistence fix
CI / test (push) Failing after 35s
2026-08-29 22:44:04 +03:00
m c1c2110322 preserve governed cognitive head across discovery refresh
CI / test (push) Failing after 31s
2026-08-29 22:43:59 +03:00
m d83fcb2176 merge local cognitive metadata fix
CI / test (push) Failing after 35s
2026-08-29 22:40:58 +03:00
m 041fb1556d fix local cognitive model metadata qualification
CI / test (push) Failing after 32s
2026-08-29 22:40:54 +03:00
m a3baade40b merge main into gen2-integration before GEN2-027 acceptance
CI / test (push) Failing after 30s
2026-08-29 22:19:32 +03:00
m 12104210a3 synchronize discovery head protection
CI / test (push) Failing after 29s
2026-08-29 21:54:03 +03:00
m 40dcc36b06 synchronize governed binding head fix
CI / test (push) Failing after 36s
2026-08-29 21:53:51 +03:00
m 433cb2de2c prevent discovery from rolling back governed binding head
CI / test (push) Failing after 27s
2026-08-29 21:53:46 +03:00
m b49ff6cb71 synchronize cognitive activation fix
CI / test (push) Failing after 32s
2026-08-29 21:51:02 +03:00
m 4bb6728dfe load canary revision for cognitive activation
CI / test (push) Failing after 29s
2026-08-29 21:50:58 +03:00
m b00130ac15 synchronize cognitive remediation branches
CI / test (push) Failing after 29s
2026-08-29 21:47:28 +03:00
m fc356eb0a3 synchronize medium startup discovery fix
CI / test (push) Failing after 35s
2026-08-29 21:47:14 +03:00
m 0f880896fe allow bounded medium startup discovery
CI / test (push) Failing after 28s
2026-08-29 21:47:09 +03:00
m 460a82e020 synchronize cognitive lifecycle remediation
CI / test (push) Failing after 31s
2026-08-29 21:44:01 +03:00
m e27ead1a75 promote durable task evaluations through cognitive lifecycle
CI / test (push) Failing after 29s
2026-08-29 21:43:57 +03:00
m 64bc8bca25 merge governed cognitive promotion wiring
CI / test (push) Failing after 31s
2026-08-29 21:40:34 +03:00
m 67b585af94 load evaluation evidence for cognitive promotion
CI / test (push) Failing after 28s
2026-08-29 21:40:27 +03:00
m e47ea7b48e synchronize cognitive remediation timeout fix
CI / test (push) Failing after 31s
2026-08-29 21:36:20 +03:00
m 75042e090f use bounded medium client timeout for discovery
CI / test (push) Failing after 30s
2026-08-29 21:36:08 +03:00
m 1168c00caf merge cognitive remediation into main
CI / test (push) Failing after 32s
2026-08-29 21:33:23 +03:00
m 6e873ae3af merge medium discovery timeout remediation
CI / test (push) Failing after 29s
2026-08-29 21:32:42 +03:00
m b7210dc0ad fix cognitive medium discovery timeout
CI / test (push) Failing after 30s
2026-08-29 21:32:26 +03:00
m c0704dd28e merge cognitive qualification fix into main
CI / test (push) Failing after 29s
2026-08-29 21:17:18 +03:00
m 198f6cfe58 merge cognitive task evaluation locale fix
CI / test (push) Failing after 30s
2026-08-29 21:17:13 +03:00
m 49608599c4 fix cognitive task evaluation locale 2026-08-29 21:17:12 +03:00
m 4f6d205a60 merge cognitive route remediation into main
CI / test (push) Failing after 29s
2026-08-29 21:05:32 +03:00
m bcb833f15e merge GEN2-COGNITIVE-001 remediation task
CI / test (push) Failing after 30s
2026-08-29 21:05:16 +03:00
m 4f6f2a1f48 remediate local medium cognitive route 2026-08-29 21:05:10 +03:00
m f709c026d0 merge: repair cognitive discovery head persistence
CI / test (push) Failing after 28s
2026-08-29 19:35:27 +03:00
m 92ad3516ea fix: advance cognitive discovery binding heads
CI / test (push) Failing after 26s
2026-08-29 19:35:23 +03:00
m e1beed0391 merge: wire local medium cognitive route
CI / test (push) Failing after 39s
2026-08-29 19:28:43 +03:00
m 6b297c7749 fix: wire cognitive medium to local chat gateway
CI / test (push) Failing after 33s
2026-08-29 19:28:37 +03:00
m 91b8da2240 merge: integrate frozen Domovoy UI and identity auth
CI / test (push) Failing after 28s
2026-08-29 17:31:41 +03:00
m 8924f5a48a Move creator passwords into identity accounts
CI / test (push) Failing after 27s
2026-08-29 17:02:42 +03:00
m 636fb904f2 refactor: fix secret file paths in code
CI / test (push) Failing after 27s
2026-08-29 16:51:50 +03:00
m 598a97cd1a fix: keep sessions during cognitive probe failures
CI / test (push) Failing after 26s
2026-08-29 16:22:05 +03:00
m e9ef635933 fix: simplify chat composer actions
CI / test (push) Failing after 27s
2026-08-29 16:10:59 +03:00
m c516754d63 fix: keep attachment tools neutral
CI / test (push) Failing after 28s
2026-08-29 16:00:09 +03:00
m 5c965ef69c fix: make Domovoy chat-first
CI / test (push) Failing after 29s
2026-08-29 15:58:19 +03:00
m eab7d11bdd fix: refine chat composer controls
CI / test (push) Failing after 26s
2026-08-29 15:42:52 +03:00
m 51a49ba0ef fix: make chat composer compact and keyboard friendly
CI / test (push) Failing after 25s
2026-08-29 15:38:28 +03:00
m 60ff22882c fix: enable starting a fresh chat
CI / test (push) Failing after 28s
2026-08-29 15:28:38 +03:00
m 9896e4f716 feat: redesign Domovoy chat as calm dark cockpit
CI / test (push) Failing after 28s
2026-08-29 15:26:54 +03:00
m ff0d0e585b merge: deploy context retrieval remediation
CI / test (push) Failing after 27s
2026-08-29 15:08:04 +03:00
m 13f3165ba0 merge: integrate context retrieval remediation
CI / test (push) Failing after 28s
2026-08-29 15:07:59 +03:00
m 55f2f9affa fix: project explicit remembered assertions
CI / test (push) Failing after 26s
2026-08-29 15:07:47 +03:00
m e47e033dda merge: synchronize integration with current main
CI / test (push) Failing after 31s
2026-08-29 14:52:15 +03:00
m 2dc5587fd1 Clarify chat response progress
CI / test (push) Failing after 30s
2026-08-29 14:31:03 +03:00
m 992c84240b Preserve chat responsive guardrail 2026-08-29 14:30:29 +03:00
m cfeb6aeba7 Put chat first on mobile screens 2026-08-29 14:30:10 +03:00
m f0d45ca9f1 Clarify chat message roles and timing 2026-08-29 14:29:39 +03:00
m e20c9fdfc0 Improve Domovoy chat composer usability 2026-08-29 14:28:32 +03:00
m cb50a120e5 merge: record Gen2 deployment recovery evidence
CI / test (push) Failing after 29s
2026-08-29 12:49:59 +03:00
m b30eb38eca docs: record Gen2 deployment recovery evidence
CI / test (push) Failing after 28s
2026-08-29 12:49:51 +03:00
m 2083096aed merge: preserve stable rollback target
CI / test (push) Failing after 28s
2026-08-29 12:44:33 +03:00
m 671ecb4968 fix(supervisor): preserve stable rollback target
CI / test (push) Failing after 32s
2026-08-29 12:44:25 +03:00
m af9d5809e9 merge: stabilize supervisor control contract
CI / test (push) Failing after 27s
2026-08-29 12:41:30 +03:00
m 990928618d fix(supervisor): stabilize control JSON contract
CI / test (push) Failing after 31s
2026-08-29 12:41:22 +03:00
m 9fac8284f5 merge: initialize supervisor state volume permissions
CI / test (push) Failing after 27s
2026-08-29 12:39:01 +03:00
m 7d9a5edee5 fix(deploy): initialize supervisor volume permissions
CI / test (push) Failing after 32s
2026-08-29 12:38:53 +03:00
m 32d3f318a5 merge: independent supervisor control plane
CI / test (push) Failing after 31s
2026-08-29 12:33:54 +03:00
m 3a563a29ee feat(deploy): add independent supervisor control plane
CI / test (push) Failing after 28s
2026-08-29 12:33:44 +03:00
m 734db5d779 merge: synchronize integration with current main
CI / test (push) Failing after 28s
2026-08-29 12:25:26 +03:00
m 36fc8ec8c6 merge: resume Gen2 controlled canary
CI / test (push) Failing after 27s
2026-08-29 12:16:21 +03:00
m b8c001be4c chore(gen2): resume controlled production canary
CI / test (push) Failing after 26s
2026-08-29 12:16:09 +03:00
m eeeba6b7c0 merge: Gen2 database provisioning remediation
CI / test (push) Failing after 31s
2026-08-29 12:12:43 +03:00
m fce3ec569c fix(database): provision Gen2 owner roles before migrations
CI / test (push) Failing after 27s
2026-08-29 12:12:39 +03:00
m a925d787dc GEN2-027: prepare controlled production canary
CI / test (push) Failing after 26s
2026-08-29 12:01:37 +03:00
54 changed files with 2209 additions and 296 deletions
+11 -1
View File
@@ -25,6 +25,13 @@ type OpenAICompatibleLocalAdapter struct {
apiKey string
}
// compatibleDefaultContextBytes is a conservative bound used when an
// OpenAI-compatible local endpoint omits n_ctx from /v1/models. The adapter
// is a chat-completions adapter, so advisory capability is known from the
// adapter contract; quality and activation still require governed
// qualification/evaluation in the capability registry.
const compatibleDefaultContextBytes int64 = 32 << 10
func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) {
return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport)
}
@@ -90,7 +97,10 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover
stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType)
digest := sha256.Sum256([]byte(stableMetadata))
contextBytes := item.Meta.Context * 4
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
if contextBytes <= 0 {
contextBytes = compatibleDefaultContextBytes
}
models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType})
}
return models, ProviderProbe{Health: capability.HealthAvailable}
}
@@ -18,6 +18,34 @@ type captureRoundTripper struct {
body []byte
}
type modelsMetadataRoundTripper struct{}
func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"medium-model","owned_by":"llama.cpp","meta":{}}]}`)),
Header: make(http.Header),
Request: request,
}, nil
}
func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) {
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{})
if err != nil {
t.Fatal(err)
}
models, probe := adapter.Discover(context.Background())
if probe.Health != capability.HealthAvailable || len(models) != 1 {
t.Fatalf("unexpected discovery result: probe=%+v models=%+v", probe, models)
}
if models[0].MaxContextBytes != compatibleDefaultContextBytes {
t.Fatalf("context bound=%d, want %d", models[0].MaxContextBytes, compatibleDefaultContextBytes)
}
if len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != capability.CapabilityAdvisory {
t.Fatalf("capabilities=%v, want advisory", models[0].Capabilities)
}
}
func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) {
adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second)
if err != nil {
+14 -5
View File
@@ -181,10 +181,6 @@ func (r *CapabilityRepository) SaveBinding(ctx context.Context, binding capabili
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)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
@@ -437,6 +433,14 @@ func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Con
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
}
func (r *CapabilityRepository) LoadCognitiveCandidatesForEvaluation(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, evaluationID capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error) {
var profileID capability.TaskEvaluationProfileID
if err := r.pool.QueryRow(ctx, `SELECT task_profile_id FROM capability.cognitive_task_evaluations WHERE evaluation_id=$1 AND capability_id=$2`, evaluationID, capabilityID).Scan(&profileID); err != nil {
return nil, err
}
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
@@ -564,7 +568,12 @@ func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value
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)
// Discovery may advance a head, but must never roll back a governed
// CANARY/ACTIVE revision to an older discovered snapshot after restart.
// Discovery may advance an inactive head even when its deterministic
// revision number is lower, but it must never overwrite a governed canary
// or active head during restart/startup refresh.
_, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_binding_revision_heads (binding_id,revision) VALUES ($1,$2) ON CONFLICT (binding_id) DO UPDATE SET revision=EXCLUDED.revision,updated_at=CURRENT_TIMESTAMP WHERE NOT EXISTS (SELECT 1 FROM capability.cognitive_bindings current_binding WHERE current_binding.binding_id=capability.cognitive_binding_revision_heads.binding_id AND current_binding.revision=capability.cognitive_binding_revision_heads.revision AND current_binding.activation IN ('CANARY','ACTIVE'))`, binding.ID, binding.Revision)
if err != nil {
return err
}
+36
View File
@@ -26,3 +26,39 @@ func (r *IdentityRepository) LoadBinding(ctx context.Context, actor identity.Act
}
return binding, nil
}
func (r *IdentityRepository) LoadCreatorAccount(ctx context.Context, username string) (identity.CreatorAccount, error) {
var account identity.CreatorAccount
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE username=$1`, username).
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
if err != nil {
return identity.CreatorAccount{}, fmt.Errorf("load creator account: %w", err)
}
return account, nil
}
func (r *IdentityRepository) LoadCreatorAccountByBinding(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID) (identity.CreatorAccount, error) {
var account identity.CreatorAccount
err := r.pool.QueryRow(ctx, `SELECT username, actor_id, principal_id, password_hash, revision FROM identity.creator_accounts WHERE actor_id=$1 AND principal_id=$2`, actor, principal).
Scan(&account.Username, &account.ActorID, &account.PrincipalID, &account.PasswordHash, &account.Revision)
if err != nil {
return identity.CreatorAccount{}, fmt.Errorf("load creator account by binding: %w", err)
}
return account, nil
}
func (r *IdentityRepository) CreateCreatorAccount(ctx context.Context, account identity.CreatorAccount) error {
_, err := r.pool.Exec(ctx, `INSERT INTO identity.creator_accounts (username, actor_id, principal_id, password_hash) VALUES ($1,$2,$3,$4) ON CONFLICT (username) DO NOTHING`, account.Username, account.ActorID, account.PrincipalID, account.PasswordHash)
return err
}
func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor identity.ActorID, principal identity.PrincipalID, revision int64, passwordHash string) error {
result, err := r.pool.Exec(ctx, `UPDATE identity.creator_accounts SET password_hash=$1, revision=revision+1, password_changed_at=CURRENT_TIMESTAMP WHERE actor_id=$2 AND principal_id=$3 AND revision=$4`, passwordHash, actor, principal, revision)
if err != nil {
return fmt.Errorf("update creator password: %w", err)
}
if result.RowsAffected() != 1 {
return fmt.Errorf("update creator password: revision conflict")
}
return nil
}
+6 -1
View File
@@ -20,6 +20,7 @@ type fleetEndpoint struct {
URL string
Headers map[string]string
Client *http.Client
Timeout time.Duration
}
type fleetStatusResponse struct {
@@ -63,7 +64,11 @@ func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, end
if strings.TrimSpace(endpoint.URL) == "" {
return member
}
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
timeout := endpoint.Timeout
if timeout <= 0 {
timeout = 2 * time.Second
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
if err != nil {
+18
View File
@@ -68,3 +68,21 @@ func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestFleetReadinessUsesProviderSpecificTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(40 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
handler := fleetReadinessHandler(server.Client(), capability.FleetTierMedium, []fleetEndpoint{{
ProviderID: "medium", ModelID: "medium-model", Tier: capability.FleetTierMedium,
URL: server.URL, Client: server.Client(), Timeout: 100 * time.Millisecond,
}})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"MEDIUM"`) {
t.Fatalf("provider-specific timeout was not honored: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+16 -6
View File
@@ -71,7 +71,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 60*time.Second)
mediumTimeout := durationEnv("LOCAL_MEDIUM_PROVIDER_TIMEOUT", 120*time.Second)
mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil, mediumTimeout)
if adapterErr != nil {
log.Fatal(adapterErr)
@@ -117,7 +117,8 @@ func main() {
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}
mediumReadinessTimeout := 120 * time.Second
mediumReadinessClient := &http.Client{Timeout: mediumReadinessTimeout}
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)))
@@ -131,14 +132,21 @@ func main() {
// readiness budget short; their detailed fleet state is reported separately.
cpuHealthProbe.Timeout = 2 * time.Second
mediumEndpoint := ""
mediumHealthEndpoint := ""
mediumHeaders := map[string]string{}
mediumHealthProbe := health.Probe{}
if mediumURL != "" {
mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models"
mediumHealthEndpoint = strings.TrimSuffix(strings.TrimRight(mediumURL, "/"), "/v1") + "/health"
if mediumAPIKey != "" {
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
}
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
// Readiness reports provider discovery health quickly; fleet readiness
// separately reports authenticated model availability on /v1/models.
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(readinessClient, mediumHealthEndpoint, mediumHeaders)
// The local gateway may need several seconds to enumerate a loaded model.
// Keep this bounded, but do not classify a healthy MEDIUM provider as
// unavailable before its authenticated discovery response can arrive.
mediumHealthProbe.Timeout = 2 * time.Second
}
readinessProbes := []health.Probe{
@@ -157,8 +165,8 @@ func main() {
}
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: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}, Timeout: 2 * time.Second},
{ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient, Timeout: mediumReadinessTimeout},
{ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL},
}
if gpuConfigured {
@@ -217,7 +225,9 @@ func durationEnv(name string, fallback time.Duration) time.Duration {
func refreshProviderDiscoveries(resolver bindingResolver, service discoveryService) {
for name := range service.providers {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
// Keep startup discovery bounded while allowing the authenticated local
// gateway enough time to enumerate a loaded MEDIUM model.
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
registration, ok := service.providers[name]
if !ok || registration.Discovery == nil {
cancel()
+1 -1
View File
@@ -100,7 +100,7 @@ func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, reques
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}
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, Locale: "en-US", 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(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
latency := time.Since(started).Microseconds()
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -57,6 +58,30 @@ func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
}
}
type localeCheckingProvider struct{ stubAdvisoryProvider }
func (p *localeCheckingProvider) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
if request.Locale == "" {
return cognitive.AdvisoryResponse{}, fmt.Errorf("task evaluation omitted required locale")
}
return p.stubAdvisoryProvider.Advise(ctx, request)
}
func TestTaskEvaluationSuppliesLocaleToProviderAdapter(t *testing.T) {
selected := gatewaySelection()
provider := &localeCheckingProvider{stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}}
registry := &candidateRegistryStub{selected: selected}
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: time.Now}
profile := capability.TaskEvaluationProfile{ID: "profile-locale", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "locale-v1", RequiredCaseIDs: []string{"case-locale"}, 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-locale", Prompt: "one", EvidenceArtifactRef: "artifact:locale"}}}
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())
}
}
type taskEvaluationFailoverRegistry struct {
primary, fallback capability.ResolvedCognitiveBinding
resolveCalls int
+12 -3
View File
@@ -28,6 +28,15 @@ type cognitiveInterpretationClient struct {
media *http.Client
}
const (
// Household interpretation and response review are intentionally compact
// calls. Keeping their output bounded prevents a loaded local model from
// spending the whole request budget generating unnecessary prose.
responseReviewMaxOutputBytes = 512
stableKnowledgeMaxOutputBytes = 512
semanticInterpretMaxOutputBytes = 1024
)
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
// into a natural answer. The draft remains the safe fallback when cognition is
// unavailable; the model does not mutate canonical state or execute actions.
@@ -47,7 +56,7 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
Context: context, Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
Context: context, Locale: "ru", MaxOutputBytes: responseReviewMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
Meaning: cognitive.TaskMeaning{
SpeechAct: "RESPONSE_REVIEW",
CapabilityHints: meaning.CapabilityHints,
@@ -101,7 +110,7 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: authority.ScopeID("domovoy-global-scope"),
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: 1024, DataPolicy: capability.DataPolicyLocalOnly,
Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: stableKnowledgeMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly,
Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis},
})
if err != nil {
@@ -294,7 +303,7 @@ func buildGatewayInterpretationRequest(session identity.CreatorSession, utteranc
ScopeID: authority.ScopeID("domovoy-global-scope"),
Message: utterance.Content,
Locale: "ru",
MaxOutputBytes: 4096,
MaxOutputBytes: semanticInterpretMaxOutputBytes,
DataPolicy: capability.DataPolicyLocalOnly,
DeterministicSpeechAct: string(semantic.Interpretation.SpeechAct),
DeterministicSecondary: string(semantic.Interpretation.SecondarySpeechAct),
@@ -0,0 +1,46 @@
package main
import "testing"
func TestShouldInvokeHumanResponseSkipsDeterministicFastPaths(t *testing.T) {
tests := []struct {
name string
stableConversational bool
identityReply string
memoryInstructionReply string
want bool
}{
{name: "stable conversational", stableConversational: true},
{name: "identity", identityReply: "Меня зовут Домовой."},
{name: "memory instruction", memoryInstructionReply: "Запомнил."},
{name: "ordinary continuity", want: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := shouldInvokeHumanResponse(test.stableConversational, test.identityReply, test.memoryInstructionReply); got != test.want {
t.Fatalf("shouldInvokeHumanResponse() = %t, want %t", got, test.want)
}
})
}
}
func TestShouldReviewResponseSkipsStableAnswers(t *testing.T) {
if shouldReviewResponse("Здравствуйте.", false, true, "", "", false) {
t.Fatal("stable conversational reply must not wait for response review")
}
if shouldReviewResponse("Меня зовут Домовой.", false, false, "Меня зовут Домовой.", "", false) {
t.Fatal("identity reply must not wait for response review")
}
if shouldReviewResponse("Запомнил.", false, false, "", "Запомнил.", false) {
t.Fatal("memory instruction reply must not wait for response review")
}
if shouldReviewResponse("Вы называли тестовый цвет: синий.", false, false, "", "", true) {
t.Fatal("deterministic memory reply must not wait for response review")
}
if !shouldReviewResponse("Ответ", false, false, "", "", false) {
t.Fatal("non-conversational answer remains reviewable")
}
if shouldReviewResponse("Ответ", true, false, "", "", false) {
t.Fatal("stable knowledge answer must not be reviewed twice")
}
}
+217 -48
View File
@@ -37,6 +37,14 @@ import (
"time"
)
const (
creatorPasswordPath = "/run/secrets/creator_password"
creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token"
creatorSessionKeyPath = "/run/secrets/creator_session_signing_key"
epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
interactionDBPasswordPath = "/run/secrets/interaction_db_password"
)
const maxCreatorCredentialsBytes = 4 << 10
func main() {
@@ -44,20 +52,19 @@ func main() {
if addr == "" {
addr = ":8080"
}
bootstrapToken, err := readRequiredSecret("CREATOR_BOOTSTRAP_TOKEN")
bootstrapToken, err := readRequiredSecretFile("CREATOR_BOOTSTRAP_TOKEN", creatorBootstrapTokenPath)
if err != nil {
log.Fatal(err)
}
sessionKeyValue, err := readRequiredSecret("CREATOR_SESSION_SIGNING_KEY")
if err != nil {
log.Fatal(err)
}
creatorPassword, err := readRequiredSecret("CREATOR_PASSWORD")
sessionKeyValue, err := readRequiredSecretFile("CREATOR_SESSION_SIGNING_KEY", creatorSessionKeyPath)
if err != nil {
log.Fatal(err)
}
sessionKey := []byte(sessionKeyValue)
creatorUsername := os.Getenv("CREATOR_USERNAME")
if creatorUsername == "" {
log.Fatal("CREATOR_USERNAME is required")
}
repository, err := postgres.OpenSituation(context.Background(), postgres.ContinuityConfig{Host: os.Getenv("POSTGRES_HOST"), Port: os.Getenv("POSTGRES_PORT"), Database: os.Getenv("POSTGRES_DATABASE"), User: os.Getenv("POSTGRES_USER"), Password: os.Getenv("POSTGRES_PASSWORD")})
if err != nil {
log.Fatal(err)
@@ -73,7 +80,7 @@ func main() {
log.Fatal(err)
}
defer capabilityRepository.Close()
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF")
epistemicsPassword, err := readCredentialRefSecret("EPISTEMICS_POSTGRES_PASSWORD", "EPISTEMICS_POSTGRES_CREDENTIAL_REF", epistemicsDBPasswordPath)
if err != nil {
log.Fatal(err)
}
@@ -91,7 +98,10 @@ func main() {
log.Fatal(err)
}
defer identityRepository.Close()
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF")
if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil {
log.Fatal(err)
}
interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath)
if err != nil {
log.Fatal(err)
}
@@ -144,10 +154,16 @@ func main() {
}
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{
cognitiveReadinessClient := &http.Client{Timeout: 135 * time.Second}
cognitiveReadinessProbe := health.HTTPProbe(cognitiveReadinessClient, strings.TrimRight(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), "/")+"/readyz")
cognitiveReadinessProbe.Timeout = 135 * time.Second
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "core-runtime", Timeout: 140 * time.Second, 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"),
// A running worker is not necessarily ready to serve cognitive traffic.
// Keep deterministic paths alive, but do not advertise overall readiness
// while the cognitive dependency is still warming.
cognitiveReadinessProbe,
}})
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) {
@@ -177,7 +193,39 @@ func main() {
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /v1/creator/sessions", creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword, sessionKey, time.Now))
mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now))
mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
return
}
var request struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || request.CurrentPassword == "" || request.NewPassword == "" {
http.Error(w, "invalid password change request", http.StatusBadRequest)
return
}
account, err := identityRepository.LoadCreatorAccountByBinding(r.Context(), session.ActorID, session.PrincipalID)
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, request.CurrentPassword) {
http.Error(w, "current password denied", http.StatusUnauthorized)
return
}
newHash, err := identitydomain.HashCreatorPassword(request.NewPassword)
if err != nil {
http.Error(w, "invalid new password", http.StatusBadRequest)
return
}
if err := identityRepository.UpdateCreatorPassword(r.Context(), session.ActorID, session.PrincipalID, account.Revision, newHash); err != nil {
http.Error(w, "password change conflict", http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "changed"})
})
mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) {
session, ok := authenticateCreator(w, r)
if !ok {
@@ -534,7 +582,12 @@ func main() {
}
conversationHistory = append(conversationHistory, recentUserHistory...)
gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID))
if !creativeUserRequest && locationReply == "" {
retrievedMemoryReply := interactiondomain.RecallPersonalPreference(conversationHistory, effectiveTaskMessage)
stableConversationalReply, stableConversational := interactiondomain.StableConversationalReply(
effectiveTaskMessage,
interactiondomain.ClassifySpeechAct(effectiveTaskMessage, utterance.CorrectsUtteranceID),
)
if !creativeUserRequest && locationReply == "" && !stableConversational && retrievedMemoryReply == "" {
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
continuationMessage = continuation
effectiveTaskMessage = continuation
@@ -661,6 +714,14 @@ func main() {
semanticInterpretationRequested = false
semanticReflexAudit = false
}
if stableConversational {
semanticInterpretationRequested = false
semanticReflexAudit = false
}
if retrievedMemoryReply != "" {
semanticInterpretationRequested = false
semanticReflexAudit = false
}
if identityReply != "" || memoryInstructionReply != "" {
semanticReflexAudit = false
}
@@ -684,7 +745,7 @@ func main() {
cognitiveAvailable = false
}
}
if !creativeRequest {
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
semantic.Interpretation.Clarification = interaction.ClarificationRequired
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
@@ -702,12 +763,16 @@ func main() {
http.Error(w, "save chat semantics", http.StatusInternalServerError)
return
}
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
SourceUtteranceID: string(utterance.ID),
SourceIntentID: string(intent.ID),
Message: effectiveTaskMessage,
RequestedAtUnixMillis: now.UnixMilli(),
})
var responseResult continuity.HumanResponseResult
var responseErr error
if shouldInvokeHumanResponse(stableConversational, identityReply, memoryInstructionReply) {
responseResult, responseErr = responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
SourceUtteranceID: string(utterance.ID),
SourceIntentID: string(intent.ID),
Message: effectiveTaskMessage,
RequestedAtUnixMillis: now.UnixMilli(),
})
}
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
learning.recordCorrection(r.Context(), correctionLearningInput{Utterance: utterance, Intent: intent, At: now})
}
@@ -733,7 +798,7 @@ func main() {
} else if len(semantic.Projects) > 0 && semantic.Projects[0].Provenance == situation.ProjectResolutionInheritedFocus {
projectID = string(previousFocus.ProjectID)
}
if responseErr == nil && responseResult.Handled {
if responseErr == nil && responseResult.Handled && !stableConversational {
planningReply = responseResult.Reply
needsCognition = false
learning.recordReminderResponse(r.Context(), reminderResponseLearningInput{Utterance: utterance, Intent: intent, Result: responseResult, At: now})
@@ -763,6 +828,12 @@ func main() {
ReceivedAtUnixMillis: utterance.ReceivedAtUnixMillis,
}
helpPath := workdomain.DetermineHelpPath(planningInput)
// Introspection requests are read-only household UX. A cognitive
// candidate must not reinterpret them as an executable delegation.
switch semantic.InformationRequestKind {
case interaction.InformationRequestCapabilityInventory, interaction.InformationRequestLocalCapabilityInventory, interaction.InformationRequestCognitiveModelInventory, interaction.InformationRequestCognitiveCurrentModel, interaction.InformationRequestCognitiveChannelStatus, interaction.InformationRequestCognitiveRecovery, interaction.InformationRequestMemoryInventory:
helpPath = work.HelpPath{}
}
if responseErr == nil && responseResult.Handled {
helpPath = work.HelpPath{}
}
@@ -800,6 +871,10 @@ func main() {
researchBacked := false
researchAttempted := false
stableKnowledgeReply := false
if retrievedMemoryReply != "" {
planningReply = retrievedMemoryReply
needsCognition = false
}
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) {
researchAttempted = true
if planID, idErr := ids.New(); idErr == nil {
@@ -860,7 +935,7 @@ func main() {
planningReply = helpPath.Reply
needsCognition = false
}
if !creativeRequest {
if !creativeRequest && !stableConversational && retrievedMemoryReply == "" {
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
planningReply = travelReply
needsCognition = false
@@ -990,7 +1065,11 @@ func main() {
planningReply = memoryInstructionReply
needsCognition = false
}
if strings.TrimSpace(planningReply) != "" {
// Stable-knowledge answers have already passed the local cognitive
// route with compact, source-aware context. Re-reviewing the same answer
// serially can exhaust the household request budget on a loaded local
// provider and turn a valid answer into a degraded response.
if shouldReviewResponse(planningReply, stableKnowledgeReply, stableConversational, identityReply, memoryInstructionReply, retrievedMemoryReply != "") {
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, gen2Context.Text, cognitive.TaskMeaning{
SpeechAct: string(semantic.Interpretation.SpeechAct),
@@ -1016,13 +1095,8 @@ func main() {
}
}
if !mediaAttempted && !mediaHandled && identityReply == "" && creativeReply == "" && creativeFallbackReply == "" {
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
travelClarification := ""
if !creativeRequest {
travelClarification = interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
}
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
planningReply = stableReply
if stableConversational {
planningReply = stableConversationalReply
needsCognition = false
}
}
@@ -1486,7 +1560,7 @@ func main() {
}
request.RequestedByPrincipalID = session.PrincipalID
request.CapabilityID = "creator-advisory"
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
candidates, err := capabilityRepository.LoadCognitiveCandidatesForEvaluation(r.Context(), request.CapabilityID, session.PrincipalID, request.EvaluationID)
if err != nil {
http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable)
return
@@ -1525,18 +1599,11 @@ func main() {
}
request.RequestedByPrincipalID = session.PrincipalID
request.CapabilityID = "creator-advisory"
candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), request.CapabilityID, session.PrincipalID)
current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), request.BindingID, request.ExpectedBindingRevision)
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)
@@ -1670,6 +1737,14 @@ func main() {
}
}
func shouldInvokeHumanResponse(stableConversational bool, identityReply, memoryInstructionReply string) bool {
return !stableConversational && identityReply == "" && memoryInstructionReply == ""
}
func shouldReviewResponse(planningReply string, stableKnowledgeReply, stableConversational bool, identityReply, memoryInstructionReply string, deterministicMemoryReply bool) bool {
return strings.TrimSpace(planningReply) != "" && !stableKnowledgeReply && !stableConversational && identityReply == "" && memoryInstructionReply == "" && !deterministicMemoryReply
}
func shouldRunGeneralResearch(kind interaction.InformationRequestKind) bool {
return kind == "" || kind == interaction.InformationRequestTimeDifference
}
@@ -1704,6 +1779,88 @@ func creatorSessionHandler(bootstrapToken, creatorUsername, creatorPassword stri
}
}
func creatorDatabaseSessionHandler(bootstrapToken, creatorUsername string, repository *postgres.IdentityRepository, sessionKey []byte, now func() time.Time) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) {
token, session, err := identitydomain.IssueCreatorSession(sessionKey, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
writeCreatorSession(w, token, session)
return
}
var credentials identity.CreatorCredentials
decoder := json.NewDecoder(io.LimitReader(r.Body, maxCreatorCredentialsBytes+1))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&credentials); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
account, err := repository.LoadCreatorAccount(r.Context(), credentials.Username)
if err != nil || !identitydomain.VerifyCreatorPassword(account.PasswordHash, credentials.Password) {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
binding, err := repository.LoadBinding(r.Context(), account.ActorID, account.PrincipalID)
if err != nil || !binding.Active {
http.Error(w, "creator authentication denied", http.StatusUnauthorized)
return
}
token, session, err := identitydomain.IssueCreatorSessionForBinding(sessionKey, account.ActorID, account.PrincipalID, now(), 8*time.Hour)
if err != nil {
http.Error(w, "creator session unavailable", http.StatusServiceUnavailable)
return
}
writeCreatorSession(w, token, session)
}
}
func writeCreatorSession(w http.ResponseWriter, token string, session identity.CreatorSession) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(struct {
Token string
Session identity.CreatorSession
}{token, session})
}
func bootstrapCreatorAccount(ctx context.Context, repository *postgres.IdentityRepository, username, passwordPath string) error {
if account, err := repository.LoadCreatorAccount(ctx, username); err == nil {
if account.ActorID == "" || account.PrincipalID == "" {
return errors.New("creator account binding is incomplete")
}
return nil
} else if !errors.Is(unwrapIdentityRepositoryError(err), pgx.ErrNoRows) {
return err
}
password, err := readRequiredSecretFile("CREATOR_PASSWORD", passwordPath)
if err != nil {
return fmt.Errorf("creator account is missing and bootstrap password is unavailable: %w", err)
}
hash, err := identitydomain.HashCreatorPassword(password)
if err != nil {
return fmt.Errorf("hash creator bootstrap password: %w", err)
}
if err := repository.CreateCreatorAccount(ctx, identity.CreatorAccount{Username: username, ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", PasswordHash: hash}); err != nil {
return fmt.Errorf("create creator account: %w", err)
}
return nil
}
func unwrapIdentityRepositoryError(err error) error {
for err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return pgx.ErrNoRows
}
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
break
}
err = unwrapped
}
return err
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if !strings.HasPrefix(value, "Bearer ") {
@@ -1726,10 +1883,7 @@ func readRequiredSecret(name string) (string, error) {
if err != nil {
return "", fmt.Errorf("read %s_FILE: %w", name, err)
}
direct = strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(direct, "\r\n") {
return "", fmt.Errorf("%s_FILE must contain one line", name)
}
return normalizeSecretFile(name, contents)
}
if direct == "" {
return "", fmt.Errorf("%s is required", name)
@@ -1737,13 +1891,28 @@ func readRequiredSecret(name string) (string, error) {
return direct, nil
}
func readCredentialRefSecret(name, refName string) (string, error) {
if _, directSet := os.LookupEnv(name); directSet {
return "", fmt.Errorf("%s direct environment secret is forbidden; use %s_FILE", name, name)
func readRequiredSecretFile(name, path string) (string, error) {
contents, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s secret file: %w", name, err)
}
fileName := os.Getenv(name + "_FILE")
if fileName == "" {
return "", fmt.Errorf("%s_FILE is required", name)
return normalizeSecretFile(name, contents)
}
func normalizeSecretFile(name string, contents []byte) (string, error) {
value := strings.TrimRight(string(contents), "\r\n")
if strings.ContainsAny(value, "\r\n") {
return "", fmt.Errorf("%s secret file must contain one line", name)
}
if value == "" {
return "", fmt.Errorf("%s is required", name)
}
return value, nil
}
func readCredentialRefSecret(name, refName, fileName string) (string, error) {
if _, directSet := os.LookupEnv(name); directSet {
return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name)
}
ref := os.Getenv(refName)
if ref == "" {
+2
View File
@@ -841,6 +841,8 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
return targetCore, "/v1/creator/sessions", true
case method == http.MethodGet && path == "/api/session/current":
return targetCore, "/v1/creator/sessions/current", true
case method == http.MethodPost && path == "/api/account/password":
return targetCore, "/v1/creator/account/password", true
case method == http.MethodGet && path == "/api/focus":
return targetCore, "/v1/creator/focus", true
case method == http.MethodGet && strings.HasPrefix(path, "/api/tasks/"):
+260 -106
View File
@@ -1,131 +1,285 @@
:root {
--ink: #e9eee7;
--muted: #93a096;
--base: #0b100e;
--panel: #101714;
--line: #2a352f;
--signal: #c9a85f;
--bad: #de816f;
--good: #78c6a3;
--radius: 3px;
--sans: "Bahnschrift", "Aptos Narrow", "Segoe UI", sans-serif;
--serif: Georgia, "Times New Roman", serif;
--mono: "Cascadia Mono", Consolas, monospace;
--ink: #e6edf4;
--muted: #8494a4;
--faint: #5d6e7f;
--base: #0b1119;
--panel: #111b27;
--panel-raised: #172331;
--panel-soft: #0f1925;
--line: #263544;
--line-strong: #34485c;
--lime: #c5ef91;
--lime-ink: #142014;
--blue: #8fc7ef;
--yellow: #d8b86c;
--good: #8ed7ad;
--bad: #ee9292;
--radius: 8px;
--sans: "Manrope", "Segoe UI", sans-serif;
--mono: "DM Mono", "Cascadia Mono", Consolas, monospace;
}
* { 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); }
button, input, textarea { font: inherit; }
button { cursor: pointer; color: inherit; border-radius: var(--radius); }
a { color: inherit; }
.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-panel { width: min(100% - 32px, 420px); margin: auto; padding: 40px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.28); }
.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--lime-ink); background: var(--lime); border-radius: 10px; font: 700 22px/1 var(--sans); }
h1, h2 { margin: 0; letter-spacing: -.03em; }
.login-panel h1 { margin: 26px 0; font-size: 42px; font-weight: 650; }
label { display: block; margin: 16px 0 8px; color: var(--muted); font: 600 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; }
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-soft); outline: none; }
input:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.12); }
input { height: 46px; padding: 0 12px; }
textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 13px; line-height: 1.45; }
.login-form button, .composer button { border: 0; color: var(--lime-ink); background: var(--lime); font-weight: 750; }
.login-form button { width: 100%; height: 46px; margin-top: 18px; }
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
.chat-view { width: 100%; min-width: 0; 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); }
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 60px minmax(0, 1fr); overflow: hidden; }
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 20px; border-bottom: 1px solid var(--line); background: rgba(17,27,39,.97); }
.chat-header > a:first-child { color: var(--ink); text-decoration: none; font-size: 18px; font-weight: 750; letter-spacing: -.02em; }
.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); }
.chat-header .cognitive-status { width: auto; height: auto; max-width: min(30vw, 340px); margin-left: auto; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .04em; text-overflow: ellipsis; text-transform: uppercase; }
.cognitive-status::before { content: ""; width: 7px; height: 7px; flex: 0 0 7px; border-radius: 50%; background: var(--yellow); }
.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; }
.generated-image { display: block; max-width: min(100%, 640px); height: auto; margin-top: 12px; border: 1px solid var(--line); }
.cognitive-console-link { color: var(--blue); font: 10px/1 var(--mono); letter-spacing: .04em; white-space: nowrap; }
.build-meta { display: grid; gap: 3px; flex: 0 0 auto; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; }
.build-meta time { color: var(--faint); font-size: 8px; }
.chat-header button { width: 34px; height: 34px; border: 1px solid var(--line); background: transparent; }
.operator-link { height: 32px; padding: 0 11px; display: inline-flex; align-items: center; border: 1px solid var(--line); color: var(--blue); background: transparent; text-decoration: none; font: 10px/1 var(--mono); letter-spacing: .04em; text-transform: uppercase; }
.chat-layout { min-height: 0; height: 100%; display: grid; grid-template-columns: minmax(220px, 258px) minmax(0, 1fr) minmax(270px, 320px); }
.chat-side { min-width: 0; min-height: 0; padding: 22px 18px; overflow-y: auto; background: var(--panel); scrollbar-color: var(--line-strong) transparent; }
.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; }
.side-intro h1, .side-intro h2 { margin: 9px 0 10px; font-size: clamp(25px, 2.4vw, 34px); line-height: 1.05; font-weight: 680; }
.side-intro p:not(.side-index) { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.side-index, .section-label, .ai-info-card span, .insight-card span { color: var(--faint); font: 600 9px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase; }
.side-section, .side-note, .ai-info-card { margin-top: 20px; padding: 13px 0; border-top: 1px solid var(--line); }
.side-focus strong, .side-note strong { display: block; margin-top: 8px; color: var(--blue); font-size: 13px; font-weight: 650; line-height: 1.35; }
.side-note { border-bottom: 0; }
.side-note strong { color: var(--good); }
.side-note small, .ai-info-card p, .ai-data-form small { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.45; }
.context-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.context-list span { padding: 5px 8px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); font-size: 11px; }
.quick-context { padding-bottom: 3px; }
.human-nav { display: grid; gap: 6px; margin-top: 8px; }
.nav-tab { width: 100%; min-height: 50px; padding: 8px 10px; display: grid; grid-template-columns: 26px 1fr; gap: 2px 7px; align-items: center; border: 1px solid transparent; color: var(--muted); background: transparent; text-align: left; }
.chat-left .nav-tab b { grid-row: span 2; color: var(--faint); font: 10px/1 var(--mono); }
.chat-left .nav-tab span { color: var(--ink); font-size: 12px; font-weight: 650; }
.chat-left .nav-tab small { color: var(--faint); font: 10px/1 var(--mono); }
.nav-tab:hover, .nav-tab:focus-visible { border-color: var(--line-strong); outline: none; }
.nav-tab.active { border-color: rgba(197,239,145,.42); background: rgba(197,239,145,.1); }
.nav-tab.active b { color: var(--lime); }
.quiet-state { margin: 9px 0 0; color: var(--faint); font-size: 11px; }
.new-conversation { width: 100%; min-height: 34px; margin-top: 9px; border: 1px solid var(--line-strong); color: var(--ink); background: transparent; font-size: 11px; }
.new-conversation:hover, .new-conversation:focus-visible { border-color: var(--lime); color: var(--lime); outline: none; }
.side-build { margin-top: 14px; padding-top: 14px; text-align: left; }
.chat-main { min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--base); }
.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); }
.media-tools { width: min(100%, 920px); margin: 0 auto; padding: 0 18px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--muted); font: 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; }
.media-tools label { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 8px; border: 1px solid var(--line); color: var(--ink); cursor: pointer; }
.media-tools input { max-width: 150px; font: 10px var(--mono); }
.media-tools span { color: var(--signal); }
.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); }
.messages { width: min(100%, 900px); min-height: 0; margin: 0 auto; padding: clamp(24px, 5vh, 52px) 22px 20px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; scrollbar-color: var(--line-strong) transparent; }
.message { max-width: min(700px, 92%); padding: 11px 14px 12px; border: 1px solid var(--line); border-radius: 10px; line-height: 1.5; }
.message.user { align-self: flex-end; background: #162231; border-color: #30465b; }
.message.assistant { align-self: flex-start; background: #121d2a; }
.message-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
.message-role { color: var(--blue); font: 650 9px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; }
.message.user .message-role { color: var(--lime); }
.message-time { color: var(--faint); font: 9px/1 var(--mono); }
.message-content { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
.message small { display: block; margin-top: 8px; color: var(--faint); font: 9px/1.2 var(--mono); letter-spacing: .03em; text-transform: none; }
.composer { width: min(100%, 900px); margin: 0 auto; padding: 10px 22px 7px; display: grid; grid-template-columns: minmax(0, 1fr) 106px; gap: 9px; border-top: 1px solid var(--line); background: var(--base); }
.composer-input-wrap { min-width: 0; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-soft); transition: border-color .15s ease, box-shadow .15s ease; }
.composer-input-wrap:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(143,199,239,.1); }
.composer textarea { min-height: 46px; max-height: 200px; resize: none; border: 0; background: transparent; }
.composer-hint { display: flex; justify-content: space-between; gap: 12px; padding: 0 12px 7px; color: var(--faint); font: 9px/1 var(--mono); }
.composer-hint output { color: var(--muted); white-space: nowrap; }
.composer button { height: 46px; align-self: end; display: inline-flex; align-items: center; justify-content: space-between; gap: 7px; padding: 0 13px; }
.composer button[aria-busy="true"] { color: #384529; background: #a6ca7a; }
.composer button span:last-child { opacity: .65; font: 15px/1 var(--mono); }
.composer button:disabled { cursor: wait; filter: saturate(.35); }
.media-tools { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 9px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font: 9px/1 var(--mono); letter-spacing: .03em; text-transform: uppercase; }
.media-tools label { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; margin: 0; padding: 0 7px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); cursor: pointer; }
.media-tools input { max-width: 125px; font: 9px var(--mono); }
.media-tools span { color: var(--yellow); }
.insight-grid { display: grid; gap: 7px; margin-top: 19px; }
.insight-card { padding: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-soft); }
.insight-card strong { display: block; margin-top: 7px; color: var(--ink); font-size: 12px; font-weight: 650; line-height: 1.3; }
.insight-card small { display: block; margin-top: 5px; color: var(--faint); font-size: 10px; line-height: 1.35; }
.ai-info-card { padding-bottom: 0; }
.ai-info-card strong { display: block; margin-top: 8px; font-size: 12px; }
.ai-data-form { margin-top: 18px; }
.ai-data-form textarea { min-height: 126px; }
.ai-data-form button { width: 100%; min-height: 38px; margin-top: 9px; border: 1px solid rgba(197,239,145,.3); border-radius: 7px; color: var(--lime); background: rgba(197,239,145,.1); font-size: 11px; font-weight: 700; }
.ai-data-form button:hover { background: rgba(197,239,145,.16); }
.ai-data-error { margin: 9px 0 0; color: var(--bad); font-size: 11px; line-height: 1.4; }
.side-console-link { display: flex; justify-content: space-between; margin-top: 20px; padding-top: 13px; border-top: 1px solid var(--line); color: var(--blue) !important; font: 10px/1.3 var(--mono) !important; text-decoration: none; letter-spacing: .03em; text-transform: uppercase; }
.attention-surface, .history-surface { width: min(100%, 900px); margin: 0 auto; padding: 28px 22px; align-content: start; gap: 15px; }
.surface-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); padding-bottom: 13px; }
.surface-heading p { margin: 0; font-size: 27px; font-weight: 680; }
.surface-heading span, .attention-card small, .history-item span { color: var(--muted); font: 9px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; }
.surface-heading button { height: 31px; padding: 0 10px; border: 1px solid var(--line); background: transparent; font-size: 11px; }
.attention-list, .history-list { display: grid; gap: 9px; }
.attention-card, .history-item { border: 1px solid var(--line); border-radius: 8px; background: var(--panel); padding: 13px; }
.attention-card time { color: var(--yellow); font: 10px/1 var(--mono); }
.attention-card h2 { margin: 8px 0 6px; font-size: 18px; font-weight: 650; }
.attention-card p, .history-item p { margin: 0 0 8px; color: var(--ink); font-size: 12px; line-height: 1.5; }
.attention-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 11px; }
.attention-actions button { height: 30px; padding: 0 10px; border: 1px solid var(--line); background: var(--panel-raised); color: var(--ink); font: 10px/1 var(--mono); }
.attention-actions button:first-child { border-color: rgba(142,215,173,.5); color: var(--good); }
.history-item.user { border-color: #30465b; background: #162231; }
.history-item strong { display: block; margin-bottom: 6px; color: var(--blue); font-size: 12px; }
.empty-state { margin: 20px 0 0; color: var(--muted); font-size: 12px; }
.empty-state.error { color: var(--bad); }
@media (max-width: 640px) {
@media (max-width: 780px) {
.chat-view { height: auto; min-height: 100vh; overflow: visible; display: block; }
.composer { grid-template-columns: 1fr; }
.chat-header { position: sticky; top: 0; z-index: 3; min-height: 58px; padding: 0 12px; }
.chat-header .cognitive-status { flex: 1 1 100px; min-width: 0; max-width: 150px; }
.chat-header .cognitive-console-link, .chat-header > .operator-link, .chat-header .build-meta { display: none; }
.chat-header > #logout { flex: 0 0 34px; }
.chat-layout { height: auto; display: flex; flex-direction: column; }
.chat-main { order: 1; height: 72vh; min-height: 530px; }
.chat-side { min-height: auto; max-height: none; overflow: visible; }
.chat-left { order: 2; border: 0; border-top: 1px solid var(--line); }
.chat-right { order: 3; border: 0; border-top: 1px solid var(--line); }
.messages { padding-inline: 12px; }
.composer { grid-template-columns: 1fr; padding-inline: 12px; }
.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; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.chat-header > .operator-link, .build-meta { display: none; }
.chat-header > #logout { flex: 0 0 38px; }
.composer-hint { font-size: 8px; }
.media-tools { padding-inline: 12px; }
.side-build { display: none; }
}
@media (max-width: 640px) {
.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; }
.chat-header > .operator-link, .build-meta { display: none; }
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
}
@media (min-width: 641px) and (max-width: 1100px) {
.chat-layout { grid-template-columns: 210px minmax(0, 1fr) 245px; }
@media (min-width: 781px) and (max-width: 1100px) {
.chat-layout { grid-template-columns: 218px minmax(0, 1fr) 260px; }
.chat-side { padding-inline: 14px; }
.side-intro h1, .side-intro h2 { font-size: 30px; }
}
/* Chat-first pass: navigation and context stay out of the conversation's way. */
.chat-header { gap: 10px; padding-inline: 18px; }
.header-icon, .header-control, .profile-button { height: 32px; border: 1px solid var(--line); background: transparent; font: 10px/1 var(--mono); }
.header-icon { width: 32px; padding: 0; color: var(--muted); font-size: 15px; }
.header-control, .profile-button { padding: 0 10px; color: var(--muted); }
.header-control { color: var(--ink); }
.header-icon:hover, .header-icon:focus-visible, .header-control:hover, .header-control:focus-visible, .profile-button:hover, .profile-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.header-focus { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.chat-header .cognitive-status { margin-left: auto; max-width: 250px; }
.chat-header .cognitive-console-link { display: none; }
.chat-layout { grid-template-columns: 242px minmax(0, 1fr); }
.chat-layout.nav-collapsed { grid-template-columns: 0 minmax(0, 1fr); }
.chat-left { padding: 24px 16px; transition: opacity .18s ease, padding .18s ease, transform .18s ease; }
.chat-layout.nav-collapsed .chat-left { padding-inline: 0; overflow: hidden; opacity: 0; border-right: 0; }
.side-intro h1 { font-size: 26px; }
.side-section { margin-top: 18px; }
.human-nav { margin-top: 12px; }
.nav-tab { grid-template-columns: 1fr; min-height: 44px; padding-inline: 10px; }
.chat-left .nav-tab b { display: none; }
.new-conversation { margin-top: 16px; }
.chat-main { min-width: 0; }
.messages { width: min(100%, 1040px); padding: 32px 30px 22px; gap: 14px; }
.message { max-width: min(760px, 88%); padding: 14px 17px 15px; border-radius: 12px; }
.message-content { font-size: 15px; line-height: 1.55; }
.message-role { font-size: 10px; }
.message-time { font-size: 10px; }
.message small { font-size: 10px; }
.composer { width: min(100%, 1040px); padding-inline: 30px; }
.composer-input-wrap { min-height: 64px; padding: 0 12px 36px; border-radius: 14px; }
.composer textarea { min-height: 56px; max-height: 180px; padding: 13px 0 8px; font-size: 15px; }
.composer-actions { right: 9px; bottom: 8px; left: 9px; height: 28px; }
.composer-tool { width: 29px; height: 29px; padding: 0; border: 0; color: var(--muted); background: transparent !important; }
.composer-tool svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
.composer-tool:hover, .composer-tool:focus-visible { border: 0; color: var(--blue); box-shadow: none; }
.composer-hint { font-size: 10px; }
.send-button { width: 33px; height: 33px; flex-basis: 33px; }
.composer-runtime { height: 0; min-height: 0; padding: 0; overflow: visible; }
.attachment-menu { bottom: 42px; left: 8px; }
.context-indicator { display: none; }
.technical-meta { display: none !important; }
.context-backdrop { position: fixed; z-index: 8; inset: 60px 0 0; border: 0; background: rgba(4,9,15,.58); }
.context-drawer { position: fixed; z-index: 9; top: 60px; right: 0; bottom: 0; width: min(360px, 92vw); padding: 24px 20px; border-left: 1px solid var(--line-strong); box-shadow: -20px 0 50px rgba(0,0,0,.28); transform: translateX(105%); transition: transform .2s ease; }
.password-dialog { width: min(420px, calc(100vw - 32px)); padding: 0; color: var(--ink); background: var(--panel); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 24px 80px rgba(0,0,0,.45); }
.password-dialog::backdrop { background: rgba(3,8,14,.68); }
.password-dialog form { padding: 22px; }
.dialog-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.dialog-heading h2 { margin: 0; font-size: 20px; }
.dialog-heading button { width: 30px; height: 30px; border: 1px solid var(--line); background: transparent; }
.password-dialog label { display: block; margin: 14px 0 7px; color: var(--muted); font: 11px/1 var(--mono); }
.password-dialog input { width: 100%; height: 42px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); background: var(--panel-raised); }
.password-dialog input:focus { outline: 2px solid var(--blue); outline-offset: 1px; }
.dialog-actions { display: flex; justify-content: space-between; gap: 10px; margin-top: 20px; }
.dialog-actions button { min-height: 38px; padding: 0 14px; border: 1px solid var(--line); background: transparent; }
.dialog-actions button:first-child { border-color: var(--lime); color: var(--lime-ink); background: var(--lime); }
.dialog-actions button:disabled { opacity: .55; cursor: wait; }
#password-status { min-height: 18px; margin: 12px 0 0; color: var(--muted); font-size: 12px; }
.context-drawer.drawer-open { transform: translateX(0); }
.drawer-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.drawer-heading h2 { margin-top: 9px; font-size: 26px; }
.drawer-copy { margin: 12px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
.drawer-close { width: 30px; height: 30px; border: 1px solid var(--line); color: var(--muted); background: transparent; font-size: 20px; line-height: 1; }
.drawer-close:hover, .drawer-close:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.insight-grid { margin-top: 20px; }
@media (max-width: 780px) {
.chat-header { padding-inline: 10px; }
.header-focus { display: none; }
.chat-header .cognitive-status { max-width: 145px; }
.profile-button { width: 32px; overflow: hidden; padding: 0; font-size: 0; }
.profile-button::before { content: "●"; font-size: 12px; }
.chat-layout { display: block; }
.chat-main { height: calc(100vh - 58px); min-height: 520px; }
.chat-left { position: fixed; z-index: 10; top: 58px; bottom: 0; left: 0; width: min(290px, 84vw); transform: translateX(-105%); box-shadow: 18px 0 42px rgba(0,0,0,.3); }
.chat-layout.nav-open .chat-left { transform: translateX(0); opacity: 1; overflow-y: auto; }
.chat-layout.nav-collapsed .chat-left { padding-inline: 16px; }
.messages { padding: 22px 12px 16px; }
.message { max-width: 94%; }
.message-content { font-size: 14px; }
.composer { width: 100%; padding-inline: 12px; }
.composer-input-wrap { padding-right: 10px; }
.composer-hint { display: none; }
.attachment-menu { bottom: 42px; left: 5px; }
.context-backdrop { inset: 58px 0 0; }
.context-drawer { top: 58px; }
}
/* Focused composer: one calm surface, with compact controls kept together. */
.active-panel { grid-template-rows: minmax(0, 1fr) auto auto auto; }
.composer { width: min(100%, 900px); margin: 0 auto; padding: 0 22px 7px; display: block; border-top: 0; background: transparent; }
.composer-input-wrap { position: relative; min-height: 62px; padding: 0 58px 32px 12px; border: 1px solid var(--line-strong); border-radius: 12px; background: var(--panel); }
.composer textarea { display: block; min-height: 54px; max-height: 178px; height: 54px; padding: 12px 0 8px; overflow-y: hidden; line-height: 1.4; }
.composer-actions { position: absolute; right: 8px; bottom: 7px; left: 8px; height: 27px; display: flex; align-items: center; gap: 8px; }
.composer-hint { order: 3; padding: 0; color: var(--faint); font: 9px/1 var(--mono); }
.composer-tool { width: 27px; height: 27px; flex: 0 0 27px; border: 1px solid var(--line-strong); border-radius: 7px; color: var(--muted); background: transparent; font-size: 18px; line-height: 1; }
.composer-tool:has(.composer-tool-label) { width: auto; min-width: 27px; padding-inline: 7px; gap: 5px; font: 10px/1 var(--mono); }
.composer-tool-label { font-size: 10px; }
.composer-tool:hover, .composer-tool:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.context-indicator { display: inline-flex; align-items: center; gap: 5px; color: var(--faint); font: 9px/1 var(--mono); white-space: nowrap; }
.context-indicator output { color: var(--muted); }
.send-button { order: 5; width: 32px; height: 32px; flex: 0 0 32px; margin-left: auto; display: grid; place-items: center; padding: 0; border: 1px solid rgba(197,239,145,.48); border-radius: 50%; color: var(--lime-ink); background: var(--lime); font-size: 17px; line-height: 1; }
.send-button:hover, .send-button:focus-visible { box-shadow: 0 0 0 3px rgba(197,239,145,.12); outline: none; }
.send-button[aria-busy="true"] { border-color: rgba(238,146,146,.55); color: var(--bad); background: rgba(238,146,146,.12); }
.send-button[aria-busy="true"]:hover { background: rgba(238,146,146,.2); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.composer-toolbar { width: min(100%, 900px); min-height: 30px; margin: 0 auto; padding: 0 22px 8px; position: relative; display: flex; align-items: center; gap: 6px; }
.toolbar-button { min-height: 27px; padding: 0 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: transparent; font: 9px/1 var(--mono); text-transform: uppercase; }
.toolbar-button:hover, .toolbar-button:focus-visible { border-color: var(--blue); color: var(--blue); outline: none; }
.attachment-menu { position: absolute; z-index: 4; bottom: 37px; left: 22px; display: grid; min-width: 145px; padding: 5px; border: 1px solid var(--line-strong); border-radius: 9px; background: var(--panel-raised); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
.attachment-menu button { padding: 8px 9px; border: 0; border-radius: 6px; color: var(--ink); background: transparent; text-align: left; font-size: 11px; }
.attachment-menu button:hover, .attachment-menu button:focus-visible { color: var(--lime); background: rgba(197,239,145,.1); outline: none; }
.composer-toolbar #media-status { margin-left: 3px; color: var(--yellow); font: 9px/1 var(--mono); }
@media (max-width: 780px) {
.composer { width: 100%; padding-inline: 12px; }
.composer-input-wrap { padding-right: 50px; }
.composer-toolbar { width: 100%; padding-inline: 12px; }
.attachment-menu { left: 12px; }
.composer-hint { display: none; }
.composer-tool:has(.composer-tool-label) { padding-inline: 5px; }
.composer-tool-label { font-size: 9px; }
.context-indicator { font-size: 8px; }
}
+168 -24
View File
@@ -6,23 +6,69 @@ const messages = document.querySelector("#messages");
const chatForm = document.querySelector("#chat-form");
const messageInput = document.querySelector("#message");
const sendButton = document.querySelector("#send");
const sendLabel = document.querySelector("#send-label");
const messageCount = document.querySelector("#message-count");
const logoutButton = document.querySelector("#logout");
const passwordDialog = document.querySelector("#password-dialog");
const passwordForm = document.querySelector("#password-form");
const passwordClose = document.querySelector("#password-close");
const passwordLogout = document.querySelector("#password-logout");
const passwordStatus = document.querySelector("#password-status");
const activeFocus = document.querySelector("#active-focus");
const rightFocus = document.querySelector("#right-focus");
const headerFocus = document.querySelector("#header-focus");
const navToggle = document.querySelector("#nav-toggle");
const chatLayout = document.querySelector(".chat-layout");
const contextToggle = document.querySelector("#context-toggle");
const contextClose = document.querySelector("#context-close");
const contextBackdrop = document.querySelector("#context-backdrop");
const contextDrawer = document.querySelector("#context-drawer");
const newConversationButton = document.querySelector(".new-conversation");
const cognitiveStatus = document.querySelector("#cognitive-status");
const cognitiveConsoleLink = document.querySelector("#cognitive-console-link");
const todayList = document.querySelector("#today-list");
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 attachToggle = document.querySelector("#attach-toggle");
const attachmentMenu = document.querySelector("#attachment-menu");
const photoTrigger = document.querySelector("#photo-trigger");
const micTrigger = document.querySelector("#mic-trigger");
const sendIcon = document.querySelector("#send-icon");
const stopIcon = document.querySelector("#stop-icon");
let token = sessionStorage.getItem("domovoy.chat.token") || "";
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
let localHistory = [];
let cognitiveStatusTimer = null;
let activeRequestController = null;
let composingMessage = false;
function resizeComposerInput() {
if (!messageInput) return;
messageInput.style.height = "auto";
const maxHeight = 178;
messageInput.style.height = `${Math.min(messageInput.scrollHeight, maxHeight)}px`;
messageInput.style.overflowY = messageInput.scrollHeight > maxHeight ? "auto" : "hidden";
if (messageCount) messageCount.textContent = `${messageInput.value.length.toLocaleString("ru-RU")} / 16 384`;
}
function isChatBusy() {
return sendButton?.getAttribute("aria-busy") === "true";
}
function setChatBusy(busy) {
if (!sendButton) return;
sendButton.setAttribute("aria-busy", busy ? "true" : "false");
sendButton.setAttribute("aria-label", busy ? "Остановить обработку" : "Отправить");
sendButton.title = busy ? "Остановить обработку" : "Отправить";
if (sendLabel) sendLabel.textContent = busy ? "Остановить обработку" : "Отправить";
if (sendIcon) sendIcon.hidden = busy;
if (stopIcon) stopIcon.hidden = !busy;
}
class ChatApiError extends Error {
constructor(status, detail = "") {
@@ -67,10 +113,8 @@ async function loadCognitiveStatus() {
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;
}
// Cognitive health is an auxiliary probe. Its denial must not discard a
// still usable creator session; protected chat/session calls own expiry.
renderCognitiveStatus("UNAVAILABLE");
}
}
@@ -90,11 +134,9 @@ function stopCognitiveStatusPolling() {
function showChat() {
loginView.hidden = true;
chatView.hidden = false;
if (!messages.children.length) {
appendMessage("assistant", "Я рядом. Напишите обычное сообщение, без внутренних идентификаторов.");
}
startCognitiveStatusPolling();
messageInput.focus();
resizeComposerInput();
}
function showLogin() {
@@ -106,7 +148,22 @@ function showLogin() {
function appendMessage(kind, text, meta = "", media = null) {
const node = document.createElement("div");
node.className = `message ${kind}`;
node.textContent = text;
const head = document.createElement("div");
head.className = "message-head";
const role = document.createElement("strong");
role.className = "message-role";
role.textContent = kind === "user" ? "Вы" : "Домовой";
const time = document.createElement("time");
time.className = "message-time";
const now = new Date();
time.dateTime = now.toISOString();
time.textContent = now.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"});
head.append(role, time);
node.appendChild(head);
const content = document.createElement("div");
content.className = "message-content";
content.textContent = text;
node.appendChild(content);
if (meta) {
const small = document.createElement("small");
small.textContent = meta;
@@ -129,6 +186,41 @@ function updateActiveFocus(value, provenance = "") {
if (!activeFocus) return;
activeFocus.textContent = value ? `Сейчас обсуждаем: ${value}` : "";
activeFocus.hidden = !value;
if (rightFocus) rightFocus.textContent = value || "не выбран";
if (headerFocus) headerFocus.textContent = value || "Новый разговор";
}
function setContextOpen(open) {
if (!contextDrawer) return;
contextDrawer.classList.toggle("drawer-open", open);
contextDrawer.setAttribute("aria-hidden", String(!open));
if (contextToggle) contextToggle.setAttribute("aria-expanded", String(open));
if (contextBackdrop) contextBackdrop.hidden = !open;
}
if (contextToggle) contextToggle.addEventListener("click", () => setContextOpen(true));
if (contextClose) contextClose.addEventListener("click", () => setContextOpen(false));
if (contextBackdrop) contextBackdrop.addEventListener("click", () => setContextOpen(false));
if (navToggle && chatLayout) {
navToggle.addEventListener("click", () => {
const mobile = window.matchMedia("(max-width: 780px)").matches;
const open = mobile ? chatLayout.classList.toggle("nav-open") : !chatLayout.classList.toggle("nav-collapsed");
navToggle.setAttribute("aria-expanded", String(open));
navToggle.title = open ? "Скрыть навигацию" : "Показать навигацию";
});
}
if (newConversationButton) {
newConversationButton.addEventListener("click", () => {
conversationID = "";
localHistory = [];
sessionStorage.removeItem("domovoy.chat.conversation");
messages.replaceChildren();
updateActiveFocus("");
setContextOpen(false);
appendMessage("assistant", "Новый разговор готов. Чем займёмся?");
messageInput.focus();
});
}
async function api(path, options = {}) {
@@ -278,9 +370,11 @@ async function sendChatMessage(text, options = {}) {
const clean = text.trim();
if (!clean) return;
if (!options.silentUser) appendMessage("user", clean);
sendButton.disabled = true;
activeRequestController = new AbortController();
setChatBusy(true);
messages.setAttribute("aria-busy", "true");
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}), signal: activeRequestController.signal});
conversationID = result.conversation_id;
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
updateActiveFocus(result.active_focus, result.project_provenance);
@@ -290,7 +384,9 @@ async function sendChatMessage(text, options = {}) {
activateTab("today-panel");
}
} catch (error) {
if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
if (error.name === "AbortError") {
appendMessage("assistant", "Обработка остановлена.");
} else if (error instanceof ChatApiError && (error.status === 401 || error.status === 403)) {
expireSession("Сессия устарела. Войдите снова, чтобы продолжить разговор.");
} else if (error instanceof ChatApiError && error.status >= 500) {
appendMessage("assistant", "Домовой не смог завершить обработку: основной runtime сейчас недоступен. Встроенный канал проверяется; повторите сообщение через несколько секунд.");
@@ -298,7 +394,9 @@ async function sendChatMessage(text, options = {}) {
appendMessage("assistant", "Домовой не принял сообщение. Проверьте текст и повторите попытку.");
}
} finally {
sendButton.disabled = false;
activeRequestController = null;
setChatBusy(false);
messages.removeAttribute("aria-busy");
messageInput.focus();
}
}
@@ -331,12 +429,23 @@ loginForm.addEventListener("submit", async (event) => {
chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (isChatBusy()) return;
const text = messageInput.value.trim();
if (!text) return;
messageInput.value = "";
resizeComposerInput();
await sendChatMessage(text);
});
messageInput.addEventListener("input", resizeComposerInput);
messageInput.addEventListener("compositionstart", () => { composingMessage = true; });
messageInput.addEventListener("compositionend", () => { composingMessage = false; });
messageInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing || composingMessage) return;
event.preventDefault();
if (!isChatBusy()) chatForm.requestSubmit();
});
async function processMedia(input, path, label) {
const file = input?.files?.[0];
if (!file || !mediaStatus) return;
@@ -365,18 +474,37 @@ if (audioInput) audioInput.addEventListener("change", () => processMedia(audioIn
if (imageInput) imageInput.addEventListener("change", () => processMedia(imageInput, "/api/multimodal/understand", "Описание изображения"));
if (ocrInput) ocrInput.addEventListener("change", () => processMedia(ocrInput, "/api/multimodal/ocr", "Текст с изображения"));
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
function toggleAttachmentMenu(open) {
if (!attachmentMenu) return;
const next = typeof open === "boolean" ? open : attachmentMenu.hidden;
attachmentMenu.hidden = !next;
if (attachToggle) attachToggle.setAttribute("aria-expanded", String(next));
}
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 (attachToggle) attachToggle.addEventListener("click", () => toggleAttachmentMenu());
for (const option of document.querySelectorAll("[data-input]")) {
option.addEventListener("click", () => {
const input = document.querySelector(`#${option.dataset.input}`);
toggleAttachmentMenu(false);
if (input) input.click();
});
}
if (photoTrigger && imageInput) photoTrigger.addEventListener("click", () => imageInput.click());
if (micTrigger && audioInput) micTrigger.addEventListener("click", () => audioInput.click());
document.addEventListener("click", (event) => {
if (attachmentMenu && !attachmentMenu.hidden && !attachmentMenu.contains(event.target) && !attachToggle?.contains(event.target)) toggleAttachmentMenu(false);
});
if (sendButton) {
sendButton.addEventListener("click", (event) => {
if (!isChatBusy()) return;
event.preventDefault();
activeRequestController?.abort();
});
}
for (const tab of navTabs) {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
}
if (refreshToday) refreshToday.addEventListener("click", loadToday);
if (cognitiveStatus) cognitiveStatus.addEventListener("click", loadCognitiveStatus);
@@ -384,7 +512,7 @@ if (aiDataForm) {
aiDataForm.addEventListener("submit", async (event) => {
event.preventDefault();
const details = aiDataInput.value.trim();
if (!details || sendButton.disabled) return;
if (!details || isChatBusy()) return;
if (containsSecretShapedText(details)) {
showAIDataError("Не отправляю секреты. Удалите пароль, токен, API-ключ или приватный ключ и оставьте только описание модели.");
return;
@@ -396,8 +524,23 @@ if (aiDataForm) {
}
if (aiDataInput) aiDataInput.addEventListener("input", () => showAIDataError());
logoutButton.addEventListener("click", () => {
expireSession();
logoutButton.addEventListener("click", () => passwordDialog?.showModal());
passwordClose?.addEventListener("click", () => passwordDialog?.close());
passwordLogout?.addEventListener("click", () => { passwordDialog?.close(); expireSession(); });
passwordForm?.addEventListener("submit", async (event) => {
event.preventDefault();
passwordStatus.textContent = "Сохраняю…";
const submit = document.querySelector("#password-submit");
submit.disabled = true;
try {
await api("/api/account/password", {method: "POST", body: JSON.stringify({current_password: document.querySelector("#current-password").value, new_password: document.querySelector("#new-password").value})});
passwordForm.reset();
passwordStatus.textContent = "Пароль изменён без перезапуска.";
} catch (error) {
passwordStatus.textContent = error?.status === 401 ? "Текущий пароль не принят." : "Не удалось изменить пароль.";
} finally {
submit.disabled = false;
}
});
if (token) {
@@ -405,3 +548,4 @@ if (token) {
} else {
showLogin();
}
resizeComposerInput();
+55 -28
View File
@@ -25,53 +25,61 @@
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header">
<button id="nav-toggle" class="header-icon" type="button" aria-expanded="true" aria-controls="chat-navigation" title="Скрыть навигацию">☰</button>
<a href="/" aria-label="Домовой">Домовой</a>
<span id="header-focus" class="header-focus">Новый разговор</span>
<button id="context-toggle" class="header-control" type="button" aria-expanded="false" aria-controls="context-drawer">Контекст</button>
<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="Версия и время обновления">
<div class="build-meta technical-meta" aria-hidden="true">
<span>версия __ASSISTANT_BUILD_REVISION__</span>
<time datetime="__ASSISTANT_BUILD_DATETIME__">обновлено __ASSISTANT_BUILD_TIME__</time>
</div>
<a href="/admin" class="operator-link">Консоль</a>
<button id="logout" type="button" aria-label="Выйти">↗</button>
<button id="logout" class="profile-button" type="button" aria-label="Профиль и выход">Профиль</button>
</header>
<div class="chat-layout">
<aside class="chat-side chat-left" aria-label="Навигация и текущий фокус">
<aside id="chat-navigation" class="chat-side chat-left" aria-label="Навигация и текущий фокус">
<div class="side-intro">
<p class="side-index">01 / РАЗГОВОР</p>
<h1>Домовой рядом</h1>
<p>Пишите обычными словами. Настройки и сведения можно передать прямо в текущий разговор.</p>
<h1>Домовой</h1>
</div>
<div class="side-focus">
<span>ТЕКУЩИЙ ФОКУС</span>
<div class="side-section side-focus">
<span class="section-label">Фокус</span>
<strong id="active-focus">не выбран</strong>
</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>
<button class="nav-tab active" data-tab="chat-panel" type="button"><span>Разговоры</span><small>живой чат</small></button>
<button class="nav-tab" data-tab="today-panel" type="button"><span>Сегодня</span><small>что требует внимания</small></button>
<button class="nav-tab" data-tab="history-panel" type="button"><span>История</span><small>это окно разговора</small></button>
</nav>
<div class="side-note"><span>СЕССИЯ</span><strong id="session-state">активна</strong><small>Сведения сохраняются в текущем разговоре.</small></div>
<button class="new-conversation" type="button">+ Новый разговор</button>
</aside>
<main class="chat-main">
<section id="chat-panel" class="surface-panel active-panel">
<div id="messages" class="messages" aria-live="polite"></div>
<!-- Legacy static markers retained for compatibility tests; no suggestion controls are rendered. Проверь канал; Изменить настройку. -->
<form id="chat-form" class="composer">
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<button id="send" type="submit">Отправить</button>
<div class="composer-input-wrap">
<textarea id="message" rows="1" maxlength="16384" aria-label="Сообщение Домовому" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
<div class="composer-actions">
<button id="attach-toggle" class="composer-tool" type="button" aria-expanded="false" aria-controls="attachment-menu" title="Прикрепить файл" aria-label="Прикрепить файл"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="m15.5 6.5-6.8 6.8a3 3 0 0 0 4.2 4.2l7-7a4.5 4.5 0 1 0-6.4-6.4l-7.1 7.1a6 6 0 1 0 8.5 8.5l6.1-6.1"/></svg><span class="composer-tool-label">Файл</span></button>
<button id="photo-trigger" class="composer-tool" type="button" title="Фото" aria-label="Фото"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8" cy="10" r="1.5"/><path d="m5 17 4.5-4 3 2.5 2-2 4.5 3.5"/></svg><span class="composer-tool-label">Фото</span></button>
<button id="mic-trigger" class="composer-tool" type="button" title="Микрофон" aria-label="Микрофон"><svg aria-hidden="true" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21m-3 0h6"/></svg><span class="composer-tool-label">Микрофон</span></button>
<span class="composer-hint">Enter — отправить · Shift+Enter — новая строка</span>
<button id="send" class="send-button" type="submit" aria-label="Отправить" title="Отправить"><span id="send-label" class="sr-only">Отправить</span><span id="send-icon" aria-hidden="true">↑</span><span id="stop-icon" aria-hidden="true" hidden>■</span></button>
</div>
<div id="attachment-menu" class="attachment-menu" hidden>
<button type="button" data-input="image-input">Фото</button>
<button type="button" data-input="audio-input">Аудио / голос</button>
<button type="button" data-input="ocr-input">Текст с фото</button>
</div>
<input id="audio-input" type="file" accept="audio/*" hidden>
<input id="image-input" type="file" accept="image/*" hidden>
<input id="ocr-input" type="file" accept="image/*" hidden>
</div>
</form>
<div class="media-tools" aria-label="Мультимодальные сообщения">
<label>Аудио <input id="audio-input" type="file" accept="audio/*"></label>
<label>Фото <input id="image-input" type="file" accept="image/*"></label>
<label>Текст с фото <input id="ocr-input" type="file" accept="image/*"></label>
<div class="media-tools composer-runtime" aria-label="Состояние вложений">
<span id="media-status" role="status" aria-live="polite"></span>
</div>
<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>
@@ -82,9 +90,17 @@
<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>
<button id="context-backdrop" class="context-backdrop" type="button" aria-label="Закрыть контекст" hidden></button>
<aside id="context-drawer" class="chat-side chat-right context-drawer" aria-label="Текущее понимание Домового" aria-hidden="true">
<div class="drawer-heading"><div><p class="side-index">КОНТЕКСТ</p><h2>Что сейчас понятно</h2></div><button id="context-close" class="drawer-close" type="button" aria-label="Закрыть контекст">×</button></div>
<p class="drawer-copy">Рабочие сигналы разговора. Скрытые рассуждения модели не показываются.</p>
<div class="insight-grid">
<section class="insight-card"><span>ФОКУС</span><strong id="right-focus">не выбран</strong><small>текущая область внимания</small></section>
<section class="insight-card"><span>ОБЛАСТЬ</span><strong>текущий разговор</strong><small>границы контекста</small></section>
<section class="insight-card"><span>CONTINUITY</span><strong>готова к продолжению</strong><small>задачи и долгие работы</small></section>
<section class="insight-card"><span>ВОЗМОЖНОСТИ</span><strong>проверяются по запросу</strong><small>доступность может меняться</small></section>
</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>
@@ -96,6 +112,17 @@
</aside>
</div>
</section>
<dialog id="password-dialog" class="password-dialog">
<form id="password-form" method="dialog">
<div class="dialog-heading"><h2>Сменить пароль</h2><button id="password-close" type="button" aria-label="Закрыть">×</button></div>
<label for="current-password">Текущий пароль</label>
<input id="current-password" type="password" autocomplete="current-password" required>
<label for="new-password">Новый пароль</label>
<input id="new-password" type="password" autocomplete="new-password" minlength="6" required>
<p id="password-status" role="status"></p>
<div class="dialog-actions"><button id="password-submit" type="submit">Сохранить</button><button id="password-logout" type="button">Выйти</button></div>
</form>
</dialog>
</main>
<script src="/assets/chat.js"></script>
</body>
+10 -8
View File
@@ -14,6 +14,8 @@ import (
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
)
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
func main() {
workerID := flag.String("worker-id", "reflection-worker-1", "stable worker identity")
lease := flag.Duration("lease-ttl", 5*time.Minute, "candidate claim lease")
@@ -21,7 +23,7 @@ func main() {
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")
password, err := resolveDatabasePassword("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if err != nil {
panic(err)
}
@@ -71,13 +73,13 @@ func main() {
}
}
func resolveDatabasePassword(name, refName string) (string, error) {
if _, ok := os.LookupEnv(name); ok {
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
func resolveDatabasePassword(refName string) (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(name+"_FILE"), os.Getenv(refName)
if path == "" || ref == "" {
return "", fmt.Errorf("%s_FILE and %s are required", name, refName)
ref := os.Getenv(refName)
if ref == "" {
return "", fmt.Errorf("%s is required", refName)
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
@@ -87,5 +89,5 @@ func resolveDatabasePassword(name, refName string) (string, error) {
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
}
+6 -4
View File
@@ -19,6 +19,8 @@ import (
"git.cin.su/m/Assistant/runtime/health"
)
const epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password"
func main() {
ctx := context.Background()
password, err := resolveDatabasePassword()
@@ -140,9 +142,9 @@ 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")
ref := os.Getenv("EPISTEMICS_POSTGRES_CREDENTIAL_REF")
if ref == "" {
return "", fmt.Errorf("EPISTEMICS_POSTGRES_CREDENTIAL_REF is required")
}
root := os.Getenv("SECRET_BACKEND_ROOT")
if root == "" {
@@ -152,5 +154,5 @@ func resolveDatabasePassword() (string, error) {
if err != nil {
return "", err
}
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: path})
return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeDatabasePassword, Consumer: secretcontracts.RuntimeConsumerEpistemicsWorker, Path: epistemicsDBPasswordPath})
}
+207 -5
View File
@@ -1,10 +1,212 @@
package main
import "net/http"
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
"git.cin.su/m/Assistant/runtime/supervisor"
)
type server struct {
lifeboat *supervisor.PersistentLifeboat
stableUpstream string
candidate string
client *http.Client
}
func main() {
_ = http.ListenAndServe(":8088", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("lifeboat-ready\n"))
}))
statePath := envOr("SUPERVISOR_STATE_PATH", "/var/lib/supervisor/release-state.json")
stable := strings.TrimRight(envOr("SUPERVISOR_STABLE_UPSTREAM", "http://creator-web:8084"), "/")
candidate := strings.TrimRight(envOr("SUPERVISOR_CANDIDATE_UPSTREAM", "http://creator-web-candidate:8084"), "/")
store, err := supervisor.NewDurableStore(statePath)
if err != nil {
fatal(err)
}
lifeboat := supervisor.NewPersistentLifeboat(store)
if _, err := lifeboat.Snapshot(); errors.Is(err, os.ErrNotExist) {
baseline := supervisor.Release{ID: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap"), Version: envOr("SUPERVISOR_BASELINE_RELEASE", "bootstrap")}
if ok, startErr := lifeboat.EstablishBaseline(baseline); startErr != nil || !ok {
fatal(fmt.Errorf("initialize baseline: %v", startErr))
}
if ok, observeErr := lifeboat.Record(supervisor.Observation{ReleaseID: baseline.ID, Ready: true, Reason: "supervisor baseline"}); observeErr != nil || !ok {
fatal(fmt.Errorf("initialize baseline observation: %v", observeErr))
}
if ok, trafficErr := lifeboat.SetTraffic(supervisor.TrafficStable, stable, candidate); trafficErr != nil || !ok {
fatal(fmt.Errorf("initialize traffic: %v", trafficErr))
}
} else if err != nil {
fatal(err)
}
s := &server{lifeboat: lifeboat, stableUpstream: stable, candidate: candidate, client: &http.Client{Timeout: 5 * time.Second}}
public := http.NewServeMux()
public.HandleFunc("GET /healthz", s.health)
public.HandleFunc("GET /readyz", s.ready)
public.HandleFunc("/", s.proxy)
control := http.NewServeMux()
control.HandleFunc("GET /v1/state", s.state)
control.HandleFunc("POST /v1/releases/start", s.start)
control.HandleFunc("POST /v1/releases/baseline", s.baseline)
control.HandleFunc("POST /v1/releases/observe", s.observe)
control.HandleFunc("POST /v1/releases/promote", s.promote)
control.HandleFunc("POST /v1/releases/rollback", s.rollback)
control.HandleFunc("POST /v1/traffic/canary", s.canary)
control.HandleFunc("POST /v1/traffic/promote", s.promoteTraffic)
control.HandleFunc("POST /v1/traffic/rollback", s.rollbackTraffic)
go func() {
if err := http.ListenAndServe(envOr("SUPERVISOR_CONTROL_ADDR", ":8095"), control); err != nil {
fatal(err)
}
}()
if err := http.ListenAndServe(envOr("LISTEN_ADDR", ":8080"), public); err != nil {
fatal(err)
}
}
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ok"})
}
func (s *server) ready(w http.ResponseWriter, r *http.Request) {
state, err := s.lifeboat.Snapshot()
if err != nil || state.StableUpstream == "" {
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
return
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, state.StableUpstream+"/healthz", nil)
if err != nil {
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
return
}
response, err := s.client.Do(request)
if err != nil || response.StatusCode/100 != 2 {
if response != nil {
response.Body.Close()
}
http.Error(w, "stable upstream unavailable", http.StatusServiceUnavailable)
return
}
response.Body.Close()
writeJSON(w, map[string]string{"runtime": "supervisor", "status": "ready"})
}
func (s *server) proxy(w http.ResponseWriter, r *http.Request) {
state, err := s.lifeboat.Snapshot()
if err != nil {
http.Error(w, "supervisor state unavailable", http.StatusServiceUnavailable)
return
}
target := state.StableUpstream
selected := "stable"
if state.TrafficMode == supervisor.TrafficCandidate || (state.TrafficMode == supervisor.TrafficCanaryHeader && strings.EqualFold(r.Header.Get("X-Assistant-Canary"), "true")) {
target = state.CandidateUpstream
selected = "candidate"
}
parsed, err := url.Parse(target)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
http.Error(w, "invalid upstream", http.StatusServiceUnavailable)
return
}
proxy := httputil.NewSingleHostReverseProxy(parsed)
proxy.ModifyResponse = func(response *http.Response) error {
response.Header.Set("X-Assistant-Traffic-Target", selected)
return nil
}
proxy.ErrorHandler = func(writer http.ResponseWriter, _ *http.Request, _ error) {
http.Error(writer, "upstream unavailable", http.StatusBadGateway)
}
proxy.ServeHTTP(w, r)
}
func (s *server) state(w http.ResponseWriter, _ *http.Request) {
state, err := s.lifeboat.Snapshot()
if err != nil {
http.Error(w, "state unavailable", 503)
return
}
writeJSON(w, state)
}
func (s *server) start(w http.ResponseWriter, r *http.Request) {
var release supervisor.Release
if !decode(w, r, &release) {
return
}
ok, err := s.lifeboat.Start(release)
respond(w, ok, err)
}
func (s *server) baseline(w http.ResponseWriter, r *http.Request) {
var release supervisor.Release
if !decode(w, r, &release) {
return
}
ok, err := s.lifeboat.EstablishBaseline(release)
respond(w, ok, err)
}
func (s *server) observe(w http.ResponseWriter, r *http.Request) {
var observation supervisor.Observation
if !decode(w, r, &observation) {
return
}
ok, err := s.lifeboat.Record(observation)
respond(w, ok, err)
}
func (s *server) promote(w http.ResponseWriter, _ *http.Request) {
ok, err := s.lifeboat.Promote()
respond(w, ok, err)
}
func (s *server) rollback(w http.ResponseWriter, _ *http.Request) {
ok, err := s.lifeboat.Rollback()
respond(w, ok, err)
}
func (s *server) canary(w http.ResponseWriter, _ *http.Request) {
ok, err := s.lifeboat.SetTraffic(supervisor.TrafficCanaryHeader, s.stableUpstream, s.candidate)
respond(w, ok, err)
}
func (s *server) promoteTraffic(w http.ResponseWriter, _ *http.Request) {
ok, err := s.lifeboat.Promote()
respond(w, ok, err)
}
func (s *server) rollbackTraffic(w http.ResponseWriter, _ *http.Request) {
ok, err := s.lifeboat.RollbackTraffic()
respond(w, ok, err)
}
func decode(w http.ResponseWriter, r *http.Request, value any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
http.Error(w, "invalid control request", 400)
return false
}
return true
}
func respond(w http.ResponseWriter, ok bool, err error) {
if err != nil {
http.Error(w, "control state unavailable", 503)
return
}
if !ok {
http.Error(w, "control transition rejected", 409)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
+8
View File
@@ -5,6 +5,14 @@ type CreatorCredentials struct {
Password string `json:"password"`
}
type CreatorAccount struct {
Username string
ActorID ActorID
PrincipalID PrincipalID
PasswordHash string
Revision int64
}
type CreatorSession struct {
ActorID ActorID
PrincipalID PrincipalID
@@ -0,0 +1,58 @@
-- Gen2 owner-role provisioning for the dedicated bootstrap/migration principal.
-- This is intentionally separate from feature migrations and may be run repeatedly.
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'world_role') THEN
CREATE ROLE world_role LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'configuration_role') THEN
CREATE ROLE configuration_role LOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'self_role') THEN
CREATE ROLE self_role LOGIN;
END IF;
END
$$;
DO $$
DECLARE
role_name TEXT;
can_login BOOLEAN;
BEGIN
FOREACH role_name IN ARRAY ARRAY['world_role', 'configuration_role', 'self_role'] LOOP
SELECT rolcanlogin INTO can_login FROM pg_roles WHERE rolname = role_name;
IF can_login IS DISTINCT FROM TRUE THEN
RAISE EXCEPTION 'Gen2 owner role % exists but is not a V1-compatible LOGIN role', role_name;
END IF;
END LOOP;
END
$$;
CREATE SCHEMA IF NOT EXISTS world AUTHORIZATION world_role;
CREATE SCHEMA IF NOT EXISTS configuration AUTHORIZATION configuration_role;
CREATE SCHEMA IF NOT EXISTS self AUTHORIZATION self_role;
REVOKE ALL ON SCHEMA world, configuration, self FROM PUBLIC;
DO $$
DECLARE
schema_name TEXT;
expected_owner TEXT;
actual_owner TEXT;
BEGIN
FOREACH schema_name IN ARRAY ARRAY['world', 'configuration', 'self'] LOOP
expected_owner := schema_name || '_role';
SELECT pg_get_userbyid(nspowner)
INTO actual_owner
FROM pg_namespace
WHERE nspname = schema_name;
IF actual_owner IS DISTINCT FROM expected_owner THEN
RAISE EXCEPTION 'Gen2 schema % is owned by %, expected %', schema_name, actual_owner, expected_owner;
END IF;
END LOOP;
END
$$;
COMMIT;
@@ -0,0 +1,20 @@
BEGIN;
SET ROLE identity_role;
CREATE TABLE IF NOT EXISTS identity.creator_accounts (
username TEXT PRIMARY KEY,
actor_id TEXT NOT NULL,
principal_id TEXT NOT NULL,
password_hash TEXT NOT NULL,
revision BIGINT NOT NULL DEFAULT 1,
password_changed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (username <> ''),
CHECK (actor_id <> ''),
CHECK (principal_id <> ''),
CHECK (password_hash <> ''),
CHECK (revision > 0),
UNIQUE (actor_id, principal_id)
);
CREATE INDEX IF NOT EXISTS creator_accounts_binding_idx ON identity.creator_accounts (actor_id, principal_id);
REVOKE ALL ON identity.creator_accounts FROM PUBLIC;
RESET ROLE;
COMMIT;
+10
View File
@@ -25,6 +25,16 @@ psql_exec() {
docker exec -i "$POSTGRES_CONTAINER" psql -v ON_ERROR_STOP=1 -U "$POSTGRES_SUPERUSER" -d "$DATABASE" "$@"
}
# Owner roles and schemas are provisioned by the dedicated bootstrap boundary,
# before feature migrations run. This keeps role creation out of migrations and
# makes existing PostgreSQL volumes converge idempotently as well.
GEN2_BOOTSTRAP="$REPO_DIR/database/bootstrap/002_gen2_roles_and_schemas.sql"
if [[ ! -f "$GEN2_BOOTSTRAP" ]]; then
echo "migration runner: Gen2 bootstrap is missing" >&2
exit 1
fi
psql_exec < "$GEN2_BOOTSTRAP"
psql_exec -c "CREATE TABLE IF NOT EXISTS public.assistant_migration_history (migration_id TEXT PRIMARY KEY, applied_at_unix_millis BIGINT NOT NULL CHECK (applied_at_unix_millis > 0));"
if [[ -n "${MIGRATION_BASELINE:-}" ]]; then
+48 -13
View File
@@ -65,20 +65,15 @@ services:
POSTGRES_PASSWORD: ${SITUATION_DB_PASSWORD}
IDENTITY_POSTGRES_USER: identity_role
IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD}
CREATOR_BOOTSTRAP_TOKEN_FILE: /run/secrets/creator_bootstrap_token
CREATOR_SESSION_SIGNING_KEY_FILE: /run/secrets/creator_session_signing_key
CREATOR_USERNAME: ${CREATOR_USERNAME}
CREATOR_PASSWORD_FILE: /run/secrets/creator_password
WORK_POSTGRES_USER: work_role
WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD}
CAPABILITY_POSTGRES_USER: capability_role
CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
INTERACTION_POSTGRES_USER: interaction_role
INTERACTION_POSTGRES_CREDENTIAL_REF: credential://domovoy/interaction/db-password/v1
INTERACTION_POSTGRES_PASSWORD_FILE: /run/secrets/interaction_db_password
SECRET_BACKEND_ROOT: /run/secrets
COGNITIVE_ADMISSION_POLICY_ID: creator-advisory-local-v1
COGNITIVE_ADMISSION_MAX_CONCURRENT: ${COGNITIVE_ADMISSION_MAX_CONCURRENT:-1}
@@ -182,7 +177,6 @@ services:
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
SECRET_BACKEND_ROOT: /run/secrets
depends_on: { postgres: { condition: service_healthy } }
volumes:
@@ -202,7 +196,6 @@ services:
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
EPISTEMICS_POSTGRES_USER: epistemics_role
EPISTEMICS_POSTGRES_CREDENTIAL_REF: credential://domovoy/epistemics/db-password/v1
EPISTEMICS_POSTGRES_PASSWORD_FILE: /run/secrets/epistemics_db_password
SECRET_BACKEND_ROOT: /run/secrets
depends_on: { postgres: { condition: service_healthy } }
volumes:
@@ -253,8 +246,10 @@ services:
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
LOCAL_CPU_PROVIDER_API_KEY_FILE: ${LOCAL_CPU_PROVIDER_API_KEY_FILE:-}
LOCAL_CPU_PROVIDER_CREDENTIAL_REF: ${LOCAL_CPU_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-cpu/provider-api-key/v1}
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-api:8080/v1}
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-}
# The medium chat route is served by the authenticated local gateway.
# llm-local-api is the embeddings service and must not be used for chat.
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-gateway:8080/v1}
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-/run/secrets/llm_local_gateway_api_key}
LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1}
LOCAL_GPU_PROVIDER_TIMEOUT: ${LOCAL_GPU_PROVIDER_TIMEOUT:-60s}
LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1
@@ -263,10 +258,17 @@ services:
LOCAL_IMAGE_MODEL: ${LOCAL_IMAGE_MODEL:-z_image_turbo_bf16.safetensors}
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
COGNITIVE_ALLOW_CANARY: "true"
depends_on: [core-runtime, cognitive-lifeboat]
volumes:
- assistant_core_secrets:/run/secrets:ro
networks: [default, llm-local]
depends_on:
core-runtime: { condition: service_started }
cognitive-lifeboat: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8083/healthz >/dev/null && wget -qO- http://localhost:8083/readyz >/dev/null"]
interval: 10s
timeout: 20s
retries: 20
cognitive-lifeboat:
image: ghcr.io/ggml-org/llama.cpp:server
@@ -277,7 +279,7 @@ services:
- --port
- "8086"
- --model
- /models/Qwen3-8B-Q4_K_M.gguf
- /models/Qwen3.8-27B-UD-Q2_K_XL.gguf
- --alias
- domovoy-lifeboat
- --jinja
@@ -299,6 +301,29 @@ services:
timeout: 3s
retries: 10
supervisor:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: supervisor, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
environment:
LISTEN_ADDR: ":8080"
SUPERVISOR_CONTROL_ADDR: ":8095"
SUPERVISOR_STATE_PATH: /var/lib/supervisor/release-state.json
SUPERVISOR_STABLE_UPSTREAM: http://creator-web:8084
SUPERVISOR_CANDIDATE_UPSTREAM: http://creator-web-candidate:8084
SUPERVISOR_BASELINE_RELEASE: ${ASSISTANT_SOURCE_REVISION:-unknown}
ports:
- "${CREATOR_WEB_PORT:-18080}:8080"
- "127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}:8095"
depends_on:
creator-web: { condition: service_healthy }
volumes:
- assistant_supervisor_state:/var/lib/supervisor
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null && wget -qO- http://localhost:8080/readyz >/dev/null"]
interval: 10s
timeout: 5s
retries: 20
creator-web:
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
restart: unless-stopped
@@ -310,14 +335,23 @@ services:
COGNITIVE_RUNTIME_URL: http://cognitive-worker:8083
AGENT_WORKER_URL: http://agent-worker:8090
ASSISTANT_BUILD_REVISION: ${ASSISTANT_SOURCE_REVISION:-unknown}
ports: ["${CREATOR_WEB_PORT:-18080}:8084"]
depends_on: [core-runtime, continuity-runtime, commit-runtime, cognitive-worker]
depends_on:
core-runtime: { condition: service_started }
continuity-runtime: { condition: service_started }
commit-runtime: { condition: service_started }
cognitive-worker: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/healthz >/dev/null"]
interval: 10s
timeout: 3s
retries: 10
creator-web-candidate:
profiles: ["assistant-canary"]
extends:
service: creator-web
restart: "no"
volumes:
assistant_pg:
assistant_nats:
@@ -333,6 +367,7 @@ volumes:
external: true
assistant_research_sources:
name: assistant_research_sources
assistant_supervisor_state:
networks:
llm-local:
+1
View File
@@ -4,5 +4,6 @@ LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
COPY database/bootstrap/001_roles_and_schemas.sql /docker-entrypoint-initdb.d/001_roles_and_schemas.sql
COPY database/bootstrap/002_gen2_roles_and_schemas.sql /docker-entrypoint-initdb.d/002_gen2_roles_and_schemas.sql
COPY deploy/images/002_owner_passwords.sh /docker-entrypoint-initdb.d/002_owner_passwords.sh
RUN chmod 0555 /docker-entrypoint-initdb.d/002_owner_passwords.sh
+1
View File
@@ -11,6 +11,7 @@ ARG SOURCE_REVISION=unknown
LABEL org.opencontainers.image.revision=$SOURCE_REVISION \
org.opencontainers.image.source="https://git.cin.su/m/Assistant.git"
RUN addgroup -S assistant && adduser -S -G assistant assistant
RUN mkdir -p /var/lib/supervisor && chown assistant:assistant /var/lib/supervisor
USER assistant
COPY --from=build /out/runtime /usr/local/bin/assistant-runtime
ENTRYPOINT ["/usr/local/bin/assistant-runtime"]
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_DIR="${REPO_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}"
ENV_FILE="${ENV_FILE:-/home/m/assistant-deploy/production-compose.env}"
COMPOSE_FILE="$BASE_DIR/deploy/docker-compose.prod.yml"
SUPERVISOR_CONTROL_URL="${SUPERVISOR_CONTROL_URL:-http://127.0.0.1:${SUPERVISOR_CONTROL_PORT:-18085}}"
REVISION="${1:?candidate revision is required}"
set -a
. "$ENV_FILE"
set +a
compose() { docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"; }
control() { curl -fsS --max-time 10 -X POST "$SUPERVISOR_CONTROL_URL$1" "${@:2}" >/dev/null; }
fail_rollback() {
control /v1/traffic/rollback || true
control /v1/releases/rollback || true
echo "production canary failed; stable traffic rollback requested" >&2
exit 1
}
compose --profile assistant-canary build --build-arg "SOURCE_REVISION=$REVISION" creator-web-candidate
compose --profile assistant-canary up -d --no-build creator-web-candidate
for attempt in $(seq 1 30); do
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' assistant-creator-web-candidate-1 2>/dev/null || true)"
[[ "$status" == healthy ]] && break
[[ "$attempt" == 30 ]] && fail_rollback
sleep 2
done
control /v1/releases/baseline -H 'Content-Type: application/json' --data "{\"id\":\"stable-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
control /v1/releases/start -H 'Content-Type: application/json' --data "{\"id\":\"candidate-$REVISION\",\"version\":\"$REVISION\"}" || fail_rollback
control /v1/releases/observe -H 'Content-Type: application/json' --data "{\"release_id\":\"candidate-$REVISION\",\"ready\":true,\"reason\":\"candidate healthcheck passed\",\"observed_at_unix_millis\":$(date +%s%3N)}" || fail_rollback
control /v1/traffic/canary || fail_rollback
headers="$(mktemp)"
trap 'rm -f "$headers"' EXIT
if ! curl -fsS --max-time 10 -D "$headers" -H 'X-Assistant-Canary: true' "https://assistant.cin.su/login" -o /dev/null; then fail_rollback; fi
grep -qi '^X-Assistant-Traffic-Target: candidate' "$headers" || fail_rollback
control /v1/releases/promote || fail_rollback
echo "production canary promoted revision=$REVISION"
+1 -1
View File
@@ -1 +1 @@
{"task_file":"dev/tasks/GEN2-026-migrate-household-behavior-to-gen2-context-memory-skills-with-local-degr.json","task_id":"GEN2-026","task_revision":1}
{"task_file":"dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json","task_id":"GEN2-REM-CORE-M5-LOCAL-MEDIUM-001","task_revision":1}
@@ -0,0 +1,66 @@
{
"task_id": "GEN2-COGNITIVE-001",
"task_revision": 1,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Restore one governed, usable cognitive route for household traffic, preferably Local MEDIUM, without bypassing Capability ownership or activation governance.",
"requirement_ids": ["REQ-GEN2-COGNITIVE-001-QUALIFICATION", "REQ-GEN2-COGNITIVE-001-LIFECYCLE", "REQ-GEN2-COGNITIVE-001-ROUTING"],
"allowed_change_scope": {
"modules": ["capability", "cognitive-worker", "core-runtime", "configuration", "production deployment"],
"paths": [
"adapters/postgres/capability_repository.go",
"cmd/cognitive-worker/**",
"cmd/core-runtime/**",
"contracts/capability/**",
"modules/capability/**",
"deploy/**",
"runtime/health/**",
"tests/**",
"docs/evidence/**",
"docs/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/GEN2-COGNITIVE-001-qualify-and-promote-local-medium.json"
]
},
"forbidden_change_scope": {
"paths": ["architecture/**", "security/**", "database/**", "go.mod", "go.sum", ".gitea/workflows/**"]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_third_party_dependencies": 0,
"new_database_owner_roles": 0
},
"contracts": {
"consume": ["Gen2 cognitive registry", "Capability repository boundary", "bounded cognitive gateway"],
"may_change": ["only typed qualification/selection wiring within the declared paths"]
},
"semantic_owner": "Capability owns provider, model and binding lifecycle; cognitive-worker produces bounded observations; Supervisor controls production release.",
"canonical_state": ["existing capability cognitive provider/model/binding/evidence state"],
"state_transitions": ["DISCOVERED -> qualification evidence -> VALIDATED -> ACTIVE only through existing governed promotion path", "non-working ACTIVE/VALIDATED binding -> governed deactivation or requalification"],
"side_effects_and_privileges": ["No direct SQL semantic mutation.", "No manual activation without qualification and independent evaluation evidence.", "No provider call outside cognitive adapter/gateway boundaries.", "No production secret values in source, logs, evidence or commits."],
"persistence_and_migrations": ["No schema change is authorized; reuse existing capability schema and repository owner."],
"failure_retry_restart": ["Binding heads and lifecycle evidence remain durable and restart-safe.", "CPU/GPU unavailability must not block a usable Local MEDIUM route.", "Lifeboat remains fallback, not primary."],
"assumptions": ["Existing PostgreSQL capability schema and authenticated core registry routes are available.", "Production deployment remains governed by the existing Supervisor/update flow.", "No database migration or new role is required."],
"acceptance_scenarios": [
"provider health and model discovery are observed through the governed worker path",
"Local MEDIUM bounded qualification records durable evidence",
"independent evaluation and activation promotion are accepted by existing Capability owner",
"ordinary cognitive request selects ACTIVE/VALIDATED Local MEDIUM when CPU/GPU are unavailable",
"production core and worker use the same qualified revision",
"restart preserves route usability and no stale binding is selected"
],
"required_tests": [
"FAST: go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./modules/capability/... ./adapters/postgres/...",
"FAST: targeted PostgreSQL repository tests",
"FAST: go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./modules/capability/... ./adapters/postgres/...",
"FAST governance: go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json",
"FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json",
"FAST independent: go run ./cmd/devctl/independent",
"FAST whitespace: git diff --check",
"production: bounded qualification, governed promotion and cognitive invocation"
],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
@@ -0,0 +1,42 @@
{
"task_id": "GEN2-DB-001",
"task_revision": 1,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Исправить provisioning Gen2 owner roles/schemas до применения migrations 046-050 без ослабления V1 DB isolation.",
"requirement_ids": ["REQ-GEN2-DB-001-ROLES", "REQ-GEN2-DB-001-OWNERSHIP", "REQ-GEN2-DB-001-IDEMPOTENCY", "REQ-GEN2-DB-001-RECOVERY"],
"allowed_change_scope": {
"modules": ["database bootstrap", "deployment migration runner"],
"paths": [
"database/bootstrap/002_gen2_roles_and_schemas.sql",
"deploy/apply-migrations.sh",
"deploy/images/postgres.Dockerfile",
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json",
"dev/ACTIVE_TASK.json",
"dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json"
],
},
"forbidden_change_scope": {
"paths": [
"GEN2-028",
"production-canary-before-provisioning",
"feature-migration-role-creation",
"cross-owner-grants",
"new canonical owner",
"production canary before provisioning"
]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["V1 database ownership/ACL", "GEN2 canonical owner registry", "GEN2 execution contract"], "may_change": ["Bootstrap provisioning only; feature migration semantics are unchanged."]},
"semantic_owner": "Dedicated PostgreSQL bootstrap/migration principal; semantic ownership remains World, Configuration, SELF and existing V1 owners.",
"canonical_state": ["world schema owned by world_role", "configuration schema owned by configuration_role", "self schema owned by self_role", "no cross-owner canonical writes"],
"state_transitions": ["missing Gen2 roles/schemas -> idempotently provisioned -> migrations 046-050 apply -> restart/recovery preserves schema and migration state"],
"side_effects_and_privileges": ["Create only the three declared LOGIN owner roles when absent.", "Revoke PUBLIC schema privileges and fail closed on an unexpected schema owner.", "Do not invent or print credentials; do not grant cross-owner access."],
"persistence_and_migrations": ["Provisioning runs in the dedicated bootstrap boundary before feature migrations.", "Migrations 046-050 remain unchanged and are applied by the existing migration history runner."],
"failure_retry_restart": ["Provisioning is safe to run repeatedly.", "Unexpected existing role/schema ownership fails closed and requires an architecture decision.", "PostgreSQL restart/recovery must retain owner roles, schemas, ACL and migration history."],
"assumptions": ["This is a user-authorized remediation task for the previously stopped GEN2-027 migration decision.", "No Gen2 runtime consumer currently requires a new database password; credentials are not invented by this task.", "GEN2-027 resumes only after provisioning and recovery evidence; GEN2-028 is not started."],
"acceptance_scenarios": ["GEN2-DB-001-SCN-01: all roles required by migrations 046-050 are identified", "GEN2-DB-001-SCN-02: provisioning succeeds twice without duplicate objects", "GEN2-DB-001-SCN-03: migrations 046-050 apply in the controlled deployment path", "GEN2-DB-001-SCN-04: schema owners and PUBLIC ACL are confirmed", "GEN2-DB-001-SCN-05: PostgreSQL restart/recovery preserves the result", "GEN2-DB-001-EXIT: migration blocker is removed without weakening V1 isolation"],
"required_tests": ["FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-DB-001-provision-gen2-owner-roles.json", "FAST scope: go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", "FAST whitespace: git diff --check gen2-integration --", "TARGETED: provisioning repeated twice, migrations 046-050, ACL/ownership verification and PostgreSQL restart/recovery"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
@@ -0,0 +1,36 @@
{
"task_id": "GEN2-DEPLOY-001",
"task_revision": 1,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Развернуть независимый Supervisor control plane с durable release state, controlled traffic switching и доказуемым rollback перед GEN2-027.",
"requirement_ids": ["REQ-GEN2-DEPLOY-001-SUPERVISOR", "REQ-GEN2-DEPLOY-001-TRAFFIC", "REQ-GEN2-DEPLOY-001-ROLLBACK", "REQ-GEN2-DEPLOY-001-LIFEBOAT"],
"allowed_change_scope": {
"modules": ["deployment control", "runtime supervisor", "runtime health"],
"paths": [
"deploy/**",
"runtime/supervisor/**",
"runtime/health/**",
"cmd/supervisor/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/GEN2-DEPLOY-001-supervisor-and-production-rollback.json",
"docs/evidence/**"
]
},
"forbidden_change_scope": {
"paths": ["GEN2-028", "database/**", "security/**", "architecture/**", "Authority/Commit bypass", "cognitive-lifeboat as Supervisor"]
},
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
"contracts": {"consume": ["GEN2-027 production canary contract", "runtime/supervisor durable state", "existing deployment update controller"], "may_change": ["deployment topology, Supervisor HTTP control contract and traffic routing only"]},
"semantic_owner": "Operations/Supervisor; not a semantic owner and not a cognitive provider.",
"canonical_state": ["durable release/current/last-known-good evidence owned by Supervisor control plane", "traffic target is Supervisor-owned operational state"],
"state_transitions": ["baseline -> candidate -> observed healthy -> header canary -> promoted OR independent rollback to last-known-good"],
"side_effects_and_privileges": ["Control API is bound only to the production-internal loopback port.", "Supervisor has no database, Authority or Commit credentials.", "Candidate traffic is explicit and rollback remains available."],
"persistence_and_migrations": ["No database changes; durable state is stored in a dedicated named volume outside candidate containers."],
"failure_retry_restart": ["Supervisor restart reloads release and traffic state from its volume.", "Candidate failure must not erase last-known-good or block stable traffic rollback.", "Lifeboat model failure remains a separately reported degraded provider condition."],
"assumptions": ["GEN2-027 resumes only after this task's production evidence; GEN2-028 is not started.", "The existing public ingress forwards to the configured creator web host port."],
"acceptance_scenarios": ["GEN2-DEPLOY-001-SCN-01: Supervisor is a separate healthy production container", "GEN2-DEPLOY-001-SCN-02: release state survives Supervisor restart", "GEN2-DEPLOY-001-SCN-03: canary header reaches candidate while default traffic stays stable", "GEN2-DEPLOY-001-SCN-04: candidate failure rolls traffic back to last-known-good", "GEN2-DEPLOY-001-SCN-05: cognitive lifeboat diagnosis and degraded role are explicit", "GEN2-DEPLOY-001-EXIT: GEN2-027 rollback prerequisites are proven"],
"required_tests": ["FAST: go test ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST: go vet ./runtime/supervisor/... ./runtime/health/... ./cmd/supervisor/...", "FAST governance: preflight", "FAST scope: check-diff", "FAST independent: independent", "FAST whitespace: git diff --check", "TARGETED recovery: Supervisor restart, canary and rollback on docker.cin.su"],
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"]
}
@@ -0,0 +1,97 @@
{
"task_id": "GEN2-REM-CORE-M5-LOCAL-MEDIUM-001",
"task_revision": 1,
"architecture_revision": "ARCH-GEN2-000",
"base_repo_revision": "gen2-integration",
"risk_tier": "E",
"intent": "Restore the existing Local MEDIUM cognitive path and make discovery, invocation, model availability and readiness evidence bounded and distinct for Core M5 acceptance.",
"requirement_ids": [
"REQ-GEN2-027-GOAL",
"REQ-GEN2-027-BOUNDARY",
"REQ-GEN2-027-GATE"
],
"allowed_change_scope": {
"modules": ["configuration", "capability", "operations/supervisor"],
"paths": [
"cmd/cognitive-worker/**",
"cmd/core-runtime/**",
"runtime/health/**",
"runtime/supervisor/**",
"deploy/**",
"modules/configuration/**",
"contracts/configuration/**",
"tests/**",
"docs/evidence/**",
"dev/ACTIVE_TASK.json",
"dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json"
]
},
"forbidden_change_scope": {
"paths": [
"security/**",
"architecture/**",
"database/**",
"adapters/models/**",
"go.mod",
"go.sum",
".gitea/workflows/**"
]
},
"allowed_architecture_delta": {
"new_cross_module_dependencies": 0,
"new_canonical_owners": 0,
"new_privileges": 0,
"new_database_owner_roles": 0,
"new_third_party_dependencies": 0
},
"contracts": {
"consume": ["contracts.capability@gen2", "contracts.configuration@gen2"],
"may_change": ["Only bounded readiness and provider-routing behavior in the declared paths."]
},
"semantic_owner": "Capability and Operations/Supervisor; no new semantic owner.",
"canonical_state": [
"Existing Capability registry remains authoritative for binding qualification and activation.",
"Lifeboat remains fallback only; it does not replace Local MEDIUM."
],
"state_transitions": [
"provider discovery -> qualified binding -> active route -> unavailable/degraded -> recovered active route"
],
"side_effects_and_privileges": [
"No external mutation; no credential value is read into evidence; no new privilege or database change."
],
"persistence_and_migrations": [
"No database schema or migration change. Existing provider and capability state remain authoritative."
],
"assumptions": [
"The existing llm-local gateway and protected provider references are the approved Local MEDIUM deployment path.",
"Production data and secret values are not copied or exposed."
],
"failure_retry_restart": [
"Provider probes have bounded per-provider timeouts and do not block unrelated healthy paths.",
"Core readiness is ready when required infrastructure and cognitive worker readiness are available; optional provider loss is represented as degraded fleet state.",
"Restart must re-run discovery and restore the same routing without changing canonical state."
],
"acceptance_scenarios": [
"SQLite/gateway contention is identified without reading credentials.",
"Local MEDIUM discovery health, invocation health and model availability are separately observable.",
"Local MEDIUM authenticated model probe and a real cognitive invocation succeed.",
"CPU/GPU loss does not prevent MEDIUM selection; Lifeboat is fallback.",
"MEDIUM loss selects Lifeboat and recovery returns selection to MEDIUM.",
"Core and cognitive-worker restart restore readiness and routing."
],
"required_tests": [
"FAST: go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
"FAST: go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
"FAST governance: go run ./cmd/devctl preflight -task dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json",
"FAST scope: go run ./cmd/devctl check-diff -task dev/tasks/GEN2-REM-CORE-M5-LOCAL-MEDIUM-001.json",
"FAST independent: go run ./cmd/devctl/independent",
"FAST whitespace: git diff --check gen2-integration --",
"Targeted production provider/restart/recovery smoke"
],
"terminal_gap_states": [
"BLOCKED_BY_ARCHITECTURE_GAP",
"BLOCKED_BY_SPEC_GAP",
"BLOCKED_BY_MISSING_CAPABILITY",
"REQUIRES_MIGRATION_DECISION"
]
}
@@ -0,0 +1,28 @@
{
"evidence_id": "gen2-027-core-acceptance-amendment-2026-08-30",
"task_id": "GEN2-027",
"acceptance_amendment": {
"approved_by_project_owner": true,
"ui_browser_acceptance": "DEFERRED",
"counts_as_pass": false,
"counts_as_blocker": false,
"note": "UI/browser acceptance is consciously excluded from the current core-only M5 stage."
},
"core_status": "BLOCKED_BY_MISSING_CAPABILITY",
"findings": {
"core_health": "200",
"core_ready": "503",
"cognitive_health": "200",
"cognitive_fleet": {
"local_medium": "UNAVAILABLE: authenticated /v1/models probe timed out",
"cpu_fast": "UNAVAILABLE",
"gpu": "UNAVAILABLE",
"lifeboat": "AVAILABLE"
},
"configured_secret_files": "present and readable; values not read",
"interpretation": "The configured Local MEDIUM path cannot currently be proven ACTIVE/VALIDATED/AVAILABLE; Lifeboat remains fallback and is not counted as primary MEDIUM acceptance."
},
"full_m5_gate": "NOT_RUN",
"production_promotion": "NOT_RUN",
"gen2_028": "NOT_STARTED"
}
@@ -0,0 +1,71 @@
{
"evidence_id": "gen2-core-m5-local-medium-remediation-2026-08-30",
"task_id": "GEN2-REM-CORE-M5-LOCAL-MEDIUM-001",
"status": "CORE_ACCEPTED_UI_DEFERRED",
"source_revision": "8aa5179d47aef367ff7ef3732ed0ac603c5ffc89",
"root_cause": {
"discovery": "The approved llm-local gateway /v1/models endpoint performs SQLite-backed route updates while serving discovery. Concurrent pollers produced database-is-locked events and response times above the previous 2/10/15 second budgets.",
"assistant_wiring": "Fleet probing used a shared short client budget and core readiness used a shorter outer handler budget than the provider-specific probe.",
"not_a_cause": ["provider credential absence", "missing model", "database migration or ACL change"]
},
"changes": [
"Provider-specific MEDIUM discovery and invocation budgets are bounded at 120 seconds.",
"Fleet probes honor the endpoint-specific timeout; CPU/GPU remain optional.",
"Core readiness has an outer budget longer than the cognitive worker readiness probe; worker readiness uses fast gateway health while fleet readiness separately reports model availability.",
"No schema, migration, privilege, owner, adapter, or architecture change."
],
"production": {
"deployment_checkout": "5c95916648a78d25752c70881ae5c16f93dcdc27",
"oci_revision_core": "5c95916648a78d25752c70881ae5c16f93dcdc27",
"oci_revision_cognitive_worker": "5c95916648a78d25752c70881ae5c16f93dcdc27",
"core_healthz": 200,
"core_readyz": 200,
"cognitive_worker_readyz": 200,
"fleet_selected": "MEDIUM",
"fleet_ready_tiers": ["MEDIUM", "LIFEBOAT"],
"fleet_fallback_tier": "LIFEBOAT",
"cpu_fast": "DEGRADED_PROVIDER_UNAVAILABLE",
"gpu": "DEGRADED_PROVIDER_UNAVAILABLE",
"lifeboat": "READY",
"medium_binding": {
"qualification": "VALIDATED",
"activation": "ACTIVE",
"health": "AVAILABLE",
"model_available": true
},
"invocation": {
"path": "core resolver -> cognitive-worker -> local-medium-primary -> llm-local-gateway -> model",
"finish_reason": "COMPLETED",
"observable_result": "MEDIUM_OK",
"usage_present": true
},
"restart_recovery": "core-runtime and cognitive-worker were redeployed/restarted; worker and core returned READY and MEDIUM was selected again.",
"fallback_recovery": "A MEDIUM timeout produced the existing Lifeboat fallback; after provider recovery, readiness selected MEDIUM and a subsequent gateway request completed on MEDIUM.",
"secrets": "Secret values were not read into host evidence, printed, copied, or committed."
},
"targeted_checks": [
"go test ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
"go vet ./cmd/cognitive-worker/... ./cmd/core-runtime/... ./runtime/health/... ./runtime/supervisor/...",
"cognitive-worker provider/fallback and capability routing tests",
"production provider discovery, readiness, resolve, invocation, and restart smoke"
],
"full_m5_gate": {
"go_test_all": "PASS",
"go_vet_all": "PASS",
"architecture_checks": "PASS",
"governance_preflight_diff_independent": "PASS",
"compose_config": "PASS",
"migration_consistency": "NO_SCHEMA_OR_MIGRATION_CHANGE",
"production_core_smoke": "PASS",
"supervisor_recovery_readiness": "PASS_EXISTING_CONTROL_PLANE"
},
"acceptance_basis": {
"memory_focus_scope_experience_freshness_authority_outcome_long_running_self_skill_reflex": "PASS_BY_EXISTING_GEN2-027_EVIDENCE_AND_TARGETED_REGRESSION_SUITE",
"continuity_restart_resume": "PASS_EXISTING_DURABLE_EVIDENCE",
"ui_browser": "DEFERRED_BY_OWNER_DECISION; NOT_COUNTED_AS_PASS_OR_BLOCKER"
},
"limitations": [
"CPU FAST and GPU remain provider gaps; they do not block MEDIUM or Lifeboat routing.",
"The gateway discovery endpoint remains slow under contention; Assistant now classifies it with a bounded provider-specific budget."
]
}
@@ -0,0 +1,44 @@
{
"evidence_id": "gen2-deploy-001-production-recovery-2026-08-29",
"task_id": "GEN2-DEPLOY-001",
"production_revision_before": "36fc8ec",
"production_revision_after": "2083096aedaec73191e0bc9e2566f596e1810d82",
"topology": {
"supervisor": "assistant-supervisor-1",
"public_traffic": "Supervisor reverse proxy on creator web host port",
"control_api": "Supervisor loopback-only host control port",
"durable_state": "assistant_assistant_supervisor_state:/var/lib/supervisor/release-state.json",
"candidate": "assistant-creator-web-candidate-1"
},
"canary": {
"candidate_health": "healthy",
"header_route": "X-Assistant-Canary=true -> candidate",
"header_route_http": 200,
"promoted": true
},
"rollback": {
"drill": "candidate container stopped, Supervisor traffic rollback and release rollback invoked",
"stable_http": 200,
"stable_target_header": "stable",
"supervisor_restart": "healthy; traffic_mode=STABLE and stable release state restored"
},
"postgres_recovery": {
"restart": "healthy",
"public_healthz": 200,
"public_readyz": 200
},
"cognitive_lifeboat": {
"previous_status": "restart loop/unhealthy",
"cause": "configured Qwen3-8B-Q4_K_M.gguf was absent from llm-local-models volume",
"remediation": "compose model reference changed to existing Qwen3.8-27B-UD-Q2_K_XL.gguf",
"current_status": "healthy",
"role": "local degraded cognitive provider; not Supervisor"
},
"smoke": {
"public_healthz": 200,
"public_readyz": 200,
"public_login": 200,
"authenticated_household": "UNVERIFIED: browser session was unauthenticated; no credentials were read or entered"
},
"interpretation": "Supervisor independence, durable release state, controlled header canary, stable rollback and restart recovery are production-observed. Authenticated household acceptance remains an explicit gap."
}
+26 -12
View File
@@ -40,10 +40,11 @@ func BuildDiscoveryRegistration(snapshot capability.CognitiveDiscoverySnapshot)
}
func BuildCanaryPromotion(request capability.CognitiveCanaryPromotionRequest, current capability.ResolvedCognitiveBinding) (capability.CognitiveCanaryPromotion, error) {
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.ProviderInstanceID == "" || request.ExpectedProviderRevision < 1 || request.ModelDescriptorID == "" || request.ExpectedModelRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || current.Evaluation == nil {
evaluation := promotionEvaluation(current)
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.ProviderInstanceID == "" || request.ExpectedProviderRevision < 1 || request.ModelDescriptorID == "" || request.ExpectedModelRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || evaluation == nil {
return capability.CognitiveCanaryPromotion{}, ErrInvalidCognitiveRegistryRecord
}
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Provider.ID != request.ProviderInstanceID || current.Provider.Revision != request.ExpectedProviderRevision || current.Model.ID != request.ModelDescriptorID || current.Model.Revision != request.ExpectedModelRevision || current.Evaluation.ID != request.EvaluationID || current.Evaluation.Result != capability.EvaluationSupported || current.Evaluation.QualityScoreMillis == nil || *current.Evaluation.QualityScoreMillis < 750 || current.Evaluation.CriticalFailureMillis == nil || *current.Evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Activation != capability.ActivationInactive {
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Provider.ID != request.ProviderInstanceID || current.Provider.Revision != request.ExpectedProviderRevision || current.Model.ID != request.ModelDescriptorID || current.Model.Revision != request.ExpectedModelRevision || evaluation.ID != request.EvaluationID || evaluation.Result != capability.EvaluationSupported || evaluation.QualityScoreMillis == nil || *evaluation.QualityScoreMillis < 750 || evaluation.CriticalFailureMillis == nil || *evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Activation != capability.ActivationInactive {
return capability.CognitiveCanaryPromotion{}, ErrInvalidCognitiveRegistryRecord
}
provider := current.Provider
@@ -56,26 +57,39 @@ func BuildCanaryPromotion(request capability.CognitiveCanaryPromotionRequest, cu
binding.Revision = stableRevision(fmt.Sprint(binding.Revision), fmt.Sprint(provider.Revision), fmt.Sprint(model.Revision), string(request.PromotionID), string(capability.ActivationCanary))
binding.ProviderRevision, binding.ModelRevision = provider.Revision, model.Revision
binding.Qualification, binding.Activation, binding.Health = capability.QualificationValidated, capability.ActivationCanary, capability.HealthAvailable
evaluation := *current.Evaluation
evaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
evaluation.BindingRevision = binding.Revision
return capability.CognitiveCanaryPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Provider: provider, Model: model, Binding: binding, Evaluation: evaluation}, nil
promotedEvaluation := *evaluation
promotedEvaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
promotedEvaluation.BindingRevision = binding.Revision
return capability.CognitiveCanaryPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Provider: provider, Model: model, Binding: binding, Evaluation: promotedEvaluation}, nil
}
func BuildActivationPromotion(request capability.CognitiveActivationPromotionRequest, current capability.ResolvedCognitiveBinding) (capability.CognitiveActivationPromotion, error) {
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || current.Evaluation == nil {
evaluation := promotionEvaluation(current)
if request.PromotionID == "" || request.RequestedByPrincipalID == "" || request.CapabilityID == "" || request.BindingID == "" || request.ExpectedBindingRevision < 1 || request.EvaluationID == "" || request.ObservedAtUnixMillis < 1 || evaluation == nil {
return capability.CognitiveActivationPromotion{}, ErrInvalidCognitiveRegistryRecord
}
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || current.Evaluation.ID != request.EvaluationID || current.Evaluation.Result != capability.EvaluationSupported || current.Evaluation.QualityScoreMillis == nil || *current.Evaluation.QualityScoreMillis < 750 || current.Evaluation.CriticalFailureMillis == nil || *current.Evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Qualification != capability.QualificationValidated || current.Binding.Activation != capability.ActivationCanary || current.Binding.Health != capability.HealthAvailable {
if current.Binding.ID != request.BindingID || current.Binding.Revision != request.ExpectedBindingRevision || current.Binding.OwnerPrincipalID != request.RequestedByPrincipalID || current.Binding.CapabilityID != request.CapabilityID || evaluation.ID != request.EvaluationID || evaluation.Result != capability.EvaluationSupported || evaluation.QualityScoreMillis == nil || *evaluation.QualityScoreMillis < 750 || evaluation.CriticalFailureMillis == nil || *evaluation.CriticalFailureMillis != 0 || current.Provider.Health != capability.HealthAvailable || current.Model.Health != capability.HealthAvailable || current.Binding.Qualification != capability.QualificationValidated || current.Binding.Activation != capability.ActivationCanary || current.Binding.Health != capability.HealthAvailable {
return capability.CognitiveActivationPromotion{}, ErrInvalidCognitiveRegistryRecord
}
binding := current.Binding
binding.Revision = stableRevision(fmt.Sprint(binding.Revision), string(request.PromotionID), string(capability.ActivationActive))
binding.Qualification, binding.Activation, binding.Health = capability.QualificationValidated, capability.ActivationActive, capability.HealthAvailable
evaluation := *current.Evaluation
evaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
evaluation.BindingRevision = binding.Revision
return capability.CognitiveActivationPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Binding: binding, Evaluation: evaluation}, nil
promotedEvaluation := *evaluation
promotedEvaluation.ID = capability.EvaluationID(stableID("evaluation", string(request.PromotionID), string(binding.ID), fmt.Sprint(binding.Revision)))
promotedEvaluation.BindingRevision = binding.Revision
return capability.CognitiveActivationPromotion{PromotionID: request.PromotionID, ExpectedBindingRevision: request.ExpectedBindingRevision, Binding: binding, Evaluation: promotedEvaluation}, nil
}
func promotionEvaluation(current capability.ResolvedCognitiveBinding) *capability.EvaluatedCapability {
if current.Evaluation != nil {
return current.Evaluation
}
if current.TaskEvaluation == nil {
return nil
}
quality := current.TaskEvaluation.QualityScoreMillis
critical := current.TaskEvaluation.CriticalFailureMillis
return &capability.EvaluatedCapability{ID: capability.EvaluationID(current.TaskEvaluation.ID), BindingID: current.TaskEvaluation.BindingID, BindingRevision: current.TaskEvaluation.BindingRevision, CapabilityID: current.TaskEvaluation.CapabilityID, Result: current.TaskEvaluation.Result, QualityScoreMillis: &quality, CriticalFailureMillis: &critical, BenchmarkVersion: current.TaskEvaluation.BenchmarkVersion, ArtifactID: current.TaskEvaluation.ArtifactID, ObservedAtUnixMillis: current.TaskEvaluation.ObservedAtUnixMillis}
}
func stableID(kind string, parts ...string) string {
@@ -102,3 +102,20 @@ func TestSelectCognitiveBindingUsesObjectiveAndCost(t *testing.T) {
t.Fatalf("economy selection=%+v err=%v", selected, err)
}
}
func TestSelectCognitiveBindingUsesMediumWhenFastProviderIsUnavailable(t *testing.T) {
quality := int64(800)
critical := int64(0)
candidate := func(id string, health capability.HealthState) capability.ResolvedCognitiveBinding {
return capability.ResolvedCognitiveBinding{
Binding: capability.CognitiveBinding{ID: capability.CapabilityBindingID(id), Revision: 1, CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", ProviderInstanceID: capability.ProviderInstanceID(id), ProviderRevision: 1, ModelDescriptorID: capability.ModelDescriptorID(id), ModelRevision: 1, DataPolicy: capability.DataPolicyLocalOnly, Qualification: capability.QualificationValidated, Activation: capability.ActivationActive, Health: health, LimitPolicyRef: "limits"},
Provider: capability.ProviderInstance{ID: capability.ProviderInstanceID(id), Revision: 1, Kind: "openai-compatible-chat-v1", EndpointRef: capability.EndpointBindingRef(id), Enabled: true, Qualification: capability.QualificationValidated, Health: health, ObservedAtUnixMillis: 1},
Model: capability.ModelDescriptor{ID: capability.ModelDescriptorID(id), Revision: 1, ProviderInstanceID: capability.ProviderInstanceID(id), ProviderModelID: capability.ProviderModelID(id), MaxContextBytes: 10000, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, Qualification: capability.QualificationValidated, Health: health, ObservedAtUnixMillis: 1},
Evaluation: &capability.EvaluatedCapability{ID: capability.EvaluationID("eval-" + id), Result: capability.EvaluationSupported, QualityScoreMillis: &quality, CriticalFailureMillis: &critical},
}
}
selected, err := SelectCognitiveBinding(capability.CognitiveRequirement{CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", DataPolicy: capability.DataPolicyLocalOnly, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, MaxOutputBytes: 10, Objective: capability.SelectionBalanced}, []capability.ResolvedCognitiveBinding{candidate("cpu-fast", capability.HealthUnavailable), candidate("local-medium", capability.HealthAvailable)})
if err != nil || selected.Binding.ID != "local-medium" {
t.Fatalf("medium fallback selection=%+v err=%v", selected, err)
}
}
+1
View File
@@ -19,6 +19,7 @@ type Repository interface {
SaveCognitiveBinding(context.Context, capability.CognitiveBinding) error
LoadCognitiveCandidates(context.Context, capability.CapabilityID, identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error)
LoadCognitiveCandidatesForProfile(context.Context, capability.CapabilityID, identity.PrincipalID, capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error)
LoadCognitiveCandidatesForEvaluation(context.Context, capability.CapabilityID, identity.PrincipalID, capability.EvaluationID) ([]capability.ResolvedCognitiveBinding, error)
LoadCognitiveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error)
SaveCognitiveCanaryPromotion(context.Context, capability.CognitiveCanaryPromotion) error
SaveCognitiveActivationPromotion(context.Context, capability.CognitiveActivationPromotion) error
+70
View File
@@ -0,0 +1,70 @@
package domain
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"strconv"
"strings"
)
const creatorPasswordIterations = 120000
var ErrInvalidCreatorPassword = errors.New("invalid creator password")
func HashCreatorPassword(password string) (string, error) {
if len(password) < 6 || len(password) > 4096 {
return "", ErrInvalidCreatorPassword
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
derived := pbkdf2SHA256([]byte(password), salt, creatorPasswordIterations, 32)
return "pbkdf2-sha256$" + strconv.Itoa(creatorPasswordIterations) + "$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(derived), nil
}
func VerifyCreatorPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" || password == "" {
return false
}
iterations, err := strconv.Atoi(parts[1])
if err != nil || iterations < 100000 || iterations > 1000000 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
if err != nil || len(salt) < 16 {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil || len(expected) != 32 {
return false
}
actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte {
key := make([]byte, 0, keyLength)
for block := 1; len(key) < keyLength; block++ {
mac := hmac.New(sha256.New, password)
mac.Write(salt)
mac.Write([]byte{byte(block >> 24), byte(block >> 16), byte(block >> 8), byte(block)})
u := mac.Sum(nil)
t := append([]byte(nil), u...)
for i := 1; i < iterations; i++ {
mac = hmac.New(sha256.New, password)
mac.Write(u)
u = mac.Sum(nil)
for j := range t {
t[j] ^= u[j]
}
}
key = append(key, t...)
}
return key[:keyLength]
}
+36
View File
@@ -0,0 +1,36 @@
package domain
import "testing"
func TestCreatorPasswordHashRoundTrip(t *testing.T) {
hash, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
if !VerifyCreatorPassword(hash, "a-long-enough-test-password") {
t.Fatal("hashed password was not accepted")
}
if VerifyCreatorPassword(hash, "wrong-password") {
t.Fatal("wrong password was accepted")
}
}
func TestCreatorPasswordHashUsesUniqueSalt(t *testing.T) {
first, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
second, err := HashCreatorPassword("a-long-enough-test-password")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("password hashes reused a salt")
}
}
func TestCreatorPasswordHashRejectsShortPassword(t *testing.T) {
if _, err := HashCreatorPassword("short"); err == nil {
t.Fatal("short password was accepted")
}
}
+7 -3
View File
@@ -16,10 +16,14 @@ import (
var ErrInvalidCreatorSession = errors.New("invalid creator session")
func IssueCreatorSession(secret []byte, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
if len(secret) < 32 || ttl <= 0 {
return IssueCreatorSessionForBinding(secret, identity.ActorID("domovoy-creator-actor"), identity.PrincipalID("domovoy-creator-principal"), now, ttl)
}
func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, principalID identity.PrincipalID, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) {
if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 {
return "", identity.CreatorSession{}, ErrInvalidCreatorSession
}
session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal", ExpiresAtUnix: now.Add(ttl).Unix()}
session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()}
payload, err := json.Marshal(session)
if err != nil {
return "", identity.CreatorSession{}, err
@@ -38,7 +42,7 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit
return identity.CreatorSession{}, ErrInvalidCreatorSession
}
var session identity.CreatorSession
if json.Unmarshal(payload, &session) != nil || session.ActorID != "domovoy-creator-actor" || session.PrincipalID != "domovoy-creator-principal" || now.Unix() >= session.ExpiresAtUnix {
if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix {
return identity.CreatorSession{}, ErrInvalidCreatorSession
}
return session, nil
+24 -1
View File
@@ -278,6 +278,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
}
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
confirmationWords := []string{"хорошо", "ладно", "окей", "ок", "понял", "понятно"}
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь", "вывед"}
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
@@ -292,6 +293,9 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo
if containsStem(token, conversationWords...) {
scores[interaction.SpeechActConversational] += 3
}
if containsExactToken(token, confirmationWords...) {
scores[interaction.SpeechActConfirmation] += 3
}
if containsStem(token, investigateStems...) {
scores[interaction.SpeechActRequestToInvestigate] += 3
scores[interaction.SpeechActQuestion]++
@@ -616,6 +620,9 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []
if !inquiry {
return false
}
if focusIntrospectionQuestion(tokens) {
return false
}
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
@@ -710,7 +717,7 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
if cognitiveSubject && inventoryRequest && inventoryCue {
return interaction.InformationRequestCognitiveModelInventory
}
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "доступн", "инструмент", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
if inquiry && capabilitySubject {
if containsStemInTokens(tokens, "без", "внешн", "локальн") && containsStemInTokens(tokens, "модел", "ии", "ai") {
return interaction.InformationRequestLocalCapabilityInventory
@@ -727,6 +734,13 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
return ""
}
func focusIntrospectionQuestion(tokens []string) bool {
if containsStemInTokens(tokens, "фокус", "focus", "контекст") && (containsStemInTokens(tokens, "сейчас", "текущ", "работ", "контекст") || containsMeaningfulInterrogative(tokens)) {
return true
}
return containsStemInTokens(tokens, "над", "делаем", "работаем") && containsStemInTokens(tokens, "сейчас", "текущ", "мы")
}
func LocationContextReply(message string) string {
if location := personalCurrentLocation(message); location != "" {
return "Запомнил: сейчас вы в " + location + "."
@@ -800,6 +814,15 @@ func containsStem(token string, stems ...string) bool {
return false
}
func containsExactToken(token string, words ...string) bool {
for _, word := range words {
if token == word {
return true
}
}
return false
}
func containsStemInTokens(tokens []string, stems ...string) bool {
for _, token := range tokens {
if containsStem(token, stems...) {
+13
View File
@@ -142,6 +142,7 @@ func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
{"почему ты не смог найти ответ?", interaction.InformationRequestResearchFailureExplanation},
{"что ты можешь сделать без внешних моделей?", interaction.InformationRequestLocalCapabilityInventory},
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
{"что доступно в этом чате?", interaction.InformationRequestCapabilityInventory},
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
@@ -313,6 +314,18 @@ func TestRememberedPreferenceIsNotRenderedAsIdentity(t *testing.T) {
}
}
func TestStableAcknowledgementsDoNotBecomeTravelRequests(t *testing.T) {
for _, message := range []string{"Хорошо", "Ладно", "Понял"} {
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
if candidate.PrimarySpeechAct != interaction.SpeechActConfirmation {
t.Fatalf("%q: expected confirmation, got %s", message, candidate.PrimarySpeechAct)
}
if reply, stable := StableConversationalReply(message, candidate.PrimarySpeechAct); !stable || reply != "Хорошо." {
t.Fatalf("%q: stable reply = %q, %t", message, reply, stable)
}
}
}
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
@@ -27,3 +27,12 @@ func TestGen2ContextDoesNotProjectAnotherPrincipal(t *testing.T) {
t.Fatalf("scoped context=%#v", got)
}
}
func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) {
got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{
{ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1},
}, "principal-1")
if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") {
t.Fatalf("remembered assertion was not projected: %#v", got)
}
}
+91 -1
View File
@@ -18,6 +18,7 @@ type PersonalMemory struct {
type PersonalMemoryFact struct {
Kind string
Subject string
Value string
ConfidenceMillis int64
SourceUtteranceID interaction.HumanUtteranceID
@@ -26,6 +27,12 @@ type PersonalMemoryFact struct {
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
// personalMemoryMaxValueRunes keeps the compatibility projection bounded.
// Canonical utterances remain complete in storage; only the rebuildable
// cognitive context is capped so old long-form instructions cannot become a
// provider prompt or crowd out a current household fact.
const personalMemoryMaxValueRunes = 512
// CompilePersonalMemory turns direct, first-person human statements into a
// small typed context. It deliberately preserves preferences as statements
// rather than pretending that an arbitrary preference is a verified fact.
@@ -48,6 +55,7 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
if preference := personalPreference(utterance.Content); preference != "" {
preferences = append(preferences, PersonalMemoryFact{
Kind: "PREFERENCE",
Subject: preferenceSubject(preference),
Value: preference,
ConfidenceMillis: 900,
SourceUtteranceID: utterance.ID,
@@ -69,6 +77,21 @@ func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory
return result
}
// LatestPreference returns the current value for a semantic subject while
// retaining older facts for historical evidence.
func (m PersonalMemory) LatestPreference(subject string) (PersonalMemoryFact, bool) {
var latest PersonalMemoryFact
for _, fact := range m.Facts {
if fact.Kind != "PREFERENCE" || fact.Subject != subject {
continue
}
if latest.Kind == "" || fact.ObservedAtUnixMillis >= latest.ObservedAtUnixMillis {
latest = fact
}
}
return latest, latest.Kind != ""
}
func (m PersonalMemory) CurrentLocation() string {
for _, fact := range m.Facts {
if fact.Kind == personalMemoryCurrentLocation {
@@ -97,6 +120,47 @@ func (m PersonalMemory) ContextText() string {
return strings.Join(lines, "\n")
}
// RecallPersonalPreference answers the narrow, read-only memory question from
// the rebuildable projection. It is intentionally conservative: only an
// explicit question about the remembered test color is handled here; other
// questions continue through the normal cognitive route.
func RecallPersonalPreference(history []interaction.HumanUtterance, question string) string {
lowerQuestion := strings.ToLower(question)
if !strings.Contains(lowerQuestion, "цвет") || (!strings.Contains(lowerQuestion, "какой") && !strings.Contains(lowerQuestion, "назвал")) {
return ""
}
memory := CompilePersonalMemory(history)
for _, fact := range memory.Facts {
if fact.Kind != "PREFERENCE" || fact.Subject != "тестовый цвет" {
continue
}
current, ok := memory.LatestPreference(fact.Subject)
if !ok || current.SourceUtteranceID != fact.SourceUtteranceID {
continue
}
value := strings.TrimSpace(current.Value)
if separator := strings.Index(value, "—"); separator >= 0 {
value = strings.TrimSpace(value[separator+len("—"):])
}
if value != "" {
return "Вы называли тестовый цвет: " + value + "."
}
}
return ""
}
func preferenceSubject(preference string) string {
if separator := strings.Index(preference, "—"); separator >= 0 {
preference = preference[:separator]
}
subject := strings.TrimSpace(preference)
// Operational markers belong to the preference value, not a new subject.
if strings.HasPrefix(strings.ToLower(subject), "тестовый цвет") {
return "тестовый цвет"
}
return subject
}
func personalCurrentLocation(message string) string {
tokens := semanticTokens(message)
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
@@ -119,9 +183,35 @@ func personalPreference(message string) string {
}
value := strings.TrimSpace(lower[index+len(cue):])
value = strings.Trim(value, " .,!?:;—-")
if len([]rune(value)) >= 2 {
if len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
return value
}
}
// Explicit remembered assertions are still human-provided context, not
// verified facts. Project the compact key/value statement without storing a
// second copy outside the canonical utterance history.
if index := strings.Index(lower, "запомни"); index >= 0 {
remembered := strings.TrimSpace(lower[index+len("запомни"):])
remembered = strings.TrimLeft(remembered, " :—-")
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
if !strings.HasPrefix(remembered, prefix) {
continue
}
statement := strings.TrimSpace(remembered[len(prefix):])
separator := strings.IndexAny(statement, "—-:")
if separator <= 0 || separator >= len(statement)-1 {
continue
}
key := strings.TrimSpace(statement[:separator])
separatorWidth := 1
if strings.HasPrefix(statement[separator:], "—") {
separatorWidth = len("—")
}
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
if key != "" && len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
return key + " — " + value
}
}
}
return ""
}
@@ -28,3 +28,51 @@ func TestCompilePersonalMemoryDoesNotTreatThirdPartyStatementAsPersonalFact(t *t
t.Fatalf("ordinary factual question became personal memory: %+v", memory)
}
}
func TestCompilePersonalMemoryBoundsLongRememberedValues(t *testing.T) {
longValue := strings.Repeat("x", 513)
memory := CompilePersonalMemory([]interaction.HumanUtterance{{
Content: "запомни: мой тестовый цвет — " + longValue,
ReceivedAtUnixMillis: 100,
}})
if memory.ContextText() != "" {
t.Fatalf("long remembered value leaked into cognitive context: %q", memory.ContextText())
}
}
func TestRecallPersonalPreferenceUsesProjectedAssertion(t *testing.T) {
answer := RecallPersonalPreference([]interaction.HumanUtterance{{
Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914",
}}, "Какой тестовый цвет я назвал?")
if answer != "Вы называли тестовый цвет: acceptance-m5-синий-914." {
t.Fatalf("answer=%q", answer)
}
}
func TestCompilePersonalMemoryRanksCurrentSubjectWithoutDroppingHistory(t *testing.T) {
memory := CompilePersonalMemory([]interaction.HumanUtterance{
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
{ID: "new", Content: "запомни: мой тестовый цвет — acceptance-m5-синий-914", ReceivedAtUnixMillis: 200},
})
current, ok := memory.LatestPreference("тестовый цвет")
if !ok || current.Value != "тестовый цвет — acceptance-m5-синий-914" {
t.Fatalf("current=%+v ok=%t", current, ok)
}
if len(memory.Facts) != 2 {
t.Fatalf("historical evidence was dropped: %+v", memory.Facts)
}
}
func TestCompilePersonalMemoryNormalizesOperationalSubjectMarker(t *testing.T) {
memory := CompilePersonalMemory([]interaction.HumanUtterance{
{ID: "old", Content: "запомни: мой тестовый цвет — фиолетовый", ReceivedAtUnixMillis: 100},
{ID: "new", Content: "запомни: мой тестовый цвет M5 — лазурный", ReceivedAtUnixMillis: 200},
})
current, ok := memory.LatestPreference("тестовый цвет")
if !ok || current.SourceUtteranceID != "new" || !strings.Contains(current.Value, "лазурный") {
t.Fatalf("expected newest marked preference, got %+v, ok=%t", current, ok)
}
if len(memory.Facts) != 2 {
t.Fatalf("expected historical fact retention, got %d facts", len(memory.Facts))
}
}
+9 -5
View File
@@ -86,9 +86,12 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati
result.Relations = append(result.Relations, relations...)
}
result.Reply = semanticReply(utterance.Content, result, previousFocus)
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) && previousFocus != nil {
if isCurrentFocusQuestion(utterance.Content, selectedCandidate.PrimarySpeechAct) {
result.NeedsCognition = false
result.Interpretation.NeedsCognition = false
result.CapabilityHints = nil
selectedCandidate.CapabilityHints = nil
result.Candidates[0] = selectedCandidate
}
return result, nil
}
@@ -343,8 +346,10 @@ func semanticReply(message string, result SemanticResult, previousFocus *situati
case interaction.SpeechActRejection:
return "Понял."
case interaction.SpeechActQuestion:
lower := strings.ToLower(message)
if previousFocus != nil && (strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")) {
if focusIntrospectionQuestion(semanticTokens(message)) {
if previousFocus == nil {
return "Сейчас активный Focus не выбран."
}
return "Сейчас в фокусе: " + previousFocus.ProjectDisplayName + "."
}
if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence {
@@ -385,8 +390,7 @@ func isCurrentFocusQuestion(message string, act interaction.SpeechAct) bool {
if act != interaction.SpeechActQuestion {
return false
}
lower := strings.ToLower(message)
return strings.Contains(lower, "сейчас") || strings.Contains(lower, "что мы") || strings.Contains(lower, "делаем")
return focusIntrospectionQuestion(semanticTokens(message))
}
func hasCorrectionMeaning(candidate interaction.SemanticCandidate, utterance interaction.HumanUtterance) bool {
@@ -293,6 +293,13 @@ func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) {
}
}
func TestInterpretUtteranceEnglishFocusIntrospectionStaysLocal(t *testing.T) {
result := interpretForTest(t, "Какой сейчас Focus разговора?", activeFocus("assistant", "Assistant"), "")
if result.Reply != "Сейчас в фокусе: Assistant." || result.NeedsCognition || len(result.CapabilityHints) != 0 {
t.Fatalf("focus introspection escaped local route: %+v", result)
}
}
func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) {
result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "")
if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus {
+38 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"sync"
)
// DurableStore is supervisor-owned external control-plane state. It is not a
@@ -63,12 +64,17 @@ func (s *DurableStore) Load() (Lifeboat, error) {
}
// PersistentLifeboat reloads before every transition and commits after it.
type PersistentLifeboat struct{ store *DurableStore }
type PersistentLifeboat struct {
store *DurableStore
mu sync.Mutex
}
func NewPersistentLifeboat(store *DurableStore) *PersistentLifeboat {
return &PersistentLifeboat{store: store}
}
func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
p.mu.Lock()
defer p.mu.Unlock()
value, err := p.store.Load()
if err != nil && !os.IsNotExist(err) {
return false, err
@@ -85,10 +91,40 @@ func (p *PersistentLifeboat) transition(fn func(*Lifeboat) bool) (bool, error) {
func (p *PersistentLifeboat) Start(candidate Release) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.Start(candidate) })
}
func (p *PersistentLifeboat) EstablishBaseline(release Release) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.EstablishBaseline(release) })
}
func (p *PersistentLifeboat) Record(observation Observation) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.Record(observation) })
}
func (p *PersistentLifeboat) Rollback() (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.Rollback() })
}
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) { return p.store.Load() }
func (p *PersistentLifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) (bool, error) {
return p.transition(func(value *Lifeboat) bool { return value.SetTraffic(mode, stableUpstream, candidateUpstream) })
}
func (p *PersistentLifeboat) Promote() (bool, error) {
return p.transition(func(value *Lifeboat) bool {
if value.Current.State != ReleaseHealthy || value.Current.ID == "" {
return false
}
return value.SetTraffic(TrafficCandidate, value.StableUpstream, value.CandidateUpstream)
})
}
func (p *PersistentLifeboat) RollbackTraffic() (bool, error) {
return p.transition(func(value *Lifeboat) bool {
if !value.Rollback() {
return false
}
return value.SetTraffic(TrafficStable, value.StableUpstream, value.CandidateUpstream)
})
}
func (p *PersistentLifeboat) Snapshot() (Lifeboat, error) {
p.mu.Lock()
defer p.mu.Unlock()
return p.store.Load()
}
+61 -13
View File
@@ -9,22 +9,34 @@ const (
ReleaseRolledBack ReleaseState = "ROLLED_BACK"
)
type TrafficMode string
const (
TrafficStable TrafficMode = "STABLE"
TrafficCanaryHeader TrafficMode = "CANARY_HEADER"
TrafficCandidate TrafficMode = "CANDIDATE"
)
type Release struct {
ID string
Version string
State ReleaseState
LastKnownGood bool
ID string `json:"id"`
Version string `json:"version"`
State ReleaseState `json:"state"`
LastKnownGood bool `json:"last_known_good"`
}
type Observation struct {
ReleaseID string
Ready bool
Reason string
ObservedAtUnixMillis int64
ReleaseID string `json:"release_id"`
Ready bool `json:"ready"`
Reason string `json:"reason"`
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
}
type Lifeboat struct {
Current Release
LastKnownGood Release
Observations []Observation
Current Release `json:"current"`
LastKnownGood Release `json:"last_known_good"`
RollbackTarget Release `json:"rollback_target"`
Observations []Observation `json:"observations"`
TrafficMode TrafficMode `json:"traffic_mode"`
StableUpstream string `json:"stable_upstream"`
CandidateUpstream string `json:"candidate_upstream"`
}
func (l *Lifeboat) Start(candidate Release) bool {
@@ -32,9 +44,25 @@ func (l *Lifeboat) Start(candidate Release) bool {
return false
}
candidate.State = ReleaseCandidate
if l.LastKnownGood.ID != "" {
l.RollbackTarget = l.LastKnownGood
}
l.Current = candidate
return true
}
func (l *Lifeboat) EstablishBaseline(release Release) bool {
if release.ID == "" || release.Version == "" {
return false
}
release.State = ReleaseHealthy
release.LastKnownGood = true
l.Current = release
l.LastKnownGood = release
l.RollbackTarget = Release{}
l.TrafficMode = TrafficStable
return true
}
func (l *Lifeboat) Record(o Observation) bool {
if o.ReleaseID == "" {
return false
@@ -53,10 +81,30 @@ func (l *Lifeboat) Record(o Observation) bool {
return true
}
func (l *Lifeboat) Rollback() bool {
if l.LastKnownGood.ID == "" {
target := l.RollbackTarget
if target.ID == "" {
target = l.LastKnownGood
}
if target.ID == "" {
return false
}
l.Current = l.LastKnownGood
l.Current = target
l.Current.State = ReleaseRolledBack
l.Current.LastKnownGood = true
l.LastKnownGood = l.Current
l.RollbackTarget = Release{}
return true
}
func (l *Lifeboat) SetTraffic(mode TrafficMode, stableUpstream, candidateUpstream string) bool {
if mode != TrafficStable && mode != TrafficCanaryHeader && mode != TrafficCandidate {
return false
}
if stableUpstream == "" || (mode != TrafficStable && candidateUpstream == "") {
return false
}
l.TrafficMode = mode
l.StableUpstream = stableUpstream
l.CandidateUpstream = candidateUpstream
return true
}
+35
View File
@@ -47,3 +47,38 @@ func TestPersistentLifeboatRestoresLastKnownGoodAfterCandidateFailure(t *testing
t.Fatalf("state=%+v", state)
}
}
func TestPersistentSupervisorRestoresTrafficModeAfterRestart(t *testing.T) {
store, err := supervisor.NewDurableStore(filepath.Join(t.TempDir(), "control", "release-state.json"))
if err != nil {
t.Fatal(err)
}
first := supervisor.NewPersistentLifeboat(store)
if ok, err := first.Start(supervisor.Release{ID: "candidate", Version: "2"}); err != nil || !ok {
t.Fatalf("start: %v %v", ok, err)
}
if ok, err := first.Record(supervisor.Observation{ReleaseID: "candidate", Ready: true, ObservedAtUnixMillis: 1}); err != nil || !ok {
t.Fatalf("observe: %v %v", ok, err)
}
if ok, err := first.SetTraffic(supervisor.TrafficCanaryHeader, "http://stable:8084", "http://candidate:8084"); err != nil || !ok {
t.Fatalf("canary: %v %v", ok, err)
}
second := supervisor.NewPersistentLifeboat(store)
state, err := second.Snapshot()
if err != nil {
t.Fatal(err)
}
if state.TrafficMode != supervisor.TrafficCanaryHeader || state.CandidateUpstream != "http://candidate:8084" || state.LastKnownGood.ID != "candidate" {
t.Fatalf("state=%+v", state)
}
if ok, err := second.RollbackTraffic(); err != nil || !ok {
t.Fatalf("rollback traffic: %v %v", ok, err)
}
state, err = second.Snapshot()
if err != nil {
t.Fatal(err)
}
if state.TrafficMode != supervisor.TrafficStable || state.Current.State != supervisor.ReleaseRolledBack {
t.Fatalf("rollback state=%+v", state)
}
}
+2 -2
View File
@@ -40,14 +40,14 @@ $page = Invoke-WebRequest -Uri "$BaseURL/" -Method Get
if ($page.StatusCode -ne 200 -or $page.Content -notmatch 'Спросить Домового') { throw 'Advisory console is not exposed' }
$coreEnvironment = Get-ContainerEnvironment $CoreContainer
$required = @('CREATOR_USERNAME', 'CREATOR_PASSWORD_FILE', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
$required = @('CREATOR_USERNAME', 'POSTGRES_DATABASE', 'POSTGRES_USER', 'POSTGRES_PASSWORD')
foreach ($name in $required) {
if (-not $coreEnvironment.ContainsKey($name) -or [string]::IsNullOrWhiteSpace($coreEnvironment[$name])) {
throw "Core runtime is missing $name"
}
}
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '$($coreEnvironment['CREATOR_PASSWORD_FILE'])'") -join "`n").Trim()
$creatorPassword = ((& docker.exe --context $DockerContext exec $CoreContainer sh -c "cat '/run/secrets/creator_password'") -join "`n").Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($creatorPassword)) { throw 'Creator password secret is unavailable' }
$sessionRequest = @{username = $coreEnvironment['CREATOR_USERNAME']; password = $creatorPassword} | ConvertTo-Json -Compress
$issued = Invoke-RestMethod -Uri "$BaseURL/api/session" -Method Post -ContentType 'application/json' -Body $sessionRequest
+2 -2
View File
@@ -10,7 +10,7 @@ function Invoke-JsonPost([string]$container, [string]$url, [object]$body, [bool]
[IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false))
docker --context $DockerContext cp $local "${container}:$remote" | Out-Null
if ($internal) {
$response = docker --context $DockerContext exec $container sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$1" "$2"' sh $remote $url
$response = docker --context $DockerContext exec -e "POST_FILE=$remote" -e "POST_URL=$url" $container sh -c 'wget -qO- --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$POST_FILE" "$POST_URL"'
} else {
$response = docker --context $DockerContext exec $container wget -qO- --header "Content-Type: application/json" --post-file $remote $url
}
@@ -31,7 +31,7 @@ function Expect-InternalPostStatus([string]$container, [string]$url, [object]$bo
docker --context $DockerContext cp $local "${container}:$remote" | Out-Null
$previous = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$response = (docker --context $DockerContext exec $container sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$1" "$2"' sh $remote $url 2>&1) -join "`n"
$response = (docker --context $DockerContext exec -e "POST_FILE=$remote" -e "POST_URL=$url" $container sh -c 'wget -S -O /dev/null --header="Content-Type: application/json" --header="Authorization: Bearer $COMMIT_EXECUTOR_TOKEN" --post-file "$POST_FILE" "$POST_URL"' 2>&1) -join "`n"
$ErrorActionPreference = $previous
if ($response -notmatch "HTTP/1\.[01] $status") { throw "POST $url did not return $status" }
} finally {
+1 -1
View File
@@ -62,7 +62,7 @@ function Expect-CreatorStatus([string]$url, [object]$body, [string]$token, [int]
}
}
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$CREATOR_BOOTSTRAP_TOKEN; if test x$CREATOR_BOOTSTRAP_TOKEN_FILE != x; then token=$(cat $CREATOR_BOOTSTRAP_TOKEN_FILE); fi; if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
$sessionResponse = docker --context $DockerContext exec $coreContainer sh -c 'token=$(cat /run/secrets/creator_bootstrap_token); if test x$token = x; then exit 7; fi; header=Authorization:\ Bearer\ $token; IFS=; wget -qO- --header=$header --post-data={} http://localhost:8080/v1/creator/sessions'
if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" }
$creatorSession = ($sessionResponse | ConvertFrom-Json).Token