P1 - forward inherits mode:
- doForward: when creating a new target session, use InheritedMode from
the source session (plan parent → plan child, cannot escalate)
- enforcement snapshot set from receiving agent
P1 - calendar_events gets permission_mode column:
- Added to 3 migration sites (sqlite init, pg init, incremental alter)
- CalendarEvent model gains PermissionMode field
- CreateCalendarEvent / UpdateCalendarEvent normalize + persist the field
- resolveCalendarSession: on new session → write event's mode;
on reuse → ModeAtMost(cur, eventMode), prevents escalation
(plan Agent's reminder fires into a workspace session = bypass)
- SendCalendarMail now takes permMode and threads it through
- fireEvent passes event.PermissionMode to all delivery paths
P1 - AdoptPlatformSession explicitly writes default mode:
- Writes DefaultPermissionMode + enforcement on adopt, instead of
relying on DB column default (avoids silent drift on schema changes)
P2 - PUT /sessions/{id}/permission endpoint:
- New handler UpdateSessionPermission (auth required, access check)
- Registers PUT route alongside existing budget/alias endpoints
- Broadcasts session_update on change
- Does NOT refresh enforcement (design: snapshot at creation)
P3 - Tests (24 new cases):
- permission_mode_test.go: InheritedMode (6 cases), SetSessionPermissionMode
roundtrip, dirty value fail-closed, NormalizePermissionMode, calendar event
roundtrip/dirty/update, adopt writes default mode, plan escalation guard,
3-level inheritance chain
- defaultsession_test.go: budget regression, permission mode regression
(pins created=false → no reset on reuse)
Deploys with: bash deploy/redeploy-gateway.sh --skip-tests
Schema migration: auto via addMissingColumns (new column default 'workspace')
177 lines
6.8 KiB
Go
177 lines
6.8 KiB
Go
package models
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// CalendarEvent 日历事件。
|
||
//
|
||
// 设计参照 Outlook:事件有时间、提醒、收件人,触发时产生一封邮件。
|
||
// 事件本身是日历实体,提醒是触发器,邮件是投递通道 —— 三者分离。
|
||
type CalendarEvent struct {
|
||
EventID string `json:"event_id"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
ReminderText string `json:"reminder_text"`
|
||
|
||
// AgentName / ToAddress 是**单收件人时代的字段**,保留作兼容与兜底:
|
||
// Recipients 为空时用它们。新代码一律读 EffectiveRecipients()。
|
||
AgentName string `json:"agent_name"`
|
||
ToAddress string `json:"to_address"`
|
||
|
||
// Recipients 是完整的收件人列表(每项是完整三维地址串)。
|
||
//
|
||
// 为什么不用 []Address 而用 []string:地址的三段语义(尤其 session 位的
|
||
// new/别名三态)在**触发那一刻**才该被解析 —— 存结构化的话,
|
||
// 「.new」这种一次性语义在建事件时就被固化,而重复事件每次触发都该
|
||
// 重新决定落到哪条会话。存原始串让 ParseAddress 在投递时做这个决定。
|
||
Recipients []string `json:"recipients"`
|
||
|
||
// DeliveryMode 决定多收件人怎么投:
|
||
// "separate"(默认)—— 每人各发一封,落在各自的会话里,互相看不到
|
||
// "together" —— 第一个是主收件人,其余进 cc_list,共享同一条线索
|
||
//
|
||
// 两种语义都需要而不是二选一:「让三个 Agent 各自独立汇报」与
|
||
// 「让 pi 主办、dsh 知情」是完全不同的任务形态,用错会让协作失败 ——
|
||
// 前者用 together 会让三个 Agent 互相看到对方的回复而趋同,
|
||
// 后者用 separate 会让 dsh 完全不知道 pi 在做什么。
|
||
DeliveryMode string `json:"delivery_mode"`
|
||
|
||
EventTime time.Time `json:"event_time"`
|
||
RemindBefore int `json:"remind_before"` // 提前多少分钟
|
||
|
||
// Recurrence:公历 none/daily/weekly/monthly/yearly + 农历两种
|
||
// lunar_monthly —— 每农历月同一日(如每月十五)
|
||
// lunar_yearly —— 每农历年同月同日(过农历生日/祭日)
|
||
//
|
||
// lunar_daily 不存在:农历的「日」与公历同长,那就是 daily。
|
||
// lunar_weekly 也不存在:农历没有「周」这个单位。
|
||
Recurrence string `json:"recurrence"`
|
||
RecurrenceEnd *time.Time `json:"recurrence_end,omitempty"`
|
||
|
||
Status string `json:"status"` // active/paused/cancelled
|
||
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
|
||
|
||
// PermissionMode 是事件触发时新建会话应采用的档位(plan / workspace / full)。
|
||
// 空 = workspace(默认)。
|
||
// 复用已有会话时不能直接搬用:要 ModeAtMost(会话现档, 事件档) ——
|
||
// 事件档表示「这件事允许到什么程度」,而会话现档可能更严(plan 档派出的
|
||
// 任务不该因为日程触发就偷偷升到 workspace)。
|
||
PermissionMode string `json:"permission_mode"`
|
||
|
||
// FiredFor 是已触发的那个 occurrence(值 = 当时的 EventTime)。
|
||
// 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 ——
|
||
// DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||
FiredFor *time.Time `json:"fired_for,omitempty"`
|
||
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
CreatedBy string `json:"created_by"`
|
||
}
|
||
|
||
// 重复规则常量。农历规则单独一组:它们的推进要经过 internal/lunar,
|
||
// 不能像公历那样 AddDate 固定天数(农历月 29~30 天、闰年 13 个月)。
|
||
const (
|
||
RecurNone = "none"
|
||
RecurDaily = "daily"
|
||
RecurWeekly = "weekly"
|
||
RecurMonthly = "monthly"
|
||
RecurYearly = "yearly"
|
||
RecurLunarMonthly = "lunar_monthly"
|
||
RecurLunarYearly = "lunar_yearly"
|
||
)
|
||
|
||
// IsLunarRecurrence 判断一条重复规则是否按农历推进。
|
||
func IsLunarRecurrence(r string) bool {
|
||
return r == RecurLunarMonthly || r == RecurLunarYearly
|
||
}
|
||
|
||
// 事件状态常量。
|
||
//
|
||
// 此前这三个值只以裸字符串形式散落在 handler、scheduler 与前端里,而更新端点
|
||
// 把 `status` 原样写进库 —— 于是一个拼错的值(比如 "pause")会变成一个
|
||
// **调度器不认识的状态**:DueEvents 只查 status='active',那条提醒于是静默失效。
|
||
// 人以为自己只是暂停了它,实际上再也恢复不了(界面的下拉框里没有这个选项)。
|
||
//
|
||
// 提成常量后,handler.validEventStatus 能对着这一份清单校验。
|
||
const (
|
||
// EventActive 生效中:到点会触发提醒。
|
||
EventActive = "active"
|
||
// EventPaused 暂停:保留事件与重复规则,但不触发。
|
||
EventPaused = "paused"
|
||
// EventCancelled 已取消:保留历史记录,不再触发也不再推进重复。
|
||
EventCancelled = "cancelled"
|
||
)
|
||
|
||
// ValidEventStatus 判断状态取值是否合法。
|
||
func ValidEventStatus(s string) bool {
|
||
switch s {
|
||
case EventActive, EventPaused, EventCancelled:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 投递模式常量。
|
||
const (
|
||
DeliverSeparate = "separate"
|
||
DeliverTogether = "together"
|
||
)
|
||
|
||
// EffectiveRecipients 返回真正要投的收件人列表。
|
||
//
|
||
// Recipients 优先;为空时退回 ToAddress,再退回 AgentName。
|
||
// 这个兜底链让旧数据(只有 agent_name 的事件)继续工作 ——
|
||
// 历史事件不迁移,读的时候归一化。
|
||
func (e *CalendarEvent) EffectiveRecipients() []string {
|
||
if len(e.Recipients) > 0 {
|
||
out := make([]string, 0, len(e.Recipients))
|
||
for _, r := range e.Recipients {
|
||
if r = strings.TrimSpace(r); r != "" {
|
||
out = append(out, r)
|
||
}
|
||
}
|
||
if len(out) > 0 {
|
||
return out
|
||
}
|
||
}
|
||
if a := strings.TrimSpace(e.ToAddress); a != "" {
|
||
return []string{a}
|
||
}
|
||
if a := strings.TrimSpace(e.AgentName); a != "" {
|
||
return []string{a}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// EffectiveDeliveryMode 归一化投递模式,未知值按 separate 处理。
|
||
//
|
||
// 默认 separate 而不是 together:separate 的失败是「Agent 各干各的」,
|
||
// together 的失败是「本该独立的 Agent 互相污染了上下文」——
|
||
// 后者更难发现也更难挽回。
|
||
func (e *CalendarEvent) EffectiveDeliveryMode() string {
|
||
if e.DeliveryMode == DeliverTogether {
|
||
return DeliverTogether
|
||
}
|
||
return DeliverSeparate
|
||
}
|
||
|
||
// CalendarAttachment 事件附件。
|
||
type CalendarAttachment struct {
|
||
AttachmentID string `json:"attachment_id"`
|
||
EventID string `json:"event_id"`
|
||
Filename string `json:"filename"`
|
||
SHA256 string `json:"sha256"`
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// CalendarView 日历视图(月/周/日)。
|
||
type CalendarView struct {
|
||
Events []CalendarEvent `json:"events"`
|
||
// 当前时间线上的事件数(用于统计徽章)
|
||
UpcomingCount int `json:"upcoming_count"`
|
||
TodayCount int `json:"today_count"`
|
||
}
|