From 5d4e064a945da8558625332f31d45172f1a42eee Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 17 Aug 2026 16:23:16 +0300 Subject: [PATCH] DEV-000065 assistant presence attention surface --- adapters/postgres/continuity_repository.go | 186 ++++++++++++++++++ ...istant-presence-attention-human-surface.md | 46 +++++ architecture/architecture-registry.json | 3 +- architecture/architecture-registry.yaml | 2 +- cmd/commit-runtime/creator_clients.go | 26 +++ cmd/commit-runtime/internal_notification.go | 135 +++++++++++++ cmd/commit-runtime/main.go | 4 + cmd/continuity-runtime/attention.go | 112 +++++++++++ cmd/continuity-runtime/due_worker.go | 183 +++++++++++++++++ cmd/continuity-runtime/due_worker_test.go | 92 +++++++++ cmd/continuity-runtime/main.go | 9 + cmd/core-runtime/capability_chat.go | 20 ++ cmd/core-runtime/capability_chat_test.go | 15 ++ cmd/creator-web/server.go | 4 + cmd/creator-web/static/assets/chat.css | 25 ++- cmd/creator-web/static/assets/chat.js | 109 +++++++++- cmd/creator-web/static/chat.html | 34 +++- contracts/continuity/types.go | 71 +++++++ database/bootstrap/001_roles_and_schemas.sql | 28 +++ .../020_continuity_attention_items.sql | 35 ++++ dev/ACTIVE_TASK.json | 4 +- ...tant-presence-attention-human-surface.json | 152 ++++++++++++++ docs/PROJECT_STATUS.md | 18 +- modules/commit/domain/commit.go | 11 +- modules/continuity/domain/prospective.go | 24 +++ modules/continuity/ports/repository.go | 7 + 26 files changed, 1335 insertions(+), 20 deletions(-) create mode 100644 architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md create mode 100644 cmd/commit-runtime/internal_notification.go create mode 100644 cmd/continuity-runtime/attention.go create mode 100644 cmd/continuity-runtime/due_worker.go create mode 100644 cmd/continuity-runtime/due_worker_test.go create mode 100644 database/migrations/020_continuity_attention_items.sql create mode 100644 dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json diff --git a/adapters/postgres/continuity_repository.go b/adapters/postgres/continuity_repository.go index 961973b..60c8cde 100644 --- a/adapters/postgres/continuity_repository.go +++ b/adapters/postgres/continuity_repository.go @@ -19,6 +19,9 @@ var ( ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict") ErrActionReceiptNotFound = errors.New("continuity action receipt not found") ErrActionIntentConflict = errors.New("action intent was already used for a different command") + ErrAttentionNotFound = errors.New("attention item not found") + ErrAttentionStateConflict = errors.New("attention item state conflict") + ErrAttentionRevisionConflict = errors.New("attention item revision conflict") ) type ContinuityConfig struct{ Host, Port, Database, User, Password string } @@ -66,6 +69,165 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ return items, nil } +func (r *ContinuityRepository) DueArmed(ctx context.Context, nowUnixMillis int64, limit int) ([]continuity.ProspectiveItem, error) { + if limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid due reminder limit") + } + rows, err := r.pool.Query(ctx, `SELECT prospective_item_id, commitment_id, owner_principal_id, scope_id, goal_id, source_intent_id, source_utterance_id, summary, trigger_at_unix_millis, state, revision + FROM continuity.prospective_items + WHERE state=$1 AND trigger_at_unix_millis <= $2 + ORDER BY trigger_at_unix_millis, prospective_item_id LIMIT $3`, continuity.ProspectiveArmed, nowUnixMillis, limit) + if err != nil { + return nil, fmt.Errorf("list due prospective items: %w", err) + } + defer rows.Close() + items := make([]continuity.ProspectiveItem, 0) + for rows.Next() { + var item continuity.ProspectiveItem + if err := rows.Scan(&item.ID, &item.CommitmentID, &item.OwnerPrincipalID, &item.ScopeID, &item.GoalID, &item.SourceIntentID, &item.SourceUtteranceID, &item.Summary, &item.TriggerAtUnixMillis, &item.State, &item.Revision); err != nil { + return nil, fmt.Errorf("scan due prospective item: %w", err) + } + items = append(items, item) + } + return items, rows.Err() +} + +func (r *ContinuityRepository) ClaimDueReminder(ctx context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, nowUnixMillis int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + + var triggered continuity.ProspectiveItem + err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items + SET state=$1, revision=revision+1 + WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 AND trigger_at_unix_millis <= $5 + RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`, + continuity.ProspectiveTriggered, id, continuity.ProspectiveArmed, revision, nowUnixMillis).Scan( + &triggered.ID, &triggered.CommitmentID, &triggered.OwnerPrincipalID, &triggered.ScopeID, &triggered.GoalID, + &triggered.SourceIntentID, &triggered.SourceUtteranceID, &triggered.Summary, &triggered.TriggerAtUnixMillis, &triggered.State, &triggered.Revision) + if errors.Is(err, pgx.ErrNoRows) { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, ErrProspectiveRevisionConflict + } + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("trigger due reminder: %w", err) + } + + var revalidating continuity.ProspectiveItem + err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items + SET state=$1, revision=revision+1 + WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 + RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`, + continuity.ProspectiveRevalidating, triggered.ID, continuity.ProspectiveTriggered, triggered.Revision).Scan( + &revalidating.ID, &revalidating.CommitmentID, &revalidating.OwnerPrincipalID, &revalidating.ScopeID, &revalidating.GoalID, + &revalidating.SourceIntentID, &revalidating.SourceUtteranceID, &revalidating.Summary, &revalidating.TriggerAtUnixMillis, &revalidating.State, &revalidating.Revision) + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("begin due reminder revalidation: %w", err) + } + + var delivered continuity.ProspectiveItem + err = tx.QueryRow(ctx, `UPDATE continuity.prospective_items + SET state=$1, revision=revision+1 + WHERE prospective_item_id=$2 AND state=$3 AND revision=$4 + RETURNING prospective_item_id,commitment_id,owner_principal_id,scope_id,goal_id,source_intent_id,source_utterance_id,summary,trigger_at_unix_millis,state,revision`, + continuity.ProspectiveDelivered, revalidating.ID, continuity.ProspectiveRevalidating, revalidating.Revision).Scan( + &delivered.ID, &delivered.CommitmentID, &delivered.OwnerPrincipalID, &delivered.ScopeID, &delivered.GoalID, + &delivered.SourceIntentID, &delivered.SourceUtteranceID, &delivered.Summary, &delivered.TriggerAtUnixMillis, &delivered.State, &delivered.Revision) + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("finish due reminder revalidation: %w", err) + } + + attention, err := continuitydomain.BuildDueReminderAttention(delivered, attentionID, nowUnixMillis) + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err + } + err = tx.QueryRow(ctx, `INSERT INTO continuity.attention_items ( + attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary, + due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id, + action_attempt_id,delivery_outcome,revision) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,'','','','',1) + ON CONFLICT (source_kind, source_prospective_item_id) DO UPDATE SET source_prospective_item_id=EXCLUDED.source_prospective_item_id + RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis, + created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`, + attention.ID, attention.OwnerPrincipalID, attention.ScopeID, attention.SourceKind, attention.SourceProspectiveID, + attention.Level, attention.State, attention.Summary, attention.DueAtUnixMillis, attention.CreatedAtUnixMillis).Scan( + &attention.ID, &attention.OwnerPrincipalID, &attention.ScopeID, &attention.SourceKind, &attention.SourceProspectiveID, + &attention.Level, &attention.State, &attention.Summary, &attention.DueAtUnixMillis, &attention.CreatedAtUnixMillis, + &attention.DeliveredAtUnixMillis, &attention.ActionIntentID, &attention.AuthorizedActionID, &attention.ActionAttemptID, + &attention.DeliveryOutcome, &attention.Revision) + if err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, fmt.Errorf("create due reminder attention: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, err + } + return delivered, attention, nil +} + +func (r *ContinuityRepository) PendingAttention(ctx context.Context, limit int) ([]continuity.AttentionItem, error) { + if limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid attention limit") + } + rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary, + due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision + FROM continuity.attention_items WHERE state=$1 ORDER BY due_at_unix_millis, attention_item_id LIMIT $2`, continuity.AttentionPending, limit) + if err != nil { + return nil, fmt.Errorf("list pending attention: %w", err) + } + defer rows.Close() + return scanAttentionRows(rows) +} + +func (r *ContinuityRepository) AttentionByOwner(ctx context.Context, owner identity.PrincipalID, limit int) ([]continuity.AttentionItem, error) { + if owner == "" || limit < 1 || limit > 100 { + return nil, fmt.Errorf("invalid attention owner query") + } + rows, err := r.pool.Query(ctx, `SELECT attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary, + due_at_unix_millis,created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision + FROM continuity.attention_items WHERE owner_principal_id=$1 ORDER BY due_at_unix_millis DESC, attention_item_id DESC LIMIT $2`, owner, limit) + if err != nil { + return nil, fmt.Errorf("list attention by owner: %w", err) + } + defer rows.Close() + return scanAttentionRows(rows) +} + +func (r *ContinuityRepository) MarkAttentionDelivering(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) { + var item continuity.AttentionItem + err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items SET state=$1, revision=revision+1 + WHERE attention_item_id=$2 AND state=$3 AND revision=$4 + RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis, + created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`, + continuity.AttentionDelivering, id, continuity.AttentionPending, revision).Scan( + &item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State, + &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID, + &item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision) + if errors.Is(err, pgx.ErrNoRows) { + return continuity.AttentionItem{}, ErrAttentionRevisionConflict + } + return item, err +} + +func (r *ContinuityRepository) MarkAttentionDelivered(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAtUnixMillis int64) (continuity.AttentionItem, error) { + var item continuity.AttentionItem + err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items + SET state=$1, revision=revision+1, delivered_at_unix_millis=$2, action_intent_id=$3, authorized_action_id=$4, + action_attempt_id=$5, delivery_outcome=$6 + WHERE attention_item_id=$7 AND state=$8 AND revision=$9 + RETURNING attention_item_id,owner_principal_id,scope_id,source_kind,source_prospective_item_id,level,state,summary,due_at_unix_millis, + created_at_unix_millis,delivered_at_unix_millis,action_intent_id,authorized_action_id,action_attempt_id,delivery_outcome,revision`, + continuity.AttentionDelivered, deliveredAtUnixMillis, intentID, authorizedID, attemptID, outcome, + id, continuity.AttentionDelivering, revision).Scan( + &item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, &item.Level, &item.State, + &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, &item.DeliveredAtUnixMillis, &item.ActionIntentID, + &item.AuthorizedActionID, &item.ActionAttemptID, &item.DeliveryOutcome, &item.Revision) + if errors.Is(err, pgx.ErrNoRows) { + return continuity.AttentionItem{}, ErrAttentionRevisionConflict + } + return item, err +} + // SatisfyDelivered owns both the Prospective CAS and its idempotency receipt in // one Continuity-local transaction. It never reaches Commit storage. func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) { @@ -135,6 +297,30 @@ type continuityRowQuerier interface { QueryRow(context.Context, string, ...any) pgx.Row } +type continuityRows interface { + Next() bool + Scan(...any) error + Err() error +} + +func scanAttentionRows(rows continuityRows) ([]continuity.AttentionItem, error) { + items := make([]continuity.AttentionItem, 0) + for rows.Next() { + var item continuity.AttentionItem + if err := rows.Scan(&item.ID, &item.OwnerPrincipalID, &item.ScopeID, &item.SourceKind, &item.SourceProspectiveID, + &item.Level, &item.State, &item.Summary, &item.DueAtUnixMillis, &item.CreatedAtUnixMillis, + &item.DeliveredAtUnixMillis, &item.ActionIntentID, &item.AuthorizedActionID, &item.ActionAttemptID, + &item.DeliveryOutcome, &item.Revision); err != nil { + return nil, fmt.Errorf("scan attention item: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan attention items: %w", err) + } + return items, nil +} + func loadActionReceipt(ctx context.Context, querier continuityRowQuerier, intentID continuity.CommittedActionIntentID) (continuity.ActionReceipt, error) { var receipt continuity.ActionReceipt err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id, diff --git a/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md b/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md new file mode 100644 index 0000000..5292c9a --- /dev/null +++ b/architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md @@ -0,0 +1,46 @@ +# ADR-0014 — Assistant Presence, Attention, and Human Surface V1 + +Status: Accepted + +Task: DEV-000065 + +## Context + +Domovoy already records human utterances, original human intent, current focus, capability requirements, capability gaps, and Continuity-owned reminder state. Before this decision, creating a reminder only persisted an `ARMED` `ProspectiveItem`; it did not by itself return to the human when the due condition arrived. + +The system already has the correct outbound mutation foundation: Commit owns authorized external actions, Capability owns notification bindings, and the notification receiver persists idempotent receipts. DEV-000065 must therefore connect due Continuity state to the existing Commit/Executor path instead of creating a parallel reminder or notification subsystem. + +## Decision + +1. Continuity remains the canonical owner of future obligations and prospective state. +2. Continuity also owns the V1 typed `AttentionItem` state because attention is the human-facing relevance decision derived from future obligations and other future internal events. +3. `AttentionItem` V1 is deliberately narrow: it records source, owner, level, state, summary, due time, and delivery evidence references. It is not a task manager, dashboard model, or capability registry. +4. A due reminder follows the existing prospective lifecycle: `ARMED → TRIGGERED → REVALIDATING → DELIVERED`. `DELIVERED` means the due obligation has reached the human-contact phase; it does not grant external mutation authority by itself. +5. Human notification remains a Commit-owned external action using the existing `deliver-prospective-notification-v1` operation and Capability-owned active notification binding. +6. Commit may authorize a prospective notification using a Continuity prospective item as the live justification. A Work `TaskTarget` is not mandatory for this operation when the operation is internally invoked for a validated due `ProspectiveItem`. +7. The due processing runtime may poll canonical PostgreSQL state, but correctness must come from persisted state transitions and idempotency constraints, not from process-local timers. +8. The Human Assistant web surface is separate from the Operator Console. Chat remains primary; Today/Attention and History are human-readable projections built through runtime APIs over canonical state. + +## Boundaries + +- Continuity writes only Continuity-owned prospective and attention state. +- Commit writes only Commit-owned action/attempt state and invokes the existing notification executor. +- Capability owns the notification binding and health/availability semantics. +- Interaction owns conversations, utterances, interpretations, and original human intent. +- No model/provider call is required for deterministic reminder firing. +- Model output never directly mutates Continuity, Commit, or Capability state. + +## Non-goals + +- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop. +- No new canonical owner. +- No generic scheduler/event-bus/autonomous-agent loop. +- No new third-party dependency. +- No new notification channel beyond the existing receiver/Windows display path. + +## Consequences + +- Reminders survive runtime restart because due detection scans canonical `ProspectiveItem` rows. +- Duplicate notification is prevented by Continuity CAS/unique attention source and Commit/receiver idempotency. +- The first attention source is reminder due events; future sources can reuse the same attention path without changing ownership. +- Human UI no longer needs internal UUIDs, binding IDs, provider IDs, or raw lifecycle enums for ordinary use. diff --git a/architecture/architecture-registry.json b/architecture/architecture-registry.json index 5efa0aa..64eac60 100644 --- a/architecture/architecture-registry.json +++ b/architecture/architecture-registry.json @@ -98,7 +98,8 @@ "id": "mod.continuity", "owns": [ "Commitment", - "ProspectiveItem" + "ProspectiveItem", + "AttentionItem" ], "database_schema": "continuity", "boundary_class": "remote_capable", diff --git a/architecture/architecture-registry.yaml b/architecture/architecture-registry.yaml index 04e4cd1..516a28a 100644 --- a/architecture/architecture-registry.yaml +++ b/architecture/architecture-registry.yaml @@ -41,7 +41,7 @@ modules: continuity: id: mod.continuity - owns: [Commitment, ProspectiveItem] + owns: [Commitment, ProspectiveItem, AttentionItem] database_schema: continuity boundary_class: remote_capable may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events] diff --git a/cmd/commit-runtime/creator_clients.go b/cmd/commit-runtime/creator_clients.go index c7a29e7..f961062 100644 --- a/cmd/commit-runtime/creator_clients.go +++ b/cmd/commit-runtime/creator_clients.go @@ -183,3 +183,29 @@ func (c creatorContinuityClient) Item(ctx context.Context, authorization string, return continuity.ProspectiveItem{}, errCreatorQueryUnavailable } } + +func (c creatorContinuityClient) InternalItem(ctx context.Context, id continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) { + target := *c.baseURL + target.Path = "/v1/prospective-items/" + url.PathEscape(string(id)) + target.RawQuery = "" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return continuity.ProspectiveItem{}, errCreatorQueryUnavailable + } + response, err := c.client.Do(request) + if err != nil { + return continuity.ProspectiveItem{}, fmt.Errorf("%w: %v", errCreatorQueryUnavailable, err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotFound { + return continuity.ProspectiveItem{}, errCreatorSubjectMissing + } + if response.StatusCode != http.StatusOK { + return continuity.ProspectiveItem{}, errCreatorQueryUnavailable + } + var item continuity.ProspectiveItem + if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&item); err != nil { + return continuity.ProspectiveItem{}, errCreatorQueryUnavailable + } + return item, nil +} diff --git a/cmd/commit-runtime/internal_notification.go b/cmd/commit-runtime/internal_notification.go new file mode 100644 index 0000000..d90c86a --- /dev/null +++ b/cmd/commit-runtime/internal_notification.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "git.cin.su/m/Assistant/adapters/notificationexecutor" + commitcontract "git.cin.su/m/Assistant/contracts/commit" + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/work" + commitdomain "git.cin.su/m/Assistant/modules/commit/domain" + "git.cin.su/m/Assistant/modules/commit/ports" + "git.cin.su/m/Assistant/platform/ids" +) + +const prospectiveNotificationActor identity.ActorID = "domovoy-creator-actor" + +type internalNotificationContinuity interface { + InternalItem(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) +} + +type internalNotificationHandler struct { + core creatorSessionSource + continuity internalNotificationContinuity + authority creatorAuthoritySource + repository creatorCommitRepository + executor ports.Executor + newID func() (ids.ID, error) + now func() time.Time +} + +type internalProspectiveNotificationRequest struct { + ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"` + ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"` +} + +func registerInternalNotificationRoutes(mux *http.ServeMux, handler internalNotificationHandler, token string) { + mux.HandleFunc("POST /v1/internal/continuity/notify-due", requireCommitToken(token, handler.notifyDue)) +} + +func (h internalNotificationHandler) notifyDue(w http.ResponseWriter, r *http.Request) { + var request internalProspectiveNotificationRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 { + http.Error(w, "invalid prospective notification request", http.StatusBadRequest) + return + } + item, err := h.continuity.InternalItem(r.Context(), request.ProspectiveItemID) + if errors.Is(err, errCreatorSubjectMissing) { + http.Error(w, "prospective item not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Continuity item unavailable", http.StatusServiceUnavailable) + return + } + if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.OwnerPrincipalID == "" || item.ScopeID == "" { + http.Error(w, "prospective item not deliverable", http.StatusConflict) + return + } + grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID) + if err != nil || scope.ID != item.ScopeID { + http.Error(w, "Creator authority unavailable", http.StatusConflict) + return + } + identityBinding, err := h.core.Binding(r.Context(), prospectiveNotificationActor, item.OwnerPrincipalID) + if err != nil { + http.Error(w, "Creator identity binding unavailable", http.StatusConflict) + return + } + capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, item.OwnerPrincipalID) + if err != nil { + http.Error(w, "notification capability unavailable", http.StatusConflict) + return + } + intentID, authorizedID, attemptID, err := internalActionIDs(h.newID) + if err != nil { + http.Error(w, "action identity unavailable", http.StatusServiceUnavailable) + return + } + title := "Домовой: напоминание" + message := "Пора: " + strings.TrimSpace(item.Summary) + payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message} + digest, err := notificationexecutor.Digest(payload) + if err != nil { + http.Error(w, "notification evidence unavailable", http.StatusInternalServerError) + return + } + proposal := commitcontract.ActionProposal{ + IntentID: intentID, ActorID: prospectiveNotificationActor, CapabilityID: commitcontract.CapabilityProspectiveNotification, + Target: work.TaskTarget{ScopeID: item.ScopeID, Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification, + NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest}, + } + action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli()) + if err != nil { + http.Error(w, "Creator authorization denied", http.StatusConflict) + return + } + if err = h.repository.SaveAuthorized(r.Context(), action); err != nil { + http.Error(w, "commit authorized action", http.StatusInternalServerError) + return + } + attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID) + if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) { + http.Error(w, "execute committed notification", http.StatusInternalServerError) + return + } + status := http.StatusCreated + if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) { + status = http.StatusAccepted + } + writeCreatorActionResponse(w, status, action, attempt) +} + +func internalActionIDs(newID func() (ids.ID, error)) (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) { + intent, err := newID() + if err != nil { + return "", "", "", err + } + authorized, err := newID() + if err != nil { + return "", "", "", err + } + attempt, err := newID() + if err != nil { + return "", "", "", err + } + return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil +} diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index 8720752..2b328c9 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -89,6 +89,10 @@ func main() { core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now, }) + registerInternalNotificationRoutes(mux, internalNotificationHandler{ + core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, + executor: dispatcher, newID: ids.New, now: time.Now, + }, executorToken) mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) { var request authorizationRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { diff --git a/cmd/continuity-runtime/attention.go b/cmd/continuity-runtime/attention.go new file mode 100644 index 0000000..b0accd2 --- /dev/null +++ b/cmd/continuity-runtime/attention.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" +) + +type attentionRepository interface { + AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error) +} + +func registerCreatorAttentionRoutes(mux *http.ServeMux, identityClient creatorIdentityClient, repository attentionRepository) { + mux.HandleFunc("GET /v1/creator/attention", func(w http.ResponseWriter, r *http.Request) { + session, ok := creatorSessionForAttention(w, r, identityClient) + if !ok { + return + } + limit, err := parseListLimit(r.URL.Query().Get("limit")) + if err != nil { + http.Error(w, "invalid attention limit", http.StatusBadRequest) + return + } + items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, limit) + if err != nil { + http.Error(w, "attention unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(continuity.AttentionListResult{Items: items}) + }) + mux.HandleFunc("GET /v1/creator/today", func(w http.ResponseWriter, r *http.Request) { + session, ok := creatorSessionForAttention(w, r, identityClient) + if !ok { + return + } + items, err := repository.AttentionByOwner(r.Context(), session.PrincipalID, 50) + if err != nil { + http.Error(w, "today unavailable", http.StatusInternalServerError) + return + } + result := continuity.TodayResult{Items: make([]continuity.TodayItem, 0, len(items))} + for _, item := range items { + result.Items = append(result.Items, continuity.TodayItem{ + Title: item.Summary, + WhenUnixMillis: item.DueAtUnixMillis, + AttentionLevel: item.Level, + AttentionState: item.State, + HumanState: humanAttentionState(item.State), + DeliveryState: humanDeliveryState(item), + SourceDescription: humanAttentionSource(item.SourceKind), + }) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(result) + }) +} + +func creatorSessionForAttention(w http.ResponseWriter, r *http.Request, identityClient creatorIdentityClient) (identity.CreatorSession, bool) { + session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization")) + if errors.Is(err, errCreatorSessionDenied) { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return identity.CreatorSession{}, false + } + if err != nil { + http.Error(w, "identity service unavailable", http.StatusServiceUnavailable) + return identity.CreatorSession{}, false + } + return session, true +} + +func humanAttentionState(state continuity.AttentionState) string { + switch state { + case continuity.AttentionPending: + return "ожидает внимания" + case continuity.AttentionDelivering: + return "доставляется" + case continuity.AttentionDelivered: + return "доставлено" + case continuity.AttentionSuppressed: + return "скрыто" + case continuity.AttentionExpired: + return "истекло" + case continuity.AttentionFailed: + return "не доставлено" + default: + return "состояние уточняется" + } +} + +func humanDeliveryState(item continuity.AttentionItem) string { + if item.State == continuity.AttentionDelivered { + return "уведомление отправлено" + } + if item.State == continuity.AttentionDelivering { + return "уведомление отправляется" + } + return "уведомление ещё не отправлено" +} + +func humanAttentionSource(kind continuity.AttentionSourceKind) string { + if kind == continuity.AttentionSourceProspectiveReminder { + return "напоминание" + } + return "событие Домового" +} diff --git a/cmd/continuity-runtime/due_worker.go b/cmd/continuity-runtime/due_worker.go new file mode 100644 index 0000000..6bff266 --- /dev/null +++ b/cmd/continuity-runtime/due_worker.go @@ -0,0 +1,183 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "time" + + commitcontract "git.cin.su/m/Assistant/contracts/commit" + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/platform/ids" +) + +type dueReminderRepository interface { + DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) + ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) + PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) + MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error) + MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error) + Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) +} + +type dueNotificationClient struct { + baseURL *url.URL + token string + client *http.Client +} + +type dueNotifier interface { + NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) +} + +type dueNotificationRequest struct { + ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"` + ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"` +} + +type dueNotificationResponse struct { + AuthorizedActionID commitcontract.AuthorizedActionID `json:"AuthorizedActionID"` + ActionIntentID commitcontract.ActionIntentID `json:"ActionIntentID"` + ActionAttemptID commitcontract.ActionAttemptID `json:"ActionAttemptID"` + State commitcontract.ActionState `json:"State"` +} + +func newDueNotificationClient(rawURL, token string, client *http.Client) (dueNotificationClient, error) { + base, err := url.Parse(rawURL) + if err != nil || base.Scheme == "" || base.Host == "" || token == "" { + return dueNotificationClient{}, fmt.Errorf("invalid Commit due notification configuration") + } + if client == nil { + client = http.DefaultClient + } + return dueNotificationClient{baseURL: base, token: token, client: client}, nil +} + +func (c dueNotificationClient) NotifyDue(ctx context.Context, item continuity.ProspectiveItem) (dueNotificationResponse, error) { + target := *c.baseURL + target.Path = "/v1/internal/continuity/notify-due" + payload, err := json.Marshal(dueNotificationRequest{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision}) + if err != nil { + return dueNotificationResponse{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) + if err != nil { + return dueNotificationResponse{}, err + } + request.Header.Set("Authorization", "Bearer "+c.token) + request.Header.Set("Content-Type", "application/json") + response, err := c.client.Do(request) + if err != nil { + return dueNotificationResponse{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted { + return dueNotificationResponse{}, fmt.Errorf("commit due notification status %d", response.StatusCode) + } + var result dueNotificationResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return dueNotificationResponse{}, err + } + if result.ActionIntentID == "" || result.AuthorizedActionID == "" || result.ActionAttemptID == "" { + return dueNotificationResponse{}, fmt.Errorf("invalid commit due notification response") + } + return result, nil +} + +type dueReminderWorker struct { + repository dueReminderRepository + commit dueNotifier + now func() time.Time + newID func() (ids.ID, error) + interval time.Duration + limit int +} + +func (w dueReminderWorker) Start(ctx context.Context) { + if w.interval <= 0 { + w.interval = 15 * time.Second + } + if w.limit <= 0 { + w.limit = 10 + } + go func() { + w.process(ctx) + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.process(ctx) + } + } + }() +} + +func (w dueReminderWorker) process(ctx context.Context) { + now := w.now() + if err := w.claimDue(ctx, now); err != nil { + log.Printf("due reminder claim skipped: %v", err) + } + if err := w.deliverPending(ctx, now); err != nil { + log.Printf("due reminder delivery skipped: %v", err) + } +} + +func (w dueReminderWorker) claimDue(ctx context.Context, now time.Time) error { + items, err := w.repository.DueArmed(ctx, now.UnixMilli(), w.limit) + if err != nil { + return err + } + for _, item := range items { + id, err := w.newID() + if err != nil { + return err + } + if _, _, err = w.repository.ClaimDueReminder(ctx, item.ID, item.Revision, continuity.AttentionItemID(id), now.UnixMilli()); err != nil { + log.Printf("due reminder %s not claimed: %v", item.ID, err) + } + } + return nil +} + +func (w dueReminderWorker) deliverPending(ctx context.Context, now time.Time) error { + items, err := w.repository.PendingAttention(ctx, w.limit) + if err != nil { + return err + } + for _, attention := range items { + if attention.SourceKind != continuity.AttentionSourceProspectiveReminder || attention.SourceProspectiveID == "" { + continue + } + delivering, err := w.repository.MarkAttentionDelivering(ctx, attention.ID, attention.Revision) + if err != nil { + log.Printf("attention %s not marked delivering: %v", attention.ID, err) + continue + } + prospective, err := w.repository.Load(ctx, delivering.SourceProspectiveID) + if err != nil { + log.Printf("attention %s source unavailable: %v", attention.ID, err) + continue + } + result, err := w.commit.NotifyDue(ctx, prospective) + if err != nil { + log.Printf("attention %s notification unavailable: %v", attention.ID, err) + continue + } + _, err = w.repository.MarkAttentionDelivered(ctx, delivering.ID, delivering.Revision, + continuity.CommittedActionIntentID(result.ActionIntentID), + continuity.CommittedAuthorizedActionID(result.AuthorizedActionID), + continuity.CommittedActionAttemptID(result.ActionAttemptID), + string(result.State), now.UnixMilli()) + if err != nil { + log.Printf("attention %s delivery outcome not persisted: %v", attention.ID, err) + } + } + return nil +} diff --git a/cmd/continuity-runtime/due_worker_test.go b/cmd/continuity-runtime/due_worker_test.go new file mode 100644 index 0000000..0d1cec6 --- /dev/null +++ b/cmd/continuity-runtime/due_worker_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/authority" + commitcontract "git.cin.su/m/Assistant/contracts/commit" + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/platform/ids" +) + +type memoryDueRepo struct { + item continuity.ProspectiveItem + attention continuity.AttentionItem +} + +func (r *memoryDueRepo) DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) { + if r.item.State == continuity.ProspectiveArmed { + return []continuity.ProspectiveItem{r.item}, nil + } + return nil, nil +} + +func (r *memoryDueRepo) ClaimDueReminder(_ context.Context, id continuity.ProspectiveItemID, revision continuity.ProspectiveRevision, attentionID continuity.AttentionItemID, now int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) { + if r.item.ID != id || r.item.Revision != revision || r.item.State != continuity.ProspectiveArmed { + return continuity.ProspectiveItem{}, continuity.AttentionItem{}, nil + } + r.item.State = continuity.ProspectiveDelivered + r.item.Revision += 3 + r.attention = continuity.AttentionItem{ID: attentionID, OwnerPrincipalID: r.item.OwnerPrincipalID, ScopeID: r.item.ScopeID, SourceKind: continuity.AttentionSourceProspectiveReminder, SourceProspectiveID: r.item.ID, Level: continuity.AttentionDue, State: continuity.AttentionPending, Summary: r.item.Summary, DueAtUnixMillis: r.item.TriggerAtUnixMillis, CreatedAtUnixMillis: now, Revision: 1} + return r.item, r.attention, nil +} + +func (r *memoryDueRepo) PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) { + if r.attention.State == continuity.AttentionPending { + return []continuity.AttentionItem{r.attention}, nil + } + return nil, nil +} + +func (r *memoryDueRepo) MarkAttentionDelivering(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision) (continuity.AttentionItem, error) { + if r.attention.ID == id && r.attention.Revision == revision { + r.attention.State = continuity.AttentionDelivering + r.attention.Revision++ + } + return r.attention, nil +} + +func (r *memoryDueRepo) MarkAttentionDelivered(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string, deliveredAt int64) (continuity.AttentionItem, error) { + if r.attention.ID == id && r.attention.Revision == revision { + r.attention.State = continuity.AttentionDelivered + r.attention.Revision++ + r.attention.ActionIntentID = intentID + r.attention.AuthorizedActionID = authorizedID + r.attention.ActionAttemptID = attemptID + r.attention.DeliveryOutcome = outcome + r.attention.DeliveredAtUnixMillis = deliveredAt + } + return r.attention, nil +} + +func (r *memoryDueRepo) Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) { + return r.item, nil +} + +type fakeDueNotifier struct{ calls int } + +func (n *fakeDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) { + n.calls++ + return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionSucceeded}, nil +} + +func TestDueReminderProducesAttentionAndNotificationOnce(t *testing.T) { + repo := &memoryDueRepo{item: continuity.ProspectiveItem{ID: "prospective", OwnerPrincipalID: identity.PrincipalID("owner"), ScopeID: authority.ScopeID("scope"), Summary: "проверить уведомление", TriggerAtUnixMillis: 10, State: continuity.ProspectiveArmed, Revision: 1}} + notifier := &fakeDueNotifier{} + now := time.UnixMilli(20) + worker := dueReminderWorker{repository: repo, commit: notifier, now: func() time.Time { return now }, newID: func() (ids.ID, error) { return ids.ID("attention"), nil }, limit: 10} + worker.process(context.Background()) + worker.process(context.Background()) + if repo.item.State != continuity.ProspectiveDelivered || repo.attention.State != continuity.AttentionDelivered { + t.Fatalf("unexpected states: prospective=%s attention=%s", repo.item.State, repo.attention.State) + } + if notifier.calls != 1 { + t.Fatalf("expected one notification call, got %d", notifier.calls) + } + if repo.attention.ActionIntentID == "" || repo.attention.DeliveredAtUnixMillis == 0 { + t.Fatal("delivery evidence was not persisted") + } +} diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index db520fa..62f46de 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -11,6 +11,7 @@ import ( "git.cin.su/m/Assistant/adapters/postgres" "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/platform/ids" "git.cin.su/m/Assistant/runtime/health" ) @@ -34,6 +35,13 @@ func main() { if executorToken == "" { log.Fatal("COMMIT_EXECUTOR_TOKEN is required") } + commitClient, err := newDueNotificationClient(envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), executorToken, &http.Client{Timeout: 10 * time.Second}) + if err != nil { + log.Fatal(err) + } + workerContext, cancelWorker := context.WithCancel(context.Background()) + defer cancelWorker() + dueReminderWorker{repository: repository, commit: commitClient, now: time.Now, newID: ids.New, interval: 5 * time.Second, limit: 10}.Start(workerContext) registerInternalExecutorRoutes(mux, repository, executorToken) mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) { var item continuity.ProspectiveItem @@ -104,6 +112,7 @@ func main() { Items []continuity.ProspectiveItem }{Items: items}) }) + registerCreatorAttentionRoutes(mux, identityClient, repository) log.Printf("continuity-runtime listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) diff --git a/cmd/core-runtime/capability_chat.go b/cmd/core-runtime/capability_chat.go index 8e19776..df00f28 100644 --- a/cmd/core-runtime/capability_chat.go +++ b/cmd/core-runtime/capability_chat.go @@ -145,6 +145,24 @@ func buildReminderRequest(message string, session identity.CreatorSession, inten func parseReminderTime(message string, now time.Time) (time.Time, error) { lower := strings.ToLower(message) + relative := regexp.MustCompile(`через\s+(\d{1,3})\s*(минуту|минуты|минут|час|часа|часов)`) + if match := relative.FindStringSubmatch(lower); len(match) > 0 { + value, err := strconv.Atoi(match[1]) + if err != nil || value < 1 { + return time.Time{}, fmt.Errorf("invalid relative reminder time") + } + unit := match[2] + if strings.HasPrefix(unit, "час") { + if value > 72 { + return time.Time{}, fmt.Errorf("relative reminder time is too far") + } + return now.Add(time.Duration(value) * time.Hour), nil + } + if value > 24*60 { + return time.Time{}, fmt.Errorf("relative reminder time is too far") + } + return now.Add(time.Duration(value) * time.Minute), nil + } day := now if strings.Contains(lower, "послезавтра") { day = now.AddDate(0, 0, 2) @@ -187,6 +205,8 @@ func reminderSummary(message string) string { for _, word := range []string{"послезавтра", "завтра", "сегодня"} { clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, "")) } + relative := regexp.MustCompile(`(?i)через\s+\d{1,3}\s*(минуту|минуты|минут|час|часа|часов)`) + clean = strings.TrimSpace(relative.ReplaceAllString(clean, "")) re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`) clean = strings.TrimSpace(re.ReplaceAllString(clean, "")) clean = strings.Trim(clean, " .,!?:;") diff --git a/cmd/core-runtime/capability_chat_test.go b/cmd/core-runtime/capability_chat_test.go index f64b866..deb0168 100644 --- a/cmd/core-runtime/capability_chat_test.go +++ b/cmd/core-runtime/capability_chat_test.go @@ -29,6 +29,21 @@ func TestBuildReminderRequestParsesTomorrowAtTen(t *testing.T) { } } +func TestBuildReminderRequestParsesRelativeMinutes(t *testing.T) { + now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.FixedZone("MSK", 3*60*60)) + request, err := buildReminderRequest("Напомни мне через 3 минуты проверить уведомление.", identity.CreatorSession{PrincipalID: "principal-1"}, interaction.OriginalHumanIntent{ID: "intent-1"}, interaction.HumanUtterance{ID: "utterance-1"}, now) + if err != nil { + t.Fatal(err) + } + trigger := time.UnixMilli(request.TriggerAtUnixMillis).In(now.Location()) + if !trigger.Equal(now.Add(3 * time.Minute)) { + t.Fatalf("unexpected trigger: %s", trigger) + } + if request.Summary != "проверить уведомление" { + t.Fatalf("unexpected summary: %q", request.Summary) + } +} + func TestContinuityReminderClientDoesNotAcceptFalseSuccess(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/creator/reminders" || r.Header.Get("Authorization") != "Bearer session" { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index bac078c..9125440 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -494,6 +494,10 @@ func creatorRoute(method, path string) (routeTarget, string, bool) { } case method == http.MethodGet && path == "/api/prospective-items": return targetContinuity, "/v1/creator/prospective-items", true + case method == http.MethodGet && path == "/api/attention": + return targetContinuity, "/v1/creator/attention", true + case method == http.MethodGet && path == "/api/today": + return targetContinuity, "/v1/creator/today", true case method == http.MethodGet && path == "/api/actions": return targetCommit, "/v1/creator/authorized-actions", true case method == http.MethodPost && path == "/api/actions/satisfy-delivered": diff --git a/cmd/creator-web/static/assets/chat.css b/cmd/creator-web/static/assets/chat.css index 095c303..4c703c7 100644 --- a/cmd/creator-web/static/assets/chat.css +++ b/cmd/creator-web/static/assets/chat.css @@ -29,12 +29,18 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; .login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; } .login-form button { width: 100%; height: 50px; margin-top: 18px; } #login-error { min-height: 20px; color: var(--bad); font-size: 13px; } -.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr) auto; } +.chat-view { min-height: 100vh; display: grid; grid-template-rows: 64px minmax(0, 1fr); } .chat-header { display: flex; align-items: center; gap: 18px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .96); } .chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); } .chat-header span { color: var(--muted); font: 10px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; } #active-focus { margin-left: auto; color: var(--good); } .chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; } +.human-nav { display: flex; align-items: center; 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; } +.nav-tab.active { color: #11150f; border-color: var(--signal); background: var(--signal); } +.operator-link { color: var(--good); } +.surface-panel { min-height: 0; position: relative; } +.active-panel { display: grid; min-height: 0; } .messages { width: min(100%, 920px); margin: 0 auto; padding: 28px 18px 120px; display: flex; flex-direction: column; gap: 14px; overflow: 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; } @@ -43,8 +49,25 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px; .composer { position: sticky; bottom: 0; width: min(100%, 920px); margin: 0 auto; padding: 16px 18px 20px; 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); } +.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; } +.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); } +.empty-state.error { color: var(--bad); } @media (max-width: 640px) { .composer { grid-template-columns: 1fr; } .composer button { width: 100%; } + .chat-header { gap: 10px; padding: 0 12px; } .chat-header span { display: none; } + .human-nav { overflow-x: auto; } + .operator-link, .nav-tab { padding: 0 9px; } } diff --git a/cmd/creator-web/static/assets/chat.js b/cmd/creator-web/static/assets/chat.js index ba27c6c..b50f58c 100644 --- a/cmd/creator-web/static/assets/chat.js +++ b/cmd/creator-web/static/assets/chat.js @@ -8,9 +8,14 @@ const messageInput = document.querySelector("#message"); const sendButton = document.querySelector("#send"); const logoutButton = document.querySelector("#logout"); const activeFocus = document.querySelector("#active-focus"); +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")); let token = sessionStorage.getItem("domovoy.chat.token") || ""; let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || ""; +let localHistory = []; function showChat() { loginView.hidden = true; @@ -37,6 +42,8 @@ function appendMessage(kind, text, meta = "") { } messages.appendChild(node); messages.scrollTop = messages.scrollHeight; + localHistory.push({kind, text, meta, at: new Date()}); + renderHistory(); } function updateActiveFocus(value, provenance = "") { @@ -52,6 +59,91 @@ async function api(path, options = {}) { return response.json(); } +function humanMeta(result) { + if (result.reminder_scheduled) return "Напоминание сохранено. Я вернусь к нему сам."; + if (result.capability_status && result.capability_status !== "AVAILABLE") return "Возможность пока не подключена."; + if (result.work_planned) return "Я разложил это как работу."; + return ""; +} + +function renderHistory() { + if (!historyList) return; + historyList.replaceChildren(); + const items = localHistory.slice(-30).reverse(); + if (!items.length) { + const empty = document.createElement("p"); + empty.className = "empty-state"; + empty.textContent = "История появится после сообщений в этом окне."; + historyList.appendChild(empty); + return; + } + for (const item of items) { + const row = document.createElement("article"); + row.className = `history-item ${item.kind}`; + const title = document.createElement("strong"); + title.textContent = item.kind === "user" ? "Вы" : "Домовой"; + const body = document.createElement("p"); + body.textContent = item.text; + const time = document.createElement("span"); + time.textContent = item.at.toLocaleTimeString("ru-RU", {hour: "2-digit", minute: "2-digit"}); + row.append(title, body, time); + historyList.appendChild(row); + } +} + +async function loadToday() { + if (!todayList || !token) return; + todayList.replaceChildren(); + const loading = document.createElement("p"); + loading.className = "empty-state"; + loading.textContent = "Смотрю, что требует внимания..."; + todayList.appendChild(loading); + try { + const result = await api("/api/today", {method: "GET"}); + todayList.replaceChildren(); + const items = result.Items || result.items || []; + if (!items.length) { + const empty = document.createElement("p"); + empty.className = "empty-state"; + empty.textContent = "Сейчас ничего срочного не требует внимания."; + todayList.appendChild(empty); + return; + } + for (const item of items) { + const card = document.createElement("article"); + card.className = "attention-card"; + const when = document.createElement("time"); + const stamp = item.WhenUnixMillis || item.when_unix_millis || 0; + when.textContent = stamp ? new Date(stamp).toLocaleString("ru-RU", {day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"}) : "сейчас"; + const title = document.createElement("h2"); + title.textContent = item.Title || item.title || "Событие Домового"; + const state = document.createElement("p"); + state.textContent = item.HumanState || item.human_state || "ожидает внимания"; + const source = document.createElement("small"); + source.textContent = [item.SourceDescription || item.source_description, item.DeliveryState || item.delivery_state].filter(Boolean).join(" · "); + card.append(when, title, state, source); + todayList.appendChild(card); + } + } catch { + todayList.replaceChildren(); + const error = document.createElement("p"); + error.className = "empty-state error"; + error.textContent = "Внимание сейчас недоступно. Чат можно продолжать."; + todayList.appendChild(error); + } +} + +function activateTab(panelID) { + for (const tab of navTabs) tab.classList.toggle("active", tab.dataset.tab === panelID); + for (const panel of document.querySelectorAll(".surface-panel")) { + const active = panel.id === panelID; + panel.hidden = !active; + panel.classList.toggle("active-panel", active); + } + if (panelID === "today-panel") loadToday(); + if (panelID === "history-panel") renderHistory(); +} + loginForm.addEventListener("submit", async (event) => { event.preventDefault(); loginError.textContent = ""; @@ -79,12 +171,11 @@ chatForm.addEventListener("submit", async (event) => { conversationID = result.conversation_id; sessionStorage.setItem("domovoy.chat.conversation", conversationID); updateActiveFocus(result.active_focus, result.project_provenance); - const semanticMeta = [result.speech_act, `cognition=${result.cognition}`]; - if (result.work_planned) semanticMeta.push("work=planned"); - if (result.capability_status) semanticMeta.push(`capability=${result.capability_kind || "unknown"}:${result.capability_status}`); - if (result.reminder_scheduled) semanticMeta.push("reminder=scheduled"); - if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`); - appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; ")); + appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result)); + if (/что сейчас требует моего внимания/i.test(text)) { + await loadToday(); + activateTab("today-panel"); + } } catch { appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime."); } finally { @@ -93,6 +184,12 @@ chatForm.addEventListener("submit", async (event) => { } }); +for (const tab of navTabs) { + tab.addEventListener("click", () => activateTab(tab.dataset.tab)); +} + +if (refreshToday) refreshToday.addEventListener("click", loadToday); + logoutButton.addEventListener("click", () => { token = ""; conversationID = ""; diff --git a/cmd/creator-web/static/chat.html b/cmd/creator-web/static/chat.html index a502d29..605fe98 100644 --- a/cmd/creator-web/static/chat.html +++ b/cmd/creator-web/static/chat.html @@ -25,16 +25,38 @@ diff --git a/contracts/continuity/types.go b/contracts/continuity/types.go index ca32991..bae1d9f 100644 --- a/contracts/continuity/types.go +++ b/contracts/continuity/types.go @@ -9,9 +9,12 @@ import ( type CommitmentID ids.ID type ProspectiveItemID ids.ID +type AttentionItemID ids.ID type ProspectiveRevision uint64 +type AttentionRevision uint64 type CommittedActionIntentID ids.ID type CommittedAuthorizedActionID ids.ID +type CommittedActionAttemptID ids.ID type ProspectiveState string @@ -26,6 +29,34 @@ const ( ProspectiveExpired ProspectiveState = "EXPIRED" ) +type AttentionLevel string + +const ( + AttentionUrgent AttentionLevel = "URGENT" + AttentionActionRequired AttentionLevel = "ACTION_REQUIRED" + AttentionDue AttentionLevel = "DUE" + AttentionImportantChange AttentionLevel = "IMPORTANT_CHANGE" + AttentionFYI AttentionLevel = "FYI" + AttentionBackground AttentionLevel = "BACKGROUND" +) + +type AttentionState string + +const ( + AttentionPending AttentionState = "PENDING" + AttentionDelivering AttentionState = "DELIVERING" + AttentionDelivered AttentionState = "DELIVERED" + AttentionSuppressed AttentionState = "SUPPRESSED" + AttentionExpired AttentionState = "EXPIRED" + AttentionFailed AttentionState = "FAILED" +) + +type AttentionSourceKind string + +const ( + AttentionSourceProspectiveReminder AttentionSourceKind = "PROSPECTIVE_REMINDER" +) + // ProspectiveItem is canonical future relevance. TriggerAtUnixMillis is an // absolute instant; scheduling engines are derived delivery mechanisms only. type ProspectiveItem struct { @@ -56,6 +87,46 @@ type ScheduleReminderResult struct { Item ProspectiveItem } +// AttentionItem is Continuity-owned human-facing relevance state. It can be +// rendered by UIs, but it is not a notification receipt and does not itself +// grant authority for external mutation. +type AttentionItem struct { + ID AttentionItemID + OwnerPrincipalID identity.PrincipalID + ScopeID authority.ScopeID + SourceKind AttentionSourceKind + SourceProspectiveID ProspectiveItemID + Level AttentionLevel + State AttentionState + Summary string + DueAtUnixMillis int64 + CreatedAtUnixMillis int64 + DeliveredAtUnixMillis int64 + ActionIntentID CommittedActionIntentID + AuthorizedActionID CommittedAuthorizedActionID + ActionAttemptID CommittedActionAttemptID + DeliveryOutcome string + Revision AttentionRevision +} + +type AttentionListResult struct { + Items []AttentionItem +} + +type TodayItem struct { + Title string + WhenUnixMillis int64 + AttentionLevel AttentionLevel + AttentionState AttentionState + HumanState string + DeliveryState string + SourceDescription string +} + +type TodayResult struct { + Items []TodayItem +} + // SatisfyDeliveredSubject is the complete serializable operation subject. It // is distinct from a Work TaskTarget, which remains the live justification. type SatisfyDeliveredSubject struct { diff --git a/database/bootstrap/001_roles_and_schemas.sql b/database/bootstrap/001_roles_and_schemas.sql index 9a42984..82fb43d 100644 --- a/database/bootstrap/001_roles_and_schemas.sql +++ b/database/bootstrap/001_roles_and_schemas.sql @@ -564,6 +564,34 @@ CREATE TABLE IF NOT EXISTS continuity.action_receipts ( recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CHECK (expected_revision > 0), CHECK (result_revision > expected_revision) ); +CREATE TABLE IF NOT EXISTS continuity.attention_items ( + attention_item_id TEXT PRIMARY KEY, + owner_principal_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_prospective_item_id TEXT NOT NULL, + level TEXT NOT NULL, + state TEXT NOT NULL, + summary TEXT NOT NULL, + due_at_unix_millis BIGINT NOT NULL, + created_at_unix_millis BIGINT NOT NULL, + delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0, + action_intent_id TEXT NOT NULL DEFAULT '', + authorized_action_id TEXT NOT NULL DEFAULT '', + action_attempt_id TEXT NOT NULL DEFAULT '', + delivery_outcome TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_kind, source_prospective_item_id), + CHECK (source_kind IN ('PROSPECTIVE_REMINDER')), + CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')), + CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')), + CHECK (summary <> ''), + CHECK (due_at_unix_millis > 0), + CHECK (created_at_unix_millis > 0), + CHECK (revision > 0), + CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> '')) +); RESET ROLE; SET ROLE interaction_role; diff --git a/database/migrations/020_continuity_attention_items.sql b/database/migrations/020_continuity_attention_items.sql new file mode 100644 index 0000000..91ee8f6 --- /dev/null +++ b/database/migrations/020_continuity_attention_items.sql @@ -0,0 +1,35 @@ +BEGIN; + +SET ROLE continuity_role; + +CREATE TABLE IF NOT EXISTS continuity.attention_items ( + attention_item_id TEXT PRIMARY KEY, + owner_principal_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_prospective_item_id TEXT NOT NULL, + level TEXT NOT NULL, + state TEXT NOT NULL, + summary TEXT NOT NULL, + due_at_unix_millis BIGINT NOT NULL, + created_at_unix_millis BIGINT NOT NULL, + delivered_at_unix_millis BIGINT NOT NULL DEFAULT 0, + action_intent_id TEXT NOT NULL DEFAULT '', + authorized_action_id TEXT NOT NULL DEFAULT '', + action_attempt_id TEXT NOT NULL DEFAULT '', + delivery_outcome TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (source_kind, source_prospective_item_id), + CHECK (source_kind IN ('PROSPECTIVE_REMINDER')), + CHECK (level IN ('URGENT','ACTION_REQUIRED','DUE','IMPORTANT_CHANGE','FYI','BACKGROUND')), + CHECK (state IN ('PENDING','DELIVERING','DELIVERED','SUPPRESSED','EXPIRED','FAILED')), + CHECK (summary <> ''), + CHECK (due_at_unix_millis > 0), + CHECK (created_at_unix_millis > 0), + CHECK (revision > 0), + CHECK (state <> 'DELIVERED' OR (delivered_at_unix_millis > 0 AND action_intent_id <> '' AND authorized_action_id <> '' AND action_attempt_id <> '' AND delivery_outcome <> '')) +); + +RESET ROLE; +COMMIT; diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 217204a..cd6c2d7 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json", - "task_id": "DEV-000064", + "task_file": "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json", + "task_id": "DEV-000065", "task_revision": 1 } diff --git a/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json b/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json new file mode 100644 index 0000000..b45a968 --- /dev/null +++ b/dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json @@ -0,0 +1,152 @@ +{ + "task_id": "DEV-000065", + "task_revision": 1, + "architecture_revision": "ADR-0014", + "base_repo_revision": "6e73318e49991b40a5ed750c604792cbd95bd325", + "risk_tier": "E", + "intent": "Add assistant presence through Continuity-owned due reminder processing, typed Attention foundation, governed outbound notification delivery, and a human-facing Assistant surface with Chat, Today/Attention, and History while preserving the Operator Console.", + "requirement_ids": [ + "ADR-0014", + "ADR-0005", + "INV-AUTH-001", + "INV-OWN-001", + "INV-CONT-001", + "INV-EPI-001", + "Capability availability is not Authority", + "Durable future obligations survive runtime restart" + ], + "allowed_change_scope": { + "modules": [ + "continuity", + "commit", + "capability", + "interaction" + ], + "paths": [ + "architecture/adrs/ADR-0014-assistant-presence-attention-human-surface.md", + "architecture/architecture-registry.yaml", + "architecture/architecture-registry.json", + "contracts/continuity/**", + "contracts/commit/**", + "contracts/capability/**", + "modules/continuity/**", + "modules/commit/**", + "adapters/postgres/**", + "database/bootstrap/**", + "database/migrations/**", + "cmd/continuity-runtime/**", + "cmd/commit-runtime/**", + "cmd/core-runtime/**", + "cmd/creator-web/**", + "tests/owner_acl/**", + "tests/cognitive_advisory/**", + "docs/PROJECT_STATUS.md", + "dev/ACTIVE_TASK.json", + "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json" + ] + }, + "forbidden_change_scope": { + "paths": [ + "architecture/ARCHITECTURE_CONSTITUTION.md", + "security/**", + "ci/**", + ".gitea/**" + ] + }, + "allowed_architecture_delta": { + "new_cross_module_dependencies": 1, + "new_canonical_owners": 0, + "new_privileges": 0, + "new_third_party_dependencies": 0, + "new_provider_adapters": 0 + }, + "contracts": { + "consume": [ + "Continuity ProspectiveItem", + "Commit prospective notification operation", + "Capability notification binding", + "Interaction ChatMessage", + "Authority Creator grant", + "CredentialRef Secret Resolver foundation" + ], + "may_change": [ + "contracts/continuity@v1", + "contracts/commit@v1" + ] + }, + "semantic_owner": "continuity", + "canonical_state": [ + "ProspectiveItem", + "AttentionItem" + ], + "state_transitions": [ + "ProspectiveItem ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED for due reminder processing", + "AttentionItem PENDING -> DELIVERING -> DELIVERED for human contact outcome", + "No ACTIVE cognitive binding promotion", + "No OpenAI activation" + ], + "side_effects_and_privileges": [ + "Continuity runtime polls canonical prospective state and does not own external mutation", + "Commit runtime remains the only outbound notification executor boundary", + "Notification delivery uses existing CapabilityBinding and idempotent receiver protocol", + "Human Assistant UI reads canonical runtime APIs and hides internal identifiers by default", + "Docker-test remains prohibited" + ], + "persistence_and_migrations": [ + "continuity owner-local attention_items table", + "no cross-owner FK", + "no cross-owner SQL join dependency", + "Commit authorized action and attempt persistence remains Commit-owned", + "Notification receipt persistence remains receiver-owned delivery evidence" + ], + "failure_retry_restart": [ + "Due detection reads ARMED rows from canonical PostgreSQL state", + "CAS/unique source constraints prevent duplicate AttentionItem creation", + "Successful delivery moves AttentionItem to DELIVERED and prevents repeated delivery", + "Unavailable LLM does not block deterministic reminder firing", + "Unavailable notification delivery records non-success without inventing completion" + ], + "assumptions": [ + "DEV-000064 is production baseline", + "Continuity is canonical owner of future obligations and attention state for this generation", + "Commit and existing notification receiver remain the outbound mutation path", + "The first human notification channel is the existing production receiver/Windows display path", + "CPU CANARY may remain available but reminder firing is deterministic and LLM-free" + ], + "acceptance_scenarios": [ + "Reminder becomes due", + "Due reminder produces governed AttentionItem", + "Attention can produce notification through Commit", + "Delivery outcome is persisted", + "No duplicate delivery after success", + "Runtime restart does not lose an ARMED reminder", + "Cancelled/suppressed reminder is not delivered", + "LLM unavailable does not prevent deterministic reminder delivery", + "No cognitive model is required merely to fire a known reminder", + "CapabilityGap is rendered human-readable", + "Human UI requires no internal IDs", + "Operator Console remains available", + "Authority boundaries remain unchanged", + "Owner ACL passes", + "LLM-free audit passes", + "Production live delayed reminder delivers once", + "Today/Attention surface is built from canonical state" + ], + "required_tests": [ + "go test ./...", + "go vet ./...", + "go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json", + "go run ./cmd/devctl check-architecture", + "go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json", + "tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1", + "tests/owner_acl/verify.ps1 -DockerContext docker-prod -PostgresContainer assistant-postgres-1", + "production live delayed reminder acceptance", + "production /chat and /healthz verification" + ], + "terminal_gap_states": [ + "BLOCKED_BY_ARCHITECTURE_GAP", + "BLOCKED_BY_SPEC_GAP", + "BLOCKED_BY_MISSING_CAPABILITY", + "BLOCKED_BY_CONFLICT" + ] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 3f3de21..2d6745b 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,22 @@ # Assistant project status -Audit date: 2026-08-17. Active implementation: DEV-000064 revision 1. +Audit date: 2026-08-17. Active implementation: DEV-000065 revision 1. + +## DEV-000065 checkpoint + +- `DEV_000065_STATUS = IN_PROGRESS` +- `LAST_COMPLETED = ADR-0014 accepted; Continuity-owned AttentionItem contract and persistence; due reminder scanner; Commit internal prospective notification endpoint; human-facing Chat / Today / History surface; relative reminder parsing for "через N минут"; deterministic due-delivery regression tests` +- `ADR_STATUS = ADR-0014 accepted` +- `OWNERSHIP_STATUS = no new owner; AttentionItem belongs to Continuity; notification delivery remains Commit/Capability/receiver path` +- `REMINDER_LIFECYCLE_STATUS = ARMED -> TRIGGERED -> REVALIDATING -> DELIVERED implemented for due scanner` +- `REMINDER_DELIVERY_STATUS = implemented through Commit deliver-prospective-notification-v1 path; production live verification pending` +- `ATTENTION_STATUS = typed AttentionItem PENDING -> DELIVERING -> DELIVERED foundation implemented` +- `HUMAN_SURFACE_STATUS = /chat remains primary; Today/Attention and History added; Operator Console preserved at /` +- `AUTHORITY_STATUS = external notification still requires Commit authorization, current Creator authority context, and active Capability notification binding` +- `LLM_FREE_STATUS = deterministic reminder firing does not require cognitive model; LLM-free env policy regression PASS` +- `TESTS_PASS_SO_FAR = go test ./...; go vet ./...; go run ./cmd/devctl preflight -active dev/ACTIVE_TASK.json; go run ./cmd/devctl check-architecture; go run ./cmd/devctl check-diff -active dev/ACTIVE_TASK.json; tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1` +- `PRODUCTION_STATUS = pending deploy/live acceptance` +- `NEXT_STEP = apply migration, deploy affected services, run owner ACL and live delayed reminder acceptance, then promote if PASS` ## DEV-000064 checkpoint diff --git a/modules/commit/domain/commit.go b/modules/commit/domain/commit.go index 694852d..1ed07fb 100644 --- a/modules/commit/domain/commit.go +++ b/modules/commit/domain/commit.go @@ -44,10 +44,17 @@ func Reconcile(action commitcontract.AuthorizedAction, previous commitcontract.A // Authorize is the Commit boundary. A proposal alone never reaches Executor. func Authorize(proposal commitcontract.ActionProposal, context AuthorizationContext, validator ports.AuthorityValidator, authorizedID commitcontract.AuthorizedActionID, authorizedAtUnixMillis int64) (commitcontract.AuthorizedAction, error) { if proposal.IntentID == "" || proposal.ActorID == "" || proposal.CapabilityID == capability.CapabilityID("") || - proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Operation == "" || authorizedAtUnixMillis <= 0 { + proposal.Operation == "" || authorizedAtUnixMillis <= 0 { return commitcontract.AuthorizedAction{}, ErrInvalidProposal } - if proposal.ActorID != context.Binding.ActorID || proposal.Target.ScopeID != context.Scope.ID { + if proposal.ActorID != context.Binding.ActorID { + return commitcontract.AuthorizedAction{}, ErrInvalidProposal + } + if proposal.Operation == commitcontract.OperationDeliverProspectiveNotification { + if proposal.Target.ScopeID != "" && proposal.Target.ScopeID != context.Scope.ID { + return commitcontract.AuthorizedAction{}, ErrInvalidProposal + } + } else if proposal.Target.TaskID == "" || proposal.Target.GoalID == "" || proposal.Target.ScopeID == "" || proposal.Target.Revision == 0 || proposal.Target.ScopeID != context.Scope.ID { return commitcontract.AuthorizedAction{}, ErrInvalidProposal } if !allowedOperation(proposal, context) { diff --git a/modules/continuity/domain/prospective.go b/modules/continuity/domain/prospective.go index 88fb848..d9cb360 100644 --- a/modules/continuity/domain/prospective.go +++ b/modules/continuity/domain/prospective.go @@ -13,6 +13,7 @@ var ( ErrInvalidTransition = errors.New("invalid prospective item transition") ErrInvalidSatisfaction = errors.New("invalid delivered satisfaction command") ErrInvalidReminder = errors.New("invalid reminder request") + ErrInvalidAttention = errors.New("invalid attention item") ) type Store struct { @@ -50,6 +51,29 @@ func BuildReminder(request contract.ScheduleReminderRequest, id contract.Prospec }, nil } +func BuildDueReminderAttention(item contract.ProspectiveItem, id contract.AttentionItemID, nowUnixMillis int64) (contract.AttentionItem, error) { + if id == "" || item.ID == "" || item.OwnerPrincipalID == "" || item.ScopeID == "" || + strings.TrimSpace(item.Summary) == "" || item.TriggerAtUnixMillis < 1 || nowUnixMillis < 1 { + return contract.AttentionItem{}, ErrInvalidAttention + } + if item.State != contract.ProspectiveDelivered && item.State != contract.ProspectiveRevalidating { + return contract.AttentionItem{}, ErrInvalidAttention + } + return contract.AttentionItem{ + ID: id, + OwnerPrincipalID: item.OwnerPrincipalID, + ScopeID: item.ScopeID, + SourceKind: contract.AttentionSourceProspectiveReminder, + SourceProspectiveID: item.ID, + Level: contract.AttentionDue, + State: contract.AttentionPending, + Summary: item.Summary, + DueAtUnixMillis: item.TriggerAtUnixMillis, + CreatedAtUnixMillis: nowUnixMillis, + Revision: 1, + }, nil +} + func (s *Store) SatisfyDelivered(id contract.ProspectiveItemID, revision contract.ProspectiveRevision, evidenceRef string) (contract.ProspectiveItem, error) { item, err := s.current(id, revision) if err != nil { diff --git a/modules/continuity/ports/repository.go b/modules/continuity/ports/repository.go index e465763..07a6738 100644 --- a/modules/continuity/ports/repository.go +++ b/modules/continuity/ports/repository.go @@ -4,9 +4,16 @@ import ( "context" "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/contracts/identity" ) type Repository interface { Save(context.Context, continuity.ProspectiveItem) error Load(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) + DueArmed(context.Context, int64, int) ([]continuity.ProspectiveItem, error) + ClaimDueReminder(context.Context, continuity.ProspectiveItemID, continuity.ProspectiveRevision, continuity.AttentionItemID, int64) (continuity.ProspectiveItem, continuity.AttentionItem, error) + PendingAttention(context.Context, int) ([]continuity.AttentionItem, error) + MarkAttentionDelivering(context.Context, continuity.AttentionItemID, continuity.AttentionRevision) (continuity.AttentionItem, error) + MarkAttentionDelivered(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string, int64) (continuity.AttentionItem, error) + AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error) } -- 2.54.0