Documentation
¶
Overview ¶
本文件实现浏览器鉴权所需的两张表:会话(sessions)与一次性 ticket(auth_tickets)。
职责:
- 凭据哈希:两张表都只存 SHA-256,明文永不落库
- ticket 的创建与**原子消费**(一次性由一条条件 UPDATE 的原子性保证)
- 会话的创建、按哈希/按 id 查询、列出、吊销、活跃与续期写入
边界:
- 不判断会话/ticket 是否「有效」:过期与吊销的判定属业务规则,由 agentd 侧做 (store 是叶子层;且 spec §11 要求把「会话过期」与「会话已吊销」作为不同 原因分别记日志,合并判定就拿不出这个区分)
- 不生成凭据明文、不设置 cookie、不做任何 HTTP 相关处理
- 叶子层纪律:本层方法错误 return 前不打日志(避免双份),由调用方带上下文记录。 签发、消费、建立、吊销四个节点的 Info 日志由 agentd 侧(Task 4/6 的调用方)打, 不在本文件任何方法里加日志——这条责任转移是刻意的,不是漏了
本文件是事件镜像两表(mirror_events / mirror_tasks)的持久化实现。
职责:
- 幂等追加远端事件副本(INSERT OR IGNORE,(task_id, seq) 复合主键即幂等键)
- 水位(远端 seq 已复制到的最大值)与开区间续拉
- 任务快照 upsert 与「任务 → 所属机器」的路由索引
边界:
- **镜像是 replication 不是真相**:权威始终在任务所在机器,本表只是副本, 可随时整表删除、按 from_seq=0 从远端重建——不存在本机独有状态
- 与 store.go 一致的叶子层纪律:方法错误 return 前不打日志;仅两个高价值 点例外(ListMirrorTasks 的坏行、DeleteMirrorTask 的破坏性操作)
本文件是 project_locations 表(项目 × 本机位置)的持久化实现。
职责:
- project_locations 的增(CreateProjectLocation)、查(GetProjectLocationByName)、 列(ListProjectLocations)、删(DeleteProjectLocation)
- 把 SQLite 的主键/UNIQUE 冲突翻译成 ErrProjectDuplicate 哨兵,供上层映射 409
边界:
- 不算 project_id:那是 internal/projectid 的纯函数,调用方算好后传进来
- 不判断路径是否存在、是不是 git 仓库——那是 agentd 侧 EnsureRepoUsable 的事
- 不做名字派生、不做名字去重——那是 agentd/projectadmin.go 的事
- 与 store.go 一致的叶子层纪律:方法错误 return 前不打日志,由调用方带上下文记录
Package store 是 handoff 的唯一持久化入口,基于 SQLite(modernc.org/sqlite,纯 Go 无 cgo)。
职责:
- 提供任务(tasks)、事件(events)、工单(tickets)、项目位置(project_locations) 四张表的建表与增删改查
- 通过 database/sql 连接池支撑单进程多 goroutine 并发访问(WAL + busy_timeout 防 SQLITE_BUSY)
- CreateTicket 用 INSERT OR IGNORE 实现按 id 幂等创建
- Open 时顺带把旧 repos 表迁入 project_locations(B62 一次性迁移,见 projects.go)
边界:
- 不含业务规则,仅保留 UpdateTaskState 对 proto.CanTransit 的一处防护性校验
- 事件派发、状态变更决策、工单审批逻辑均不在此层
- 叶子层:方法错误 return 前不打日志(避免双份),由调用方带上下文记录; 仅 Open 成功打 Info、UpdateTaskState 非法迁移打 Warn 两个高价值点例外
Index ¶
- Constants
- Variables
- func HashCredential(plain string) string
- type MirrorTask
- type Session
- type Store
- func (s *Store) ActiveTasksByRepoPath(repoPath string) ([]proto.Task, error)
- func (s *Store) ActiveTasksByWorkDir(workDir string) ([]proto.Task, error)
- func (s *Store) AnswerTicket(id, answer string) error
- func (s *Store) AppendEvent(taskID string, typ proto.EventType, payload any) (proto.Event, error)
- func (s *Store) AppendMirrorEvent(taskID string, ev proto.Event) (inserted bool, err error)
- func (s *Store) Close() error
- func (s *Store) ConsumeAuthTicket(hash string, now time.Time) (string, time.Time, error)
- func (s *Store) CountEvents(taskID string, afterSeq, throughSeq int64) (int, error)
- func (s *Store) CreateAuthTicket(hash, deviceName string, createdAt, expiresAt time.Time) error
- func (s *Store) CreateProjectLocation(loc *proto.ProjectLocation) error
- func (s *Store) CreateSession(sess *Session) error
- func (s *Store) CreateTask(t *proto.Task) error
- func (s *Store) CreateTicket(tk *proto.Ticket) (bool, error)
- func (s *Store) DeleteMirrorTask(taskID string) (int, error)
- func (s *Store) DeleteProjectLocation(name string) error
- func (s *Store) EventsFrom(taskID string, fromSeq int64, limit int) ([]proto.Event, error)
- func (s *Store) EventsFromAsc(taskID string, fromSeq int64, limit int) ([]proto.Event, error)
- func (s *Store) FindReusableGrant(taskID, fingerprint string) (*proto.Ticket, error)
- func (s *Store) GetProjectLocationByName(name string) (proto.ProjectLocation, error)
- func (s *Store) GetTask(id string) (*proto.Task, error)
- func (s *Store) GetTicket(id string) (*proto.Ticket, error)
- func (s *Store) LatestEvent(taskID string) (*proto.Event, error)
- func (s *Store) ListMirrorTasks() ([]MirrorTask, error)
- func (s *Store) ListProjectLocations() ([]proto.ProjectLocation, error)
- func (s *Store) ListSessions() ([]Session, error)
- func (s *Store) ListTasks() ([]proto.Task, error)
- func (s *Store) MarkTicketDelivered(id string) error
- func (s *Store) MirrorEventsFrom(taskID string, fromSeq int64, limit int) ([]proto.Event, error)
- func (s *Store) MirrorTaskTarget(taskID string) (string, bool, error)
- func (s *Store) MirrorWatermark(taskID string) (int64, error)
- func (s *Store) PendingTickets(taskID string) ([]proto.Ticket, error)
- func (s *Store) RevokeSession(id string, at time.Time) error
- func (s *Store) SessionByID(id string) (*Session, error)
- func (s *Store) SessionByTokenHash(hash string) (*Session, error)
- func (s *Store) SetEventHook(fn func(proto.Event))
- func (s *Store) SetTaskField(id, field, value string) error
- func (s *Store) SetTaskUsage(id, model string, ctxTokens int, ctxWindow *int) error
- func (s *Store) TaskCumulative(taskID string) (*proto.Cumulative, error)
- func (s *Store) TicketHasEvent(taskID, ticketID string) (bool, error)
- func (s *Store) TouchSession(id string, lastSeen, expiresAt time.Time) error
- func (s *Store) UndeliveredAnswers(taskID string) ([]proto.Ticket, error)
- func (s *Store) UpdateProjectLocation(name, newName, newPath string) (proto.ProjectLocation, error)
- func (s *Store) UpdateTaskState(id string, st proto.TaskState) error
- func (s *Store) UpsertMirrorTask(target string, task proto.Task, fetchedAt time.Time) error
- func (s *Store) UpsertSpend(taskID string, e proto.SpendEntry) error
- func (s *Store) VoidPendingTickets(taskID string) (int, error)
Constants ¶
const VoidAnswer = "__void__"
VoidAnswer 是 VoidPendingTickets 写入的占位答案值。
语义:任务已终结(executor 已不存在)时挂起工单不再可能被回答,作废后 PendingTickets(answer IS NULL)天然不再返回它们——协调者看到的是「无挂起项」 而非可操作的假象;作废原因由调用方(RecoverOnStartup 的 failed 事件)留痕。
Variables ¶
var ErrBadTransit = errors.New("非法状态迁移")
ErrBadTransit 表示任务状态迁移违反状态机(见 proto.CanTransit)。
var ErrNotFound = errors.New("记录不存在")
ErrNotFound 表示按 id 查询的记录不存在。
var ErrProjectDuplicate = errors.New("项目位置冲突(项目、名字或路径已存在)")
ErrProjectDuplicate 表示位置登记冲突,三种成因合用一个哨兵:
- project_id 已存在 → 这个项目在本机已经有位置了(ADR-0008 只允许一个)
- name 已被占用 → 引用名撞了
- path 已被另一个项目指向 → 两个项目声称在同一个目录
为什么合并成一个哨兵:三者在 HTTP 上都是 409,且报文由 agentd 侧按上下文 拼装(它知道是自动登记还是人工登记);分成三个哨兵只会让映射层多两个分支。
Functions ¶
func HashCredential ¶ added in v0.3.0
HashCredential 计算凭据明文的 SHA-256 十六进制串。
参数:
- plain: 凭据明文(ticket 或 cookie 值)
返回:
- 64 字符的小写十六进制哈希
注意:
- 为什么不加盐、不用 bcrypt:输入是 256 位高熵随机串(不是人选的口令), 没有字典攻击面;而查表必须是 O(1) 精确匹配,加盐会退化成逐行比对
Types ¶
type MirrorTask ¶ added in v0.3.0
type MirrorTask struct {
Task proto.Task
Target string // §5.1 透明路由的索引:这条任务该转发给谁
FetchedAt time.Time
}
MirrorTask 是 mirror_tasks 的一行:远端任务的快照副本 + 路由信息。
type Session ¶ added in v0.3.0
type Session struct {
ID string
TokenHash string
DeviceName string
CreatedAt time.Time
ExpiresAt time.Time
LastSeenAt time.Time
RevokedAt *time.Time
}
Session 是一个浏览器会话记录。
注意:
- TokenHash 是 cookie 明文的 SHA-256;明文不落库,本结构体永远拿不到它
- RevokedAt 为 nil 表示未吊销;是否「有效」还要看 ExpiresAt,由调用方判定
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store 持有 SQLite 数据库连接池,是 store 包对外唯一入口。
func Open ¶
Open 打开(必要时创建)path 处的 SQLite 数据库并建表。
参数:
- path: 数据库文件路径;父目录需已存在
返回:
- 可用的 Store;打开或建表失败时返回错误
注意:
- driver 为 modernc.org/sqlite(纯 Go 实现,无需 cgo)
- 每次调用幂等:建表均带 IF NOT EXISTS
func (*Store) ActiveTasksByRepoPath ¶
ActiveTasksByRepoPath 返回仓库路径为 repoPath 的全部非终态任务。
参数:
- repoPath: 仓库绝对路径;空串返回空切片
注意:
- 与 ActiveTasksByWorkDir 的区别:那个按**工作目录**判定(managed worktree 各不相同),本方法按**仓库**判定——注销一条位置会影响这个项目下的所有 任务,包括从它长出来的 managed worktree
- 原属 repos.go(B62 迁移后 repos 表删除),项目注销的占用校验仍在用它
func (*Store) ActiveTasksByWorkDir ¶
ActiveTasksByWorkDir 返回工作目录为 workDir 的全部非终态任务。
参数:
- workDir: 工作目录绝对路径(原地模式即仓库路径);空串返回空切片
返回:
- 非终态任务切片(可能为空),按创建时间倒序
- 查询失败返回错误(调用方按「查不出来就保守拒发」处置)
注意:
- 终态清单取自 proto.TerminalStates,避免与状态机定义漂移
- 空 workDir 直接返回空切片:不查是刻意的,managed 模式每任务一棵新树, 天然不冲突,不需要这个判据
- WHERE 里对空 work_dir 的兜底是给**旧库历史行**的:早期原地模式的 work_dir 存空串(proto.Task.Workdir() 的回退就是为它们写的),那些任务 同样占着仓库。新派发的任务 work_dir 一定是满的
func (*Store) AnswerTicket ¶
AnswerTicket 填写工单答案,工单随即从待办中移出。
参数:
- id: 工单 ID
- answer: 人工答复内容
注意:
- 以 answer IS NULL 为更新条件:工单不存在或已回答(不可重复回答)均返回 ErrNotFound
- 回答成功后刷新所属任务的 updated_at(子查询取 task_id):answer 落库是任务 活动信号,看门狗以此判定「stalled 之后是否有回复」从而二次告警(P1-15a); 否则「已 stalled → 协调者回答 → executor 仍死」永远不再告警
func (*Store) AppendEvent ¶
AppendEvent 为任务追加一条事件,返回落库后带自增 seq 的完整事件。
参数:
- taskID: 所属任务 ID(与任务表无外键约束,任务不存在时也可追加)
- typ: 事件类型
- payload: 任意可 JSON 序列化的值,序列化后存入 payload 列
返回:
- 带全局自增 seq 的事件记录
注意:
- seq 由 AUTOINCREMENT 全局分配,跨任务单调递增,可作为全局时间轴排序依据
func (*Store) AppendMirrorEvent ¶ added in v0.3.0
AppendMirrorEvent 幂等追加一条远端事件副本。
参数:
- taskID: 所属任务 ID
- ev: 事件(Seq 为**远端库**的自增 seq,原值保留)
返回:
- inserted: true=本次真的插入了新行;false=该 (task_id, seq) 已存在 (重连补拉重复到达),本次为 no-op
- err: 数据库错误
注意:
- INSERT OR IGNORE 的幂等语义就是「重连补拉天然去重」:远端是权威,本机 不重编号,重连凭 seq 续拉即可
func (*Store) ConsumeAuthTicket ¶ added in v0.3.0
ConsumeAuthTicket 原子认领一张未消费的 ticket,并返回它的设备名与过期时刻。
参数:
- hash: ticket 明文的 SHA-256
- now: 认领时刻,写入 consumed_at
返回:
- deviceName / expiresAt: 该 ticket 的登记信息
- ErrNotFound: 不存在或已被消费(对调用方是同一个结论:这张票不能用)
注意:
- 一次性**由 SQL 条件 UPDATE 的原子性保证**,不靠「先查后改」——后者在并发下 会让同一张 ticket 换出两个会话
- **过期不在这里判**:返回 expiresAt 交给调用方比较。原因是本表的时间以 RFC3339Nano 文本存储,Go 的格式化会裁掉尾部零,导致「整秒」与「带小数秒」 的两个时刻在 SQLite 里按字典序比较时次序反转(`.` 的码位小于 `Z`)。 把比较放到 Go 侧用 time.Time 做,才是无坑的
func (*Store) CountEvents ¶
CountEvents 统计任务在 (afterSeq, throughSeq] 区间内的事件条数。
参数:
- taskID: 任务 ID
- afterSeq: 区间下界(不含)
- throughSeq: 区间上界(含)
返回:
- 区间内事件条数
- 数据库错误
注意:
- 用途是 WS 重放截断后的缺口核对:seq 由 AUTOINCREMENT 全局分配,跨任务 交错,单任务的 seq **不连续**,因此无法靠「seq 是否逐格衔接」判断缺口, 只能按区间实际条数核对
func (*Store) CreateAuthTicket ¶ added in v0.3.0
CreateAuthTicket 落库一张一次性 ticket。
参数:
- hash: ticket 明文的 SHA-256(由调用方算好;本层不接触明文)
- deviceName: 签发时登记的设备名,纯展示
- createdAt / expiresAt: 签发与过期时刻
返回:
- 数据库错误;重复 hash 会因主键冲突报错(概率上不可能,视为真故障)
func (*Store) CreateProjectLocation ¶
func (s *Store) CreateProjectLocation(loc *proto.ProjectLocation) error
CreateProjectLocation 写入一条项目位置。
参数:
- loc: 位置条目;ProjectID/Name/Path/OriginURL 必须非空,CreatedAt 由调用方给定
返回:
- 错误:项目/名字/路径任一已存在时返回包装 ErrProjectDuplicate 的错误;其余为写库故障
func (*Store) CreateSession ¶ added in v0.3.0
CreateSession 落库一个新会话。
参数:
- sess: 会话数据;ID 与 TokenHash 必须非空,RevokedAt 被忽略(新会话必未吊销)
返回:
- 数据库错误
func (*Store) CreateTask ¶
CreateTask 写入一个新任务。
参数:
- t: 任务数据;ID 必须非空且唯一,重复 ID 返回底层主键冲突错误
注意:
- 状态迁移合法性由 UpdateTaskState 校验,此处仅原样入库,不含业务规则
func (*Store) CreateTicket ¶
CreateTicket 幂等创建工单:同 id 重复调用不报错,第二次起 created 为 false。
参数:
- tk: 工单数据;ID 必须非空
返回:
- created: true 表示本次为首次创建(id 此前不存在)
- err: 数据库错误
注意:
- 通过 INSERT OR IGNORE 实现幂等,与 upsert 不同:已存在时保留旧数据,不覆盖
- answer/answered_at 一律由 AnswerTicket 写入,入参中的值被忽略
func (*Store) DeleteMirrorTask ¶ added in v0.3.0
DeleteMirrorTask 删除一条镜像任务及其全部镜像事件。
返回:
- 被删除的事件条数(任务快照本身不存在时不报错,返回 0)
注意:
- 这是本包唯一的破坏性镜像操作,成功打 Info(task_id + 事件条数)
- 两个删放在一个事务里:镜像任务是复制品,删除必须原子,不能留 「快照没了、事件还在」的半截
func (*Store) DeleteProjectLocation ¶
DeleteProjectLocation 删除一条位置。
返回:
- 错误:位置不存在时返回 ErrNotFound(而非静默成功——调用方需要知道自己删错了名字)
注意:
- 只删登记,**不动磁盘上的仓库**
func (*Store) EventsFrom ¶
EventsFrom 返回任务 taskID 在 seq 之后的事件,按 seq 升序,最多 limit 条。
语义(重要):事件数超过 limit 时返回**最新**的 limit 条(截断掉最旧的), 保证读到的始终是「离 now 最近」的窗口——attach 的 recent_events 依赖 「最新窗口」,若返回最旧的 limit 条,>limit 的积压会让新事件(如 completed) 永远读不到,违反「不丢新事件」。
注意:**WS 重放不得使用本方法**——截断最旧会让客户端 cursor 越过缺口,缺口 永不补齐;WS 重放请用 EventsFromAsc(截断尾部、可续拉)。
参数:
- taskID: 任务 ID
- fromSeq: 起始 seq(不含),传 0 表示从头
- limit: 返回条数上限
注意:
- 借助 idx_events_task(task_id, seq) 索引加速按任务的时间线扫描
func (*Store) EventsFromAsc ¶
EventsFromAsc 返回任务 taskID 在 fromSeq 之后的事件,按 seq 升序,最多 limit 条。
与 EventsFrom(最新窗口)语义相反(重要):事件数超过 limit 时截断**窗口尾部**、 保留最旧的 limit 条——客户端 cursor 只前进到「确实收到」的最后一条,被截掉的 尾部缺口可凭更大 from_seq 重连续拉,缺口永远可补齐;EventsFrom 截最旧会让 cursor 越过缺口,缺口永久丢失(见该方法的注意)。
用途:WS 重放(server.handleEvents)必须用本方法从头补起。
参数:
- taskID: 任务 ID
- fromSeq: 起始 seq(不含),传 0 表示从头
- limit: 返回条数上限
注意:
- 借助 idx_events_task(task_id, seq) 索引加速按任务的时间线扫描
func (*Store) FindReusableGrant ¶
FindReusableGrant 查同任务、同指纹、已被协调者批准且已送达的 gate 工单。
参数:
- taskID: 任务 id(复用严格限制在任务内,见 spec §3.4)
- fingerprint: 权限描述全文的 sha256;空串直接返回无匹配
返回:
- 命中时返回该工单;无匹配返回 (nil, nil);查询出错返回错误
注意:
- answer 必须**严格等于** "allow"——gate 的翻译规则就是严格相等, 这里放宽(如 LIKE 'allow%')会让 "allowed once, then never" 之类的 人工笔误变成一张长期通行证
- delivered_at 必须非空:应答落库但中继失败的工单不构成有效先例, executor 侧那次请求根本没收到批准
func (*Store) GetProjectLocationByName ¶
func (s *Store) GetProjectLocationByName(name string) (proto.ProjectLocation, error)
GetProjectLocationByName 按引用名查询单条位置。
返回:
- 位置条目;不存在时返回 ErrNotFound
func (*Store) GetTask ¶
GetTask 按 id 读取任务;不存在返回 ErrNotFound。
参数:
- id: 任务 ID
返回:
- 任务数据;不存在时返回 ErrNotFound
func (*Store) GetTicket ¶
GetTicket 按 id 读取工单;不存在返回 ErrNotFound。
参数:
- id: 工单 ID
返回:
- 工单数据;不存在时返回 ErrNotFound
func (*Store) LatestEvent ¶
LatestEvent 返回任务最新一条事件(seq 最大)。
返回:
- 任务无任何事件时返回 ErrNotFound
注意:
- 看门狗(stalled 判定)与启动恢复用「最新事件时刻」判断任务是否卡住, 单条取最新即可,不必拉全量事件
func (*Store) ListMirrorTasks ¶ added in v0.3.0
func (s *Store) ListMirrorTasks() ([]MirrorTask, error)
ListMirrorTasks 返回全部镜像任务快照,按快照时刻降序。
注意:
- 单行快照解析失败只 Warn 跳过(副本脏了不该让看板挂掉),不使整个列表失败
- 返回的切片可能为空,恒不为 nil
func (*Store) ListProjectLocations ¶
func (s *Store) ListProjectLocations() ([]proto.ProjectLocation, error)
ListProjectLocations 返回本机全部项目位置,按名字字典序。
注意:
- 返回的 Status 字段恒为空——实际状态由 agentd 侧现场探测后填充
func (*Store) ListSessions ¶ added in v0.3.0
ListSessions 列出全部会话(含已吊销与已过期),按创建时刻降序。
注意:已吊销的会话也返回——`handoff sessions` 要能看到「这台设备已经被吊销了」, 直接消失反而让人怀疑是不是漏看了
func (*Store) ListTasks ¶
ListTasks 返回全部任务,按 created_at 降序(最新在前)。
注意:
- created_at 统一为 UTC RFC3339Nano 文本,字典序即时间序,可直接排序
- **不填充 Task.Cumulative**。列表页不显示累计消耗,为每一行做一次 SUM 是纯浪费;要拿累计值请用 GetTask。这不是 bug,改之前先想清楚代价。
func (*Store) MarkTicketDelivered ¶
MarkTicketDelivered 标记工单应答已送达 executor。
参数:
- id: 工单 ID
注意:
- 幂等:已标记的工单重复调用不报错(delivered_at IS NULL 条件不成立即无影响)
- 只有真正把应答交到 executor 手上(RespondPermission/Send 返回成功)之后 才可调用——这是 RecoverStuck 判断「该不该重投」的唯一依据
func (*Store) MirrorEventsFrom ¶ added in v0.3.0
MirrorEventsFrom 返回任务 taskID 的镜像事件中 seq 大于 fromSeq 的条目, 按 seq 升序,最多 limit 条。
注意:fromSeq 是**开区间**(seq > fromSeq),与本机 EventsFromAsc 的语义一致; 远端 seq 原值保留,重连凭它续拉。
func (*Store) MirrorTaskTarget ¶ added in v0.3.0
MirrorTaskTarget 返回任务所属机器(mirror_tasks 的路由索引查询)。
返回:
- ok=true: target 有效(该任务有镜像记录)
- ok=false: 任务没有镜像记录(从没发现过它),target 为空串
- err: 数据库错误
func (*Store) MirrorWatermark ¶ added in v0.3.0
MirrorWatermark 返回某任务镜像事件的当前水位(远端 seq 已复制的最大值)。
返回:任务无任何镜像事件时为 0——「首次订阅从头拉」与「没镜像过」同义。
func (*Store) PendingTickets ¶
PendingTickets 返回任务下所有未回答工单(answer IS NULL),按 created_at 升序。
参数:
- taskID: 任务 ID
注意:
- 用于 agent 阻塞等待人工答复的场景;回答过的工单不会出现在结果中
func (*Store) RevokeSession ¶ added in v0.3.0
RevokeSession 吊销一个尚未吊销的会话。
返回:
- ErrNotFound: 会话不存在**或**已被吊销(两者对调用方是同一个结论:这次吊销没改变什么)
func (*Store) SessionByID ¶ added in v0.3.0
SessionByID 按会话 id 查会话;不存在返回 ErrNotFound。
func (*Store) SessionByTokenHash ¶ added in v0.3.0
SessionByTokenHash 按 cookie 哈希查会话;不存在返回 ErrNotFound。
注意:已过期或已吊销的会话**照样返回**——有效性由调用方判定,这样才能把 「不存在 / 已过期 / 已吊销」记成三种不同的鉴权失败原因
func (*Store) SetEventHook ¶ added in v0.3.0
SetEventHook 注册「事件落库后」的回调。传 nil 可取消。
调用时机:INSERT 成功、proto.Event 组装完成之后,AppendEvent 返回之前, **同步**调用。同步是刻意的——它保证「事件入库顺序 == 钩子观察顺序」, 派生出的帧流才能与事件流对齐。
边界(违反会死锁或自我递归):
- **钩子内不得回调本 Store 的任何方法**。只允许做不回到数据库的动作, 比如往文件追加一行。
- 钩子不得长时间阻塞:它跑在 AppendEvent 的调用栈上,会拖慢事件落库。
- 钩子 panic 由本方法内部 recover:一个可见性副作用不该让已经成功的 事件落库变成失败。
func (*Store) SetTaskField ¶
SetTaskField 更新任务白名单内的单个字段(branch/executor_session/plan_summary/done_note)。
参数:
- id: 任务 ID
- field: 字段名,仅允许白名单四项
- value: 新值
注意:
- 白名单之外(如 id/state)返回错误;任务不存在时不报错(不影响其他行即返回 nil)
func (*Store) SetTaskUsage ¶ added in v0.3.0
SetTaskUsage 一次性更新任务的实际模型名与 context 占用。
参数:
- id: 任务 ID
- model: 实际模型名;**空串表示本次不更新该列**(保留既有值)
- ctxTokens: 当前 context 占用;**0 表示不更新**
- ctxWindow: 上下文窗口上限;**nil 表示不更新**
为什么空值语义是「不更新」而不是「清空」:用量与模型名往往来自**不同的帧** (grok 的窗口在会话建立时到、占用在每次模型调用后到),若空值等于清空, 后到的那一帧会把先到的那一半抹掉。
注意:
- 三个参数全为空时是空操作,不打库
- 任务不存在时不报错(与 SetTaskField 一致,不影响其他行即返回 nil)
func (*Store) TaskCumulative ¶ added in v0.3.0
func (s *Store) TaskCumulative(taskID string) (*proto.Cumulative, error)
TaskCumulative 对该任务的全部账目求和,得到累计消耗。
返回:
- 没有任何账目行时返回 (nil, nil)。**不返回零值结构**——0 会被读成 「一共花了 0」,而真相是「还不知道」
- 花费状态按四条规则定(known=非 unknown 行的 ticks 之和, missing=unknown 行的条数,est=是否含 estimated 行): missing==0 && !est → reported;missing==0 && est → estimated; missing>0 && known>0 → partial(**下界**);missing>0 && known==0 → unknown
注意:estimated 与 missing 同时成立时按 partial——漏账比不准要紧, 而 partial 的展示(下界)也已经隐含了「别当真」。
**本方法读路径高频,成功不打日志**;只在调用方需要时报错(扫描/遍历出错 由调用方带上下文记录)。
func (*Store) TicketHasEvent ¶
TicketHasEvent 判定某工单的通知事件(permission_request / question)是否已落库。
参数:
- taskID: 任务 ID(事件按任务分区存储)
- ticketID: 工单 ID,与事件 payload 里的 ticket_id 精确比对
返回:
- 是否已有对应通知事件
- 数据库错误
注意:
- 用途是「工单已创建但通知事件缺失」的自愈判定(崩溃恰好落在两次写之间): 仅凭工单存在就认定为重放,会把协调者的唤醒事件永久吞掉
- payload 是 JSON 文本,这里取回后在 Go 侧精确解码比对,不用 LIKE 匹配 (ticket_id 含 `_` 等 LIKE 通配符,字符串匹配会误判)
- 单任务的问答类事件量级在几十条,全量扫描代价可忽略;且只在重放分支调用
func (*Store) TouchSession ¶ added in v0.3.0
TouchSession 写回会话的最后活跃时刻与过期时刻(滑动续期)。
注意:调用方负责节流——本方法每次调用都真写库
func (*Store) UndeliveredAnswers ¶
UndeliveredAnswers 返回任务里「已应答但未送达 executor」的工单,按应答时间升序。
参数:
- taskID: 任务 ID
返回:
- 待重投的工单列表(可能为空)
- 数据库错误
注意:
- 作废工单(answer = VoidAnswer)不在其中:它们是「任务已终结,不会再被回答」 的墓碑,不是待送达的裁决
- 这是「协调者 reply 得到 502 之后」的可恢复面:列表非空即说明有裁决卡在半路
func (*Store) UpdateProjectLocation ¶ added in v0.3.0
func (s *Store) UpdateProjectLocation(name, newName, newPath string) (proto.ProjectLocation, error)
UpdateProjectLocation 改一条位置登记的引用名与/或路径。
参数:
- name: 当前引用名(定位用)
- newName: 新引用名;空串表示不改
- newPath: 新路径;空串表示不改
返回:更新后的记录;
- ErrNotFound:name 不存在
- ErrProjectDuplicate:新名字或新路径已被占用
func (*Store) UpdateTaskState ¶
UpdateTaskState 将任务状态迁移到 st,迁移合法性由 proto.CanTransit 校验。
参数:
- id: 任务 ID;不存在返回 ErrNotFound
- st: 目标状态
返回:
- 非法迁移返回 ErrBadTransit;任务不存在返回 ErrNotFound
注意:
- 非法迁移是本包唯一打 Warn 日志的点:排障时可直接定位谁想从哪迁到哪被拒
- 写回采用 CAS(WHERE state = 读到的旧状态):若并发写者先变更了状态,本方法返回 ErrBadTransit,调用方应用最新快照重试意图;不会静默覆盖并发迁移
func (*Store) UpsertMirrorTask ¶ added in v0.3.0
UpsertMirrorTask 覆盖式写入(或更新)一条任务快照。
参数:
- target: 任务所在机器的名字(本机 cfg.Targets 的键)
- task: 任务体 JSON,整体序列化进 snapshot 列
- fetchedAt: 本次快照的时刻(UI 据此显示数据新旧)
注意:
- upsert 语义:同 task_id 重复调用用新快照覆盖旧快照,不报错
func (*Store) UpsertSpend ¶ added in v0.3.0
func (s *Store) UpsertSpend(taskID string, e proto.SpendEntry) error
UpsertSpend 记一条消耗账目;同 (taskID, e.Key) **覆盖**既有行。
参数:
- taskID: 所属任务
- e: 账目。三个 token 分项的口径是归一化后的值——**输入不含缓存**、 **缓存输入 = 读缓存 + 写缓存**、**输出含 reasoning**。四家 executor 的 原始字段含义互不相同(codex/grok 的输入含缓存要减,claudecode/opencode 的要加;opencode 的 reasoning 与 output 平行要加,codex/grok 的是子集 不能加),归一化在各 adapter 的 spend.go 里完成,本方法不做换算。
注意:
- e.Key 为空时直接返回错误——没有键就没有幂等,宁可报错也不写一行永远 去不掉重的账
- 覆盖而非累加是刻意的,理由见 proto.SpendEntry 的注释
- **不打成功日志**:频率与 assistant 消息同级,会刷屏;错误由调用方 handleSpend 打(见 Task 2)
func (*Store) VoidPendingTickets ¶
VoidPendingTickets 把任务全部未回答工单作废(answer 置为 VoidAnswer)。
参数:
- taskID: 任务 ID
返回:
- 被作废的工单数(本次更新行数)
注意:
- 幂等:重复调用第二次起返回 0(已作废的工单不再更新)
- 不删除工单:request/answered_at 等审计痕迹保留,回答语义上视为已终结; 回答过的工单不受影响
- 由 agentd 启动恢复(RecoverOnStartup)在判定任务 dead 后调用;hub 侧等待 者随进程消亡不存在,无需清理