Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18fbc7f105 | ||
|
|
a0dce634f3 | ||
|
|
5d4e064a94 | ||
|
|
6e73318e49 |
@@ -19,6 +19,9 @@ var (
|
|||||||
ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict")
|
ErrProspectiveRevisionConflict = errors.New("prospective item revision conflict")
|
||||||
ErrActionReceiptNotFound = errors.New("continuity action receipt not found")
|
ErrActionReceiptNotFound = errors.New("continuity action receipt not found")
|
||||||
ErrActionIntentConflict = errors.New("action intent was already used for a different command")
|
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 }
|
type ContinuityConfig struct{ Host, Port, Database, User, Password string }
|
||||||
@@ -66,6 +69,184 @@ func (r *ContinuityRepository) ByOwner(ctx context.Context, owner identity.Princ
|
|||||||
return items, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ContinuityRepository) MarkAttentionFailed(ctx context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||||
|
var item continuity.AttentionItem
|
||||||
|
err := r.pool.QueryRow(ctx, `UPDATE continuity.attention_items
|
||||||
|
SET state=$1, revision=revision+1, action_intent_id=$2, authorized_action_id=$3,
|
||||||
|
action_attempt_id=$4, delivery_outcome=$5
|
||||||
|
WHERE attention_item_id=$6 AND state=$7 AND revision=$8
|
||||||
|
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.AttentionFailed, 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
|
// SatisfyDelivered owns both the Prospective CAS and its idempotency receipt in
|
||||||
// one Continuity-local transaction. It never reaches Commit storage.
|
// one Continuity-local transaction. It never reaches Commit storage.
|
||||||
func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) {
|
func (r *ContinuityRepository) SatisfyDelivered(ctx context.Context, command continuity.SatisfyDeliveredCommand) (continuity.ActionReceipt, error) {
|
||||||
@@ -135,6 +316,30 @@ type continuityRowQuerier interface {
|
|||||||
QueryRow(context.Context, string, ...any) pgx.Row
|
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) {
|
func loadActionReceipt(ctx context.Context, querier continuityRowQuerier, intentID continuity.CommittedActionIntentID) (continuity.ActionReceipt, error) {
|
||||||
var receipt continuity.ActionReceipt
|
var receipt continuity.ActionReceipt
|
||||||
err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id,
|
err := querier.QueryRow(ctx, `SELECT action_intent_id,authorized_action_id,owner_principal_id,prospective_item_id,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -98,7 +98,8 @@
|
|||||||
"id": "mod.continuity",
|
"id": "mod.continuity",
|
||||||
"owns": [
|
"owns": [
|
||||||
"Commitment",
|
"Commitment",
|
||||||
"ProspectiveItem"
|
"ProspectiveItem",
|
||||||
|
"AttentionItem"
|
||||||
],
|
],
|
||||||
"database_schema": "continuity",
|
"database_schema": "continuity",
|
||||||
"boundary_class": "remote_capable",
|
"boundary_class": "remote_capable",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ modules:
|
|||||||
|
|
||||||
continuity:
|
continuity:
|
||||||
id: mod.continuity
|
id: mod.continuity
|
||||||
owns: [Commitment, ProspectiveItem]
|
owns: [Commitment, ProspectiveItem, AttentionItem]
|
||||||
database_schema: continuity
|
database_schema: continuity
|
||||||
boundary_class: remote_capable
|
boundary_class: remote_capable
|
||||||
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
may_depend_on: [contracts.work, contracts.identity, contracts.authority, platform.events]
|
||||||
|
|||||||
@@ -183,3 +183,29 @@ func (c creatorContinuityClient) Item(ctx context.Context, authorization string,
|
|||||||
return continuity.ProspectiveItem{}, errCreatorQueryUnavailable
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -89,6 +89,10 @@ func main() {
|
|||||||
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository,
|
||||||
executor: dispatcher, reconciler: dispatcher, newID: ids.New, now: time.Now,
|
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) {
|
mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var request authorizationRequest
|
var request authorizationRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
|
|||||||
@@ -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 "событие Домового"
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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)
|
||||||
|
MarkAttentionFailed(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string) (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
|
||||||
|
}
|
||||||
|
if result.State != commitcontract.ActionSucceeded {
|
||||||
|
_, err = w.repository.MarkAttentionFailed(ctx, delivering.ID, delivering.Revision,
|
||||||
|
continuity.CommittedActionIntentID(result.ActionIntentID),
|
||||||
|
continuity.CommittedAuthorizedActionID(result.AuthorizedActionID),
|
||||||
|
continuity.CommittedActionAttemptID(result.ActionAttemptID),
|
||||||
|
string(result.State))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("attention %s delivery failure outcome not persisted: %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
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
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) MarkAttentionFailed(_ context.Context, id continuity.AttentionItemID, revision continuity.AttentionRevision, intentID continuity.CommittedActionIntentID, authorizedID continuity.CommittedAuthorizedActionID, attemptID continuity.CommittedActionAttemptID, outcome string) (continuity.AttentionItem, error) {
|
||||||
|
if r.attention.ID == id && r.attention.Revision == revision {
|
||||||
|
r.attention.State = continuity.AttentionFailed
|
||||||
|
r.attention.Revision++
|
||||||
|
r.attention.ActionIntentID = intentID
|
||||||
|
r.attention.AuthorizedActionID = authorizedID
|
||||||
|
r.attention.ActionAttemptID = attemptID
|
||||||
|
r.attention.DeliveryOutcome = outcome
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type unknownDueNotifier struct{ calls int }
|
||||||
|
|
||||||
|
func (n *unknownDueNotifier) NotifyDue(context.Context, continuity.ProspectiveItem) (dueNotificationResponse, error) {
|
||||||
|
n.calls++
|
||||||
|
return dueNotificationResponse{ActionIntentID: "intent", AuthorizedActionID: "authorized", ActionAttemptID: "attempt", State: commitcontract.ActionUnknownOutcome}, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDueReminderUnknownOutcomeDoesNotMarkAttentionDelivered(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 := &unknownDueNotifier{}
|
||||||
|
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())
|
||||||
|
if repo.attention.State != continuity.AttentionFailed {
|
||||||
|
t.Fatalf("unknown outcome must not be delivered, got %s", repo.attention.State)
|
||||||
|
}
|
||||||
|
if repo.attention.DeliveryOutcome != string(commitcontract.ActionUnknownOutcome) {
|
||||||
|
t.Fatalf("missing unknown outcome evidence: %#v", repo.attention)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"git.cin.su/m/Assistant/adapters/postgres"
|
"git.cin.su/m/Assistant/adapters/postgres"
|
||||||
"git.cin.su/m/Assistant/contracts/continuity"
|
"git.cin.su/m/Assistant/contracts/continuity"
|
||||||
|
"git.cin.su/m/Assistant/platform/ids"
|
||||||
"git.cin.su/m/Assistant/runtime/health"
|
"git.cin.su/m/Assistant/runtime/health"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,13 @@ func main() {
|
|||||||
if executorToken == "" {
|
if executorToken == "" {
|
||||||
log.Fatal("COMMIT_EXECUTOR_TOKEN is required")
|
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)
|
registerInternalExecutorRoutes(mux, repository, executorToken)
|
||||||
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var item continuity.ProspectiveItem
|
var item continuity.ProspectiveItem
|
||||||
@@ -104,6 +112,7 @@ func main() {
|
|||||||
Items []continuity.ProspectiveItem
|
Items []continuity.ProspectiveItem
|
||||||
}{Items: items})
|
}{Items: items})
|
||||||
})
|
})
|
||||||
|
registerCreatorAttentionRoutes(mux, identityClient, repository)
|
||||||
log.Printf("continuity-runtime listening on %s", addr)
|
log.Printf("continuity-runtime listening on %s", addr)
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|||||||
@@ -145,6 +145,24 @@ func buildReminderRequest(message string, session identity.CreatorSession, inten
|
|||||||
|
|
||||||
func parseReminderTime(message string, now time.Time) (time.Time, error) {
|
func parseReminderTime(message string, now time.Time) (time.Time, error) {
|
||||||
lower := strings.ToLower(message)
|
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
|
day := now
|
||||||
if strings.Contains(lower, "послезавтра") {
|
if strings.Contains(lower, "послезавтра") {
|
||||||
day = now.AddDate(0, 0, 2)
|
day = now.AddDate(0, 0, 2)
|
||||||
@@ -187,6 +205,8 @@ func reminderSummary(message string) string {
|
|||||||
for _, word := range []string{"послезавтра", "завтра", "сегодня"} {
|
for _, word := range []string{"послезавтра", "завтра", "сегодня"} {
|
||||||
clean = strings.TrimSpace(strings.ReplaceAll(strings.ToLower(clean), word, ""))
|
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)?`)
|
re := regexp.MustCompile(`(?i)(в|на)\s+[0-2]?\d(?::[0-5]\d)?`)
|
||||||
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
|
clean = strings.TrimSpace(re.ReplaceAllString(clean, ""))
|
||||||
clean = strings.Trim(clean, " .,!?:;")
|
clean = strings.Trim(clean, " .,!?:;")
|
||||||
|
|||||||
@@ -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) {
|
func TestContinuityReminderClientDoesNotAcceptFalseSuccess(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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" {
|
if r.URL.Path != "/v1/creator/reminders" || r.Header.Get("Authorization") != "Bearer session" {
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ func creatorRoute(method, path string) (routeTarget, string, bool) {
|
|||||||
}
|
}
|
||||||
case method == http.MethodGet && path == "/api/prospective-items":
|
case method == http.MethodGet && path == "/api/prospective-items":
|
||||||
return targetContinuity, "/v1/creator/prospective-items", true
|
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":
|
case method == http.MethodGet && path == "/api/actions":
|
||||||
return targetCommit, "/v1/creator/authorized-actions", true
|
return targetCommit, "/v1/creator/authorized-actions", true
|
||||||
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
|
case method == http.MethodPost && path == "/api/actions/satisfy-delivered":
|
||||||
|
|||||||
@@ -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, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
#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 { 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 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; }
|
.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); }
|
#active-focus { margin-left: auto; color: var(--good); }
|
||||||
.chat-header button { width: 38px; height: 38px; border: 1px solid var(--line); background: transparent; }
|
.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; }
|
.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 { 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.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 { 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 { height: 48px; align-self: end; }
|
||||||
.composer button:disabled { cursor: wait; filter: saturate(.3); }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.composer { grid-template-columns: 1fr; }
|
.composer { grid-template-columns: 1fr; }
|
||||||
.composer button { width: 100%; }
|
.composer button { width: 100%; }
|
||||||
|
.chat-header { gap: 10px; padding: 0 12px; }
|
||||||
.chat-header span { display: none; }
|
.chat-header span { display: none; }
|
||||||
|
.human-nav { overflow-x: auto; }
|
||||||
|
.operator-link, .nav-tab { padding: 0 9px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ const messageInput = document.querySelector("#message");
|
|||||||
const sendButton = document.querySelector("#send");
|
const sendButton = document.querySelector("#send");
|
||||||
const logoutButton = document.querySelector("#logout");
|
const logoutButton = document.querySelector("#logout");
|
||||||
const activeFocus = document.querySelector("#active-focus");
|
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 token = sessionStorage.getItem("domovoy.chat.token") || "";
|
||||||
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
let conversationID = sessionStorage.getItem("domovoy.chat.conversation") || "";
|
||||||
|
let localHistory = [];
|
||||||
|
|
||||||
function showChat() {
|
function showChat() {
|
||||||
loginView.hidden = true;
|
loginView.hidden = true;
|
||||||
@@ -37,6 +42,8 @@ function appendMessage(kind, text, meta = "") {
|
|||||||
}
|
}
|
||||||
messages.appendChild(node);
|
messages.appendChild(node);
|
||||||
messages.scrollTop = messages.scrollHeight;
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
localHistory.push({kind, text, meta, at: new Date()});
|
||||||
|
renderHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateActiveFocus(value, provenance = "") {
|
function updateActiveFocus(value, provenance = "") {
|
||||||
@@ -52,6 +59,91 @@ async function api(path, options = {}) {
|
|||||||
return response.json();
|
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) => {
|
loginForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
loginError.textContent = "";
|
loginError.textContent = "";
|
||||||
@@ -79,12 +171,11 @@ chatForm.addEventListener("submit", async (event) => {
|
|||||||
conversationID = result.conversation_id;
|
conversationID = result.conversation_id;
|
||||||
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
sessionStorage.setItem("domovoy.chat.conversation", conversationID);
|
||||||
updateActiveFocus(result.active_focus, result.project_provenance);
|
updateActiveFocus(result.active_focus, result.project_provenance);
|
||||||
const semanticMeta = [result.speech_act, `cognition=${result.cognition}`];
|
appendMessage("assistant", result.reply_text || "Сообщение записано.", humanMeta(result));
|
||||||
if (result.work_planned) semanticMeta.push("work=planned");
|
if (/что сейчас требует моего внимания/i.test(text)) {
|
||||||
if (result.capability_status) semanticMeta.push(`capability=${result.capability_kind || "unknown"}:${result.capability_status}`);
|
await loadToday();
|
||||||
if (result.reminder_scheduled) semanticMeta.push("reminder=scheduled");
|
activateTab("today-panel");
|
||||||
if (result.clarification) semanticMeta.push(`clarification=${result.clarification}`);
|
}
|
||||||
appendMessage("assistant", result.reply_text || "Сообщение записано.", semanticMeta.join("; "));
|
|
||||||
} catch {
|
} catch {
|
||||||
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
appendMessage("assistant", "Чат сейчас недоступен. Сообщение не принято runtime.");
|
||||||
} finally {
|
} 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", () => {
|
logoutButton.addEventListener("click", () => {
|
||||||
token = "";
|
token = "";
|
||||||
conversationID = "";
|
conversationID = "";
|
||||||
|
|||||||
@@ -25,16 +25,38 @@
|
|||||||
|
|
||||||
<section id="chat-view" class="chat-view" hidden>
|
<section id="chat-view" class="chat-view" hidden>
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<a href="/" aria-label="Консоль создателя">Домовой</a>
|
<a href="/chat" aria-label="Домовой">Домовой</a>
|
||||||
|
<nav class="human-nav" aria-label="Разделы Домового">
|
||||||
|
<button class="nav-tab active" data-tab="chat-panel" type="button">Чат</button>
|
||||||
|
<button class="nav-tab" data-tab="today-panel" type="button">Сегодня</button>
|
||||||
|
<button class="nav-tab" data-tab="history-panel" type="button">История</button>
|
||||||
|
<a href="/" class="operator-link">Оператор</a>
|
||||||
|
</nav>
|
||||||
<span id="active-focus">фокус: не выбран</span>
|
<span id="active-focus">фокус: не выбран</span>
|
||||||
<span id="session-state">сессия активна</span>
|
<span id="session-state">сессия активна</span>
|
||||||
<button id="logout" type="button" aria-label="Выйти">↗</button>
|
<button id="logout" type="button" aria-label="Выйти">↗</button>
|
||||||
</header>
|
</header>
|
||||||
<div id="messages" class="messages" aria-live="polite"></div>
|
<section id="chat-panel" class="surface-panel active-panel">
|
||||||
<form id="chat-form" class="composer">
|
<div id="messages" class="messages" aria-live="polite"></div>
|
||||||
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
<form id="chat-form" class="composer">
|
||||||
<button id="send" type="submit">Отправить</button>
|
<textarea id="message" rows="3" maxlength="16384" placeholder="Напишите Домовому обычное сообщение..." required></textarea>
|
||||||
</form>
|
<button id="send" type="submit">Отправить</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section id="today-panel" class="surface-panel attention-surface" hidden>
|
||||||
|
<div class="surface-heading">
|
||||||
|
<p>Сегодня / внимание</p>
|
||||||
|
<button id="refresh-today" type="button">Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div id="today-list" class="attention-list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="history-panel" class="surface-panel history-surface" hidden>
|
||||||
|
<div class="surface-heading">
|
||||||
|
<p>История</p>
|
||||||
|
<span>Последние сообщения текущего окна</span>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script src="/assets/chat.js"></script>
|
<script src="/assets/chat.js"></script>
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import (
|
|||||||
|
|
||||||
type CommitmentID ids.ID
|
type CommitmentID ids.ID
|
||||||
type ProspectiveItemID ids.ID
|
type ProspectiveItemID ids.ID
|
||||||
|
type AttentionItemID ids.ID
|
||||||
type ProspectiveRevision uint64
|
type ProspectiveRevision uint64
|
||||||
|
type AttentionRevision uint64
|
||||||
type CommittedActionIntentID ids.ID
|
type CommittedActionIntentID ids.ID
|
||||||
type CommittedAuthorizedActionID ids.ID
|
type CommittedAuthorizedActionID ids.ID
|
||||||
|
type CommittedActionAttemptID ids.ID
|
||||||
|
|
||||||
type ProspectiveState string
|
type ProspectiveState string
|
||||||
|
|
||||||
@@ -26,6 +29,34 @@ const (
|
|||||||
ProspectiveExpired ProspectiveState = "EXPIRED"
|
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
|
// ProspectiveItem is canonical future relevance. TriggerAtUnixMillis is an
|
||||||
// absolute instant; scheduling engines are derived delivery mechanisms only.
|
// absolute instant; scheduling engines are derived delivery mechanisms only.
|
||||||
type ProspectiveItem struct {
|
type ProspectiveItem struct {
|
||||||
@@ -56,6 +87,46 @@ type ScheduleReminderResult struct {
|
|||||||
Item ProspectiveItem
|
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
|
// SatisfyDeliveredSubject is the complete serializable operation subject. It
|
||||||
// is distinct from a Work TaskTarget, which remains the live justification.
|
// is distinct from a Work TaskTarget, which remains the live justification.
|
||||||
type SatisfyDeliveredSubject struct {
|
type SatisfyDeliveredSubject struct {
|
||||||
|
|||||||
@@ -532,6 +532,20 @@ ON CONFLICT DO NOTHING;
|
|||||||
INSERT INTO capability.capability_implementation_heads(implementation_id, revision, capability_kind, owner_principal_id)
|
INSERT INTO capability.capability_implementation_heads(implementation_id, revision, capability_kind, owner_principal_id)
|
||||||
VALUES ('internal-reminder-scheduler-v1', 1, 'SCHEDULE_REMINDER', 'domovoy-creator-principal')
|
VALUES ('internal-reminder-scheduler-v1', 1, 'SCHEDULE_REMINDER', 'domovoy-creator-principal')
|
||||||
ON CONFLICT (implementation_id) DO NOTHING;
|
ON CONFLICT (implementation_id) DO NOTHING;
|
||||||
|
INSERT INTO capability.notification_bindings (
|
||||||
|
binding_id, capability_id, owner_principal_id, revision, state, https_origin,
|
||||||
|
delivery_path, receipt_path, protocol, secret_ref, verified_at_unix_millis, health
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'domovoy-notification-binding', 'prospective-notification-delivery', 'domovoy-creator-principal',
|
||||||
|
2, 'ACTIVE', 'https://assistant.cin.su', '/delivery/v1/deliveries', '/delivery/v1/receipts',
|
||||||
|
'IDEMPOTENT_RECEIPT_V1', 'domovoy-notification-write-v1', 1, 'AVAILABLE'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO capability.notification_binding_heads(capability_id, owner_principal_id, binding_id, revision)
|
||||||
|
VALUES ('prospective-notification-delivery', 'domovoy-creator-principal', 'domovoy-notification-binding', 2)
|
||||||
|
ON CONFLICT (capability_id, owner_principal_id) DO UPDATE SET binding_id=EXCLUDED.binding_id, revision=EXCLUDED.revision
|
||||||
|
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision;
|
||||||
RESET ROLE;
|
RESET ROLE;
|
||||||
|
|
||||||
SET ROLE continuity_role;
|
SET ROLE continuity_role;
|
||||||
@@ -564,6 +578,34 @@ CREATE TABLE IF NOT EXISTS continuity.action_receipts (
|
|||||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CHECK (expected_revision > 0), CHECK (result_revision > expected_revision)
|
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;
|
RESET ROLE;
|
||||||
|
|
||||||
SET ROLE interaction_role;
|
SET ROLE interaction_role;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
SET ROLE capability_role;
|
||||||
|
|
||||||
|
INSERT INTO capability.notification_bindings (
|
||||||
|
binding_id, capability_id, owner_principal_id, revision, state, https_origin,
|
||||||
|
delivery_path, receipt_path, protocol, secret_ref, verified_at_unix_millis, health
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'domovoy-notification-binding',
|
||||||
|
'prospective-notification-delivery',
|
||||||
|
'domovoy-creator-principal',
|
||||||
|
2,
|
||||||
|
'ACTIVE',
|
||||||
|
'https://assistant.cin.su',
|
||||||
|
'/delivery/v1/deliveries',
|
||||||
|
'/delivery/v1/receipts',
|
||||||
|
'IDEMPOTENT_RECEIPT_V1',
|
||||||
|
'domovoy-notification-write-v1',
|
||||||
|
FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
|
||||||
|
'AVAILABLE'
|
||||||
|
)
|
||||||
|
ON CONFLICT (binding_id, revision) DO UPDATE SET
|
||||||
|
https_origin = EXCLUDED.https_origin,
|
||||||
|
verified_at_unix_millis = EXCLUDED.verified_at_unix_millis,
|
||||||
|
health = EXCLUDED.health;
|
||||||
|
|
||||||
|
INSERT INTO capability.notification_binding_heads (
|
||||||
|
capability_id, owner_principal_id, binding_id, revision
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'prospective-notification-delivery',
|
||||||
|
'domovoy-creator-principal',
|
||||||
|
'domovoy-notification-binding',
|
||||||
|
2
|
||||||
|
)
|
||||||
|
ON CONFLICT (capability_id, owner_principal_id) DO UPDATE SET
|
||||||
|
binding_id = EXCLUDED.binding_id,
|
||||||
|
revision = EXCLUDED.revision
|
||||||
|
WHERE capability.notification_binding_heads.revision < EXCLUDED.revision;
|
||||||
|
|
||||||
|
RESET ROLE;
|
||||||
|
COMMIT;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"task_file": "dev/tasks/DEV-000064-capability-resolution-selection-gap-foundation.json",
|
"task_file": "dev/tasks/DEV-000065-assistant-presence-attention-human-surface.json",
|
||||||
"task_id": "DEV-000064",
|
"task_id": "DEV-000065",
|
||||||
"task_revision": 1
|
"task_revision": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
+17
-1
@@ -1,6 +1,22 @@
|
|||||||
# Assistant project status
|
# 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; notification binding origin corrected to assistant.cin.su revision 2`
|
||||||
|
- `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
|
## DEV-000064 checkpoint
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,17 @@ func Reconcile(action commitcontract.AuthorizedAction, previous commitcontract.A
|
|||||||
// Authorize is the Commit boundary. A proposal alone never reaches Executor.
|
// 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) {
|
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("") ||
|
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
|
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
|
return commitcontract.AuthorizedAction{}, ErrInvalidProposal
|
||||||
}
|
}
|
||||||
if !allowedOperation(proposal, context) {
|
if !allowedOperation(proposal, context) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var (
|
|||||||
ErrInvalidTransition = errors.New("invalid prospective item transition")
|
ErrInvalidTransition = errors.New("invalid prospective item transition")
|
||||||
ErrInvalidSatisfaction = errors.New("invalid delivered satisfaction command")
|
ErrInvalidSatisfaction = errors.New("invalid delivered satisfaction command")
|
||||||
ErrInvalidReminder = errors.New("invalid reminder request")
|
ErrInvalidReminder = errors.New("invalid reminder request")
|
||||||
|
ErrInvalidAttention = errors.New("invalid attention item")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -50,6 +51,29 @@ func BuildReminder(request contract.ScheduleReminderRequest, id contract.Prospec
|
|||||||
}, nil
|
}, 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) {
|
func (s *Store) SatisfyDelivered(id contract.ProspectiveItemID, revision contract.ProspectiveRevision, evidenceRef string) (contract.ProspectiveItem, error) {
|
||||||
item, err := s.current(id, revision)
|
item, err := s.current(id, revision)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,9 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"git.cin.su/m/Assistant/contracts/continuity"
|
"git.cin.su/m/Assistant/contracts/continuity"
|
||||||
|
"git.cin.su/m/Assistant/contracts/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
Save(context.Context, continuity.ProspectiveItem) error
|
Save(context.Context, continuity.ProspectiveItem) error
|
||||||
Load(context.Context, continuity.ProspectiveItemID) (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)
|
||||||
|
MarkAttentionFailed(context.Context, continuity.AttentionItemID, continuity.AttentionRevision, continuity.CommittedActionIntentID, continuity.CommittedAuthorizedActionID, continuity.CommittedActionAttemptID, string) (continuity.AttentionItem, error)
|
||||||
|
AttentionByOwner(context.Context, identity.PrincipalID, int) ([]continuity.AttentionItem, error)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user