diff --git a/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md b/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md index de151cf64..ec3b19505 100644 --- a/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md +++ b/.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md @@ -4,148 +4,197 @@ ## 先说结论 -日志接入以共享事件目录声明事实,由生成的类型约束字段;Core 负责通道、单行格式、查询和保留计划,平台负责实际文件操作。PR2 已把 macOS 生产写入器(负责排队和写盘的组件)与宿主调用点接到 v2;Windows 继续使用 v1。macOS 升级前的 v1 文件不显示、不解析,只参与保留计划并按 30 天自然过期。PR3 已在 Core 实现角色通道、上游诊断、有界日志队列与插件目录,并接入 macOS 统一写入器和诊断开关。PR4 让链接进 Core 的辅助 crate 经 Core 注入的函数指针进入同一队列,日志门禁覆盖这些 crate。后续接入必须使用已校验的身份生成通道,不能把显示名称或用户输入变成目录。 +日志的每一种事实都先在共享事件目录里声明,再由生成的类型约束字段。Core 负责通道、单行格式、脱敏、查询和保留计划,平台只负责实际文件操作。macOS 全部写入 v2 会话文件,Windows 继续使用 v1。排查问题时,维护者能顺着同一个 op(操作关联身份)看到三层轨迹:用户按了什么,Core 执行了哪个请求,以及 Git 命令带着哪些已脱敏参数、怎样结束。 ## 问题 -旧链路有五类问题:直接使用系统日志或标准错误输出会漏收;字符串调用没有共同的事件与字段规范;自由文本格式无法作为可靠解析契约;设置页时间线与语言服务内存日志是两个不同证据源;逐事件跨语言调用、过滤前编码及反复打开文件增加开销。 +旧链路有五类问题: -旧 Note 用固定分类加范围标签(scope,用来细分功能的字符串)限制目录数量,阻止任意调用方字符串进入路径。这保护了路径安全,却不能自然表达语言服务器、版本控制提供者和插件各自的日志,也无法给插件原生日志一个会话内的独立位置。 +- 直接使用系统日志或标准错误输出,日志会漏收。 +- 字符串调用没有共同的事件与字段规范。 +- 自由文本格式不能作为可靠的解析契约。 +- 设置页时间线和语言服务内存日志是两个不同的证据源。 +- 逐事件跨语言调用、在过滤之前编码,以及反复打开文件,都增加开销。 -## 决策 +旧 Note 用固定分类加范围标签(scope,用来细分功能的字符串)限制目录数量,阻止任意调用方字符串进入路径。这保护了路径安全,但不能自然表达语言服务器、版本控制提供者和插件各自的日志,也无法给插件原生日志一个会话内的独立位置。 + +v2 第一版接入后还有一个缺口:日志只记录了宿主认为值得记录的结果,看不到用户按了哪个按钮、按钮背后调用了哪个 Core 请求、实际运行了哪条 Git 命令。Git 的生命周期日志写在 Swift 层,只覆盖部分操作,也不知道 Core 实际传给进程的参数和 stderr。Bug 报告里常见的“点了 Fetch 没反应”因此无法从日志定位。 -### 取代范围与阶段边界 +## 决策 -本 Note **部分取代** [双端模块日志与诊断快照](2026-10-20-modular-application-logging.md) 中固定分类目录、scope 作为主要细分方式和 v1 记录格式的设计决定。旧 Note 的脱敏、导出冻结、平台 I/O 所有权及未迁移的 Windows 行为继续有效;它没有被完全取代,不移入 archived。 +### 取代范围 -PR1 已实现的是纯计算规则、共享事件目录、Swift 事件类型与 span、Rust 静态元数据、生成器、用法检查及存量基线。span 是同一次操作的计时记录,成功或失败只提交一个终态。四个 v2 命令是显式调用才生效的新入口;v1 公开项和 dispatcher 命令继续兼容。字段目录校验与通用批量编码是独立能力,不能因为编码器可接收标量就绕过事件目录。 +本 Note **部分取代** [双端模块日志与诊断快照](2026-10-20-modular-application-logging.md) 中三项设计:固定分类目录、以 scope 作为主要细分方式,以及 v1 记录格式。旧 Note 中的脱敏、导出冻结、平台 I/O 所有权和 Windows 的现有行为继续有效。它没有被完全取代,因此不移入 archived。日志窗口与诊断导出的决策见 [应用级日志窗口与诊断导出](../feature/2026-11-03-structured-logging-log-window.md)。 -macOS 的宿主调用点、类型化写入与诊断收集已落地。PR4 的日志窗口与导出决策见 [应用级日志窗口与诊断导出](../feature/2026-11-03-structured-logging-log-window.md)。组件验证不能替代真实产品工作流验收,Windows 未迁移。 +Rust 的 v1 公开 API、旧 dispatcher 命令和共享的只读兼容查询继续保留,供 Windows 使用。v2 命令都要显式调用才生效。字段目录校验和通用批量编码是两项独立能力:编码器能接收标量,不等于可以绕过事件目录。 ### 宿主依赖与操作所有权 -组合根创建一个进程拥有的 `MacLogStore`,通过 `AppServices.logSink`、服务构造器和 `ModuleContext.logSink` 传入相同的 `LogSink`。`LogScope` 保存同一操作的 `operationID` 和可信身份;生成事件仍决定通道,scope 不能改写路由。成对操作由一个调用方持有生成 span,开始使用单调时钟,成功或失败只提交一次终态。 +组合根(负责组装依赖的地方)创建进程唯一的 `MacLogStore`,再通过 `AppServices.logSink`、服务构造器和 `ModuleContext.logSink` 传入同一个 `LogSink`。`LogScope` 保存同一操作的 `operationID` 和可信身份。生成的事件决定通道,scope 不能改写路由。成对的操作由一个调用方持有生成的 span(同一次操作的计时记录):开始时取单调时钟,成功或失败只提交一次终态。 + +不要新增全局可变的日志单例。单例会隐藏依赖,混淆测试和退出时的所有权,还会让多个项目各自的生命周期影响进程日志。启动环境和进程适配器的创建早于 writer,它们最多先保留八条类型化事实,等应用注入 sink 后再交出。不要为此把错误改成自由字符串,也不要为早期调用另建 writer。 + +唯一的例外是 `UserActionTrace.applicationSink`。用户入口散布在菜单、命令面板和插件动作里,许多入口拿不到服务对象。组合根在创建 `MacLogStore` 时设置一次这个只在主线程访问的引用,测试可以给每次调用显式传入 sink。 + +正确做法:把 sink 作为构造器参数传入服务,在拥有恢复决定或告知用户的边界 emit。不要这样做:底层打印错误后抛出,每一层再重复打印;也不要把 stdout、环境变量值、源码或协议 detail 塞进 error 或 text 字段。 + +各类失败的结构化来源如下: + +- Core 在已有 log 事件的 error 组件里附加维护告警分类。 +- LSP 的拥有者通过独立的结构化回调传递 WARN 或 ERROR 的阶段、受控错误分类和进程退出码。组合根不从自由文本猜错误。 +- Git 命令和 Core 请求的失败由 Core 轨迹记录:Core 错误码保存为 `error_code`,进程退出码保存为 `exit_code`,两者不合并。 +- 更新下载保留已有的失败枚举和 HTTP 状态。 +- freeze 无论成功或失败,都保留相同的计数、屏障状态和完成时的 native revision,不记录正文。 + +### 执行轨迹分三层,由同一个 op 串起来 + +| 层 | 事件 | 记录位置 | +| --- | --- | --- | +| 用户操作 | `app.action.performed` | 最外层 UI 入口的 `UserActionTrace` | +| Core 请求 | `app.core.request.*` | Core 请求分发处,实现在 `diagnostics/request_trace.rs` | +| 外部进程 | `vcs.git.command.*` | Core 的 Git 进程执行点,实现在 `git/command_trace.rs` | + +**用户操作。** 每个用户入口在最外层调用 `UserActionTrace.run` 或 `UserActionTrace.action`。它记录一条 INFO,并把操作 id、surface、op 和开始时间绑定到 `UserActionContext.current`。嵌套调用不记录也不重新绑定,所以共用的模型方法可以再包一层,外层 id 生效。id 是稳定的 catalog token,例如 `workspace.remote.test`、`vcs.git.fetch`、`menu:`,不能使用本地化标题或用户内容。 + +**跨越异步边界。** `Task {}` 继承当前操作,`Task.detached` 和 GCD 不继承,因此用 `UserActionContext.detached` 和 `UserActionContext.mainAsync` 显式携带。桥接层的 `RustCoreBridge.RequestOrigin.resolve` 把当前操作转成请求的可选 `origin {source, trigger, operation}`。操作开始超过 60 秒后不再声称 `source: user`,但保留 `trigger` 和 `operation`。这样运行会话之类长期存在的后续请求仍能关联到最初的点击,又不会被当成用户正在等待的工作记成 INFO。 + +**Core 请求。** Core 在 `runtime/dispatcher.rs` 分发每个请求时,把 op 和来源放进线程局部上下文,并在请求结束时记录唯一的终态。op 依次取 `origin.operation`、请求的 `operationId` 和请求 `id`。领域代码不再为“请求开始”“请求完成”另加日志,因此工作区、插件、运行和构建按钮背后的调用都有记录,不需要逐个调用点接入。按键、每帧、轮询和监听批次的方法列在 `request_trace::is_hot` 里,不记录;`diagnostics.*` 和 `core.ping` 永远不记录,日志系统不能记录自己的排空和查询。 + +**外部进程。** Core 启动的每个 Git 进程在执行点记录一条终态,本地和远程工作区都经过同一处。argv 只经过 `format_arguments` 进入日志:提交说明换成 ``,凭据和个人身份类 `-c` 值换成 ``,URL 用户信息被改写。stderr 只保留最后 8 个非空行,逐行脱敏。stdout、stdin 和环境变量永远不记录。退出码表示“回答”的情形(例如 `git diff --quiet` 的 1)不算失败。可见命令指经 `execute_git` 执行、作为 invocation 返回给宿主并显示在 Git 控制台的命令;内部读取指 Core 经 `capture_git` 执行、自己解析输出的命令。来源不是用户的内部读取,非零退出由调用方判断含义;调用方据此返回错误时,由请求轨迹记录失败。 + +**级别。** 用户来源的请求和用户来源的可见命令,成功或取消是 INFO(`finished`)。后台或未知来源的成功或取消,以及用户操作中内部读取的成功或取消,是 DEBUG(`traced`),只在详细诊断开启时落盘。失败是 WARN(`failed`)。取消是一种结局,不是失败,所以记在成功终态上,并带 `cancelled`。 + +### 失败级别与宿主的边界 + +Core 轨迹的失败固定为 WARN,因为 Core 不知道宿主会重试、降级还是放弃。ERROR 留给知道最终结果的那一层。 + +宿主要告知用户的失败,如果事实已经全部在同一 op 的 Core 失败轨迹里,提示的“查看日志”就用 `LogRecordReference` 引用这条轨迹,宿主不再记录。Git 的规则在 `GitService.failureReference`:有进程以非零状态退出时引用 `vcs/git` 的 `vcs.git.command.failed`;没有这样的进程而请求失败(参数校验失败、仓库不可用)时引用 `app/core` 的 `app.core.request.failed`;进程全部以 0 退出、只是操作结果报告失败时,Core 没有失败轨迹,不返回引用,由调用方记录自己的终态。宿主只在掌握 Core 没有的事实时,才记录自己的终态:降级到了哪条路径、重试几次后放弃、留下了什么残留。最终没有完成用户请求时,这条终态是 ERROR。 + +由此产生的可见变化是:用户可见的 Git 失败以前由 Swift 记成 ERROR,现在是一条 WARN 级别的 Core 轨迹,由通知引用。原来的 `MacGitLifecycleLog` 已删除。 + +正确做法:`pendingNotificationReference = result.logReference`。不要这样做:为同一次失败另声明一个宿主事件,再记一次 ERROR。这样一次失败会有两条终态,而且宿主那条没有 argv 和 stderr。 + +### Core 事件队列、DEBUG 门与排空 + +现有的请求事件流只覆盖单个请求,覆盖不到执行轨迹、析构、后台清理和辅助 crate 的失败。因此 Core 有一个独立的有界交接队列 `diagnostics::core_events`(交给宿主写入的内存队列): + +- 只接纳 owner 为 `lithe-core` 的目录事实,入队前完成规范化和脱敏,最多 256 条。 +- 队列满时,新事实只计入丢弃数。同一批次里事件、通道、字段和 op 都相同的事实只保留一条。 +- 队列没有文件 writer、后台任务或独立持久化。宿主一次 drain 取走全部事实,同时重置计数,丢弃汇总转成 `app` 通道的 `log.dropped`。 -不要新增全局可变日志单例:这会隐藏依赖、混淆测试与退出所有权,并让多个项目各自的生命周期影响进程日志。现有启动环境与进程适配器早于 writer 创建,最多保留八条类型化事实,之后由应用注入 sink 并排空;不把错误改成自由字符串,不为早期调用另建 writer。 +DEBUG 在入队前过滤。`MacLogStore` 在详细诊断开关或通道根集合变化时,调用 `diagnostics.configureCoreEvents { debug, roots }`,默认关闭。被拒绝的 DEBUG 直接返回成功,不计入丢弃数。构造字段前先调用 `core_events::accepts`,使后台轨迹在正式使用时只花一次分支判断。只在宿主侧过滤是行不通的:一次后台刷新就会产生几十条 DEBUG,占满 256 条队列后,之后的 WARN 和 ERROR 都会被丢弃。 -正确做法:把 sink 作为构造器参数传入服务,在拥有恢复或告知用户决定的边界 emit。不要这样做:底层打印错误后抛出,再让每层重复打印;也不要把命令输出、环境变量值、源码或协议 detail 塞进 error/text。Core 在已有 log 事件的 error 组件中附加维护告警分类;LSP 的拥有者通过独立结构化回调传递 WARN/ERROR 的阶段、受控错误分类和进程退出码,不在组合根从自由文本猜错误。Git 命令与请求各自保留真实失败终态,Core 字符串错误码通过 LogFailure 保存为 error_code,并与进程退出码分别保存;更新下载保留已有失败枚举与 HTTP 状态。freeze 的失败和成功都保留相同计数、屏障状态及完成时 native revision,不记录正文。 +排空时机: + +- 写入器在每次写入批次、flush 和退出时排空一次。 +- 没有其他宿主活动时,由 Core 的唤醒回调安排排空。插件 `lithe/log` 事实、后台 Git 轨迹、析构和后台清理都可能在没有宿主事件时产生;只靠写入器自己的节奏,它们会一直留在内存里,既不落盘,也不推给日志窗口。 + +Core 在队列旁保留一个唤醒回调槽位。drain 之后的第一条事实或第一次丢弃计数触发一次回调,同一个 drain 周期内的后续变化合并,不再回调。drain 在队列锁内清除请求标记,之后入队的事实一定会再次触发;安装回调时已有待取事实,也立即回调。回调在持有槽位锁时同步执行,移除也取这把锁,所以移除返回后 Core 不会再使用宿主的 context,宿主可以释放它。回调不得重入 Core,只安排宿主已有的 drain。这个槽位属于已有的进程队列,不是新的日志单例:它不写文件、不持有 sink,服务的日志依赖仍从构造器注入。 + +每个进程只有组合根创建的应用 writer 调用 `observeCoreFacts()` 安装回调。回调复用宿主事件已有的 100 ms 单次排空调度,已有调度时不重复安排,不增加空闲轮询或第二个 writer。测试和其他临时 `MacLogStore` 不安装,避免两个 writer 争抢同一个进程队列;只有在独立子进程里验证 Core 落盘的测试自己安装。`finishSession` 先停止接纳并移除回调,再做最后一次排空。 + +排空得到的 `CoreLogRecord` 只含标量,保留 event、目录级别、channel、op 和源 ts;`MacLogStore`、`LogScope` 和测试用的 `TypedLogRecorder` 都实现了它。同一个 writer 分配 seq,并以 `validateCatalog: true` 写入。相同的失败状态只记录一次 `app.logging.diagnostics_failed`,避免出现“drain → 失败日志 → drain”的循环;目标状态改变后允许重试。 ### 入队、批量和资源上限 -`emit` 只在短锁内按级别和详细诊断分类快照过滤、入队;默认关闭 DEBUG,详细诊断持续 15 分钟。队列最多 8000 条,普通级别最多使用 6000 条,剩余 2000 条留给 WARN/ERROR;过载累计 `log.dropped`,不在调用线程执行 I/O。每 100 ms 或达到 256 条时交给 Core 批量目录校验、脱敏和编码。 +`emit` 只在短锁内按级别和详细诊断分类的快照过滤并入队。DEBUG 默认关闭,详细诊断持续 15 分钟。队列最多 8000 条,普通级别最多占用 6000 条,剩余 2000 条留给 WARN 和 ERROR。过载时累计 `log.dropped`,不在调用线程执行 I/O。写入器每 100 ms 或攒够 256 条时,交给 Core 批量做目录校验、脱敏和编码。 -单一串行 I/O 队列拥有序号、会话文件、目录切换和保留执行。句柄最多 32 个,按最近使用次序淘汰;Core 以事件身份、字段、op 和通道的结构哈希合并相邻重复,跨批携带上一条输入,不逐条序列化判重。广播仅包含已编码证据,最近窗口上限为 2000 条或 2 MiB;最多 16 个订阅者,每个订阅缓存最多八批,窗口关闭必须取消订阅任务。 +一个串行 I/O 队列拥有序号、会话文件、目录切换和保留执行,句柄最多 32 个,按最近使用淘汰。Core 用事件身份、字段、op 和通道的结构哈希合并相邻重复,并跨批次携带上一条输入,不逐条序列化判重。广播只包含已编码的证据,最近窗口上限为 2000 条或 2 MiB;最多 16 个订阅者,每个订阅最多缓存八批,窗口关闭时必须取消订阅任务。 -控制操作最多同时接纳四项,串行执行。每项持有包含取消状态和单调截止时间的操作令牌;开始前检查,查询、目录切换和发布控制结果前再次检查。已接纳日志的一次有界 drain(排空快照)由进程 I/O 队列拥有,独立于控制操作取消,必须写完该快照及丢弃汇总后才报告控制超时,不能让取消丢掉后缀。原生同步文件操作无法中途撤销,截止时间不是强制中断磁盘 I/O 的时限;调用方只在队列停止修改后恢复。退出使用独立接纳路径,先停止接纳,无论 drain 成功或失败都在队列上关闭句柄、结束订阅;任务取消或四个普通控制已占满不能跳过清理。 +控制操作最多同时接纳四项,串行执行。每项持有带取消状态和单调截止时间的操作令牌,在开始前、查询前、目录切换前和发布控制结果前检查。已接纳日志的一次有界 drain 由进程 I/O 队列拥有,与控制操作的取消无关:必须写完这批快照和丢弃汇总,才报告控制超时,不能因为取消而丢掉后半部分。原生同步文件操作无法中途撤销,截止时间不是强制中断磁盘 I/O 的时限,调用方只在队列停止修改后恢复。退出走独立的接纳路径:先停止接纳,无论 drain 成功或失败,都在队列上关闭句柄、结束订阅;任务取消或四个普通控制已占满,都不能跳过这一步。 ### 收集预算与文件替换恢复 -原生枚举只提供路径、长度和修改时间,Core 先做分类和会话筛选,再分配 200 个文件、单文件 2 MiB、总计 10 MiB 的读取预算。相同会话先读所有活动文件,再读轮转 1、轮转 2;同层按修改时间从新到旧,最后按路径稳定排序。不能按文件名字倒序分配预算,否则 `z~2.log` 会挤掉仍在写入的 `a.log`。通道根到现有六类设置筛选的映射也由 Core 持有,update、remote、agent 继续出现在 Application,terminal 与 debug 进入 Run;详细诊断使用同一映射。 +原生枚举只提供路径、长度和修改时间。Core 先做分类和会话筛选,再分配读取预算:200 个文件、单文件 2 MiB、总计 10 MiB。同一会话先读所有活动文件,再读轮转 1、轮转 2;同一层按修改时间从新到旧,最后按路径稳定排序。不能按文件名倒序分配预算,否则 `z~2.log` 会挤掉仍在写入的 `a.log`。通道根到现有六类设置筛选的映射也由 Core 持有:update、remote、agent 出现在 Application,terminal 和 debug 进入 Run,详细诊断使用同一映射。 -缓存句柄每次写入前比较原生设备号与 inode(文件在磁盘上的身份)、链接数及文件长度。路径被删除、原子替换或截断时,关闭旧句柄,重新建立文件头并打开活动路径;不可写时使用既有默认目录回退。不能只检查路径存在:原子替换后路径还在,旧句柄仍会悄悄写入失去路径的旧文件。 +缓存的句柄每次写入前,比较原生设备号和 inode(文件在磁盘上的身份)、链接数和文件长度。路径被删除、被原子替换或被截断时,关闭旧句柄,重新写文件头并打开活动路径;不可写时使用既有的默认目录回退。只检查路径是否存在是不够的:原子替换后路径还在,旧句柄却会悄悄写进已经失去路径的旧文件。 ### macOS 只收集 v2 -设置时间线和诊断导出只读取 `sessions/` 下的 v2 文件,未安装 collector 时仅导出系统快照。旧 `/lithe.*.log` 和根目录 `lithe.log` 不显示、不解析、不改写,也不迁移到新会话。文件清单仍交给 Core 保留计划,旧文件只按年龄删除,不能因总量压力提前删除。Rust 的 v1 公开 API、旧 dispatcher 命令及共享只读兼容查询能力保持不变,供 Windows 等现有消费者使用。 +设置时间线和诊断导出只读取 `sessions/` 下的 v2 文件,没有安装 collector 时只导出系统快照。旧的 `/lithe.*.log` 和根目录 `lithe.log` 不显示、不解析、不改写,也不迁移到新会话。它们仍出现在交给 Core 保留计划的文件清单里,只按年龄删除,不会因为总量压力被提前删除。 ### 按会话组织,以受控通道决定相对路径 -会话是一次应用进程运行。Core 从宿主传入的开始时间和完整 UUID(会话唯一编号)生成 `sessions/YYYY-MM-DDTHH-MM-SS_<8位小写十六进制>/`,不读取时钟或文件系统。 +一个会话就是一次应用进程运行。Core 根据宿主传入的开始时间和完整 UUID(会话唯一编号)生成 `sessions/YYYY-MM-DDTHH-MM-SS_<8位小写十六进制>/`,自己不读取时钟或文件系统。 -通道是“固定根 + 已校验 ID 段”,不是自由路径。根的封闭集合及段语法以 `shared/logging/README.md` 和共享 Core 契约为准;通道支持根、非插件根/段及插件宿主的 `plugins//host`。Core 生成如 `app.log`、`vcs/git.log` 和 `plugins//host.log` 的会话相对路径。轮转文件使用 `git~1.log`,分隔符 `~` 不属于通道字符集,避免合法 ID `git.1` 的活动日志与 `git` 的轮转相互覆盖。段还拒绝末尾点号和 Windows 设备名,使同一身份在双端都能安全落盘。每会话接纳最多 128 个通道,新超额通道映射到会话根的 `_overflow.log`,已经接纳的通道保留路径。溢出文件的头使用 `_overflow`,轮转沿用相同规则,超额通道也受单文件预算约束。 +通道是“固定根 + 已校验的 ID 段”,不是自由路径。根的封闭集合和段的语法以 `shared/logging/README.md` 和共享 Core 契约为准。Core 生成 `app.log`、`vcs/git.log`、`plugins//host.log` 这样的会话相对路径;轮转文件为 `git~1.log`。分隔符 `~` 不属于通道字符集,因此合法 ID `git.1` 的活动日志不会和 `git` 的轮转文件互相覆盖。段还拒绝末尾点号和 Windows 设备名,同一身份在双端都能安全落盘。每个会话最多接纳 128 个通道,之后新增的通道映射到会话根的 `_overflow.log`,已接纳的通道保留原路径;溢出文件使用相同的文件头和轮转规则,也受单文件预算约束。 -这里明确推翻“调用方字符串不进目录”的绝对限制,改成“只有来自已校验插件、provider、server 或内部连接身份的 ID 才能进入目录”。理由是这些身份有明确所有者、稳定格式和数量预算,可以同时表达证据来源与插件隔离;任意 scope、主机名、显示名称和用户文本仍不能进入目录。语法校验只证明字符安全,调用方还必须证明 ID 的真实来源。 +这里明确推翻“调用方字符串不进目录”的绝对限制,改为:只有来自已校验的插件、provider、server 或内部连接身份的 ID,才能进入目录。这些身份有明确的所有者、稳定的格式和数量预算,可以同时表达证据来源和插件隔离。任意 scope、主机名、显示名称和用户文本仍然不能进入目录。语法校验只证明字符安全,调用方还必须证明 ID 的真实来源。 -正确做法:从已验证的 provider 身份得到 `vcs/git`,交给 Core 解析相对路径。不要这样做:把远程主机名、项目路径或命令行作为段名,即便它碰巧通过字符校验。 +正确做法:从已验证的 provider 身份得到 `vcs/git`,交给 Core 解析相对路径。不要这样做:把远程主机名、项目路径或命令行当作段名,即使它恰好通过了字符校验。 -平台将相对路径连接到实际生效的可写日志根,负责防止原生路径和符号链接越界;不得连接到 app bundle 或安装目录。日志是用户运行状态,不参与签名和 Sparkle 增量基线,不得跨 worktree 复制。PR1 类型和生成物没有运行时文件操作。 +平台把相对路径连接到实际生效的可写日志根,负责防止原生路径和符号链接越界,不得连接到 app bundle 或安装目录。日志属于用户运行状态,不参与签名和 Sparkle 增量基线,不跨 worktree 复制。生成的类型和生成物没有运行时文件操作。 ### 单行格式是契约,重复身份放文件头 -每个 v2 文件的第一行表示会话、进程、应用版本、通道和开始时间;记录行只保留每条变化的事实。例如: +每个 v2 文件的第一行记录会话、进程、应用版本、通道和开始时间;记录行只保留每条变化的事实。例如: ```text #lithe-log v2 session=12345678-1234-1234-1234-123456789abc pid=5123 app=0.42.0 channel=vcs/git started=2026-11-03T08:00:00.000Z -2026-11-03T08:00:01.123Z ERROR seq=42 event=vcs.git.command.failed op=7f3a msg="Git fetch failed" command=fetch duration_ms=311 error="fetch failed" error_code=128 error_domain=git provider=git +2026-11-03T08:00:01.123Z WARN seq=42 event=vcs.git.command.failed op=7f3a msg="Git fetch failed" arguments="fetch --prune origin" command=fetch duration_ms=311 exit_code=128 repository=/src/app source=user stderr="fatal: could not read from remote repository." ``` -时间统一为 UTC 毫秒;级别封闭为 DEBUG、INFO、WARN、ERROR;顺序为时间、级别、`seq`、`event`、可选 `op`、`msg`,其余字段按键排序。`seq` 是宿主分配的会话内单调序号,不补零;无状态编码器不能证明跨批次单调性。`op` 是操作关联身份,不是事件字段或路径段。 +时间统一为 UTC 毫秒。级别封闭为 DEBUG、INFO、WARN、ERROR。字段顺序为时间、级别、`seq`、`event`、可选的 `op`、`msg`,其余字段按键排序。`seq` 是宿主分配的会话内单调序号,不补零;无状态的编码器无法证明跨批次单调。`op` 是操作关联身份,不是事件字段,也不是路径段。 -编码器和查询复用结构化字段脱敏,先检查完整字段名(包括 `client_secret`、`auth_token` 等前缀形式),再处理文本;不能只把字段拼成字符串后依赖文本正则的单词边界。编码器负责转义与 8 KiB 单行预算,换行只作为转义值存在。超长优先缩短上下文字段,再缩短消息,并标记 `truncated=1`;身份与键开销仍无法容纳时返回错误,不能悄悄删键。重复聚合与队列丢弃采用普通 `log.repeated`、`log.dropped` 事件。精确语法、错误码及边界由 `shared/contracts/rust-core-api.md` 和 `shared/fixtures/diagnostics/logging-v2.json` 持有,不在 Note 中另建字段手册。 +编码器和查询复用结构化字段脱敏:先检查完整字段名(包括 `client_secret`、`auth_token` 这类前缀形式),再处理文本。不能把字段拼成字符串后,只依赖文本正则的单词边界。编码器负责转义和 8 KiB 的单行预算,换行只作为转义值出现。超长时先缩短上下文字段,再缩短消息,并标记 `truncated=1`;身份和键的开销仍放不下时返回错误,不能悄悄删键。重复聚合和队列丢弃使用普通的 `log.repeated`、`log.dropped` 事件。精确语法、错误码和边界由 `shared/contracts/rust-core-api.md` 和 `shared/fixtures/diagnostics/logging-v2.json` 持有,本 Note 不另建字段手册。 ### 事件目录与生成类型约束接入 -`shared/logging/events/` 是事件声明的唯一来源,声明真实来源、级别、通道、英文事实模板、字段含义、owner(维护模块)和限流元数据。无真实来源的根保持空目录,不预造事件。事件消息供诊断使用;面向用户的界面文案仍走共享 i18n。 - -生成器输出 `LogEvents.generated.swift` 和 `catalog_generated.rs`。Swift 事件输入尚未脱敏,不能自行序列化落盘;Rust 静态表提供事件查找和字段形状校验,不分配运行时目录缓存。macOS 宿主已连接目录校验、共享脱敏与编码,不能把“生成类型”误当作已经安全的文本。Core 对 error/text 原始输入先脱敏、去控制字符、按 UTF-8 边界截断至 1 KiB,再严格校验字段;不得在 Swift 重写隐私规则或放宽 validate_fields。日志身份转换不得影响插件资源是否加载;语言 ID 与通道段并非同一集合,c++ 及超长合法语言 ID 都必须继续加载。浏览器返回的语法加载失败同样始终记录插件事件,语言使用有界的可选 version 字段,不能因 LogSegment 拒绝它就丢掉整条失败。 - -新增或迁移调用先修改事件 JSON,再生成并使用类型化事件;不手写任意消息、级别或字段字典绕过目录。具体操作规范见 develop-lithe 的日志接入 reference,声明规则唯一来源为 `shared/logging/README.md`。 +`shared/logging/events/` 是事件声明的唯一来源,声明真实来源、级别、通道、英文事实模板、字段含义、owner(维护模块)和 rate 元数据。没有真实来源的根保持空目录,不预造事件。事件消息供诊断使用;面向用户的界面文案仍走共享 i18n。 -用法门禁按文件和规则核对旧调用的精确计数。基线只减不增,迁移后必须删除对应存量计数,不能换位置藏违规;永久例外另有明确架构理由与精确计数。该检查覆盖已列出的直接写法,不是跨文件别名分析,也不能代替人工审查。目录中的 rate 只是元数据,PR1 不执行运行时限流。 +生成器输出 `LogEvents.generated.swift` 和 `catalog_generated.rs`。Swift 的事件值是尚未脱敏的输入,不能自行序列化落盘。Rust 静态表提供事件查找和字段形状校验,不分配运行时目录缓存。Core 对 error 和 text 的原始输入先脱敏、去除控制字符、按 UTF-8 边界截断到 1 KiB,再严格校验字段。不得在 Swift 重写隐私规则,也不得放宽 `validate_fields`。 -### PR3:由引擎声明身份,macOS 接入统一写入器 +日志身份转换不得影响插件资源是否加载。语言 ID 和通道段不是同一个集合,`c++` 和超长的合法语言 ID 都必须继续加载。浏览器返回的语法加载失败始终记录插件事件,语言放在有界的可选 version 字段里,不能因为 `LogSegment` 拒绝它就丢掉整条失败。 -同一个引擎承载语言服务、版本控制、构建、工具链和插件后端,provider ID(提供者身份)可能含冒号,不能直接成为目录段。宿主显式传 `role` 和 VCS 的 `logSubject`,Core 校验身份并选择目录事件族:语言服务用 `lsp.*`,VCS 用 `vcs.*`,其他插件进程用 `plugins.host.*`。目录模板和必填身份字段仍唯一决定通道,不扩大 schema 允许任意 channel。旧宿主不传 role 时继续按语言服务处理。macOS 各启动者显式传角色和插件 ID,VCS 传真实 target.vcs;远程语言会话仍属于 language_server。启动响应返回 Core 生成的 channel,宿主用它登记详细诊断 I/O。 +新增或迁移调用时,先修改事件 JSON,再生成并使用类型化事件;不手写任意的消息、级别或字段字典来绕过目录。操作规范见 develop-lithe 的日志参考页,声明规则的唯一来源是 `shared/logging/README.md`。 -合法语言 ID 不等于合法路径段。遇到非法段或缺少身份时,Core 用原始身份的 SHA-256 前 32 位小写十六进制加 `invalid-` 前缀;缺值时哈希完整 provider ID。这样不会阻断进程启动或静默丢日志,也不把原文泄漏成目录。正确做法是 VCS 宿主从已注册目录传 `role:vcs, logSubject:git`;不要拆 `vcsProvider::` 猜身份。 +用法门禁没有存量额度,任何违规都直接失败。永久例外写进 allowlist,带架构理由和精确计数;计数少于或多于实际都失败,防止空出来的名额被新写法占用。门禁只覆盖列出的直接写法,不做跨文件的别名分析,也不能代替人工审查。目录中的 rate 只是元数据,写入器还不执行运行时限流;需要限流的高频失败在发送侧自行合并(见辅助 crate 一节)。 -引擎在日志源头选择封闭的事实类型,既有英文 message/detail/error 留作 v1 兼容;v2 使用目录 ID 和标量字段,经过同一个 Core 目录规范化、脱敏与通道推导边界。stderr 的读取状态最多保留 4 KiB 半行,按 LF 切分、去末尾 CR、忽略空行并冲刷 EOF 残余;每单调秒接纳 200 行,后续读取跨窗或结束时汇总丢弃计数。测试直接传毫秒值,不装计时器、不等真实一秒。text 落盘前仍执行 1 KiB 规范化。 +### 引擎声明身份,macOS 接入统一写入器 -`window/logMessage` 与 `window/showMessage` 的明确严重度选择独立目录事件:上游 error 为 ERROR、warning 为 WARN、info 为 INFO、详细或未知类型为 DEBUG。未分类 stderr 仅作为 DEBUG 排障证据,不能因为输出在标准错误流就标成警告;最终进程或协议失败在实际失败边界记录 ERROR。其他通知只记录方法,不把协议正文变成 text。trace 默认关闭,显式开关与发往上游的 `$/setTrace` 共用有序写锁,成功写入后才改变采集状态;只接纳限长并脱敏的 message 摘要,排除可能含 RPC 原始载荷和源码的 verbose。凭据脱敏和截断无法把源码转换为可落盘诊断;旧内存 detail 的兼容不代表允许把它写入 v2。macOS 已删除消息字符串匹配和重复 session 状态事实,按目录元数据取 v2 级别。带 Core 记录的 log 不再触发重复 onLogIssue,宿主自身启动/发现/请求失败仍走既有错误边界。 +同一个引擎承载语言服务、版本控制、构建、工具链和插件后端,provider ID(提供者身份)可能含冒号,不能直接成为目录段。宿主显式传 `role` 和 VCS 的 `logSubject`,Core 校验身份后选择事件族:语言服务用 `lsp.*`,VCS 用 `vcs.*`,其他插件进程用 `plugins.host.*`。目录模板和必填身份字段仍然唯一决定通道,schema 不因此放开任意 channel。旧宿主不传 role 时继续按语言服务处理。macOS 的各个启动者显式传角色和插件 ID,VCS 传真实的 `target.vcs`;远程语言会话仍属于 language_server。启动响应返回 Core 生成的 channel,宿主用它登记详细诊断的 I/O。 -### 官方插件通过新的类型化协议交付事实 - -官方 Node、Java、Rust SDK 使用独立协商的 `litheLogging` 1,通过 `lithe/log` 把操作结果交给 Core。插件只提供注册过的操作、稳定类别、计数和单调耗时;Core 从已安装插件身份选通道和目录事件。成功终态为 INFO、实际失败为 ERROR、回退和真实恢复为 WARN。高频成功可在注册或当前调用上抑制,失败继续可见;取消不是失败。 - -采用新协议是因为 stderr 无法可靠表达处理后的结果、原操作身份与字段边界。给自由消息加 `[ERROR]` 不能解决这个问题,还会把错误正文和用户内容带入日志。正确做法是 SDK 在结果边界发送一条类型化终态,处理函数只补它真正拥有的降级;不要先记录错误再返回同一次错误,或把 SQL、HTTP 正文、构建输出写进诊断。 +合法的语言 ID 不等于合法的路径段。遇到非法段或缺少身份时,Core 用原始身份的 SHA-256 前 32 位小写十六进制,加上 `invalid-` 前缀;缺值时哈希完整的 provider ID。这样既不阻断进程启动、不静默丢日志,也不把原文泄漏成目录名。正确做法:VCS 宿主从已注册的目录传 `role:vcs, logSubject:git`。不要这样做:拆开 `vcsProvider::` 来猜身份。 -Core 只接纳协商成功且字段符合封闭契约的事实,每个进程会话最多每分钟 600 条,使用现有 256 条队列与单一宿主写入器。非法输入每会话只告警一次,不回显被拒内容;越限只计丢弃数。SDK 初始化阶段最多缓存八条类型化事实,capability 响应后排空;正常终态在 RPC 响应前发出,使 Core 能将 wire 请求编号映射到真实宿主 op。协议与 fixture 以共享插件契约为准,不再维护自由文本日志 API。 +启动之前的宿主事实也必须落到同一通道。宿主调用纯命令 `diagnostics.resolveEngineLogSubject`,Core 复用启动时的 `SessionLogIdentity`;Swift 不再实现一份哈希,也不自行用 providerID 拼段。manager 按 providerID 缓存成功或失败的结果(最多 128 项,满时清空),阶段、缓存、degradation 和 issue 事件都使用这个段。解析失败只是不发对应的宿主 v2 事件,不改变既有的启动和缓冲行为;参数错误沿用 `invalid_request`。 -详细诊断已开启时,新会话注册可能早于初始化响应。Core 此时只保存最新开关值,等握手完成后把 `$/setTrace` 排在 `initialized` 后;初始化前关闭会撤销待发送的启用。不要在注册时立即写通知,否则严格遵循 LSP 的服务器会丢弃它,宿主却以为已经开启。 +引擎在日志源头选择封闭的事实类型。既有的英文 message、detail、error 保留给 v1 兼容;v2 使用目录 ID 和标量字段,经过同一个 Core 边界完成规范化、脱敏和通道推导。v1 的 detail、message、level 必须保持原字节,例如 `projectCount=N`、navigation 的格式文本和通知的原始 JSON。`EngineLogInput` 另外携带源头字段,v2 不从 detail 反解。 -Core 拥有会话生命周期状态。`*.session.updated` 直接附到已有 `type:stateChanged` 事件,复用 `enqueue_runtime_event` 内同一个 `prepare_catalog_event` 校验,不额外推送 v1 log 行,也不改 type/state/message 等兼容字段。Swift 在 switch 前对任何带 coreLog 的事件转发一次,manager 清除 UI 身份后仍把退休会话的最终 Core 事实交给 sink,不套用 UI 过期回调检查;VCS、构建、工具链和后端会话同样收到生命周期事实。不要通过新增 log 行补证据,否则 Windows frontendTrace 与 macOS 100 条缓冲都会改变。 +stderr 是唯一的明确行为变化。读取状态最多保留 4 KiB 的半行,按 LF 切分、去掉末尾 CR、忽略空行,在 EOF 时冲刷残余;每个单调秒接纳 200 行,后续读取跨过窗口或结束时汇总丢弃计数。v1 detail 保留该行文本和行内空格,v2 再做脱敏和 1 KiB 限长。新增的丢弃事实只用 `count=N`,不以 JSON 冒充旧的结构化 detail。测试直接传毫秒值,不装计时器,也不等真实的一秒。 -宿主只拥有启动编排阶段,在实际调用点发 `lsp.session.stage_changed`(requested/resolving/registered/stopping/cancelled)和 `lsp.cache.cleaned`(removed 计数),由 manager 的注入 LogSink 接收。两者选 DEBUG,因为描述内部阶段和维护信息;缓存清理不是 Core 配置键清理,不能复用 configuration.cleaned。ready/terminated 已由 Core protocol.initialized 与 session.updated 表达,宿主继续保留原 v1 行但不重复发 v2。 +`window/logMessage` 和 `window/showMessage` 按上游明确给出的严重度选择独立的目录事件:error 为 ERROR,warning 为 WARN,info 为 INFO,详细或未知类型为 DEBUG。未分类的 stderr 只是 DEBUG 级别的排障证据,不能因为输出在标准错误流就标成警告;进程或协议的最终失败在实际失败的边界记录 ERROR。其他通知只记录方法,不把协议正文变成 text。上游 trace 默认关闭。显式开关和发往上游的 `$/setTrace` 共用一把有序写锁,成功写入后才改变采集状态。只接纳限长并脱敏的 message 摘要,排除可能含 RPC 原始载荷和源码的 verbose:凭据脱敏和截断无法把源码变成可以落盘的诊断。详细诊断已开启时,新会话的注册可能早于初始化响应。Core 此时只保存最新的开关值,等握手完成后把 `$/setTrace` 排在 `initialized` 之后;初始化前关闭会撤销待发送的启用。不要在注册时立即发通知,否则严格遵循 LSP 的服务器会丢弃它,宿主却以为已经开启。 -启动前的宿主事实也必须落到同一通道。正确做法是调用纯命令 `diagnostics.resolveEngineLogSubject`,Core 直接复用启动的 SessionLogIdentity;不要让 Swift 再实现哈希或自行用 providerID 构造段。manager 按 providerID 缓存成功或失败(最多 128 项,满时清空),阶段、缓存、degradation/issue 都使用该段;解析失败仅不发该宿主 v2 事件,保留既有启动与缓冲行为。参数错误沿用 invalid_request。 +详细诊断的分类不在宿主另写:会话通道根按 `diagnostics.resolveLogChannel` 返回的 category 归入当前开关(lsp → language、vcs → git、plugins → plugins),与持久化 DEBUG 的准入共用 `diagnosticRoots`。类别开关变化、新会话注册和 15 分钟的单次到期,都向活动会话发送 `setDetailedDiagnostics`,退出时关闭。 -六处非 LSP 直接打印改为进程级 Core 队列:只接纳 owner 为 lithe-core 的目录事实,入队前规范化,最多 256 条,重复待取事实与超额计数,由宿主一次 drain 取走并重置。队列没有文件 writer、后台任务或独立持久化;遍历只记相对路径和稳定错误类别,远程清理只记类别,不记录地址、scratch 名称或输出。现有请求事件流只覆盖单个请求,无法覆盖析构和后台清理,因此选择独立有界 handoff(交给宿主写入的内存队列)。 +Core 拥有会话生命周期状态。`*.session.updated` 直接附到已有的 `type:stateChanged` 事件上,复用 `enqueue_runtime_event` 里的同一个 `prepare_catalog_event` 校验,不额外推送 v1 log 行,也不改 type、state、message 等兼容字段。Swift 在 switch 之前,把任何带 coreLog 的事件转发一次。manager 清除 UI 身份后,仍把退休会话的最终 Core 事实交给 sink,不套用 UI 过期回调的检查;VCS、构建、工具链和后端会话同样收到生命周期事实。不要用新增 log 行来补证据,否则 Windows 的 frontendTrace 会改变。 -只靠宿主 drain 取走时,没有其他宿主活动就没有人来取:插件 `lithe/log` 事实、析构和后台清理事实会一直留在内存,既不落盘,也不推给日志窗口。因此 Core 在队列旁保留一个唤醒回调槽位。drain 之后第一条事实或第一次丢弃计数触发一次回调,同一 drain 周期内的后续变化合并,不再回调;drain 在队列锁内清除请求标记,之后入队的事实一定会再次触发。安装时已有待取事实也立即回调。回调在持有槽位锁时同步执行,移除也取这把锁,所以移除返回后 Core 不会再使用宿主的 context,宿主可以释放它。回调不得重入 Core,只负责安排宿主已有的 drain。这个槽位属于已有的进程队列,不是新的日志单例:它不写文件、不持有 sink,服务的日志依赖仍从构造器注入。 +宿主只拥有启动编排的阶段,在实际调用点发 `lsp.session.stage_changed`(requested、resolving、registered、stopping、cancelled)和 `lsp.cache.cleaned`(removed 计数),由 manager 注入的 LogSink 接收。两者都是 DEBUG,因为它们描述内部阶段和维护信息。缓存清理不是 Core 的配置键清理,不能复用 `configuration.cleaned`。ready 和 terminated 已由 Core 的 `protocol.initialized` 和 `session.updated` 表达,宿主保留原有 v1 行,但不重复发 v2。macOS 已删除消息字符串匹配和重复的 session 状态事实,按目录元数据取 v2 级别。带 Core 记录的 log 不再触发重复的 onLogIssue;宿主自身的启动、发现和请求失败仍走既有的错误边界。 -`${logDir}` 从启动上下文的绝对 `logDirectory` 展开,共用语言服务与后端启动路径;宿主创建当前应用会话的插件 files 目录。Core 只拼接文本,不创建目录,不从插件安装位置推导可写位置。这些文件属于运行状态,不跨 worktree 复用,不改变签名或 Sparkle delta 源基线。macOS 当前日志会话目录由统一存储器提供,Core plugin_files_path 决定相对部分。平台以 mkdirat/openat/O_NOFOLLOW 逐段创建 0700 目录并拒绝符号链接,避免检查与创建之间被换成越界链接。存储不可用时省略路径;使用变量的模板由 Core 按既有 invalidRequest 拒绝。远程进程无法写本机日志目录,同样省略并拒绝该变量。安装阶段只用未创建的占位目录校验展开。静态只读边界验证与组件测试不等于真实应用 bundle hash smoke。 - -目录适配器抛出的真实 I/O 错误也按存储不可用处理,由 Core 检查模板是否依赖 `${logDir}`。不使用该变量的插件继续启动;不能把可选日志目录变成全部语言服务、构建、VCS、后端和调试器的前置条件。 - -### 保留以完整会话为主,Core 只计算删除计划 +语言服务日志只有磁盘通道这一个来源。旧的语言服务日志视图曾保留 100 条 v1 内存缓冲,日志窗口替换该视图时已一并删除。 -年龄和总量策略按完整会话删除,先过期再从最老会话释放空间。当前会话及其所有文件始终受保护,预算可能暂时无法满足。单通道轮转以 10 MiB 为阈值,共保留三段(含活动文件);Core 用 `~N.log` 生成轮转名,例如 `vcs/git~1.log`、`vcs/git~2.log`,活动文件仍为 `vcs/git.log`。`~` 不属于任何合法通道段,因此 `lsp/foo.1.log` 永远是 `lsp/foo.1` 的活动文件;查询和性能保留分类同时使用新命名,不能再剥掉点号数字后缀。实际轮转由平台执行。 +`${logDir}` 由启动上下文的绝对 `logDirectory` 展开,语言服务和后端的启动路径共用。宿主创建当前应用会话的插件 files 目录:Core 的 `plugin_files_path` 决定相对部分,`MacLogStore` 的存储器提供当前会话目录。平台用 `mkdirat`、`openat` 和 `O_NOFOLLOW` 逐段创建 0700 目录并拒绝符号链接,避免在检查和创建之间被换成越界链接。Core 只拼接文本,不创建目录,也不从插件安装位置推导可写位置。这些文件属于运行状态,不跨 worktree 复用,不改变签名或 Sparkle delta 的源基线。 -保留计划的默认值是 30 天、总量 100 MiB、性能 20 MiB、每插件每会话 files 20 MiB。性能预算可删除非活动会话内的性能文件;插件超额返回告警,只有启动清理允许删除非活动会话中最老的插件文件。这两项是完整会话删除之外的明确例外;告警去重由后续宿主负责。 +存储不可用(包括目录适配器抛出真实 I/O 错误)时,宿主省略 `logDirectory`,由 Core 检查模板是否依赖 `${logDir}`,依赖时按既有的 `invalidRequest` 拒绝。不使用该变量的插件照常启动,可选的日志目录不能变成全部语言服务、构建、VCS、后端和调试器的前置条件。远程进程无法写本机日志目录,同样省略并拒绝该变量。安装阶段只用未创建的占位目录校验展开。 -旧 `/lithe.*.log` 不迁移、不改写。共享查询 API 保留 v1 只读能力,但 macOS 不向它提供 v1 输入;旧文件计入总量但仅按年龄删除,不因总量压力提前清理。Core 接收平台采集的文件清单和显式时间,返回相对路径计划;平台执行并报告真实 I/O 结果,不能把计划当成删除成功。 - -### PR3 的兼容输入与 macOS I/O - -v1 的 detail/message/level 是兼容边界,必须保持原字节:例如 projectCount=N、navigation 的 format 文本和通知的原 JSON。EngineLogInput 另外携带源头字段,fact 不再解析 detail。stderr 是唯一明确行为变化:按 LF 切行、去尾部 CR,v1 detail 保留该行文本及空格;v2 再脱敏与限长。新增 dropped 只用 count=N,不以 JSON 冒充旧结构化 detail。 +### 官方插件通过新的类型化协议交付事实 -Core 的目录遍历和 scratch 清理复用 PR1 已声明的 app.search.directory_skipped、app.workspace.directory_skipped 和 app.workspace.scratch_cleanup_failed;error 只含固定消息、稳定类别与固定 domain,不含绝对路径、scratch 名或远程地址。删除重复 app.project.directory_skipped、remote.scratch.cleanup_failed。remote process/upload 按 ClientError 变体映射 timeout、closed、transport、spawn、protocol 或 remote_<协议码>,不保留正文。 +官方 Node、Java、Rust SDK 使用独立协商的 `litheLogging` 1,通过 `lithe/log` 把操作结果交给 Core。插件只提供注册过的操作、稳定类别、计数和单调耗时;Core 从已安装插件的身份选择通道和目录事件。成功终态为 INFO,实际失败为 ERROR,回退和真实恢复为 WARN。高频成功可以在注册时或当前调用上抑制,失败始终可见;取消不是失败。 -CoreLogRecord 只接受标量,保留 event、目录 level、channel、op 和源 ts。LogSink 的 MacLogStore、LogScope 和测试 TypedLogRecorder 三个实现均支持它。MacLogStore 每次 drain、flush 和 finishSession 都批量调用一次 drainCoreEvents,由同一个 writer 分配 seq 并 validateCatalog:true;丢弃汇总转为 app 的 log.dropped。每个进程只有组合根创建的应用 writer 安装 Core 唤醒回调;回调只复用宿主事件已有的 100 ms 单次 drain 调度,已有调度时不重复安排,不增加空闲轮询或独立 writer。测试和其他临时 MacLogStore 不安装,避免两个 writer 争抢同一个进程队列。finishSession 先停止接纳并移除回调,再做最后一次 drain。 +采用新协议,是因为 stderr 无法可靠表达处理后的结果、原操作身份和字段边界。给自由消息加 `[ERROR]` 解决不了这个问题,还会把错误正文和用户内容带进日志。正确做法:SDK 在结果边界发送一条类型化终态,处理函数只补它真正拥有的降级。不要这样做:先记录错误再返回同一次错误,或把 SQL、HTTP 正文、构建输出写进诊断。 -详细诊断不在宿主另写分类:会话通道根按 Core `diagnostics.resolveLogChannel` 返回的 category 归入当前开关(lsp→language、vcs→git、plugins→plugins),与持久化 DEBUG 的准入共用 `diagnosticRoots`。当前类别开关、新会话注册和 15 分钟单次到期向活动会话发送 setDetailedDiagnostics,退出时关闭。相同失败状态只记录一次 app.logging.diagnostics_failed,避免 drain→失败日志→drain 循环;改变目标状态允许重试。 +Core 只接纳协商成功、字段符合封闭契约的事实,每个进程会话每分钟最多 600 条,使用现有的 256 条队列和单一宿主写入器。非法输入每个会话只告警一次,不回显被拒内容;超出限额只计丢弃数。SDK 在初始化阶段最多缓存八条类型化事实,capability 响应后再交出;正常终态在 RPC 响应之前发出,使 Core 能把 wire 请求编号映射到真实的宿主 op。协议和 fixture 以共享插件契约为准,不再维护自由文本日志 API。 -Core 记录直接进入磁盘通道。PR3 曾为旧的语言服务日志视图保留 100 条 v1 内存缓冲,PR4 用日志窗口替换该视图时已一并删除;现在语言服务日志只有磁盘通道这一个来源。 +### 辅助 crate 经注入的函数指针进入 Core 队列 -### PR4:辅助 crate 经注入的函数指针进入 Core 队列 +agent-host、git-host、ide-host 和 lithe-remote 的客户端链接进 Core。Core 依赖它们,它们不能反过来依赖 `core_events`,否则形成循环依赖。因此每个辅助 crate 只带一个 `failure_log` 模块:公开本 crate 用到的事件 ID 列表和一个安装函数,内部只保存一个先装先得的函数指针,调用点只交出事件 ID 和标量字段。Core 的 `diagnostics::helper_failures::install()` 用一次性初始化把四个指针都指向同一个转换函数,转换函数把字段交给 `core_events::record`,失败时计入丢弃。这些事件在共享目录里以 `lithe-core` 为 owner 声明,校验、脱敏和通道推导与 Core 自己的事件完全相同。 -agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core,但 Core 依赖它们,它们不能反过来依赖 Core 的 `core_events`,否则形成循环依赖。每个辅助 crate 因此只带一个 `failure_log` 模块:公开本 crate 用到的事件 ID 列表和一个安装函数,内部只保存一个函数指针(先装先得),调用点只交出事件 ID 和标量字段。Core 的 `diagnostics::helper_failures::install()` 用一次性初始化把四个 crate 的指针都指向同一个转换函数,转换函数把字段交给 `core_events::record`,失败时计入丢弃。事件仍在共享目录里以 `lithe-core` 为 owner 声明,校验、脱敏和通道推导与 Core 自己的事件完全相同。 +安装点是所有会进入辅助 crate 的 Core 入口:请求分发、Agent 打开的 FFI、IDE 请求分发器挂接和 Git askpass 的独立入口。新增这类入口时必须同时调用 `install()`。未安装时辅助 crate 只累加未注入计数,不回退到 stderr:askpass 等入口的标准输出和错误输出本身是协议或用户可见输出,回退会污染它们,也会重新引入门禁禁止的直接打印。 -安装点是所有会进入辅助 crate 的 Core 入口:请求分发、Agent 打开的 FFI、IDE 请求分发器挂接和 Git askpass 独立入口。新增这类入口必须同时调用 `install()`。未安装时辅助 crate 只累加未注入计数,不回退到 stderr:askpass 等入口的标准输出和错误输出本身是协议或用户可见输出,回退会污染它们,也会重新引入门禁禁止的直接打印。 +收尾失败按错误类别区分。对端已断开、进程已退出、连接已关闭这类“对象已经不在”的情况属于预期的缺失,不记录。权限不足、超时后进程仍在运行、清理目录失败(目录不存在除外)等真正留下残留的情况记录 WARN。端口转发的接受和连接失败可能逐连接发生,`client/forward.rs` 在发送侧让每一族失败在进程内每 5 秒最多记录一条,与目录的 rate=12 一致。处理凭据的 askpass 写回失败只记录阶段和错误类别,写入后把凭据字节清零,测试证明凭据不出现在事件字段里。 -收尾失败按错误类别区分。对端已断开、进程已退出、连接已关闭这类“对象已经不在”的类别属于预期的缺失,不记录;权限不足、超时后进程仍在运行、清理目录失败(不存在除外)等真正留下残留的类别记录 WARN。端口转发的接受和连接失败可能逐连接发生,除目录 rate 外再加 5 秒的进程级限流。处理凭据的 askpass 写回失败只记阶段和错误类别,写入后清零凭据字节,测试证明凭据不出现在事件字段中。 +Core 的目录遍历和 scratch 清理使用 `app.search.directory_skipped`、`app.workspace.directory_skipped` 和 `app.workspace.scratch_cleanup_failed`;error 只含固定消息、稳定类别和固定 domain,不含绝对路径、scratch 名或远程地址。远程进程和上传按 `ClientError` 的变体映射为 timeout、closed、transport、spawn、protocol 或 `remote_<协议码>`,不保留正文。 -日志门禁随之扩展到这四个 crate。运行在远端主机上的 `lithe-remote-server`(`rust/lithe-remote/src/server/`、`src/bin/`)和 ide-host 的独立可执行文件不链接进本机 Core,没有可注入的 sink,暂时排除在门禁外;远端服务端日志回传和 Monaco 控制台转发在 Issue #1680 中跟踪。 +日志门禁覆盖这四个 crate。运行在远端主机上的 `lithe-remote-server`(`rust/lithe-remote/src/server/`、`src/bin/`)和 ide-host 的独立可执行文件不链接进本机 Core,没有可注入的 sink,暂时排除在门禁外;远端服务端日志回传和 Monaco 控制台转发在 Issue #1680 中跟踪。 ### DAP:记录状态与失败,保留程序内容边界 @@ -160,26 +209,61 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, 调试变量、内存、源码或被调试程序输出塞入 text/error。原始 stderr 继续属于调试控制台,不代表持久化。 共享 Core 队列复用宿主既有 drain 和队列唤醒回调,不创建第二个 writer、轮询或应用退出后仍持有的缓存。 +### 保留以完整会话为主,Core 只计算删除计划 + +年龄和总量策略按完整会话删除:先删过期的,再从最老的会话开始释放空间。当前会话的所有文件始终受保护,预算可能暂时无法满足。单通道以 10 MiB 为轮转阈值,共保留三段(含活动文件):活动文件为 `vcs/git.log`,轮转文件为 `vcs/git~1.log`、`vcs/git~2.log`。`lsp/foo.1.log` 永远是 `lsp/foo.1` 的活动文件,查询和性能保留分类都使用这套命名,不能再剥掉“点号加数字”的后缀。实际轮转由平台执行。 + +保留计划的默认值为 30 天、总量 100 MiB、性能 20 MiB、每插件每会话 files 20 MiB。性能预算可以删除非活动会话里的性能文件。插件超额时返回告警,只有启动清理允许删除非活动会话中最老的插件文件。这两项是完整会话删除之外的明确例外;告警去重由宿主负责。Core 接收平台采集的文件清单和显式时间,返回相对路径计划;平台执行删除并报告真实的 I/O 结果,不能把计划当成删除成功。 + ## 考虑过的备选方案 -- JSON Lines:通用解析方便,但每行重复键名和身份信息、手工阅读噪声较多。采用有严格语法与 fixture 的单行文本,代价是维护专门编解码器。 -- 平铺文件加 scope:不能同时自然表达会话保留和插件证据边界;继续允许自由 scope 还会保留字符串接入问题。 -- 手写字符串 API:字段、级别和事件身份只能靠约定,编译器不能发现漂移,因此改用共享声明和生成类型。 -- 每插件自带宿主 writer:线程、队列、脱敏和预算会分裂。插件原生文件只能进入宿主管理的独立 files 区。 -- 在编辑器标签页查看日志:会将应用级证据绑定到项目,并延续语言服务内存与磁盘两份来源;PR4 改为应用级日志窗口。 -- 现在删除 v1:会破坏仍直接使用旧 Rust API 的 Windows。保留兼容入口,迁移完成后再单独评估删除。 -- 辅助 crate 依赖 Core 直接记录:形成循环依赖。把事件队列拆成第三个公共 crate 可以解开循环,但目录校验和脱敏都在 Core,拆出去要么复制规则,要么只搬一个空壳队列,不如注入一个函数指针简单。 -- 辅助 crate 未注入时回退到 stderr:askpass 等入口的输出是协议的一部分,回退会污染协议,也让门禁不得不为每个 crate 开 allowlist。 -- 宿主定时轮询 drainCoreEvents:空闲时也持续唤醒,落盘延迟取决于周期,违背不增加空闲轮询的约束。改为 Core 有事实时合并唤醒。 -- 每条事实回调一次,或在回调里直接 drain:突发事实会产生大量跨语言调用;在记录线程里 drain 会把磁盘 I/O 带到 Core 调用方线程,还要重入 Core。回调只安排宿主已有的单次 drain。 +- **JSON Lines**:通用解析方便,但每行都重复键名和身份信息,手工阅读噪声多。采用有严格语法和 fixture 的单行文本,代价是维护专门的编解码器。 +- **平铺文件加 scope**:不能同时自然表达会话保留和插件证据边界;继续允许自由 scope,也会保留字符串接入问题。 +- **手写字符串 API**:字段、级别和事件身份只能靠约定,编译器发现不了漂移,因此改用共享声明和生成类型。 +- **每个插件自带宿主 writer**:线程、队列、脱敏和预算会分裂。插件原生文件只能进入宿主管理的独立 files 区。 +- **在编辑器标签页查看日志**:会把应用级证据绑定到项目,并延续语言服务内存和磁盘两份来源,因此改为应用级日志窗口。 +- **现在删除 v1**:会破坏仍直接使用旧 Rust API 的 Windows。保留兼容入口,迁移完成后再单独评估删除。 +- **辅助 crate 依赖 Core 直接记录**:会形成循环依赖。把事件队列拆成第三个公共 crate 可以解开循环,但目录校验和脱敏都在 Core,拆出去要么复制规则,要么只搬走一个空壳队列,不如注入一个函数指针简单。 +- **辅助 crate 未注入时回退到 stderr**:askpass 等入口的输出是协议的一部分,回退会污染协议,门禁也不得不为每个 crate 开 allowlist。 +- **继续在 Swift 记录 Git 生命周期**:Swift 不知道 Core 最终传给进程的参数、退出码和 stderr,只能覆盖经过它的那部分操作,远程工作区和 Core 内部发起的 Git 调用都会漏掉。在执行点记录只需一处,所有调用方自动覆盖。 +- **只记录失败**:失败日志无法回答“用户点了之后到底执行了什么”。成功轨迹按来源分级:用户操作的成功是 INFO,后台的成功是 DEBUG,平时不落盘。 +- **在 Swift 的每个 Core 调用点记录请求**:需要逐个改调用点,新调用点很容易漏掉,而且同一请求在 Swift 和 Core 各有一份时间和结果。改为在 Core 分发处记录一次,宿主只传 `origin`。 +- **宿主定时轮询 Core 队列**:应用空闲时也持续唤醒,落盘延迟取决于轮询周期。改为 Core 有事实时合并唤醒宿主。 +- **在每个响应信封里标记待取事实**:只在有请求返回时生效,插件 `lithe/log`、析构和后台线程留下的事实要等下一次无关请求才落盘。唤醒回调覆盖所有来源,因此响应里不再携带标记。 +- **每条事实回调一次,或在回调里直接 drain**:突发事实会产生大量跨语言调用;在记录线程里 drain 会把磁盘 I/O 带到 Core 调用方的线程,还要重入 Core。回调只安排宿主已有的单次排空。 +- **只在宿主侧过滤 DEBUG**:后台 DEBUG 会先占满 256 条的 Core 队列,之后的 WARN 和 ERROR 被丢弃,因此必须在入队前过滤。 +- **Core 轨迹的失败直接记 ERROR**:Core 不知道宿主会不会重试或降级,ERROR 会把可恢复的失败误报成用户请求失败;同一次失败再由宿主记录还会产生两条终态。 ## 后果 -收益是路径、格式、查询和删除选择可独立验证,事件身份与字段可以在生成阶段审查;新能力为批量跨语言调用与统一证据源提供基础。代价是需要维护生成器、专用语法和迁移基线,macOS 通过提前过滤、批量调用与句柄复用减少边界和磁盘开销;真实应用性能仍需要独立工作流证据。平台仍需承担有界队列、时间和序号、文件句柄、真实 I/O 失败、告警去重以及导出冻结的责任。 +收益: + +- 路径、格式、查询和删除选择都可以独立验证。 +- 事件身份和字段在生成阶段就能审查。 +- 一次 Bug 报告的日志能顺着同一个 op 看到用户操作、Core 请求和 Git 命令的参数、退出码和 stderr。 +- 新增的 Core 请求和 Git 调用不需要逐个接入日志。 + +代价: + +- 需要维护生成器、专用语法、编解码器和 allowlist。 +- 每个用户入口都要选一个稳定 id 并包在最外层。跨越分离任务时要显式携带操作上下文,否则请求会退化成后台来源。 +- 新增按键、每帧或轮询类 Core 方法时,必须同步更新 `request_trace::is_hot`,否则每次按键都会产生一条记录。 +- Core 开始依赖某个 Git 命令新的文档化退出码时,必须同步更新 `exit_is_answer`,否则成功的回答会被记成 WARN。 +- 用户可见的 Git 失败从 ERROR 变为 WARN 的 Core 轨迹,按 ERROR 筛选的人需要改为通过通知引用定位。 +- macOS 通过提前过滤、批量调用和句柄复用减少跨边界和磁盘开销,真实应用的性能仍需要独立的工作流证据。 +- 平台仍要承担有界队列、时间和序号、文件句柄、真实 I/O 失败、告警去重和导出冻结的责任。 ## 验证 -阶段 C 的 Rust 回归用例 `lifecycle_catalog_identity_preserves_v1_event_without_extra_log` 在修复前因额外 log 行失败,修复后覆盖五角色身份与 v1 wire 字段不变;`diagnostics_resolve_engine_subject_matches_startup_and_rejects_invalid_arguments` 覆盖非法段与通用参数错误。Swift 用 TypedLogRecorder 验证阶段各一次、不重复 ready/terminated、失败缓存和真实 Core 的 c++ 通道;本轮按任务要求只编译生产与测试目标,测试执行交由沙箱外。 +关键回归用例: + +- `lifecycle_catalog_identity_preserves_v1_event_without_extra_log`:五种角色的身份正确,v1 wire 字段不变,没有额外的 log 行。 +- `diagnostics_resolve_engine_subject_matches_startup_and_rejects_invalid_arguments`:非法段和通用参数错误。 +- `rust/lithe-core/src/tests/core_request_traces.rs`:真实 Git 仓库上的用户命令在操作 op 下记录脱敏后的 argv;失败命令记 WARN,带退出码和 stderr 末尾;后台读取只在详细诊断开启时记录。 +- `command_trace.rs` 和 `request_trace.rs` 的单元测试:argv 脱敏与截断、文档化的回答退出码、结果分类、stderr 末尾、热点方法、来源优先级和取消。 +- `macos/Tests/LitheTests/UserActionTraceTests.swift`:嵌套调用只记录外层、分离任务和主线程延后执行仍携带操作、60 秒后不再声称用户来源。 +- `macos/Tests/LitheTests/CoreRequestTraceTests.swift`:通过真实 Core 确认用户 Git 命令在操作 op 下落盘,后台读取等待详细诊断。 +- Swift 的 `TypedLogRecorder` 测试:阶段各发一次、不重复 ready 和 terminated、失败缓存,以及真实 Core 下的 `c++` 通道。 以下是复验入口,不是整机验收通过的声明: @@ -187,6 +271,8 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, - `node scripts/logging/verify-log-usage.mjs` - `node --test --test-timeout=60000 scripts/logging/test-log-events.mjs` - `cargo test --manifest-path rust/Cargo.toml -p lithe-core diagnostics` +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core core_request_traces` +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core command_trace` - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --test core_log_waker` - `./scripts/rust-core/verify-rust-core-comments.sh` - `./scripts/architecture/verify-shared-contracts.sh` @@ -194,11 +280,14 @@ agent-host、git-host、ide-host 和 lithe-remote 客户端被链接进 Core, - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` - `./scripts/agent-notes/verify-agent-notes.sh` -fixture 覆盖往返、转义、非法输入、路径段、轮转与保留、共享旧日志兼容、macOS 排除 v1 与按龄清理、查询不完整性。`--check` 只读比较生成物,不能用重新生成掩盖声明或生成器错误;Windows 边界脚本通过也不能代替 Windows Rust 编译。 +fixture 覆盖往返、转义、非法输入、路径段、轮转与保留、共享旧日志兼容、macOS 排除 v1 与按年龄清理,以及查询的不完整性。`--check` 只读比较生成物,不能用重新生成来掩盖声明或生成器的错误。Windows 边界脚本通过也不能代替 Windows Rust 编译。 ## 适用范围 - `rust/lithe-core/src/diagnostics/` +- `rust/lithe-core/src/git/command_trace.rs` +- `rust/lithe-core/src/runtime/dispatcher.rs` +- `rust/lithe-core/src/tests/core_request_traces.rs` - `rust/lithe-core/src/debug/engine.rs` - `rust/lithe-agent-host/src/failure_log.rs` - `rust/lithe-git-host/src/failure_log.rs` @@ -209,5 +298,8 @@ fixture 覆盖往返、转义、非法输入、路径段、轮转与保留、共 - `shared/contracts/rust-core-api.md` - `shared/fixtures/diagnostics/logging-v2.json` - `macos/Sources/LitheModuleAPI/Logging/` +- `macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift` - `macos/Sources/Lithe/Platform/MacOS/Logging/` - `macos/Tests/LitheTests/MacLogStoreTests.swift` +- `macos/Tests/LitheTests/UserActionTraceTests.swift` +- `macos/Tests/LitheTests/CoreRequestTraceTests.swift` diff --git a/.agents/skills/develop-lithe/references/logging.md b/.agents/skills/develop-lithe/references/logging.md index 5d71c1a4d..2c85f24c4 100644 --- a/.agents/skills/develop-lithe/references/logging.md +++ b/.agents/skills/develop-lithe/references/logging.md @@ -1,20 +1,24 @@ # 日志接入 -新增、迁移或审查日志事件、写入器、诊断采集、界面警告,以及任何丢弃错误的代码时遵守本页。通用规则见 +新增、迁移或审查日志事件、写入器、诊断采集、界面警告、用户操作入口,以及任何丢弃错误的代码时遵守本页。通用规则见 [Lithe 开发规范](../SKILL.md);路径均相对于仓库根目录。 -事件声明、字段编码、限额、生成器和门禁扫描范围以 `shared/logging/README.md` 为唯一规则来源。本页规定什么必须记录、怎样设计一条日志,以及接入、豁免和审查动作,不另列一套会漂移的语法或参数表。 +事件声明、字段编码、限额、生成器、Core 事件队列和门禁扫描范围以 `shared/logging/README.md` 为唯一规则来源。本页规定什么必须记录、怎样设计一条日志、执行轨迹怎样接入,以及豁免和审查动作,不另列一套会漂移的语法或参数表。 判断一次失败是否要记录,只问一个问题:**这次失败有没有丢掉数据、能力或清理结果。** 丢了就记录;什么也没丢、只是目标本来就不在了,就属于第二节的例外。 +判断一次操作要不要留下轨迹,只问另一个问题:**维护者拿到诊断包时,能不能看出用户按了什么、Core 执行了哪些请求、启动了哪些外部命令、各自怎样结束。** 这三层由第七节的执行轨迹统一记录,业务代码不再逐处补写。 + ## 一、必须记录的情形 -下表中的情形必须在处理边界记录一条目录事件。处理边界是决定重试、降级、回退、放弃或告知用户的那一层,不是抛出错误的最底层。 +下表中的情形必须记录一条目录事件。失败类情形在处理边界记录;处理边界是决定重试、降级、回退、放弃或告知用户的那一层,不是抛出错误的最底层。 | 情形 | 级别 | 要求 | | --- | --- | --- | -| 操作最终失败,用户的请求没有完成 | ERROR | 在拥有处理决定的边界记录一次;成对操作由 span 的 fail 记录。 | -| 显示给用户的错误:横幅、弹窗、通知、行内错误、状态栏错误 | ERROR 或 WARN(按处理结果) | 与提示出自同一边界、同一 op;提示文案走 i18n,事件消息是英文事实,两者分开维护。 | +| 用户从菜单、快捷键、命令面板、工具栏、按钮、右键菜单、通知动作或插件动作发起操作 | INFO | 最外层入口用 `UserActionTrace` 记录一次 `app.action.performed`,它的 op 贯穿后续的 Core 请求和外部进程(第七节)。 | +| 每个 Core 请求、Core 启动的每个外部进程 | 用户来源成功为 INFO,其余成功或取消为 DEBUG,失败为 WARN | Core 在请求分发处和进程执行点各记录一条终态(第七节);宿主和领域代码不再为同一请求或进程另记一条。 | +| 操作最终失败,用户的请求没有完成 | ERROR | 在拥有处理决定的边界记录一次;成对操作由 span 的 fail 记录。失败的全部事实已经在同一 op 的 Core 失败轨迹里时,不另记,界面提示引用那条轨迹(第七节)。 | +| 显示给用户的错误:横幅、弹窗、通知、行内错误、状态栏错误 | ERROR 或 WARN(按处理结果) | 与提示出自同一边界、同一 op;提示文案走 i18n,事件消息是英文事实,两者分开维护。提示的事实已在同一 op 的 Core 失败轨迹里时,提示引用那条轨迹。 | | 显示给用户的警告 | WARN | 同上。只有界面提示、没有日志的警告不得合入。 | | 功能降级、回退到备用路径、使用缓存或默认值代替失败的读取 | WARN | 记录降级原因的结构化分类和降级后的状态。 | | 失败后自动恢复,或重试后成功 | WARN | 记录尝试次数和最终结果;不要逐次记录每一次重试。 | @@ -24,7 +28,7 @@ | 上游组件报告的警告和错误(LSP `window/logMessage`、插件宿主消息、子进程 stderr) | 按上游严重度映射 | 进入该组件已有的通道,不另建自由文本通道。 | | 生命周期里程碑和有排障价值的操作终态 | INFO | 只记里程碑和终态,不记每个内部步骤。 | -“用户受影响”按处理后的结果判断:内部重试后成功记 WARN,最终没有完成用户请求记 ERROR。不得把上游的 ERROR 标签机械地升级成产品 ERROR,也不得用 INFO 掩盖真正的失败。 +“用户受影响”按处理后的结果判断:内部重试后成功记 WARN,最终没有完成用户请求记 ERROR。不得把上游的 ERROR 标签机械地升级成产品 ERROR,也不得用 INFO 掩盖真正的失败。Core 轨迹的失败固定为 WARN,原因见第七节。 日志写入器自身的失败(写盘、轮转、句柄关闭、队列溢出)不能经过它自己记录。写入器把这类失败计入自身的失败和丢弃计数,导出诊断包时写进 `collection-status.txt`;不得为此另开 NSLog、stderr 或第二个写入器。 @@ -32,14 +36,14 @@ 只有以下四类可以不记录。例外是封闭清单,不得自行扩充;需要新类别时先修改本页并说明理由。 -1. **用户取消。** `CancellationError`、`CocoaError(.userCancelled)` 等由用户或调用方主动取消的结果不是失败。取消之后的清理如果失败,清理失败仍然按第一节判断。 +1. **用户取消。** `CancellationError`、`CocoaError(.userCancelled)` 等由用户或调用方主动取消的结果不是失败。取消之后的清理如果失败,清理失败仍然按第一节判断。Core 轨迹把取消记成带 `cancelled` 的成功终态,不记成失败。 2. **预期的缺失。** 目标本来就不在,调用方按“没有”处理,并且这正是正常路径。包括两种情况: - 探测可选的文件、配置、缓存或功能时目标不存在。 - 收尾时操作的对象已经不在:对端已经断开、子进程已经退出、连接已经关闭、要删除的文件已经不存在。典型错误类别是 `NotConnected`、`BrokenPipe`、`ConnectionReset`、`ConnectionAborted`、`UnexpectedEof`、`NotFound`,以及对已退出进程的 kill。 只有这些类别属于缺失。权限不足、格式损坏、读到一半失败、超时后对象仍然存在,都不属于缺失,必须记录。 3. **随输入实时重算的校验提示。** 表单里随键入即时更新、不阻断已经发生的操作、不反映系统状态的提示(例如“名称不能为空”)。提交后真正失败的操作仍然必须记录。 -4. **同一次失败已经记录过。** 下层处理边界、同一操作的 span fail,或同一代码路径上导致这次收尾的那次失败已经记录过。不得再记录一次,见第六节。 +4. **同一次失败已经记录过。** 下层处理边界、同一操作的 span fail、同一 op 的 Core 请求或进程轨迹,或同一代码路径上导致这次收尾的那次失败已经记录过。不得再记录一次,见第六节。 按错误类别判断例外时,在一个辅助函数里集中过滤:只放过上面列出的类别,其余类别照常记录。不要在每个调用点复制一份 match,也不要因为“收尾失败通常无害”把整个调用点豁免掉。拿不到错误类别、确实无法区分正常收尾和异常时,才整体豁免,并在原因里写明为什么无法区分。 @@ -55,19 +59,19 @@ let override = try? fileStorage.read(projectRoot.appending(path: ".editorconfig" let _ = stream.shutdown(Shutdown::Both); ``` -原因要写清楚是什么已经不在了或者为什么不是失败。“不重要”“忽略”“暂时”“旧代码”不是原因。豁免标记只用于丢弃错误的规则,不能豁免 NSLog、stderr、`print` 等绕过统一入口的写法,那类永久例外写进 allowlist(第九节)。 +原因要写清楚是什么已经不在了或者为什么不是失败。“不重要”“忽略”“暂时”“旧代码”不是原因。豁免标记只用于丢弃错误的规则,不能豁免 NSLog、stderr、`print` 等绕过统一入口的写法,那类永久例外写进 allowlist(第十节)。 ## 三、如何设计一条日志 按以下顺序设计,每一步的答案都要能写进事件声明或代码审查说明。 -1. **先写下读者的问题。** 维护者拿到用户附在 Issue 里的诊断包时,要靠这条日志回答什么问题?例如“Git fetch 为什么失败、失败在哪个远程、用了多久”。回答不了具体问题的日志不要加。 +1. **先写下读者的问题。** 维护者拿到用户附在 Issue 里的诊断包时,要靠这条日志回答什么问题?例如“Git fetch 为什么失败、失败在哪个远程、用了多久”。回答不了具体问题的日志不要加。先查第七节的执行轨迹是否已经回答了这个问题;已经回答的不要再加。 2. **确定事实和终态。** 一条事件描述一个已经发生的事实,事件 ID 的结果词用过去时(`fetch_failed`、`index_degraded`)。成对操作用 span,只产生一条带耗时的终态。 3. **确定 owner 和通道。** owner 是拥有这次操作结果的模块;通道由目录模板和已校验的身份决定(`lsp/{server}`、`vcs/{provider}`、`plugins/{plugin}/host`),不按显示名称、路径或用户输入分通道。 4. **按处理后的影响选级别。** 用第一节的表,不按“看起来严重”选级别。 -5. **选最窄的字段类型。** 计数、耗时、体积用数值;阶段和结果用受控 token;失败用 `error` 保留稳定 code 和 domain。不把结构化信息拼进消息(第七节)。 -6. **用 op 串起同一操作。** 有实际操作上下文时传 `operationID`。用户在日志窗口里选中一行,能看到同一 op 的全部记录及相对耗时;每层重新生成编号会切断这条链。 -7. **控制频率。** 循环、逐按键、逐帧、逐连接路径不用 INFO 逐项记录;需要时用 DEBUG 或在发送侧有界汇总,并按目录声明 rate。 +5. **选最窄的字段类型。** 计数、耗时、体积用数值;阶段和结果用受控 token;失败用 `error` 保留稳定 code 和 domain。不把结构化信息拼进消息(第八节)。 +6. **用 op 串起同一操作。** 用户入口由 `UserActionTrace` 生成 op,Core 请求和外部进程沿用它;其他有实际操作上下文的事件传同一个 `operationID`。用户在日志窗口里选中一行,能看到同一 op 的全部记录及相对耗时;每层重新生成编号会切断这条链。 +7. **控制频率。** 循环、逐按键、逐帧、逐连接路径不用 INFO 逐项记录;需要时用 DEBUG 或在发送侧有界汇总,并按目录声明 rate。高频 Core 方法列入 `is_hot`(第七节)。 8. **在日志窗口里验证。** 触发一次真实场景,在日志窗口(⌘⇧L)中按通道和事件过滤能找到这条记录,字段齐全、没有敏感内容,同一 op 的调用链完整。界面提示带“查看日志”时,点击能定位到这条记录。 反例与正例: @@ -75,12 +79,14 @@ let _ = stream.shutdown(Shutdown::Both); | 反例 | 问题 | 正例 | | --- | --- | --- | | `catch { return nil }` | 失败被吞掉,诊断包里没有任何痕迹 | 在处理边界记录 `*.load_failed`(WARN,带 `error`),再返回降级值 | -| 横幅显示“同步失败”,没有日志 | 用户报告问题时无从排查 | 同一边界记录 `vcs.fetch.failed`,横幅的“查看日志”指向它 | +| 横幅显示“同步失败”,没有日志 | 用户报告问题时无从排查 | 横幅的“查看日志”引用同一 op 的 `vcs.git.command.failed`;宿主有降级、放弃等额外事实时,再在同一边界记录自己的终态 | | 消息 `"fetch origin failed: \(error)"` | 结构化信息拼进消息,未经字段脱敏 | 消息模板 `Fetch failed`,字段 `remote`(token)、`error`(error)、`duration` | | 适配器、服务和视图各记录一次同一个失败 | 一次失败出现三次,计数失真 | 适配器返回结构化错误,服务决定降级时记录一次 | +| Swift 在调用 Core 前后各记一条“开始执行 git fetch”“git fetch 失败” | 与 Core 的进程轨迹重复,且拿不到真实的 argv、退出码和 stderr | 不写;Core 在执行点记录 `vcs.git.command.*`,宿主只引用 | | 重试循环里每次失败都记 WARN | 噪声淹没真正的终态 | 一条终态带 `attempts` 和最后一次 `error` | | 关闭连接、终止子进程、取消请求时,所有失败一律记 WARN | 正常关闭每天都会触发,日志窗口被假警告刷满,真正的残留反而看不见 | 辅助函数按错误类别过滤:对象已经不在的类别写豁免,权限不足、超时后仍在运行等类别记录 | | 新增 `logSink: (any LogSink)? = nil` 参数,生产构造不传 | 代码看似记录了,生产环境里事件全部丢失 | 组合根把真实 sink 一路传到构造处,测试替身才使用 `nil` | +| 按钮回调里用 `Task.detached` 或 `DispatchQueue.main.async` 执行动作 | 丢掉用户操作上下文,Core 请求没有 `trigger` 和 op,看不出是哪个按钮触发的 | 用 `UserActionContext.detached` 或 `UserActionContext.mainAsync` | ## 四、只使用目录生成的事件 @@ -102,15 +108,16 @@ let _ = stream.shutdown(Shutdown::Both); | macOS 应用服务集合 | `AppServices.logSink` | | macOS 功能模块 | 激活时的 `ModuleContext.logSink` | | SwiftUI 视图 | 环境值 `applicationLogSink` | +| 没有服务容器的用户操作入口(AppKit 菜单、通知动作) | `UserActionTrace.applicationSink`,启动时安装一次;测试传入自己的 sink | | Swift 侧的 Rust Core 桥接 | `RustCoreBridge.coreLogSink` | | Rust Core 的请求处理 | 请求事件流携带的目录事件 | -| Rust Core 中没有请求上下文的代码(析构、后台清理) | `diagnostics::core_events` 进程级有界队列,由宿主在写入节奏中一次取走 | +| Rust Core 的执行轨迹和没有请求上下文的代码(析构、后台清理) | `diagnostics::core_events` 进程级有界队列;宿主在写入批次、flush 和退出时排空,Core 唤醒回调再安排一次排空 | | 链接进 Core 的辅助 crate(agent-host、git-host、ide-host、lithe-remote 客户端) | 本 crate 的 `failure_log` 模块;sink 由 Core 的 `diagnostics::helper_failures::install()` 注入 | 规则: - 给类型新增 `logSink` 参数时,同一次改动必须把真实 sink 传到每个生产构造处,包括默认参数里隐式构造的下层对象。`= nil` 默认值只为测试替身保留;生产路径依赖默认值等于没有记录。 -- 不得新增全局可变日志单例,不得在下层自行创建写入器。辅助 crate 的 `failure_log` 是唯一允许的进程级入口,它只保存 Core 注入的函数指针,不写文件。 +- 不得新增全局可变日志单例,不得在下层自行创建写入器。辅助 crate 的 `failure_log` 和 `UserActionTrace.applicationSink` 是仅有的进程级入口:前者只保存 Core 注入的函数指针,后者只引用组合根创建的写入器,都不写文件。 - 辅助 crate 不依赖 Core(依赖方向相反,会形成循环),只通过 `failure_log` 交出事件 ID 和标量字段;事件仍在共享目录中以 `lithe-core` 为 owner 声明,由 Core 校验、脱敏后入队。 - sink 尚未注入时,辅助 crate 只累加未注入计数,不回退到 stderr 或其他输出。新增会调用辅助 crate 的 Core 入口(FFI 函数、独立进程入口)时,必须在入口处调用 `helper_failures::install()`,否则这条路径上的事件全部丢失。 - sink 注入前就可能失败的 macOS 代码(启动早期的设置解码、配置迁移)有界缓存类型化事件,注入后一次性补发;不得另建自由字符串通道,也不得无界缓存。 @@ -125,10 +132,71 @@ macOS 的日志窗口和诊断包只读取 v2 会话文件;v1 文件只进入 - 有实际操作上下文时传 `operationID`,持久化层用 `op` 关联跨模块证据。不得把 op 重新声明为普通字段,不得用用户内容作为操作编号。 - 错误只在处理它的边界记录一次。下层把错误和稳定分类返回;上层决定恢复、降级或告知用户时记录。不得“记录后再抛出”;span 的 fail 已经记录该操作失败时,不再额外写相同错误。 - 一次失败引发的后续收尾失败,属于第二节第 4 类,不再单独记录。 +- Core 请求和外部进程的终态由 Core 记录,不属于任何宿主调用方。宿主只决定界面提示是否引用它,以及是否需要为自己掌握的额外事实记录一条终态(第七节)。 + +## 七、执行轨迹:用户操作、Core 请求与外部进程 + +执行轨迹回答“用户按了什么、Core 执行了什么、外部命令怎样结束”。它分三层,由同一个 op 串起来: + +| 层 | 事件 | 由谁记录 | 级别 | +| --- | --- | --- | --- | +| 用户操作 | `app.action.performed`(通道 `app`) | 最外层 UI 入口的 `UserActionTrace` | INFO | +| Core 请求 | `app.core.request.finished` / `.traced` / `.failed`(通道 `app/core`) | Core 请求分发处 `rust/lithe-core/src/runtime/dispatcher.rs`,实现在 `diagnostics/request_trace.rs` | 用户来源的成功或取消为 `finished`(INFO);后台或未知来源的成功或取消为 `traced`(DEBUG);失败为 `failed`(WARN) | +| 外部进程 | `vcs.git.command.finished` / `.traced` / `.failed`(通道 `vcs/git`) | Core 的 Git 进程执行点 `rust/lithe-core/src/git/command_trace.rs`,本地和远程工作区都经过这里 | 来源为用户的可见命令(定义见 7.4),成功或取消为 `finished`(INFO);其余成功或取消为 `traced`(DEBUG);失败为 `failed`(WARN) | + +### 7.1 用户操作入口 + +- 每个用户入口在最外层调用 `UserActionTrace.run(_:surface:sink:_:)`;SwiftUI 控件直接调用模型方法时用 `UserActionTrace.action(_:surface:_:)` 包装回调。它记录一条 `app.action.performed`,并把 `UserAction`(id、surface、op、开始时间)绑定到 `UserActionContext.current`,供后续请求继承。 +- 嵌套调用既不记录也不重新绑定,外层 id 生效。因此在共用的模型方法上再包一层是安全的,但入口必须包在最外层:例如 `GitFeatureModel.withGitOperation` 为每个 Git 操作生成 `vcs.git.<方法名>`,菜单或按钮先包了自己的 id 时以菜单或按钮为准。 +- id 是稳定的 catalog token:1 到 64 字节,字符为 ASCII 字母数字和 `._:/-$`,不合规时记为 `invalid`。不得使用本地化标题、文件名、分支名或任何用户内容。命名按 `<领域>.<对象>.<动作>`,例如 `workspace.remote.test`、`run.panel.stop`、`build.task.run`;主菜单用固定 id(`open-project`、`show-logs`);右键和下拉菜单用 `menu:`,标题来自用户内容的菜单项记在所属子菜单的 id 下,菜单项声明了 `actionID` 时用它;插件动作用 `plugin:/`;通知动作用通知声明的动作 id。 +- surface 取 `UserActionSurface` 的值:`menu`、`shortcut`、`palette`、`toolbar`、`button`、`context_menu`、`notification`、`plugin`,按用户实际点击的位置选。 +- 只记录用户主动发起的入口。定时刷新、文件监听、窗口恢复等自动行为不调用 `UserActionTrace`,它们的 Core 请求按后台来源记录。 + +### 7.2 跨越异步边界 + +- `Task {}` 继承 `UserActionContext.current`;`Task.detached` 和 GCD 不继承。为用户操作服务的分离任务用 `UserActionContext.detached`,需要等菜单或弹窗收起后再执行的主线程工作用 `UserActionContext.mainAsync`。不得把 `UserAction` 存进属性留到以后使用。 +- 桥接层用 `RustCoreBridge.RequestOrigin.resolve` 给每个请求附上可选的 `origin`:Git 执行来源是后台时记 `source: background`;当前操作开始不到 60 秒时记 `source: user`、`trigger` 和 `operation`;超过 60 秒只保留 `trigger` 和 `operation`,不再声称是用户工作(例如运行会话后续的长期请求);没有当前操作时沿用已知的 Git 执行来源。 +- Core 在请求开始时把 op 和来源放进线程局部上下文,同一请求里启动的 Git 进程自动继承。op 依次取 `origin.operation`、请求的 `operationId`、请求 `id`。在其他线程上结束的轨迹(例如流式历史)必须在离开请求线程前用 `request_trace::current()` 取快照。 + +### 7.3 Core 请求轨迹 + +- 每个 Core 请求在分发处记录一条终态,覆盖所有宿主、工作区、插件和按钮背后的调用。领域代码不得为“请求开始”“请求完成”另加日志。 +- 字段:`method`;请求载荷里的 `operation` 选择器(如 `git.write` 的 `fetch`);`source`;`trigger`;`path` 取载荷中第一个绝对路径的 `root`、`workspaceRoot`、`repositoryRoot`、`projectRoot`、`rootPath`、`directory` 或 `path`;`remote`;`duration`;取消时 `cancelled`,失败时 `error`(code 与 domain 为 `core`)。不记录载荷的其他内容。 +- 按键、每帧、轮询和监听批次触发的方法列入 `request_trace::is_hot`,不记录。新增这类方法时必须在同一次改动里加入列表。`diagnostics.*` 和 `core.ping` 永远不记录:日志系统不得记录自己的排空、查询和编码调用。 + +### 7.4 外部进程轨迹 -例如 Git 命令由拥有该命令生命周期的调用方 begin,再按真实结果 finish 或 fail;底层进程适配器只返回退出码和结构化错误。 +- Core 启动的每个 Git 进程在执行点记录一条终态,字段为 `command`、`arguments`、`repository`、`source`、`remote`、`exit_code`、`duration`,失败或非零退出时带 `stderr`,取消时带 `cancelled`,失败时带 `error`。 +- 可见命令指经 `execute_git` 执行、作为 invocation 返回给宿主并显示在 Git 控制台的命令;内部读取指 Core 经 `capture_git` 执行、自己解析输出的命令。即使在用户操作中,内部读取的成功或取消也只记 `traced`。 +- argv 只能经过 `command_trace::format_arguments` 进入日志:`-m`、`--message`、`-m…`、`--message=…` 的值换成 ``;`-c` 后键名含凭据或个人身份标记(`credential`、`token`、`password`、`user.`、`author.` 等)的值换成 ``;URL 中的用户信息和共享脱敏规则覆盖的路径与令牌被改写;结果超过 1 KiB 时在末尾截断并以 `…` 标记。不得在其他地方格式化 argv。 +- stderr 只保留进程最后 16 KiB 输出中的最后 8 个非空行,逐行脱敏后用 ` | ` 连接,超过 1 KiB 时丢弃最早的内容并以 `…` 标记。stdout、stdin 和进程环境永远不记录。 +- 结果分类:Core 返回取消错误为取消;返回其他错误为失败;退出码 0 为成功;退出码按文档表示“回答”而不是错误的(`exit_is_answer`,如 `git config --get` 的 1、`git diff --quiet` 的 1)为成功;来源不是用户的内部读取,非零退出视为成功,由调用方判断含义,调用方据此返回错误时由请求轨迹记录失败;其余为失败。Core 开始依赖某个命令的另一个文档化退出码时,在同一次改动里给 `exit_is_answer` 加一行。 +- 将来为其他外部进程增加轨迹时,沿用同一模型:在执行点记录唯一终态,argv 只经过一个集中的脱敏格式化函数,stderr 只保留脱敏后的末尾几行。 -## 七、字段选择与隐私 +### 7.5 失败级别与宿主边界 + +- Core 轨迹的失败固定为 WARN:Core 不知道宿主会重试、降级还是放弃,ERROR 留给知道结果的那一层。 +- 宿主要告知用户的失败,如果事实已经全部在同一 op 的 Core 失败轨迹里,提示的“查看日志”引用这条轨迹,宿主不再记录。Git 的引用规则:有进程以非零状态退出时引用通道 `vcs/git` 的 `vcs.git.command.failed`;没有这样的进程而请求失败(参数校验、仓库不可用)时引用通道 `app/core` 的 `app.core.request.failed`;进程全部以 0 退出、只是操作结果报告失败时,Core 没有失败轨迹,不返回引用,由调用方记录自己的终态。op 都取 `GitExecutionContext.traceOperationID`。实现见 `GitService.failureReference`。 +- 宿主只在掌握 Core 没有的事实时才记录自己的终态:降级到哪条路径、重试了几次后放弃、留下了什么残留。最终没有完成用户请求时这条终态为 ERROR。 + +正例与反例: + +```swift +// 正例:GitService 把同一 op 的 Core 失败轨迹放进结果,提示直接引用它 +LogRecordReference(channel: "vcs/git", event: VcsGitCommandFailed.id, op: execution.traceOperationID) +pendingNotificationReference = result.logReference + +// 反例:为同一次失败另声明一个宿主事件,再记一次 ERROR +sink.emit(GitFetchNoticeFailed(remote: remote, error: error, operationID: op)) +``` + +### 7.6 DEBUG 门与排空 + +- Core 在入队前丢弃宿主没有开启的 DEBUG 事实:宿主通过 `diagnostics.configureCoreEvents { debug, roots }` 告诉 Core 详细诊断是否开启、开启哪些通道根,默认关闭。`MacLogStore` 在详细诊断状态变化时调用它。只在宿主侧过滤会让后台 DEBUG 占满 256 条的 Core 队列;队列满时新事实只计入丢弃数,之后的 WARN 和 ERROR 会因此丢失。 +- 构造字段前先调用 `core_events::accepts(level, root)`,被拒绝的事实只花一次分支判断。 +- 只有应用写入器调用 `MacLogStore.observeCoreFacts()` 安装 Core 唤醒回调。记录事实或增加丢弃数后,Core 在两次排空之间最多回调一次;回调只安排 100 毫秒后的单次排空,不在回调里直接排空。结束会话先移除回调再做最后一次排空。不得为 Core 队列增加空闲轮询定时器,也不得在响应信封里再加待取标记。 + +## 八、字段选择与隐私 选择能表达事实的最窄类型,不把结构化信息拼进 message 或自由文本: @@ -138,20 +206,20 @@ macOS 的日志窗口和诊断包只读取 v2 会话文件;v1 文件只进入 | 身份、阶段、版本 | 身份用 id;受控短词用 token;版本用 version。id 必须来自已验证的真实身份,字符合法不等于来源可信。 | | 文件位置 | 使用 path,由共享边界脱敏,不自己拼用户绝对路径。 | | 失败 | 使用 error,保留稳定 code/domain 等结构化类别,不传未清理的整段错误转储。 | -| 无法结构化的上游诊断 | 仅在必须保留 stderr 行、LSP logMessage 等上游原文时使用 text,并在声明中写 reason 说明结构化字段为什么不能代替;“方便调试”不是理由。 | +| 无法结构化的上游诊断和外部进程命令行 | 仅在必须保留 stderr 行、LSP logMessage 等上游原文,或经第七节集中格式化函数脱敏的 argv 时使用 text,并在声明中写 reason 说明结构化字段为什么不能代替;“方便调试”不是理由。 | -不得记录源码、编辑缓冲区、环境变量值、命令输出、凭据、终端历史或协议正文,也不得把它们塞进 text、error、token、路径或消息模板绕过。命令只记录受控名称、阶段、退出码与耗时,不记录完整命令行。处理凭据的代码路径记录失败时,只记阶段和错误类别,并用测试证明凭据不会出现在事件字段里。 +不得记录源码、编辑缓冲区、文件内容、提交说明正文、环境变量值、进程的 stdout 与 stdin、凭据、终端历史或协议正文,也不得把它们塞进 text、error、token、路径或消息模板绕过。外部进程只通过第七节的集中格式化函数记录 argv,只记录脱敏后的 stderr 末尾几行。处理凭据的代码路径记录失败时,只记阶段和错误类别,并用测试证明凭据不会出现在事件字段里。 每个动态通道段必须能追溯到已校验的插件、provider、server 或内部连接 ID,不得使用主机名、显示名称、项目路径和任意用户输入。 -## 八、界面提示与日志的对应 +## 九、界面提示与日志的对应 -- 每个显示给用户的警告和错误都对应第一节的一条事件。提示和事件在同一个处理边界产生,共用同一个 op。 +- 每个显示给用户的警告和错误都对应一条事件:第一节的宿主事件,或者同一 op 的 Core 失败轨迹。提示和事件共用同一个 op。 - 提示需要“查看日志”时,携带日志记录引用(通道、事件,有 op 时带 op),打开日志窗口并定位到这条记录。打开日志窗口只走统一的打开请求,不为某个插件或 provider 写专门分支。 - 插件发给宿主的消息和警告由宿主记入 `plugins/{plugin}/host` 通道,再按插件声明显示;插件不能绕过宿主写日志文件。 - 日志消息、事件名、通道、字段、级别名和过滤语法不翻译;提示文案、按钮和日志窗口自身的界面文字走共享 i18n。 -## 九、门禁与豁免 +## 十、门禁与豁免 `node scripts/logging/verify-log-usage.mjs` 扫描 macOS、Rust Core 和链接进 Core 的辅助 crate 的生产代码。扫描的目录和排除范围以共享 README 为准;被排除的目录必须在 README 中写明原因和后续计划。门禁没有存量额度:引入门禁时的全部存量已经迁移完毕,任何违规都直接失败。规则分两类: @@ -163,9 +231,9 @@ macOS 的日志窗口和诊断包只读取 v2 会话文件;v1 文件只进入 - allowlist 的计数必须等于实际出现次数。少于实际失败;多于实际同样失败,必须同步调低,防止空出来的名额被新写法占用。 - 不得利用别名、包装函数或动态调用逃避门禁,也不得为了通过门禁放宽扫描范围或规则。 - 迁移机器读取的性能标记等协议输出时,同步修改它的消费者,不得直接删除协议输出。 -- 门禁无法静态识别以下三类问题,由第二、五、八节和审查清单保证:界面提示没有对应日志、生产构造没有传入 sink、收尾失败没有按错误类别区分。 +- 门禁无法静态识别以下问题,由第二、五、七、九节和审查清单保证:界面提示没有对应日志、生产构造没有传入 sink、收尾失败没有按错误类别区分、用户入口没有 `UserActionTrace`、异步跳转丢失操作上下文。 -## 十、验证 +## 十一、验证 ```sh node scripts/logging/generate-log-events.mjs --check @@ -174,21 +242,26 @@ node scripts/logging/verify-log-usage.mjs 修改目录、生成器或门禁本身时,再运行 `node --test --test-timeout=60000 scripts/logging/test-log-events.mjs`(目录、生成器和门禁共用这一个测试脚本),以及直接受影响的 Core、辅助 crate 或宿主测试。只读核验只用 `--check`,不执行会写生成物的命令。 +修改执行轨迹时运行对应测试:Core 请求轨迹在 `rust/lithe-core/src/tests/core_request_traces.rs`,Git 进程轨迹、argv 脱敏和 `exit_is_answer` 在 `rust/lithe-core/src/git/command_trace.rs` 的测试模块,宿主侧的入口与来源解析在 `macos/Tests/LitheTests/UserActionTraceTests.swift` 和 `macos/Tests/LitheTests/CoreRequestTraceTests.swift`。 + 按错误类别过滤的辅助函数必须有测试:对象已经不在的类别(例如 `NotConnected`、`BrokenPipe`)不发事件,意外类别(例如 `PermissionDenied`)发事件。 -新增或修改用户可见的警告和错误时,按第三节第 8 步在真实应用的日志窗口里确认记录存在、字段正确、“查看日志”能定位;无法在本机验证时在 PR 说明中写明。 +新增或修改用户可见的警告和错误、用户入口时,按第三节第 8 步在真实应用的日志窗口里确认记录存在、字段正确、同一 op 的链条完整、“查看日志”能定位;无法在本机验证时在 PR 说明中写明。 -## 十一、审查清单 +## 十二、审查清单 - 第一节的每种情形都有事件:界面警告和错误、降级、回退、自动恢复、重试耗尽、留下残留的清理失败、丢弃的错误。没有只出现在界面上的警告。 +- 新增的用户入口在最外层用 `UserActionTrace`,id 是稳定 token 而不是本地化标题或用户内容,surface 与实际位置一致;异步跳转用 `UserActionContext.detached` 或 `UserActionContext.mainAsync`。 +- 没有为 Core 请求或 Git 进程另写开始、完成或失败日志;新增的高频 Core 方法已列入 `is_hot`;Core 新依赖的文档化退出码已加入 `exit_is_answer`。 +- 宿主提示的事实都在 Core 轨迹里时,提示引用 Core 轨迹而不是重复记录;宿主自己的终态只描述 Core 没有的事实。 - 豁免标记属于第二节的四类之一,写明类别和具体原因;绕过统一入口的例外只在 allowlist 里,计数精确。 - 收尾类失败按错误类别过滤:对象已经不在的类别豁免,其余类别记录;没有因为“通常无害”整体豁免,也没有把正常关闭记成 WARN。 - 事件有真实来源和 owner,级别按处理后的用户影响选择;高频路径没有 INFO 逐项输出。 - 声明先于调用,使用生成类型;没有手写 ID、消息、字段字典、第二套写入器或直接打印;没有残留无调用点的事件。 - 新增的 `logSink` 参数在每个生产构造处都传入真实 sink;`nil` 只出现在测试替身中。新增的 Core 入口如果会调用辅助 crate,已经调用 `helper_failures::install()`。 - 同一操作只有一个 span 所有者和一条终态;错误只在处理边界记录一次,op 贯穿调用链。 -- 动态段来源可信并经过校验;字段类型最窄,description 具体,text 的 reason 说明不可替代性;没有禁止记录的内容。 +- 动态段来源可信并经过校验;字段类型最窄,description 具体,text 的 reason 说明不可替代性;argv 只经过集中格式化函数;没有禁止记录的内容。 - 生成物检查和用法门禁通过,门禁零违规。 -- 写入、查询、保留和导出保持有界,文件位于平台可写存储。 +- 写入、查询、保留和导出保持有界,文件位于平台可写存储;Core 队列没有新增轮询。 当前决策见 `.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md`。 diff --git a/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift b/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift index cc278f4cb..e97049d7c 100644 --- a/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/CommitFileDeletionFeatureModel.swift @@ -103,7 +103,7 @@ final class CommitFileDeletionFeatureModel: ObservableObject { for url in urls { guard isCurrent(), !Task.isCancelled, contains(url, in: root), files.fileExists(at: url) else { return false } guard !hasUnsavedEdits(url) else { reportUnsaved(); return false } - try await Task.detached(priority: .userInitiated) { try files.trashItem(at: url) }.value + try await UserActionContext.detached(priority: .userInitiated) { try files.trashItem(at: url) }.value if isCurrent(), !hasUnsavedEdits(url) { closeCleanDocuments(url) } } return true diff --git a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index 260046eca..ceb1af425 100644 --- a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -440,7 +440,7 @@ final class DocumentFeatureModel: ObservableObject { result = .failure(StandaloneFileOpenFailure(error: error)) } } else { - result = await Task.detached(priority: .userInitiated) { + result = await UserActionContext.detached(priority: .userInitiated) { Self.readStandaloneFile(at: normalizedURL, using: fileStorage, logSink: self.logSink) .map { DocumentReadDetails(text: $0, encoding: .utf8) } }.value @@ -605,7 +605,7 @@ final class DocumentFeatureModel: ObservableObject { var openFailure: StandaloneFileOpenFailure? if relativePath == nil, !fileOperations.supportsDocumentEncoding { let fileStorage = self.fileStorage - let result = await Task.detached(priority: .userInitiated) { + let result = await UserActionContext.detached(priority: .userInitiated) { Self.readStandaloneFile(at: normalizedURL, using: fileStorage, logSink: self.logSink) }.value switch result { @@ -645,7 +645,7 @@ final class DocumentFeatureModel: ObservableObject { // suffix alone must not hide an actual encoding error. let fileStorage = self.fileStorage let logSink = self.logSink - let header = await Task.detached(priority: .userInitiated) { () -> Data? in + let header = await UserActionContext.detached(priority: .userInitiated) { () -> Data? in do { return try fileStorage.readPrefix(from: normalizedURL, byteCount: BinaryFileViewerRegistry.headerByteCount) } catch { diff --git a/macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift b/macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift index 7c6df16bf..2c56aecb4 100644 --- a/macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift +++ b/macos/Sources/Lithe/Application/Features/PluginNativeUIFeature.swift @@ -29,6 +29,8 @@ final class PluginNativeUIFeature: ObservableObject, PluginWebPageHandle { private let request: (String, ToolingJSONValue) async throws -> ToolingJSONValue private let endRequests: () -> Void private let saveState: (State) -> Void + private let pluginID: String + private let logSink: (any LogSink)? private var task: Task? private var generation = UUID() private var isClosed = false @@ -36,8 +38,11 @@ final class PluginNativeUIFeature: ObservableObject, PluginWebPageHandle { init(declaration: PluginNativeUI, state: State = .init(), validateNodes: @escaping (PluginNativeUI, ToolingJSONValue) throws -> [PluginNativeUI.Node], request: @escaping (String, ToolingJSONValue) async throws -> ToolingJSONValue, - close: @escaping () -> Void = {}, saveState: @escaping (State) -> Void = { _ in }) { + close: @escaping () -> Void = {}, saveState: @escaping (State) -> Void = { _ in }, + pluginID: String = "", logSink: (any LogSink)? = nil) { self.declaration = declaration + self.pluginID = pluginID + self.logSink = logSink self.nodes = declaration.nodes var restored = state restored.values = state.values?.filter { id, value in @@ -139,9 +144,14 @@ final class PluginNativeUIFeature: ObservableObject, PluginWebPageHandle { @discardableResult func perform(_ action: PluginNativeUI.Action) -> Task? { guard canPerform(action) else { return nil } - return start(action.kind == .refresh ? nil : action) + return UserActionTrace.run(actionID(action), surface: .plugin, sink: logSink) { + start(action.kind == .refresh ? nil : action) + } } + /// The action log names a plugin action by its declared ids, never its title. + func actionID(_ action: PluginNativeUI.Action) -> String { "plugin:\(pluginID)/\(action.id)" } + /// Hiding/disabling a window cancels every request; late replies cannot publish. func close() { isClosed = true diff --git a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift index ee05facf5..c366fc958 100644 --- a/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/VcsProvidersFeatureModel.swift @@ -268,7 +268,7 @@ final class VcsProvidersFeatureModel: ObservableObject { let planner = planner let generation = generation detectionTask = Task { [weak self] in - let result = await Task.detached(priority: .userInitiated) { + let result = await UserActionContext.detached(priority: .userInitiated) { Result { try planner.detectRoots( workspaceURL: workspaceURL, manifests: sources.map(\.manifest), mappings: mappings @@ -685,7 +685,7 @@ final class VcsProvidersFeatureModel: ObservableObject { guard !sources.isEmpty else { return true } let mappings = mappingStore.mappings(workspaceURL: workspaceURL) let planner = planner, logSink = logSink, generation = generation - let detection = await Task.detached(priority: .userInitiated) { () -> VcsDetection? in + let detection = await UserActionContext.detached(priority: .userInitiated) { () -> VcsDetection? in do { return try planner.detectRoots(workspaceURL: workspaceURL, manifests: sources.map(\.manifest), mappings: mappings) } catch { logSink?.emit(VcsRootsFallbackSelected(error: error, stage: "detection")) @@ -741,7 +741,7 @@ final class VcsProvidersFeatureModel: ObservableObject { guard paths.count == changes.count else { return false } let planner = planner, roots = allRootIDs, ambiguous = ambiguous.map(\.id), generation = generation do { - let owners = try await Task.detached(priority: .userInitiated) { + let owners = try await UserActionContext.detached(priority: .userInitiated) { try planner.owners(roots: roots, ambiguous: ambiguous, paths: paths) }.value guard self.generation == generation, !Task.isCancelled, @@ -846,7 +846,7 @@ final class VcsProvidersFeatureModel: ObservableObject { let rootIDs = allRootIDs let ambiguousIDs = ambiguous.map(\.id) let generation = generation - let owners = await Task.detached(priority: .userInitiated) { () -> [String?]? in + let owners = await UserActionContext.detached(priority: .userInitiated) { () -> [String?]? in do { return try planner.owners(roots: rootIDs, ambiguous: ambiguousIDs, paths: [sourcePath, destinationPath]) } catch { logSink?.emit(VcsRootsFallbackSelected(error: error, stage: "ownership")) @@ -926,7 +926,7 @@ final class VcsProvidersFeatureModel: ObservableObject { roots[index].negotiation = reply.negotiation } let planner = planner - let value = try await Task.detached(priority: .userInitiated) { + let value = try await UserActionContext.detached(priority: .userInitiated) { try planner.decodeReply( operation, capabilities: reply.negotiation.capabilities, reply: reply.value, as: Value.self ) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift index 09d00d2fa..8b17cd295 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+Logging.swift @@ -165,6 +165,12 @@ extension RustCoreBridge { func drainCoreLogEvents() throws -> CoreLogDrain { try executeResult(command: "diagnostics.drainCoreEvents", payload: [String: String]()).get() } + private struct CoreEventConfiguration: Encodable { let debug: Bool; let roots: [String]? } + /// Core skips building DEBUG facts the host would drop; `roots: nil` admits every root. + func configureCoreEvents(debug: Bool, roots: [String]?) throws { + let _: [String: String] = try executeResult(command: "diagnostics.configureCoreEvents", + payload: CoreEventConfiguration(debug: debug, roots: roots)).get() + } /// Core calls `callback` at most once per drain cycle, from any thread, while /// it owns the installation. The callback must only schedule host work. diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 5aaa3bf56..c99257745 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -35,6 +35,28 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let payload: Payload var gitExecution: GitExecutionOptions? = nil var remoteWorkspace: RemoteWorkspace? = nil + var origin: RequestOrigin? = nil + } + + /// Who started a request. Core logs user requests at INFO under the action's + /// operation and keeps background or unattributed requests at DEBUG. + struct RequestOrigin: Encodable, Equatable { + var source: String? + var trigger: String? + var operation: String? + + /// Long-lived descendants of an old action keep its correlation but no + /// longer claim to be user work; a background Git poller always wins. + static func resolve(action: UserAction?, gitSource: GitExecutionSource, + now: ContinuousClock.Instant = .now) -> RequestOrigin? { + if gitSource == .background { return RequestOrigin(source: GitExecutionSource.background.rawValue) } + if let action { + let fresh = action.startedAt.duration(to: now) < .seconds(60) + return RequestOrigin(source: fresh ? GitExecutionSource.user.rawValue : nil, + trigger: action.id, operation: action.operationID) + } + return gitSource == .unknown ? nil : RequestOrigin(source: gitSource.rawValue) + } } private struct Envelope: Decodable { @@ -4629,6 +4651,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { var gitOptions = gitPreferences?.snapshot ?? GitExecutionOptions() gitOptions.interactive = execution != nil gitOptions.source = execution?.source ?? GitExecutionSource.current + let origin = RequestOrigin.resolve(action: execution?.action ?? UserActionContext.current, gitSource: gitOptions.source) let remoteWorkspace: RemoteWorkspace? switch Result(catching: { try self.remoteWorkspace(for: command, payload: payload) }) { case .success(let value): remoteWorkspace = value @@ -4643,7 +4666,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { command: command, payload: payload, gitExecution: (observesGit || command == "workspace.searchEverywhere") ? gitOptions : nil, - remoteWorkspace: remoteWorkspace + remoteWorkspace: remoteWorkspace, + origin: origin ) ), let request = String(data: requestData, encoding: .utf8), diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index b918b8660..ef26e7316 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -536,6 +536,7 @@ struct LitheApp: App { } } self.applicationLogWriter = applicationLogWriter + UserActionTrace.applicationSink = applicationLogWriter _logWindowCoordinator = StateObject(wrappedValue: logWindowCoordinator) settings.logWindowSink = applicationLogWriter let logWindowModel = LogWindowModel(logging: applicationLogWriter, preferences: settings.logWindowPreferences, @@ -664,6 +665,12 @@ struct LitheApp: App { private var model: AppModel { projectSessions.activeModel } + /// Menu items log under their command id. A menu item's own key equivalent + /// also fires here, so its surface stays `menu`. + private func menuAction(_ id: String, _ body: () -> Void) { + UserActionTrace.run(id, surface: .menu, sink: applicationLogWriter, body) + } + private var restartApplication: ApplicationRestartAction { let appDelegate = appDelegate return ApplicationRestartAction { appDelegate.requestRestart() } @@ -706,62 +713,66 @@ struct LitheApp: App { .windowStyle(.hiddenTitleBar) .commands { CommandGroup(after: .windowList) { - Button("Logs") { logWindowCoordinator.open() } + Button("Logs") { menuAction("show-logs") { logWindowCoordinator.open() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "show-logs")) } CommandGroup(replacing: .newItem) { Button("Open Project…") { - model.chooseProject() + menuAction("open-project") { model.chooseProject() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "open-project")) Button("New Window") { - projectSessions.ensurePrimaryWindowAvailable() - projectWindowLauncher.presentPrimary() + menuAction("new-window") { + projectSessions.ensurePrimaryWindowAvailable() + projectWindowLauncher.presentPrimary() + } } } CommandGroup(after: .saveItem) { Button("Save") { - model.saveActiveDocument() + menuAction("save") { model.saveActiveDocument() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "save")) .disabled(model.activeDocument == nil) Button("Close Project") { - model.closeProject() + menuAction("close-project") { model.closeProject() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "close-project")) .disabled(model.workspaceURL == nil) Button("Close File") { - model.closeStandaloneFile() + menuAction("close-file") { model.closeStandaloneFile() } } .disabled(model.standaloneFileURL == nil) } CommandGroup(replacing: .appSettings) { Button("Settings…") { - model.showSettings() + menuAction("settings") { model.showSettings() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "settings")) } CommandGroup(after: .appInfo) { Button("Check for Updates…") { - Task { await updateChecker.checkForUpdates(manual: true, presentingDetails: true) } + menuAction("check-for-updates") { + Task { await updateChecker.checkForUpdates(manual: true, presentingDetails: true) } + } } .disabled(updateChecker.isBusy) Button("Export Diagnostics Bundle…") { - model.openLogs(.init(presentsExport: true)) + menuAction("export-diagnostics-bundle") { model.openLogs(.init(presentsExport: true)) } } } CommandMenu("Navigate") { Group { Button("Back") { - model.navigateBack() + menuAction("navigate-back") { model.navigateBack() } } .litheKeyboardShortcut( model.keyboardShortcutFeature.primaryKeyPress(for: "navigate-back") @@ -769,7 +780,7 @@ struct LitheApp: App { .disabled(!model.canNavigateBack) Button("Forward") { - model.navigateForward() + menuAction("navigate-forward") { model.navigateForward() } } .litheKeyboardShortcut( model.keyboardShortcutFeature.primaryKeyPress(for: "navigate-forward") @@ -779,13 +790,13 @@ struct LitheApp: App { Divider() Button("Find Action…") { - model.openFindAction() + menuAction("find-action") { model.openFindAction() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-action")) .disabled(!model.canPerformShortcutCommand(id: "find-action")) Button("Search Everywhere…") { - model.toggleSearchEverywhere() + menuAction("search-everywhere") { model.toggleSearchEverywhere() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "search-everywhere")) .disabled(model.workspaceURL == nil) @@ -793,31 +804,31 @@ struct LitheApp: App { Divider() Button("Find in File…") { - model.showFindBar() + menuAction("find-in-file") { model.showFindBar() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-in-file")) .disabled(model.activeDocument == nil) Button("Replace in File…") { - model.showReplaceBar() + menuAction("replace-in-file") { model.showReplaceBar() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "replace-in-file")) .disabled(model.activeDocument == nil) Button("Find Next") { - model.navigateFind(offset: 1) + menuAction("find-next") { model.navigateFind(offset: 1) } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-next")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) Button("Find Previous") { - model.navigateFind(offset: -1) + menuAction("find-previous") { model.navigateFind(offset: -1) } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) Button("Go to Line…") { - model.showGoToLine() + menuAction("go-to-line") { model.showGoToLine() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) .disabled(model.activeDocument == nil) @@ -826,7 +837,7 @@ struct LitheApp: App { Divider() Button("Go to Definition") { - model.goToDefinition() + menuAction("go-to-definition") { model.goToDefinition() } } .litheKeyboardShortcut( model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-definition") @@ -834,13 +845,13 @@ struct LitheApp: App { .disabled(!model.canPerformShortcutCommand(id: "go-to-definition")) Button("Go to Implementation") { - model.goToImplementation() + menuAction("go-to-implementation") { model.goToImplementation() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-implementation")) .disabled(!model.supportsLanguageServerFeature(.implementation)) Button("Find Usages") { - model.findReferences() + menuAction("find-usages") { model.findReferences() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-usages")) .disabled(!model.supportsLanguageServerFeature(.references)) @@ -848,13 +859,13 @@ struct LitheApp: App { Divider() Button("Find in Files…") { - model.openProjectSearch() + menuAction("search-in-project") { model.openProjectSearch() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "search-in-project")) .disabled(model.workspaceURL == nil) Button("Replace in Files…") { - model.openProjectReplace() + menuAction("replace-in-project") { model.openProjectReplace() } } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "replace-in-project")) .disabled(model.workspaceURL == nil) @@ -862,14 +873,14 @@ struct LitheApp: App { CommandMenu("History") { Button("Show Local History…") { - if let fileURL = model.activeDocument?.url { - model.showLocalHistory(for: fileURL) + menuAction("local-history") { + if let fileURL = model.activeDocument?.url { model.showLocalHistory(for: fileURL) } } } .disabled(model.activeDocument == nil) Button("Show Project Local History…") { - model.showProjectLocalHistory() + menuAction("project-local-history") { model.showProjectLocalHistory() } } .disabled(model.workspaceURL == nil) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift index 17a9def3f..9018e0c51 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+DatabasePlugin.swift @@ -12,7 +12,7 @@ extension AppModel { store.set(true, forKey: DatabasePluginTransition.suggestionKey) showNotification( "Database is now a plugin. Install it from the Marketplace, and your saved connections and passwords move into it.", - action: WorkbenchNotification.Action(title: "Open Marketplace") { [weak self] in + action: WorkbenchNotification.Action(id: "plugins.marketplace.open", title: "Open Marketplace") { [weak self] in self?.showPluginMarketplace(searching: "Database") } ) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 61f9c1d43..aad6e6241 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -354,7 +354,7 @@ extension AppModel { } func performShortcutCommand(id: String) { - workbenchUIRegistry.performAction(id: id, model: self) + workbenchUIRegistry.performAction(id: id, model: self, surface: .shortcut) } func canPerformShortcutCommand(id: String) -> Bool { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift index f82eadeb0..7dba81108 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaPlugin.swift @@ -13,7 +13,7 @@ extension AppModel { hasSuggestedJavaPlugin = true showNotification( L10n.string("Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept."), - action: WorkbenchNotification.Action(title: L10n.string("Open Marketplace")) { [weak self] in + action: WorkbenchNotification.Action(id: "plugins.marketplace.open", title: L10n.string("Open Marketplace")) { [weak self] in self?.showPluginMarketplace(searching: "Java Support") } ) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift index 4647cd816..7c30b0511 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift @@ -29,7 +29,8 @@ extension AppModel { func showNotificationLog(_ id: UUID) { notificationFeature.showLog(id) } func performNotificationAction(_ id: UUID) { - notificationFeature.perform(id) + guard let action = notificationFeature.notifications.first(where: { $0.id == id })?.action else { return } + UserActionTrace.run(action.id, surface: .notification, sink: services.logSink) { notificationFeature.perform(id) } } func setNotificationHovered(_ id: UUID, isHovered: Bool) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginNativeUI.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginNativeUI.swift index 837099c91..1477857fd 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginNativeUI.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginNativeUI.swift @@ -47,7 +47,7 @@ extension AppModel { } try store.setState(data, pluginID: context.pluginID, windowID: context.windowID, workspaceURL: workspace) } catch { self.showNotification(error.localizedDescription, logReference: self.notificationFailure(error, operation: "plugin_native_ui")) } - }) + }, pluginID: context.pluginID, logSink: services.logSink) guard services.pluginWebPages.registerNative(feature, windowID: contribution.id, pluginID: context.pluginID) else { feature.close() throw PluginWebBridgeFailure.unavailable("The plugin panel is disabled or already registered.") diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift index 7cd393664..5dafd1e44 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheModuleAPI import LitheSearchModule @MainActor @@ -100,7 +101,7 @@ extension AppModel { let state = workbenchUIRegistry.state(forActionID: action.id, model: self) guard state.isVisible && state.isEnabled else { return } dismissSearchEverywhere() - action.perform() + UserActionTrace.run(action.id, surface: .palette, sink: services.logSink) { action.perform() } } func openSearchResult(_ result: FileSearchResult) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index 5412fa2ae..09c3d22c3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -99,7 +99,7 @@ extension AppModel { performToolWindowContribution(contribution, owner: owner, openOnly: true) } else if !workbenchFeature.isVisible(.init(rawValue: id)) { // Host-owned setup entries can exist before an optional module is enabled. - workbenchUIRegistry.perform(contribution, model: self) + workbenchUIRegistry.perform(contribution, model: self, surface: nil) } } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index bf5cb7fd8..536d87c19 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -178,6 +178,8 @@ enum LitheFeatureAvailability { struct WorkbenchNotification: Identifiable, Equatable { /// A button that takes the user to where they act on the notification. struct Action: Equatable { + /// Catalog token the user-action log records, never the localized title. + let id: String /// Localization key of the button title. let title: String let perform: @MainActor () -> Void diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 23cb5dc38..2b0c400f7 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -121,7 +121,7 @@ enum LitheActionRegistry { contributionID: contribution.id, isEnabled: state.isEnabled, isChecked: state.isChecked, localizesTitle: definition != nil, perform: { [weak model] in guard let model else { return } - model.workbenchUIRegistry.perform(contribution, model: model) + model.workbenchUIRegistry.perform(contribution, model: model, surface: nil) }) } } @@ -130,7 +130,7 @@ enum LitheActionRegistry { action(id, model: model) { guard let window = LitheCommandCatalog.toolWindow(for: id), let contribution = model.workbenchContributions.first(where: { $0.kind == .toolWindow && $0.id == window.rawValue }) else { return } - model.workbenchUIRegistry.perform(contribution, model: model) + model.workbenchUIRegistry.perform(contribution, model: model, surface: nil) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Logging/MacGitLifecycleLog.swift b/macos/Sources/Lithe/Platform/MacOS/Logging/MacGitLifecycleLog.swift deleted file mode 100644 index ea9b17bce..000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Logging/MacGitLifecycleLog.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import LitheModuleAPI -import LitheGitModule - -/// Owns operation spans across synchronous Core callbacks, including concurrent Git requests. -final class MacGitLifecycleLog: @unchecked Sendable { - private let lock = NSLock() - private let sink: (any LogSink)? - private var requests: [String: VcsGitRequestSpan] = [:] - private var commands: [String: VcsGitCommandSpan] = [:] - init(sink: (any LogSink)?) { self.sink = sink } - @discardableResult - func receive(_ event: GitExecutionEvent) -> LogRecordReference? { - guard let sink, let provider = LogSegment("git") else { return nil } - return lock.withLock { () -> LogRecordReference? in - let key = event.operationId - let commandKey = key + ":" + String(event.invocationId ?? 0) - let failed = event.error != nil || (event.exitCode ?? 0) != 0 - let failureCode = event.error?.code ?? "process_failed" - let error = LogFailure(domain: "git.core", code: failureCode) - // Only fixed known Git subcommands enter the token; argv and user references stay in the console. - let knownCommands: Set = ["fetch", "push", "pull", "clone", "status", "diff", "log", "show", "config", "rev-parse", "for-each-ref", "worktree", "checkout", "switch", "commit", "reset", "rebase", "merge", "cherry-pick", "stash", "tag", "branch", "remote", "ls-files", "ls-tree", "cat-file", "add", "rm", "restore", "init", "symbolic-ref", "update-ref", "merge-base", "check-ignore", "reflog", "apply", "cherry", "describe", "version"] - let command = event.arguments?.first(where: { knownCommands.contains($0) }) ?? "git" - switch event.type { - case "requestStarted": - guard requests.count < 256 else { return nil } - requests[key] = VcsGitRequestSpan.begin(command: command, provider: provider, operationID: key, emit: sink.emit) - case "started": - guard commands.count < 256 else { return nil } - commands[commandKey] = VcsGitCommandSpan.begin(command: command, provider: provider, operationID: key, emit: sink.emit) - case "finished": - guard var span = commands.removeValue(forKey: commandKey) else { return nil } - if failed && event.expectedExit != true { - return span.fail(error, exitCode: event.exitCode.map(Int64.init), failureCode: failureCode) - } else { span.finish(exitCode: event.exitCode.map(Int64.init)) } - case "requestFinished": - guard var span = requests.removeValue(forKey: key) else { return nil } - if failed { return span.fail(error, exitCode: event.exitCode.map(Int64.init), failureCode: failureCode) } - else { span.finish(exitCode: event.exitCode.map(Int64.init)) } - default: break - } - return nil - } - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift b/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift index f4949a386..024e25e3a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Logging/MacLogStore.swift @@ -42,6 +42,9 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck private var diagnosticExpiry: DispatchWorkItem? private var logSessions: [String: String] = [:] private var diagnosticAttempts: [String: Bool] = [:] + // Last DEBUG gate sent to Core (nil until the first attempt). A failed push + // is not retried until the desired state changes, like engine sessions. + private var coreGate: CoreGate? private var directory: URL? private var sequence: UInt64 = 0 private var previousEvent: RustCoreBridge.LogInput? @@ -173,8 +176,19 @@ final class MacLogStore: ApplicationLogging, LogWindowLogging, LogSink, @uncheck func releaseSession(_ id: String) { queue.async { [self] in logSessions.removeValue(forKey: id); diagnosticAttempts.removeValue(forKey: id) } } + private struct CoreGate: Equatable { let debug: Bool; let roots: [String]? } + private func updateSessionDiagnostics() { let state = lock.withLock { (!stopped && diagnosticNow() < diagnosticUntil, diagnosticRoots) } + let gate = CoreGate(debug: state.0, roots: state.0 ? state.1.map { $0.sorted() } : nil) + if gate != coreGate { + coreGate = gate + do { + try core.configureCoreEvents(debug: gate.debug, roots: gate.roots) + } catch { + emit(AppLoggingDiagnosticsFailed(error: LogFailure(domain: "core", code: (error as? RustCoreBridge.CoreCallError)?.code ?? "trace_failed"))) + } + } for (id, channel) in logSessions { // Core owns the root-to-category mapping (configureDiagnostic), so // engine sessions follow the same admission as persisted DEBUG facts. diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index c8cf7b6b1..ed93df43c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -102,8 +102,6 @@ final class MacServiceContainer { let logSink = applicationLogging as? any LogSink let pluginWebPageLifecycle = providedPluginWebPageLifecycle ?? .shared let gitExecutionJournal = GitExecutionJournal() - let gitLifecycleLog = MacGitLifecycleLog(sink: logSink) - gitExecutionJournal.onLifecycleEvent = { gitLifecycleLog.receive($0) } let remoteWorkspaces = RemoteWorkspaceRegistry() let logStore = applicationLogging as? MacLogStore let rustCore = RustCoreBridge(coreLogSink: logSink, diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index 400975958..1aa1151b8 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -63,8 +63,10 @@ struct LitheContextMenuItem: Identifiable { } let id = UUID() - let kind: Kind + var kind: Kind let title: String + /// Catalog token for the action log when the i18n key is not specific enough. + var actionID: String? = nil var localizesTitle = true var description: String? = nil let systemImage: String? @@ -80,11 +82,12 @@ struct LitheContextMenuItem: Identifiable { let shortcut: String? let role: Role var isEnabled: Bool - let action: () -> Void + var action: () -> Void static func action( _ title: String, localizesTitle: Bool = true, + actionID: String? = nil, description: String? = nil, systemImage: String? = nil, iconKind: LitheIconKind? = nil, @@ -100,6 +103,7 @@ struct LitheContextMenuItem: Identifiable { Self( kind: .action, title: title, + actionID: actionID, localizesTitle: localizesTitle, description: description, systemImage: systemImage, @@ -115,6 +119,37 @@ struct LitheContextMenuItem: Identifiable { ) } + /// The action log names an item by its explicit id or its i18n key, never by + /// the localized title. An item whose title is user content (a branch, a + /// file, a session) logs under its submenu's id. + @MainActor + func traced(surface: UserActionSurface, parentID: String? = nil) -> Self { + let id = actionID ?? (localizesTitle ? Self.traceID(forKey: title) : parentID ?? "menu:item") + var item = self + switch kind { + case .submenu(let children): + item.kind = .submenu(children.map { $0.traced(surface: surface, parentID: id) }) + case .action: + let perform = action + item.action = { UserActionTrace.run(id, surface: surface, sink: UserActionTrace.applicationSink, perform) } + case .separator: + break + } + return item + } + + static func traceID(forKey key: String) -> String { + var slug = "" + for scalar in key.lowercased().unicodeScalars { + if scalar.isASCII, CharacterSet.alphanumerics.contains(scalar) { slug.unicodeScalars.append(scalar) } + else if !slug.isEmpty, slug.last != "-" { slug.append("-") } + } + while slug.last == "-" { slug.removeLast() } + slug = String(slug.prefix(59)) + while slug.last == "-" { slug.removeLast() } + return slug.isEmpty ? "menu:item" : "menu:" + slug + } + static var separator: Self { Self( kind: .separator, @@ -643,11 +678,13 @@ final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { adjacentTo row: NSRect? = nil, parentWindow: NSWindow? = nil, trigger: NSView? = nil, + surface: UserActionSurface = .contextMenu, onDismiss: (() -> Void)? = nil, onPresented: ((UUID) -> Void)? = nil ) { dismiss() guard !items.isEmpty else { return } + let items = items.map { $0.traced(surface: surface) } if items.contains(where: \.usesSystemMenu) { let menu = LitheSystemContextMenu.makeMenu(items: items) menu.appearance = appearance diff --git a/macos/Sources/Lithe/Views/Components/LitheDropdown.swift b/macos/Sources/Lithe/Views/Components/LitheDropdown.swift index c90ef7567..9f0f5f282 100644 --- a/macos/Sources/Lithe/Views/Components/LitheDropdown.swift +++ b/macos/Sources/Lithe/Views/Components/LitheDropdown.swift @@ -222,7 +222,8 @@ struct LitheDropdownPopover: NSViewRepresentable { items: items, at: menuPoint, appearance: appearance, locale: environment.locale, opensUpward: opensUpward, anchored: true, adjacentTo: opensToSide ? NSRect(x: window.frame.minX, y: rect.minY, - width: window.frame.width, height: rect.height) : nil, parentWindow: window, trigger: anchor + width: window.frame.width, height: rect.height) : nil, parentWindow: window, trigger: anchor, + surface: .menu ) { [weak self] in guard let self else { return } self.menuIsPresented = false diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index dbba50a14..f21a4f7d0 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -156,7 +156,7 @@ struct GitLogView: View { let visibleHashes = visibleCommitHashes let options: GitGraphDisplayOptions = showLongGraphEdges ? .expanded : .compact let highlightsCurrentBranch = graphProjectionIdentity.highlightsCurrentBranch - let task = Task.detached(priority: .userInitiated) { + let task = UserActionContext.detached(priority: .userInitiated) { let layout = GitGraphLayoutService.layout(commits: commits, references: references, repositoryCommits: repositoryCommits, visibleHashes: visibleHashes, options: options) let referenceIndex = GitGraphReferenceGroup.ReferenceIndex(references) diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index b4126f357..5423b3b0c 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -1,3 +1,4 @@ +import LitheModuleAPI import SwiftUI import LitheGitModule @@ -173,7 +174,7 @@ struct GitWorktreesView: View { let query = searchText let inspection = feature.gitWorktreeInspection let currentChangeCount = feature.gitChangedFileCount - let projection = await Task.detached(priority: .userInitiated) { + let projection = await UserActionContext.detached(priority: .userInitiated) { GitWorktreeListProjection.items( worktrees: worktrees, query: query, @@ -193,7 +194,7 @@ struct GitWorktreesView: View { } else { changes = [] } - let rows = await Task.detached(priority: .userInitiated) { + let rows = await UserActionContext.detached(priority: .userInitiated) { changes.map(GitWorktreeRowsSnapshot.Row.change) }.value guard taskIdentity == worktreeChangesProjectionIdentity else { return } @@ -216,7 +217,7 @@ struct GitWorktreesView: View { } let query = historySearchText.trimmingCharacters(in: .whitespacesAndNewlines) let commits = inspection.commits - let rows = await Task.detached(priority: .userInitiated) { + let rows = await UserActionContext.detached(priority: .userInitiated) { let projection = query.isEmpty ? commits : commits.filter { $0.subject.localizedCaseInsensitiveContains(query) || $0.authorName.localizedCaseInsensitiveContains(query) diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index 7de4c2490..a3b3db226 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -1,3 +1,4 @@ +import LitheModuleAPI import SwiftUI import LitheExecutionModule @@ -152,10 +153,10 @@ struct LanguageTestsView: View { private var testToolbar: some View { HStack(spacing: 4) { - Button { + Button(action: UserActionTrace.action("tests.run", surface: .toolbar) { guard let item = selectedItem else { return } model.runTest(providerID: item.providerID, scope: scope(for: item)) - } label: { + }) { LitheIDEAIcon(resourcePath: selectedItemWasRun ? "debugger/rerun.svg" : "expui/run/run.svg", size: 16, fallbackSystemImage: "play", preservesOriginalColors: true, usesIDEADisabledAppearance: true) } @@ -164,7 +165,7 @@ struct LanguageTestsView: View { .accessibilityLabel(selectedItemWasRun ? "Rerun selected test" : "Run selected test") .disabled(service.isRunning || selectedItem == nil || model.workspaceURL == nil) - Button(action: model.stopTests) { + Button(action: UserActionTrace.action("tests.stop", surface: .toolbar, model.stopTests)) { LitheIDEAIcon(resourcePath: "expui/run/stop.svg", size: 16, fallbackSystemImage: "stop.fill", preservesOriginalColors: true, usesIDEADisabledAppearance: true) } @@ -173,9 +174,9 @@ struct LanguageTestsView: View { .disabled(!service.isRunning) if let item = selectedItem, item.providerID == "java", item.kind != .workspace { - Button { + Button(action: UserActionTrace.action("tests.debug", surface: .toolbar) { model.debugTest(providerID: item.providerID, scope: scope(for: item)) - } label: { + }) { LitheIDEAIcon(resourcePath: "expui/run/debug.svg", size: 16, fallbackSystemImage: "ladybug", preservesOriginalColors: true, usesIDEADisabledAppearance: true) } @@ -184,7 +185,7 @@ struct LanguageTestsView: View { .disabled(service.isRunning || item.fileURL == nil) } - Button(action: model.refreshTests) { + Button(action: UserActionTrace.action("tests.refresh", surface: .toolbar, model.refreshTests)) { LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, fallbackSystemImage: "arrow.clockwise", preservesOriginalColors: true, usesIDEADisabledAppearance: true) } diff --git a/macos/Sources/Lithe/Views/Run/BuildOutputView.swift b/macos/Sources/Lithe/Views/Run/BuildOutputView.swift index 349f0dea9..88ca9f19e 100644 --- a/macos/Sources/Lithe/Views/Run/BuildOutputView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildOutputView.swift @@ -1,5 +1,6 @@ import LitheCoreContracts import LitheExecutionModule +import LitheModuleAPI import SwiftUI /// Output, diagnostics, and test results of the last plugin build-system run. @@ -15,7 +16,7 @@ struct BuildOutputView: View { ) { stateLabel if feature.isRunning { - Button(action: feature.stopRun) { + Button(action: UserActionTrace.action("build.task.stop", feature.stopRun)) { Image(systemName: "stop.fill") } .litheIconButton() diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index 83b7b491b..48500dc85 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -133,7 +133,9 @@ struct BuildSystemToolWindowView: View { isEnabled: feature.isRunning || selection != nil ) { if feature.isRunning { - feature.stopRun() + UserActionTrace.run("build.task.stop", surface: .toolbar, sink: UserActionTrace.applicationSink) { + feature.stopRun() + } } else if let selection { run(selection.runnable, entryID: selection.entryID) } @@ -152,15 +154,17 @@ struct BuildSystemToolWindowView: View { } toolbarButton(help: String(format: L10n.string("Reload %@ projects"), title), - isEnabled: !feature.isDetecting, action: refresh) { + isEnabled: !feature.isDetecting, + action: UserActionTrace.action("build.projects.reload", surface: .toolbar, refresh)) { treeIcon("expui/general/refresh.svg") } ForEach(toolbarOptions, id: \.resolved.id) { option in let isOn = option.resolved.value == .bool(true) - toolbarButton(help: option.resolved.displayName, isEnabled: true, isSelected: isOn) { - setOption(option.resolved.id, value: .bool(!isOn)) - } label: { + toolbarButton(help: option.resolved.displayName, isEnabled: true, isSelected: isOn, + action: UserActionTrace.action("build.option.toggle", surface: .toolbar) { + setOption(option.resolved.id, value: .bool(!isOn)) + }) { if let asset = icons.option(option.declared) { WorkbenchContributionIcon(asset: asset, role: .action) } else { @@ -196,7 +200,7 @@ struct BuildSystemToolWindowView: View { help: String, isEnabled: Bool, isSelected: Bool = false, - action: @escaping () -> Void, + action: @escaping @MainActor () -> Void, @ViewBuilder label: () -> Label ) -> some View { Button(action: action, label: label) @@ -278,12 +282,14 @@ struct BuildSystemToolWindowView: View { if case .failed(let message) = entry.modelState { LitheBanner(status: .error, layout: .editor) { Text(verbatim: message).lineLimit(3).textSelection(.enabled) - Button("Retry") { reloadModel(entry) } + Button("Retry", action: UserActionTrace.action("build.projects.reload") { reloadModel(entry) }) } } else if entry.isReloadRequired { LitheBanner(status: .warning, layout: .editor) { Text(String(format: L10n.string("%@ build files changed"), entry.instance.displayName)) - Button("Reload") { feature.loadModel(entry.id, reason: .refresh) } + Button("Reload", action: UserActionTrace.action("build.projects.reload") { + feature.loadModel(entry.id, reason: .refresh) + }) .disabled(entry.modelState == .loading) } } @@ -793,7 +799,9 @@ struct BuildSystemToolWindowView: View { VStack(alignment: .leading, spacing: 4) { Label(message, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(LitheTheme.error).lineLimit(3).textSelection(.enabled) - Button("Retry") { feature.loadDependencies(entry.id, project: project, reason: .refresh) } + Button("Retry", action: UserActionTrace.action("build.dependencies.reload") { + feature.loadDependencies(entry.id, project: project, reason: .refresh) + }) .buttonStyle(.litheNoPress) } .padding(.vertical, 3) @@ -965,8 +973,27 @@ struct BuildSystemToolWindowView: View { argumentsPrompt = nil } + /// Toolbar, tree activation, context menu and the arguments sheet all run here; + /// an outer menu trace keeps its own id. private func run(_ runnable: Runnable, entryID: String) { guard !feature.isRunning else { return } + UserActionTrace.run(Self.actionID(runnable), surface: .button, sink: UserActionTrace.applicationSink) { + start(runnable, entryID: entryID) + } + } + + private static func actionID(_ runnable: Runnable) -> String { + switch runnable { + case .task(_, _, true): "build.task.arguments" + case .task: "build.task.run" + case .tests: "build.tests.run" + case .discoveredTest: "build.test.run" + case .application: "build.application.run" + case .debugApplication: "build.application.debug" + } + } + + private func start(_ runnable: Runnable, entryID: String) { switch runnable { case .task(let request, let title, true): argumentsPrompt = ArgumentsPrompt(entryID: entryID, title: title, request: request) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 0d10f7bbf..24cc3d48c 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -1,3 +1,4 @@ +import LitheModuleAPI import SwiftUI struct RunView: View { @@ -121,9 +122,9 @@ struct RunView: View { HStack(spacing: 4) { Text("—") .foregroundStyle(LitheTheme.RunEmptyState.text) - Button("Identify and Generate") { + Button("Identify and Generate", action: UserActionTrace.action("run.configurations.generate") { feature.requestRunConfigurationGeneration() - } + }) .buttonStyle(.plain) .foregroundStyle(LitheTheme.RunEmptyState.link) } @@ -165,9 +166,9 @@ struct RunView: View { .font(LitheTheme.uiFont(size: 12)) .foregroundStyle(LitheTheme.warning) } else if feature.recoveryAction != .none { - Button { + Button(action: UserActionTrace.action("run.configurations.generate") { feature.requestRunConfigurationGeneration() - } label: { + }) { Label( feature.recoveryAction == .fixPermissions ? runText("Retry Identification") @@ -267,7 +268,8 @@ struct RunView: View { Text(notice.title).litheBannerTitle() Text(notice.message).lineLimit(2) if feature.configurationDiagnostics.contains(where: { $0.code == "staleFingerprint" }) { - Button("Identify Again") { feature.requestRunConfigurationGeneration() } + Button("Identify Again", + action: UserActionTrace.action("run.configurations.generate") { feature.requestRunConfigurationGeneration() }) } else if feature.blockingToolchainDiagnostic != nil { Button("Project · JDK & Maven") { model.showSettings(category: .project) } } @@ -365,7 +367,7 @@ struct RunView: View { Text(String(format: runText("%lld selected"), Int64(browser.checkedIDs.count))) .font(LitheTheme.uiFont(size: 11)) .foregroundStyle(LitheTheme.secondaryText) - Button(action: runCheckedConfigurations) { + Button(action: UserActionTrace.action("run.checked.start", runCheckedConfigurations)) { LitheIDEAIcon(resourcePath: "expui/run/run.svg", size: 16, fallbackSystemImage: "play.fill", preservesOriginalColors: true) } @@ -374,11 +376,11 @@ struct RunView: View { .help("Run checked configurations without restarting running services") .disabled(feature.configurationStatus != .ready || feature.isLoadingProject) - Button { + Button(action: UserActionTrace.action("run.checked.stop") { for session in feature.moduleSessions where browser.checkedIDs.contains(session.configurationID) && session.isRunning { feature.stopModule(session) } - } label: { + }) { LitheIDEAIcon(resourcePath: "expui/run/stop.svg", size: 16, fallbackSystemImage: "stop.fill", preservesOriginalColors: true) } @@ -396,7 +398,7 @@ struct RunView: View { .help("Clear selection") } - Button { + Button(action: UserActionTrace.action("run.panel.start-or-restart") { contentTab = .console if let configuration = selectedRunnableConfiguration { model.startRunConfiguration(configuration) @@ -405,7 +407,7 @@ struct RunView: View { } else { model.runSelectedConfiguration() } - } label: { + }) { if selectedSessionHasRun { LitheIDEAIcon(resourcePath: "expui/run/rerun_stroke.svg", size: 16, fallbackSystemImage: "arrow.clockwise") @@ -422,7 +424,9 @@ struct RunView: View { .disabled(feature.isLoadingProject) if feature.hasBuildProviderRun && primaryOutputSelected && primaryRunMatchesSelection { - Button { feature.saveBuildProviderConfiguration() } label: { + Button(action: UserActionTrace.action("run.provider-configuration.save") { + feature.saveBuildProviderConfiguration() + }) { Image(systemName: "square.and.arrow.down") } .buttonStyle(runActionButtonStyle) @@ -431,13 +435,13 @@ struct RunView: View { .disabled(feature.isLoadingProject) } - Button { + Button(action: UserActionTrace.action("run.panel.stop") { if let session = selectedModuleSession { feature.stopModule(session) } else { model.stopSelectedRun() } - } label: { + }) { LitheIDEAIcon(resourcePath: "expui/run/stop.svg", size: 16, fallbackSystemImage: "stop.fill", preservesOriginalColors: true) } @@ -448,7 +452,7 @@ struct RunView: View { .accessibilityLabel("Stop run") if let session = selectedModuleSession, feature.canUpdateService(session) { - Button { model.updateRunningService(session) } label: { + Button(action: UserActionTrace.action("run.service.update") { model.updateRunningService(session) }) { Image(systemName: "bolt.fill") } .buttonStyle(runActionButtonStyle) @@ -458,9 +462,9 @@ struct RunView: View { .accessibilityLabel("Update running service") } - Button { + Button(action: UserActionTrace.action("run.configurations.generate") { feature.requestRunConfigurationGeneration() - } label: { + }) { LitheIDEAIcon(resourcePath: "expui/general/refresh.svg", size: 16, fallbackSystemImage: "arrow.clockwise", preservesOriginalColors: true) } diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift index d9e268411..6e5b1ef6a 100644 --- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift +++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -1,4 +1,5 @@ import AppKit +import LitheModuleAPI import SwiftUI import LitheTerminalModule @@ -52,9 +53,9 @@ struct TerminalView: View { // IslandsTabPainter paints 4pt inside each tab's layout bounds. .padding(.horizontal, 4) } - Button { + Button(action: UserActionTrace.action("terminal.new") { _ = model.createTerminalSession() - } label: { + }) { LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, fallbackSystemImage: "plus", preservesOriginalColors: true) } diff --git a/macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift b/macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift index 792171239..adeea3bff 100644 --- a/macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Workbench/PluginNativeToolWindowView.swift @@ -115,7 +115,7 @@ struct PluginNativePanel: View { if feature.state.expanded.contains(row.id) != expanded { feature.toggle(row.id) } }, contextMenu: { row in actions(feature.declaration.contextMenu).map { action in - .action(action.title, localizesTitle: false, isEnabled: !feature.isBusy) { + .action(action.title, localizesTitle: false, actionID: feature.actionID(action), isEnabled: !feature.isBusy) { feature.select(row.id) feature.perform(action) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift index 50197f6a9..59d1f485e 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift @@ -275,16 +275,24 @@ struct WorkbenchModuleUIRegistry { return action.state(model) } - func performAction(id: String, model: AppModel) { + /// `surface` names the UI entry that invoked the action; `nil` marks host + /// restore or replay, which is not a user action and writes no action line. + func performAction(id: String, model: AppModel, surface: UserActionSurface?) { let state = state(forActionID: id, model: model) - guard state.isVisible && state.isEnabled else { return } - actions[id]?.perform(model) + guard state.isVisible && state.isEnabled, let action = actions[id] else { return } + trace(id, surface: surface, model: model) { action.perform(model) } } - func perform(_ contribution: ModuleContribution, model: AppModel) { + func perform(_ contribution: ModuleContribution, model: AppModel, surface: UserActionSurface?) { let state = state(for: contribution, model: model) - guard state.isVisible && state.isEnabled, let actionID = contribution.actionID else { return } - actions[actionID]?.perform(model) + guard state.isVisible && state.isEnabled, let actionID = contribution.actionID, + let action = actions[actionID] else { return } + trace(actionID, surface: surface, model: model) { action.perform(model) } + } + + private func trace(_ id: String, surface: UserActionSurface?, model: AppModel, _ body: () -> Void) { + guard let surface else { return body() } + UserActionTrace.run(id, surface: surface, sink: model.services.logSink, body) } func selectedToolContent( diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 3a37e9512..a89472132 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -883,7 +883,7 @@ struct WorkbenchView: View { case "workbench.debug-start-or-restart": debugLaunchButton case "workbench.stop-execution": stopExecutionButton default: - Button { moduleUIRegistry.perform(contribution, model: model) } label: { + Button { moduleUIRegistry.perform(contribution, model: model, surface: .toolbar) } label: { HStack(spacing: 6) { moduleUIRegistry.icon(for: contribution, role: .action) Text(verbatim: contribution.title) @@ -1037,7 +1037,7 @@ struct WorkbenchView: View { let control = WorkbenchModuleUIComposition.mainToolbarDebugControl(for: model) let isRunningConfiguration = model.activeTestTarget == nil && model.runFeatureIfActive?.isSelectedConfigurationRunning == true return Button { - moduleUIRegistry.performAction(id: "workbench.run-start-or-restart", model: model) + moduleUIRegistry.performAction(id: "workbench.run-start-or-restart", model: model, surface: .toolbar) } label: { LitheIDEAIcon( resourcePath: control.map { DebugToolbarPresentation.ideaAssetPath(for: $0) } @@ -1065,7 +1065,7 @@ struct WorkbenchView: View { private var debugLaunchButton: some View { Button { - moduleUIRegistry.performAction(id: "workbench.debug-start-or-restart", model: model) + moduleUIRegistry.performAction(id: "workbench.debug-start-or-restart", model: model, surface: .toolbar) } label: { LitheIDEAIcon( resourcePath: isDebugSessionActive @@ -1087,7 +1087,7 @@ struct WorkbenchView: View { private var stopExecutionButton: some View { Button { - moduleUIRegistry.performAction(id: "workbench.stop-execution", model: model) + moduleUIRegistry.performAction(id: "workbench.stop-execution", model: model, surface: .toolbar) } label: { LitheIDEAIcon( resourcePath: "expui/run/stop_stroke.svg", @@ -1393,7 +1393,7 @@ struct WorkbenchView: View { if let entry = contribution(for: tool) { if !entry.hasDeclarativeContent && model.workbenchFeature.isVisible(tool) { model.workbenchFeature.setVisibility(tool, isVisible: false) - } else { moduleUIRegistry.perform(entry, model: model) } + } else { moduleUIRegistry.perform(entry, model: model, surface: .button) } } else if isToolAvailable(tool) { model.workbenchFeature.toggleVisibility(tool) } } diff --git a/macos/Sources/Lithe/Views/Workspace/CloneRepositoryView.swift b/macos/Sources/Lithe/Views/Workspace/CloneRepositoryView.swift index a7ae73844..692829081 100644 --- a/macos/Sources/Lithe/Views/Workspace/CloneRepositoryView.swift +++ b/macos/Sources/Lithe/Views/Workspace/CloneRepositoryView.swift @@ -1,4 +1,5 @@ import AppKit +import LitheModuleAPI import SwiftUI struct CloneRepositoryView: View { @@ -78,7 +79,7 @@ struct CloneRepositoryView: View { .buttonStyle(LitheActionButtonStyle()) .disabled(model.isCloningRepository) - Button { + Button(action: UserActionTrace.action("workspace.clone") { Task { errorMessage = await model.cloneRepository( remote: remoteURL, @@ -88,7 +89,7 @@ struct CloneRepositoryView: View { model.workbenchFeature.isCloneRepositoryPresented = false } } - } label: { + }) { HStack(spacing: 7) { if model.isCloningRepository { ProgressView().controlSize(.small) diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index dbbf5d350..faa286e97 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -414,9 +414,9 @@ struct NewProjectView: View { } .buttonStyle(LitheActionButtonStyle()) .keyboardShortcut(.cancelAction) - Button(LocalizedStringKey("Create")) { + Button(LocalizedStringKey("Create"), action: UserActionTrace.action("workspace.project.create") { feature.create { dismiss() } - } + }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) .disabled(!feature.canCreate) diff --git a/macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift index d0eaaaf54..6c58f88a9 100644 --- a/macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift @@ -1,3 +1,4 @@ +import LitheModuleAPI import SwiftUI struct OpenProjectLocationDialog: View { @@ -29,14 +30,14 @@ struct OpenProjectLocationDialog: View { .buttonStyle(LitheActionButtonStyle()) .keyboardShortcut(.cancelAction) - Button("New Window") { + Button("New Window", action: UserActionTrace.action("workspace.open.new-window") { onResolve(.newWindow, doNotAskAgain) - } + }) .buttonStyle(LitheActionButtonStyle()) - Button("This Window") { + Button("This Window", action: UserActionTrace.action("workspace.open.this-window") { onResolve(.thisWindow, doNotAskAgain) - } + }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) } diff --git a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift index 76061ca9d..b13f0bb5e 100644 --- a/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift +++ b/macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift @@ -120,7 +120,8 @@ struct OpenProjectPanel: View { case .failed(let message): Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(LitheTheme.warning) Text(verbatim: message).multilineTextAlignment(.center).textSelection(.enabled) - Button(LocalizedStringKey("Retry")) { feature.browseRemoteFolder() } + Button(LocalizedStringKey("Retry"), + action: UserActionTrace.action("workspace.remote.browse") { feature.browseRemoteFolder() }) .buttonStyle(LitheActionButtonStyle()) default: ProgressView().controlSize(.small) @@ -136,20 +137,20 @@ struct OpenProjectPanel: View { private var footer: some View { HStack(spacing: LitheTheme.ActionButton.spacing) { - Button(LocalizedStringKey("Remote Development")) { + Button(LocalizedStringKey("Remote Development"), action: UserActionTrace.action("workspace.remote.manage") { // End this panel's connection before the management window opens. feature.close() dismiss() - DispatchQueue.main.async { model.showRemoteConnections() } - } + UserActionContext.mainAsync { model.showRemoteConnections() } + }) .buttonStyle(LitheActionButtonStyle()) Spacer() Button(LocalizedStringKey("Cancel")) { dismiss() } .buttonStyle(LitheActionButtonStyle()) .keyboardShortcut(.cancelAction) - Button(LocalizedStringKey("Open")) { + Button(LocalizedStringKey("Open"), action: UserActionTrace.action("workspace.open") { if feature.selection == nil { openLocalFolder() } else { feature.openBrowsedFolder() } - } + }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) .disabled(feature.selection == nil ? localPath.isEmpty : (feature.browser?.isLoading ?? true)) @@ -186,6 +187,6 @@ struct OpenProjectPanel: View { let url = URL(fileURLWithPath: localPath, isDirectory: true) dismiss() // The sheet must be gone before the workspace replaces this window's content. - DispatchQueue.main.async { model.openProject(url) } + UserActionContext.mainAsync { model.openProject(url) } } } diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 8e775555c..197fd1962 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -147,9 +147,9 @@ struct ProjectSidebarView: View { titleVisibility: .visible, presenting: model.pendingProjectItemDeletion ) { request in - Button("Move to Trash", role: .destructive) { + Button("Move to Trash", role: .destructive, action: UserActionTrace.action("workspace.item.trash") { Task { await model.confirmProjectItemDeletion(request) } - } + }) .lithePointer() Button("Cancel", role: .cancel) { model.cancelProjectItemDeletion() @@ -227,9 +227,9 @@ struct ProjectSidebarView: View { .help("Reveal Active File in Project Tree") } if selectedContent == .dependencies { - Button { + Button(action: UserActionTrace.action("workspace.dependencies.refresh") { dependencyRefreshRevision &+= 1 - } label: { + }) { LitheSystemIcon(systemImage: "arrow.clockwise") } .litheIconButton() @@ -242,9 +242,9 @@ struct ProjectSidebarView: View { .help("Refreshing project") .modifier(LitheToolWindowHoverAction()) } else { - Button { + Button(action: UserActionTrace.action("workspace.refresh") { Task { await model.refreshWorkspace() } - } label: { + }) { LitheIDEAIcon( resourcePath: "expui/general/refresh.svg", size: LitheTheme.Metrics.toolbarIconSize, diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift index c6d32eabd..360408648 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift @@ -56,9 +56,12 @@ struct ProjectSwitcherPopover: View { ScrollView { VStack(alignment: .leading, spacing: 0) { VStack(spacing: 0) { - actionRow(icon: "expui/general/add.svg", title: "New Project…", action: onNewProject) - actionRow(icon: "expui/general/open.svg", title: L10n.string("Open…"), action: onOpenProject) + actionRow("workspace.project.new", icon: "expui/general/add.svg", title: "New Project…", + action: onNewProject) + actionRow("open-project", icon: "expui/general/open.svg", title: L10n.string("Open…"), + action: onOpenProject) actionRow( + "workspace.clone.show", icon: "expui/general/vcs.svg", title: "Clone Repository…", action: onCloneRepository @@ -121,8 +124,9 @@ struct ProjectSwitcherPopover: View { .padding(.bottom, 5) } - private func actionRow(icon: String, title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { + private func actionRow(_ id: String, icon: String, title: String, + action: @escaping () -> Void) -> some View { + Button(action: UserActionTrace.action(id, action)) { HStack(spacing: 8) { LitheIDEAIcon(resourcePath: icon, size: LitheDropdownMetrics.iconSize, preservesOriginalColors: true) @@ -136,10 +140,10 @@ struct ProjectSwitcherPopover: View { } private func openProjectRow(_ projectModel: AppModel) -> some View { - return Button { + return Button(action: UserActionTrace.action("workspace.project.switch") { isPresented = false projectSessions.activateSession(projectModel.id) - } label: { + }) { projectRowContent( name: projectModel.projectName, path: projectModel.workspaceURL?.path ?? "", @@ -152,10 +156,10 @@ struct ProjectSwitcherPopover: View { private func recentProjectRow(_ project: RecentProject) -> some View { let exists = model.fileExists(at: project.url) - return Button { + return Button(action: UserActionTrace.action("workspace.project.open-recent") { guard exists else { return } onOpenRecentProject(project) - } label: { + }) { projectRowContent( name: project.name, path: project.path, diff --git a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift index 118eb5555..a5efe963f 100644 --- a/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift +++ b/macos/Sources/Lithe/Views/Workspace/RemoteConnectionsView.swift @@ -73,12 +73,12 @@ struct RemoteConnectionsView: View { )) Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack(spacing: 2) { - Button { feature.addConnection() } label: { + Button(action: UserActionTrace.action("workspace.remote.add") { feature.addConnection() }) { LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, fallbackSystemImage: "plus") } .litheToolbarIconButton() .help(Text(LocalizedStringKey("New SSH Connection"))) - Button { feature.removeSelection() } label: { + Button(action: UserActionTrace.action("workspace.remote.remove") { feature.removeSelection() }) { Image(systemName: "minus").font(LitheTheme.uiFont(size: 12, weight: .medium)) } .litheToolbarIconButton(isEnabled: feature.selection != nil) @@ -134,7 +134,8 @@ struct RemoteConnectionsView: View { LitheSettingsRow("Remote folder", info: "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder.") { textField("Remote folder", text: binding(\.remoteDirectory), prompt: connection.lastKnownHost?.homeDirectory ?? "~") - Button(LocalizedStringKey("Browse…")) { feature.browseRemoteFolder() } + Button(LocalizedStringKey("Browse…"), + action: UserActionTrace.action("workspace.remote.browse") { feature.browseRemoteFolder() }) .buttonStyle(LitheActionButtonStyle()) .disabled(!feature.canConnect || connection.problem != nil || feature.selectionTestState == .testing) @@ -156,7 +157,8 @@ struct RemoteConnectionsView: View { LitheIDEAIcon(resourcePath: "expui/webReferences/server.svg", size: 32, fallbackSystemImage: "server.rack") Text(LocalizedStringKey("No SSH connections yet.")) .foregroundStyle(LitheTheme.secondaryText) - Button(LocalizedStringKey("New SSH Connection")) { feature.addConnection() } + Button(LocalizedStringKey("New SSH Connection"), + action: UserActionTrace.action("workspace.remote.add") { feature.addConnection() }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -183,10 +185,12 @@ struct RemoteConnectionsView: View { Spacer(minLength: 8) statusPill(connection, state: state) if state == .testing { - Button(LocalizedStringKey("Stop")) { feature.cancelTest() } + Button(LocalizedStringKey("Stop"), + action: UserActionTrace.action("workspace.remote.test.cancel") { feature.cancelTest() }) .buttonStyle(LitheActionButtonStyle()) } else { - Button(LocalizedStringKey("Test Connection")) { feature.testSelection() } + Button(LocalizedStringKey("Test Connection"), + action: UserActionTrace.action("workspace.remote.test") { feature.testSelection() }) .buttonStyle(LitheActionButtonStyle()) .disabled(connection.problem != nil) } @@ -344,9 +348,8 @@ struct RemoteConnectionsView: View { if feature.isLocating { ProgressView().controlSize(.small) } else { - Button(LocalizedStringKey(host.location == nil ? "Look Up Location" : "Look Up Again")) { - feature.locateSelectedHost() - } + Button(LocalizedStringKey(host.location == nil ? "Look Up Location" : "Look Up Again"), + action: UserActionTrace.action("workspace.remote.locate") { feature.locateSelectedHost() }) .buttonStyle(LitheActionButtonStyle()) } } @@ -467,12 +470,12 @@ struct RemoteConnectionsView: View { Button(LocalizedStringKey("Cancel")) { closePanel() } .buttonStyle(LitheActionButtonStyle()) .keyboardShortcut(.cancelAction) - Button(LocalizedStringKey("Save")) { + Button(LocalizedStringKey("Save"), action: UserActionTrace.action("workspace.remote.save") { if feature.save() { closePanel() } - } + }) .buttonStyle(LitheActionButtonStyle()) .disabled(!feature.canSave) - Button(LocalizedStringKey("Open")) { feature.openSelection() } + Button(LocalizedStringKey("Open"), action: UserActionTrace.action("workspace.remote.open") { feature.openSelection() }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) .disabled(!feature.canSave || !feature.canConnect || feature.selection?.problem != nil @@ -579,7 +582,8 @@ private struct RemoteFolderBrowserView: View { Button(LocalizedStringKey("Cancel")) { feature.closeBrowser() } .buttonStyle(LitheActionButtonStyle()) .keyboardShortcut(.cancelAction) - Button(LocalizedStringKey("Choose This Folder")) { feature.chooseBrowserFolder() } + Button(LocalizedStringKey("Choose This Folder"), + action: UserActionTrace.action("workspace.remote.folder.choose") { feature.chooseBrowserFolder() }) .buttonStyle(LitheActionButtonStyle(isPrimary: true)) .keyboardShortcut(.defaultAction) .disabled(browser.isLoading) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 50e075e67..f2cd22ed4 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1392,14 +1392,36 @@ package final class GitFeatureModel: ObservableObject { return saveChangesPolicy?() ?? .stash } + /// Every user Git operation runs here. A panel control that started it without + /// an outer menu, command or shortcut trace logs as `vcs.git.`, the + /// model method that owns the operation. func withGitOperation( title: String? = nil, plannedArguments: [String]? = nil, afterOperation: (@MainActor () async -> Void)? = nil, + caller: String = #function, + _ operation: () async -> T + ) async -> T { + await UserActionTrace.run(Self.operationActionID(caller), surface: .button, + sink: UserActionTrace.applicationSink) { + await runGitOperation(title: title, plannedArguments: plannedArguments, + afterOperation: afterOperation, caller: caller, operation) + } + } + + /// `#function` reads `pushReviewed(_:to:)`; the action id keeps the name only. + nonisolated static func operationActionID(_ caller: String) -> String { + "vcs.git." + (caller.split(separator: "(", maxSplits: 1).first.map(String.init) ?? caller) + } + + private func runGitOperation( + title: String?, plannedArguments: [String]?, + afterOperation: (@MainActor () async -> Void)?, + caller: String, _ operation: () async -> T ) async -> T { if let worktreeOperationHost { return await worktreeOperationHost.withGitOperation(title: title, plannedArguments: plannedArguments, - afterOperation: afterOperation, operation) + afterOperation: afterOperation, caller: caller, operation) } if GitExecutionContext.current != nil { let result = await operation() @@ -1493,7 +1515,7 @@ package final class GitFeatureModel: ObservableObject { /// wait for unrelated repositories, stashes, history or project services. private func withGitChangeOperation( _ changes: [GitChange], additionalRoots: [URL] = [], onWorktreeSettled: @escaping @MainActor () -> Void = {}, - _ operation: () async -> T + caller: String = #function, _ operation: () async -> T ) async -> T { let generation = workspaceCommitGeneration let workspace = workspaceURLProvider?()?.standardizedFileURL @@ -1511,7 +1533,7 @@ package final class GitFeatureModel: ObservableObject { self.invalidateChangeReads(completedRequests) self.worktreeOperationHost?.invalidateChangeReads(completedRequests) await self.refreshAfterChangeMutation(requests: completedRequests, onWorktreeSettled: onWorktreeSettled) - }, operation) + }, caller: caller, operation) } private func invalidateChangeReads(_ requests: [URL: UUID]) { @@ -1527,9 +1549,9 @@ package final class GitFeatureModel: ObservableObject { } private func recordingGitCommand( - _ operation: () async -> GitService.CommandResult + caller: String = #function, _ operation: () async -> GitService.CommandResult ) async -> GitService.CommandResult { - await withGitOperation(operation) + await withGitOperation(caller: caller, operation) } private func trimGitConsole() { diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift index c3368a6a3..8bd5d671c 100644 --- a/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift +++ b/macos/Sources/LitheGitModule/Models/GitExecutionJournal.swift @@ -1,12 +1,10 @@ import Combine import Foundation -import LitheModuleAPI /// Window-owned history shared by Git feature workflows, GitHub actions and /// requests made before the Git feature activates. /// Native callbacks only update bounded storage; consumers coalesce publication. package final class GitExecutionJournal: @unchecked Sendable { - package var onLifecycleEvent: (@Sendable (GitExecutionEvent) -> LogRecordReference?)? package let changes = PassthroughSubject() private let lock = NSLock() private var contexts: [String: GitExecutionContext] = [:] @@ -18,10 +16,6 @@ package final class GitExecutionJournal: @unchecked Sendable { private var entryIDs: [String: Set] = [:] private var hidden: Set = [] private var omittedHistory = false - private var failedReferences: [String: LogRecordReference] = [:] - package func takeFailureReference(operationID: String) -> LogRecordReference? { - lock.withLock { failedReferences.removeValue(forKey: operationID) } - } package init() {} @@ -62,15 +56,6 @@ package final class GitExecutionJournal: @unchecked Sendable { } package func receive(_ event: GitExecutionEvent, at root: URL? = nil) { - if ["requestStarted", "requestFinished", "started", "finished"].contains(event.type) { - if let reference = onLifecycleEvent?(event) { - lock.withLock { - if failedReferences.count >= 256 { failedReferences.removeAll(keepingCapacity: true) } - failedReferences[event.operationId] = reference - } - } - } - let changed = lock.withLock { receiveLocked(event, root: root) } if changed { changes.send() } } diff --git a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift index 567037f3c..5a039eaea 100644 --- a/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitExecutionModels.swift @@ -1,4 +1,5 @@ import Foundation +import LitheModuleAPI /// Versioned Core diagnostics. Absolute directories are native display context. package struct GitExecutionEvent: Decodable, Sendable { @@ -51,6 +52,8 @@ package final class GitExecutionContext: @unchecked Sendable { @TaskLocal package static var current: GitExecutionContext? package let operationID: String package let source: GitExecutionSource + /// The user action that started this execution, kept across detached hops. + package let action: UserAction? private let lock = NSLock() private var cancelled = false private var records: [Record] = [] @@ -114,9 +117,17 @@ package final class GitExecutionContext: @unchecked Sendable { } } - package init(operationID: String = UUID().uuidString, source: GitExecutionSource = .unknown) { + package init(operationID: String = UUID().uuidString, source: GitExecutionSource = .unknown, + action: UserAction? = UserActionContext.current) { self.operationID = operationID self.source = source + self.action = action + } + + /// The `op` Core gives this execution's request and Git process records: + /// the action's operation unless a background poller owns the request. + package var traceOperationID: String { + source == .background ? operationID : ((action ?? UserActionContext.current)?.operationID ?? operationID) } package func requestCancellation() { lock.withLock { cancelled = true } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 8b54fecef..6246746b1 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -507,7 +507,7 @@ package struct GitService: Sendable { let operations = self.operations let execution = GitExecutionContext.current return await withTaskCancellationHandler { - await Task.detached(priority: .userInitiated) { + await UserActionContext.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(execution) { operation(operations) } }.value } onCancel: { @@ -742,23 +742,23 @@ package struct GitService: Sendable { func executionSettings(_ request: GitConfigurationEdit, save: Bool) async -> Result { let operations = self.operations let context = save ? GitExecutionContext.current : nil - return await Task.detached(priority: .userInitiated) { + return await UserActionContext.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(context) { operations.executionSettings(request, save: save) } }.value } func executableResolution() async -> Result { let operations = self.operations - return await Task.detached(priority: .userInitiated) { operations.executableResolution() }.value + return await UserActionContext.detached(priority: .userInitiated) { operations.executableResolution() }.value } func remoteURL(at rootURL: URL, remote: String) async -> String? { let operations = self.operations - return await Task.detached(priority: .utility) { + return await UserActionContext.detached(priority: .utility) { operations.remoteURL(at: rootURL, remote: remote) }.value } func answerAuthentication(requestID: String, answer: String?) async -> Bool { let operations = self.operations - return await Task.detached(priority: .userInitiated) { operations.answerAuthentication(requestID: requestID, answer: answer) }.value + return await UserActionContext.detached(priority: .userInitiated) { operations.answerAuthentication(requestID: requestID, answer: answer) }.value } func repositorySetup(at root: URL, scope: GitIdentityScope) async -> Result { @@ -777,7 +777,7 @@ package struct GitService: Sendable { private func setupMutation(_ operation: @escaping @Sendable (any GitOperations) -> Result) async -> Result { let context = GitExecutionContext.current let operations = self.operations - return await Task.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(context) { operation(operations) } }.value + return await UserActionContext.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(context) { operation(operations) } }.value } func interactiveRebaseSession(at root: URL) async -> Result { @@ -800,7 +800,7 @@ package struct GitService: Sendable { let operations = self.operations let execution = GitExecutionContext.current let response = await withTaskCancellationHandler { - await Task.detached(priority: .userInitiated) { + await UserActionContext.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(execution) { operation(operations) } }.value } onCancel: { @@ -1240,7 +1240,7 @@ package struct GitService: Sendable { package func fetchPlan(options: GitFetchOptions, at root: URL? = nil) async -> Result { let operations = self.operations - return await Task.detached(priority: .userInitiated) { root.map { operations.fetchPlan(options: options, at: $0) } ?? operations.fetchPlan(options: options) }.value + return await UserActionContext.detached(priority: .userInitiated) { root.map { operations.fetchPlan(options: options, at: $0) } ?? operations.fetchPlan(options: options) }.value } package func fetch(at root: URL, options: GitFetchOptions, operationID: String) async -> CommandResult { @@ -1305,7 +1305,7 @@ package struct GitService: Sendable { at repositoryRoot: URL ) async -> Result { let operations = self.operations - return await Task.detached(priority: .userInitiated) { + return await UserActionContext.detached(priority: .userInitiated) { operations.pushPreview(reference, pushTarget: pushTarget, at: repositoryRoot) }.value } @@ -1385,7 +1385,7 @@ package struct GitService: Sendable { let startedAt = ContinuousClock.now let execution = GitExecutionContext.current let result = await withTaskCancellationHandler { - await Task.detached(priority: .userInitiated) { + await UserActionContext.detached(priority: .userInitiated) { GitExecutionContext.$current.withValue(execution) { operation(operations) } }.value } onCancel: { @@ -1408,7 +1408,7 @@ package struct GitService: Sendable { branchDeletion: result?.branchDeletion, historyRewrite: result?.historyRewrite, warnings: result?.warnings ?? [], - logReference: execution.flatMap { executionJournal?.takeFailureReference(operationID: $0.operationID) } + logReference: execution.flatMap { Self.failureReference(result, execution: $0) } ) performanceLogger.emit( PerfGitCommandFinished(duration: .milliseconds(elapsedMilliseconds(since: startedAt)), operation: String(operationName.prefix(while: { $0 != "(" })), succeeded: commandResult.succeeded) @@ -1416,6 +1416,17 @@ package struct GitService: Sendable { return commandResult } + /// Core records a failed Git process under the request's trace operation, and a + /// request that failed without one as a Core request. An operation-level failure + /// whose processes all exited 0 left no Core failure, so the caller records it. + static func failureReference(_ result: GitProcessResult?, execution: GitExecutionContext) -> LogRecordReference? { + if let result, result.exitCode == 0, result.operationErrorMessage == nil, result.stashRestoreConflict == nil { return nil } + let failedProcess = result?.invocations.contains { $0.exitCode != 0 } == true + if result?.exitCode == 0, !failedProcess { return nil } + return LogRecordReference(channel: failedProcess ? "vcs/git" : "app/core", + event: failedProcess ? VcsGitCommandFailed.id : AppCoreRequestFailed.id, op: execution.traceOperationID) + } + package func consolePresentation(_ request: GitConsolePresentationRequest) async -> GitConsolePresentation? { await read { $0.consolePresentation(request) } } @@ -1464,7 +1475,7 @@ package struct GitService: Sendable { let operations = self.operations let startedAt = ContinuousClock.now let source = GitExecutionSource.current - let task = Task.detached(priority: priority) { + let task = UserActionContext.detached(priority: priority) { GitExecutionSource.$current.withValue(source) { operation(operations) } } let result = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } @@ -1497,7 +1508,7 @@ package struct GitService: Sendable { ) async -> T? { let operations = self.operations let source = GitExecutionSource.current - let task = Task.detached(priority: priority) { + let task = UserActionContext.detached(priority: priority) { GitExecutionSource.$current.withValue(source) { operation(operations) } } return await withTaskCancellationHandler { diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index 83c0043ce..963783b26 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -362,7 +362,7 @@ public final class ProjectHistoryFeatureModel: ObservableObject { do { let historicalText = try await localHistoryService.content(for: entry) let currentText = try currentText(for: request.fileURL) - let rows = await Task.detached(priority: .userInitiated) { + let rows = await UserActionContext.detached(priority: .userInitiated) { LocalHistoryDiffBuilder.rows(old: historicalText, current: currentText) }.value guard selectedLocalHistoryEntry?.id == entry.id else { return } @@ -404,7 +404,7 @@ public final class ProjectHistoryFeatureModel: ObservableObject { do { let historicalText = try await localHistoryService.content(for: entry) let currentText = try currentText(for: fileURL) - let rows = await Task.detached(priority: .userInitiated) { + let rows = await UserActionContext.detached(priority: .userInitiated) { LocalHistoryDiffBuilder.rows(old: historicalText, current: currentText) }.value guard selectedProjectLocalHistoryEntry?.id == entry.id else { return } diff --git a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift index f3e9a5bbd..9ebbe7e42 100644 --- a/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift +++ b/macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift @@ -192,6 +192,30 @@ public struct AgentStatisticsDecodeFailed: LogEvent { } } +public struct AppActionPerformed: LogEvent { + public static let id = "app.action.performed" + public static let level: LogLevel = .info + public static let messageTemplate = "User action {action} performed" + public static let ratePerMinute = 600 + public static let owner = "LitheModuleAPI" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`action`: String, `surface`: String, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.app, segments: []) + var _logValues: [String: LogFieldValue] = [:] + _logValues["action"] = .token(`action`) + _logValues["surface"] = .token(`surface`) + self.fields = _logValues + } +} + public struct AppActionSearchFailed: LogEvent { public static let id = "app.action_search.failed" public static let level: LogLevel = .error @@ -263,6 +287,96 @@ public struct AppCoreFallbackSelected: LogEvent { } } +public struct AppCoreRequestFailed: LogEvent { + public static let id = "app.core.request.failed" + public static let level: LogLevel = .warn + public static let messageTemplate = "Core request {method} failed" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`duration`: Duration, `error`: any Error, `method`: String, `operation`: (String)? = nil, `path`: (URL)? = nil, `remote`: (Bool)? = nil, `source`: String, `trigger`: (String)? = nil, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.app, segments: [LogSegment("core")!]) + var _logValues: [String: LogFieldValue] = [:] + _logValues["duration"] = .duration(`duration`) + _logValues["error"] = .error(`error`) + _logValues["method"] = .token(`method`) + if let value = `operation` { _logValues["operation"] = .token(value) } + if let value = `path` { _logValues["path"] = .path(value) } + if let value = `remote` { _logValues["remote"] = .bool(value) } + _logValues["source"] = .token(`source`) + if let value = `trigger` { _logValues["trigger"] = .token(value) } + self.fields = _logValues + } +} + +public struct AppCoreRequestFinished: LogEvent { + public static let id = "app.core.request.finished" + public static let level: LogLevel = .info + public static let messageTemplate = "Core request {method} finished" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`cancelled`: (Bool)? = nil, `duration`: Duration, `method`: String, `operation`: (String)? = nil, `path`: (URL)? = nil, `remote`: (Bool)? = nil, `source`: String, `trigger`: (String)? = nil, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.app, segments: [LogSegment("core")!]) + var _logValues: [String: LogFieldValue] = [:] + if let value = `cancelled` { _logValues["cancelled"] = .bool(value) } + _logValues["duration"] = .duration(`duration`) + _logValues["method"] = .token(`method`) + if let value = `operation` { _logValues["operation"] = .token(value) } + if let value = `path` { _logValues["path"] = .path(value) } + if let value = `remote` { _logValues["remote"] = .bool(value) } + _logValues["source"] = .token(`source`) + if let value = `trigger` { _logValues["trigger"] = .token(value) } + self.fields = _logValues + } +} + +public struct AppCoreRequestTraced: LogEvent { + public static let id = "app.core.request.traced" + public static let level: LogLevel = .debug + public static let messageTemplate = "Core request {method} traced" + public static let ratePerMinute = 600 + public static let owner = "lithe-core" + public static let debugOnly = false + public let channel: LogChannel + public let fields: [String: LogFieldValue] + public let operationID: String? + public var reference: LogRecordReference { + LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) + } + + public init(`cancelled`: (Bool)? = nil, `duration`: Duration, `method`: String, `operation`: (String)? = nil, `path`: (URL)? = nil, `remote`: (Bool)? = nil, `source`: String, `trigger`: (String)? = nil, operationID: String? = nil) { + self.operationID = operationID + self.channel = LogChannel(.app, segments: [LogSegment("core")!]) + var _logValues: [String: LogFieldValue] = [:] + if let value = `cancelled` { _logValues["cancelled"] = .bool(value) } + _logValues["duration"] = .duration(`duration`) + _logValues["method"] = .token(`method`) + if let value = `operation` { _logValues["operation"] = .token(value) } + if let value = `path` { _logValues["path"] = .path(value) } + if let value = `remote` { _logValues["remote"] = .bool(value) } + _logValues["source"] = .token(`source`) + if let value = `trigger` { _logValues["trigger"] = .token(value) } + self.fields = _logValues + } +} + public struct AppCredentialsCleanupFailed: LogEvent { public static let id = "app.credentials.cleanup_failed" public static let level: LogLevel = .warn @@ -5119,10 +5233,10 @@ public struct VcsCommitCleanupFailed: LogEvent { public struct VcsGitCommandFailed: LogEvent { public static let id = "vcs.git.command.failed" - public static let level: LogLevel = .error + public static let level: LogLevel = .warn public static let messageTemplate = "Git {command} failed" public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" + public static let owner = "lithe-core" public static let debugOnly = false public let channel: LogChannel public let fields: [String: LogFieldValue] @@ -5131,16 +5245,19 @@ public struct VcsGitCommandFailed: LogEvent { LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) } - public init(`command`: String, `duration`: Duration, `error`: any Error, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { + public init(`arguments`: String, `command`: String, `duration`: Duration, `error`: (any Error)? = nil, `exitCode`: (Int64)? = nil, `remote`: (Bool)? = nil, `repository`: (URL)? = nil, `source`: String, `stderr`: (String)? = nil, operationID: String? = nil) { self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) + self.channel = LogChannel(.vcs, segments: [LogSegment("git")!]) var _logValues: [String: LogFieldValue] = [:] + _logValues["arguments"] = .text(`arguments`) _logValues["command"] = .token(`command`) _logValues["duration"] = .duration(`duration`) - _logValues["error"] = .error(`error`) + if let value = `error` { _logValues["error"] = .error(value) } if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) + if let value = `remote` { _logValues["remote"] = .bool(value) } + if let value = `repository` { _logValues["repository"] = .path(value) } + _logValues["source"] = .token(`source`) + if let value = `stderr` { _logValues["stderr"] = .text(value) } self.fields = _logValues } } @@ -5150,7 +5267,7 @@ public struct VcsGitCommandFinished: LogEvent { public static let level: LogLevel = .info public static let messageTemplate = "Git {command} finished" public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" + public static let owner = "lithe-core" public static let debugOnly = false public let channel: LogChannel public let fields: [String: LogFieldValue] @@ -5159,79 +5276,29 @@ public struct VcsGitCommandFinished: LogEvent { LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) } - public init(`command`: String, `duration`: Duration, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { + public init(`arguments`: String, `cancelled`: (Bool)? = nil, `command`: String, `duration`: Duration, `exitCode`: (Int64)? = nil, `remote`: (Bool)? = nil, `repository`: (URL)? = nil, `source`: String, `stderr`: (String)? = nil, operationID: String? = nil) { self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) + self.channel = LogChannel(.vcs, segments: [LogSegment("git")!]) var _logValues: [String: LogFieldValue] = [:] + _logValues["arguments"] = .text(`arguments`) + if let value = `cancelled` { _logValues["cancelled"] = .bool(value) } _logValues["command"] = .token(`command`) _logValues["duration"] = .duration(`duration`) if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) + if let value = `remote` { _logValues["remote"] = .bool(value) } + if let value = `repository` { _logValues["repository"] = .path(value) } + _logValues["source"] = .token(`source`) + if let value = `stderr` { _logValues["stderr"] = .text(value) } self.fields = _logValues } } -public struct VcsGitCommandStarted: LogEvent { - public static let id = "vcs.git.command.started" +public struct VcsGitCommandTraced: LogEvent { + public static let id = "vcs.git.command.traced" public static let level: LogLevel = .debug - public static let messageTemplate = "Git {command} started" + public static let messageTemplate = "Git {command} traced" public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" - public static let debugOnly = true - public let channel: LogChannel - public let fields: [String: LogFieldValue] - public let operationID: String? - public var reference: LogRecordReference { - LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) - } - - public init(`command`: String, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { - self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) - var _logValues: [String: LogFieldValue] = [:] - _logValues["command"] = .token(`command`) - if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) - self.fields = _logValues - } -} - -public struct VcsGitRequestFailed: LogEvent { - public static let id = "vcs.git.request.failed" - public static let level: LogLevel = .error - public static let messageTemplate = "Git request {command} failed" - public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" - public static let debugOnly = false - public let channel: LogChannel - public let fields: [String: LogFieldValue] - public let operationID: String? - public var reference: LogRecordReference { - LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) - } - - public init(`command`: String, `duration`: Duration, `error`: any Error, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { - self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) - var _logValues: [String: LogFieldValue] = [:] - _logValues["command"] = .token(`command`) - _logValues["duration"] = .duration(`duration`) - _logValues["error"] = .error(`error`) - if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) - self.fields = _logValues - } -} - -public struct VcsGitRequestFinished: LogEvent { - public static let id = "vcs.git.request.finished" - public static let level: LogLevel = .info - public static let messageTemplate = "Git request {command} finished" - public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" + public static let owner = "lithe-core" public static let debugOnly = false public let channel: LogChannel public let fields: [String: LogFieldValue] @@ -5240,41 +5307,19 @@ public struct VcsGitRequestFinished: LogEvent { LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) } - public init(`command`: String, `duration`: Duration, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { + public init(`arguments`: String, `cancelled`: (Bool)? = nil, `command`: String, `duration`: Duration, `exitCode`: (Int64)? = nil, `remote`: (Bool)? = nil, `repository`: (URL)? = nil, `source`: String, `stderr`: (String)? = nil, operationID: String? = nil) { self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) + self.channel = LogChannel(.vcs, segments: [LogSegment("git")!]) var _logValues: [String: LogFieldValue] = [:] + _logValues["arguments"] = .text(`arguments`) + if let value = `cancelled` { _logValues["cancelled"] = .bool(value) } _logValues["command"] = .token(`command`) _logValues["duration"] = .duration(`duration`) if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) - self.fields = _logValues - } -} - -public struct VcsGitRequestStarted: LogEvent { - public static let id = "vcs.git.request.started" - public static let level: LogLevel = .debug - public static let messageTemplate = "Git request {command} started" - public static let ratePerMinute = 600 - public static let owner = "LitheGitModule" - public static let debugOnly = true - public let channel: LogChannel - public let fields: [String: LogFieldValue] - public let operationID: String? - public var reference: LogRecordReference { - LogRecordReference(channel: channel.rawValue, event: Self.id, op: operationID) - } - - public init(`command`: String, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil, `provider`: LogSegment, operationID: String? = nil) { - self.operationID = operationID - self.channel = LogChannel(.vcs, segments: [`provider`]) - var _logValues: [String: LogFieldValue] = [:] - _logValues["command"] = .token(`command`) - if let value = `exitCode` { _logValues["exit_code"] = .int(value) } - if let value = `failureCode` { _logValues["failure_code"] = .token(value) } - _logValues["provider"] = .id(`provider`) + if let value = `remote` { _logValues["remote"] = .bool(value) } + if let value = `repository` { _logValues["repository"] = .path(value) } + _logValues["source"] = .token(`source`) + if let value = `stderr` { _logValues["stderr"] = .text(value) } self.fields = _logValues } } @@ -6042,71 +6087,3 @@ public struct RunProcessExecutionSpan: Sendable { return event.reference } } - -public struct VcsGitCommandSpan: Sendable { - private var state: LogSpanState - private let emit: @Sendable (any LogEvent) -> Void - private let `command`: String - private let `provider`: LogSegment - - private init(`command`: String, `provider`: LogSegment, operationID: String, clock: LogSpanClock, emit: @escaping @Sendable (any LogEvent) -> Void) { - state = LogSpanState(operationID: operationID, clock: clock) - self.emit = emit - self.`command` = `command` - self.`provider` = `provider` - } - - public static func begin(`command`: String, `provider`: LogSegment, operationID: String = UUID().uuidString.lowercased(), clock: LogSpanClock = .continuous, emit: @escaping @Sendable (any LogEvent) -> Void) -> Self { - let span = Self(command: `command`, provider: `provider`, operationID: operationID, clock: clock, emit: emit) - // Started is a DEBUG event; the sink drops it unless verbose diagnostics are on. - emit(VcsGitCommandStarted(command: `command`, provider: `provider`, operationID: operationID)) - return span - } - - public mutating func finish(`exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil) { - guard let duration = state.end() else { return } - emit(VcsGitCommandFinished(command: `command`, duration: `duration`, exitCode: `exitCode`, failureCode: `failureCode`, provider: `provider`, operationID: state.operationID)) - } - - @discardableResult - public mutating func fail(_ error: any Error, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil) -> LogRecordReference? { - guard let duration = state.end() else { return nil } - let event = VcsGitCommandFailed(command: `command`, duration: `duration`, error: `error`, exitCode: `exitCode`, failureCode: `failureCode`, provider: `provider`, operationID: state.operationID) - emit(event) - return event.reference - } -} - -public struct VcsGitRequestSpan: Sendable { - private var state: LogSpanState - private let emit: @Sendable (any LogEvent) -> Void - private let `command`: String - private let `provider`: LogSegment - - private init(`command`: String, `provider`: LogSegment, operationID: String, clock: LogSpanClock, emit: @escaping @Sendable (any LogEvent) -> Void) { - state = LogSpanState(operationID: operationID, clock: clock) - self.emit = emit - self.`command` = `command` - self.`provider` = `provider` - } - - public static func begin(`command`: String, `provider`: LogSegment, operationID: String = UUID().uuidString.lowercased(), clock: LogSpanClock = .continuous, emit: @escaping @Sendable (any LogEvent) -> Void) -> Self { - let span = Self(command: `command`, provider: `provider`, operationID: operationID, clock: clock, emit: emit) - // Started is a DEBUG event; the sink drops it unless verbose diagnostics are on. - emit(VcsGitRequestStarted(command: `command`, provider: `provider`, operationID: operationID)) - return span - } - - public mutating func finish(`exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil) { - guard let duration = state.end() else { return } - emit(VcsGitRequestFinished(command: `command`, duration: `duration`, exitCode: `exitCode`, failureCode: `failureCode`, provider: `provider`, operationID: state.operationID)) - } - - @discardableResult - public mutating func fail(_ error: any Error, `exitCode`: (Int64)? = nil, `failureCode`: (String)? = nil) -> LogRecordReference? { - guard let duration = state.end() else { return nil } - let event = VcsGitRequestFailed(command: `command`, duration: `duration`, error: `error`, exitCode: `exitCode`, failureCode: `failureCode`, provider: `provider`, operationID: state.operationID) - emit(event) - return event.reference - } -} diff --git a/macos/Sources/LitheModuleAPI/Logging/UserActionTrace.swift b/macos/Sources/LitheModuleAPI/Logging/UserActionTrace.swift new file mode 100644 index 000000000..0ccd7f43a --- /dev/null +++ b/macos/Sources/LitheModuleAPI/Logging/UserActionTrace.swift @@ -0,0 +1,100 @@ +import Foundation + +/// The UI surface that invoked a user action, written as the catalog token. +public enum UserActionSurface: String, Sendable { + case menu, shortcut, palette, toolbar, button, contextMenu = "context_menu", notification, plugin +} + +/// One user-started action. `operationID` becomes the `op` of the action line and +/// of every Core request and Git process the action causes. +public struct UserAction: Sendable { + public let id: String + public let surface: UserActionSurface + public let operationID: String + public let startedAt: ContinuousClock.Instant + + public init(id: String, surface: UserActionSurface, operationID: String = UUID().uuidString, + startedAt: ContinuousClock.Instant = .now) { + self.id = id + self.surface = surface + self.operationID = operationID + self.startedAt = startedAt + } +} + +public enum UserActionContext { + /// `Task {}` inherits this value; `Task.detached` and GCD hops do not, so + /// detached work that serves the action must start through `detached`. + @TaskLocal public static var current: UserAction? + + /// `Task.detached` that keeps the current action for Core request origins. + @discardableResult + public static func detached(priority: TaskPriority? = nil, + operation: sending @escaping () async -> T) -> Task { + let action = current + return Task.detached(priority: priority) { await $current.withValue(action) { await operation() } } + } + + @discardableResult + public static func detached(priority: TaskPriority? = nil, + operation: sending @escaping () async throws -> T) -> Task { + let action = current + return Task.detached(priority: priority) { try await $current.withValue(action) { try await operation() } } + } + + /// `DispatchQueue.main.async` that keeps the current action, for UI that must + /// finish dismissing a sheet or menu before the action's work starts. + public static func mainAsync(_ body: @escaping @MainActor () -> Void) { + let action = current + DispatchQueue.main.async { MainActor.assumeIsolated { $current.withValue(action) { body() } } } + } +} + +/// Records the outermost UI entry point once and binds it for the work it starts. +/// Nested entry points inside an already traced action neither log nor rebind. +public enum UserActionTrace { + /// The application log, installed once at launch for entry points that have + /// no service container, such as AppKit menus. Tests pass their own sink. + @MainActor public static var applicationSink: (any LogSink)? + + @discardableResult + public static func run(_ id: String, surface: UserActionSurface, sink: (any LogSink)?, + _ body: () throws -> T) rethrows -> T { + guard UserActionContext.current == nil else { return try body() } + return try UserActionContext.$current.withValue(start(id, surface: surface, sink: sink), operation: body) + } + + /// Runs on the caller's isolation, so main-actor UI work stays on the main actor. + @discardableResult + public static func run(_ id: String, surface: UserActionSurface, sink: (any LogSink)?, + isolation: isolated (any Actor)? = #isolation, _ body: () async throws -> T) async rethrows -> T { + guard UserActionContext.current == nil else { return try await body() } + return try await UserActionContext.$current.withValue(start(id, surface: surface, sink: sink)) { + try await body() + } + } + + /// A traced control action for SwiftUI controls that call a model directly. + /// `body` is a plain closure so method references and stored callbacks pass + /// without a Sendable conversion; the returned closure runs it on the main actor. + @MainActor + public static func action(_ id: String, surface: UserActionSurface = .button, + _ body: @escaping () -> Void) -> @MainActor () -> Void { + { run(id, surface: surface, sink: applicationSink, body) } + } + + /// Action ids are catalog tokens, never localized titles. + public static func normalizedID(_ id: String) -> String { + let allowed = Set("._:/-$".utf8) + let valid = !id.isEmpty && id.utf8.count <= 64 && id.utf8.allSatisfy { + ($0 >= 0x30 && $0 <= 0x39) || ($0 >= 0x41 && $0 <= 0x5A) || ($0 >= 0x61 && $0 <= 0x7A) || allowed.contains($0) + } + return valid ? id : "invalid" + } + + private static func start(_ id: String, surface: UserActionSurface, sink: (any LogSink)?) -> UserAction { + let action = UserAction(id: normalizedID(id), surface: surface) + sink?.emit(AppActionPerformed(action: action.id, surface: surface.rawValue, operationID: action.operationID)) + return action + } +} diff --git a/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift b/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift index c2becfc6e..57513dccd 100644 --- a/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift +++ b/macos/Sources/LitheSearchModule/Application/SearchFeatureModel.swift @@ -1,4 +1,5 @@ import Combine +import LitheModuleAPI import Foundation public struct ProjectReplacementApplyResult: Sendable { @@ -136,7 +137,7 @@ public final class SearchFeatureModel: ObservableObject { isSearching = true await indexMutationTask?.value let operations = self.operations - let results = await Task.detached(priority: .userInitiated) { + let results = await UserActionContext.detached(priority: .userInitiated) { operations.search( at: workspaceURL, query: query, @@ -273,7 +274,7 @@ public final class SearchFeatureModel: ObservableObject { ) async -> [URL] { await indexMutationTask?.value let operations = self.operations - let results = await Task.detached(priority: .userInitiated) { + let results = await UserActionContext.detached(priority: .userInitiated) { operations.searchEverywhere( at: workspaceURL, query: query, @@ -364,7 +365,7 @@ public final class SearchFeatureModel: ObservableObject { isLoadingProjectReplacement = true await indexMutationTask?.value let operations = self.operations - let results = await Task.detached(priority: .userInitiated) { + let results = await UserActionContext.detached(priority: .userInitiated) { operations.previewReplacement( at: workspaceURL, query: query, diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index c11cc8ed5..e50ac69aa 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -428,7 +428,7 @@ package final class WorkspaceFeatureModel: ObservableObject { if !(error is CancellationError) { self.logSink?.emit(AppWorkspaceRollbackFailed(error: error)) } } guard isCurrent(), self.indexingOwnerID == ownerID else { throw CancellationError() } - let status = try await Task.detached(priority: .userInitiated) { + let status = try await UserActionContext.detached(priority: .userInitiated) { try operations.configureIndexing(at: root, policy: policy, refresh: refresh, ownerID: ownerID) }.value guard isCurrent(), self.indexingOwnerID == ownerID else { @@ -506,7 +506,7 @@ package final class WorkspaceFeatureModel: ObservableObject { guard isCurrent() else { return .stale } indexingStatus = status indexingIssue = nil - snapshot = try await Task.detached(priority: .userInitiated) { + snapshot = try await UserActionContext.detached(priority: .userInitiated) { try operations.snapshot(at: workspaceURL, visibilityRules: rules).map { snapshot in Self.listingTree(of: snapshot, reloading: previouslyLoaded, operations: operations, rules: rules) } @@ -863,10 +863,10 @@ package final class WorkspaceFeatureModel: ObservableObject { let fileOperations = self.fileOperations, logSink = self.logSink var movedByVersionControl = false if request.kind == .rename, let moveTrackedItem { - let exists = await Task.detached(priority: .userInitiated) { fileOperations.fileExists(at: destination) }.value + let exists = await UserActionContext.detached(priority: .userInitiated) { fileOperations.fileExists(at: destination) }.value if !exists { movedByVersionControl = await moveTrackedItem(request.targetURL, destination) } } - let errorMessage = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in + let errorMessage = movedByVersionControl ? nil : await UserActionContext.detached(priority: .userInitiated) { () -> String? in guard !fileOperations.fileExists(at: destination) else { return "An item named '\(name)' already exists" } @@ -947,7 +947,7 @@ package final class WorkspaceFeatureModel: ObservableObject { if workspaceGeneration == generation { isPerformingProjectItemOperation = false } } let fileOperations = self.fileOperations, logSink = self.logSink - let validationError = await Task.detached(priority: .userInitiated) { + let validationError = await UserActionContext.detached(priority: .userInitiated) { Self.projectMoveError(sources, to: target, workspace: workspace, files: fileOperations) }.value guard workspaceGeneration == generation, workspaceURL == workspace else { return } @@ -972,12 +972,12 @@ package final class WorkspaceFeatureModel: ObservableObject { let previousMarks = directoryMarks var movedByVersionControl = false if let moveTrackedItem { - let preflight = await Task.detached(priority: .userInitiated) { + let preflight = await UserActionContext.detached(priority: .userInitiated) { Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) }.value if preflight == nil { movedByVersionControl = await moveTrackedItem(source, destination) } } - let error = movedByVersionControl ? nil : await Task.detached(priority: .userInitiated) { () -> String? in + let error = movedByVersionControl ? nil : await UserActionContext.detached(priority: .userInitiated) { () -> String? in do { // Recheck just before writing; the native move also refuses overwrite. if let error = Self.projectMoveError([source], to: target, workspace: workspace, files: fileOperations) { @@ -1057,7 +1057,7 @@ package final class WorkspaceFeatureModel: ObservableObject { let generation = workspaceGeneration guard let sources = validatedCopySources(urls, in: directory, workspace: operationWorkspaceURL) else { return false } let fileOperations = self.fileOperations, logSink = self.logSink - let result = await Task.detached(priority: .userInitiated) { + let result = await UserActionContext.detached(priority: .userInitiated) { Self.copyProjectItems(sources, in: directory, fileOperations: fileOperations, logSink: logSink) }.value guard workspaceURL == operationWorkspaceURL, workspaceGeneration == generation else { return false } @@ -1121,7 +1121,7 @@ package final class WorkspaceFeatureModel: ObservableObject { isPerformingProjectItemOperation = true let destination = availableDuplicateURL(for: sourceURL) let fileOperations = self.fileOperations, logSink = self.logSink - let errorMessage = await Task.detached(priority: .userInitiated) { () -> String? in + let errorMessage = await UserActionContext.detached(priority: .userInitiated) { () -> String? in do { try fileOperations.copyItem(at: sourceURL, to: destination) return nil @@ -1297,7 +1297,7 @@ package final class WorkspaceFeatureModel: ObservableObject { package func readFile(at workspaceURL: URL, relativePath: String) async -> String? { let operations = self.operations - return await Task.detached(priority: .userInitiated) { + return await UserActionContext.detached(priority: .userInitiated) { operations.readFile(at: workspaceURL, relativePath: relativePath) }.value } @@ -1807,7 +1807,7 @@ package final class WorkspaceFeatureModel: ObservableObject { let rules = visibilityRules let loaded = Self.loadedDirectoryPaths(in: root) let base = FileNode(url: root.url, isDirectory: true, children: nil) - let relisted = await Task.detached(priority: .userInitiated) { + let relisted = await UserActionContext.detached(priority: .userInitiated) { Self.listingTree(base, reloading: loaded, at: workspaceURL, operations: operations, rules: rules) }.value guard self.workspaceURL == workspaceURL, workspaceGeneration == generation, @@ -1843,7 +1843,7 @@ package final class WorkspaceFeatureModel: ObservableObject { let rules = visibilityRules let loaded = Self.loadedDirectoryPaths(in: currentRoot) let base = FileNode(url: currentRoot.url, isDirectory: true, children: nil) - let relisted = await Task.detached(priority: .userInitiated) { + let relisted = await UserActionContext.detached(priority: .userInitiated) { Self.listingTree(base, reloading: loaded, at: workspaceURL, operations: operations, rules: rules) }.value guard workspaceGeneration == generation, self.workspaceURL == workspaceURL else { return } @@ -1893,7 +1893,7 @@ package final class WorkspaceFeatureModel: ObservableObject { let rules = visibilityRules let id = UUID() let paths = missing.map { $0.url.standardizedFileURL.path } - let worker = Task.detached(priority: .userInitiated) { + let worker = UserActionContext.detached(priority: .userInitiated) { guard !Task.isCancelled else { return nil as [String: [FileNode]]? } let result = operations.childRows(of: missing, at: workspaceURL, visibilityRules: rules) return Task.isCancelled ? nil : result diff --git a/macos/Tests/LitheTests/CoreRequestTraceTests.swift b/macos/Tests/LitheTests/CoreRequestTraceTests.swift new file mode 100644 index 000000000..ad9287fd2 --- /dev/null +++ b/macos/Tests/LitheTests/CoreRequestTraceTests.swift @@ -0,0 +1,96 @@ +import Foundation +import LitheGitModule +import LitheModuleAPI +import Testing +@testable import Lithe + +private final class CoreTraceBundleMarker: NSObject {} + +private final class LineBuffer: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + var lines: [String] { lock.withLock { values } } + func append(_ lines: [String]) -> [String] { lock.withLock { values += lines; return values } } +} + +@Suite("Core request and Git process traces") +struct CoreRequestTraceTests { + @Test + func userGitCommandReachesTheLogUnderItsActionAndBackgroundReadsWaitForDiagnostics() async throws { + // Core's trace queue and DEBUG gate belong to the process. Run this real + // Core-to-file boundary alone so other stores cannot drain or reconfigure it. + if ProcessInfo.processInfo.environment["LITHE_CORE_TRACE_CHILD"] != "1" { + let image = try #require(Bundle(for: CoreTraceBundleMarker.self).executableURL) + var environment = ProcessInfo.processInfo.environment + environment["LITHE_CORE_TRACE_CHILD"] = "1" + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: CommandLine.arguments[0]), + arguments: ["--test-bundle-path", image.path, "--testing-library", "swift-testing", "--filter", + "LitheTests.CoreRequestTraceTests/userGitCommandReachesTheLogUnderItsActionAndBackgroundReadsWaitForDiagnostics"], + currentDirectoryURL: FileManager.default.temporaryDirectory, + environment: environment, timeout: .seconds(20)) + let output = String(decoding: result.output, as: UTF8.self) + #expect(result.terminationStatus == 0, "Isolated Core trace failed: \(output)") + #expect(output.contains("Test run with 1 test in 1 suite passed")) + return + } + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let repository = root.appendingPathComponent("repository") + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let core = RustCoreBridge() + for arguments in [["init", "-q"], ["config", "user.name", "Trace Test"], + ["config", "user.email", "trace@example.invalid"], ["config", "commit.gpgsign", "false"]] { + #expect(core.gitCommand(at: repository, arguments: arguments)?.exitCode == 0) + } + + let writer = MacLogStore(defaultDirectory: root.appendingPathComponent("logs")) + // This child process has no application writer, so the test writer owns + // Core's pending-fact waker until finishSession removes it. + writer.observeCoreFacts() + let stream = try await writer.subscribe() + let buffer = LineBuffer() + let traced = TestGate() + let consumer = Task { + for await batch in stream { + let lines = buffer.append(batch.map(\.line)) + if lines.contains(where: { $0.contains("event=vcs.git.command.finished") }), + lines.contains(where: { $0.contains("event=app.core.request.finished") }) { + traced.open() + break + } + } + } + defer { consumer.cancel() } + + // No flush: Core's pending-fact waker schedules the drain. + let action = try #require(UserActionTrace.run("vcs.commit", surface: .button, sink: writer) { () -> UserAction? in + #expect(core.gitCommand(at: repository, arguments: ["commit", "--allow-empty", "-m", "secret subject"])?.exitCode == 0) + return UserActionContext.current + }) + #expect(await traced.waitUntilOpen(timeout: .seconds(5))) + let lines = buffer.lines + let operation = "op=\(action.operationID)" + #expect(lines.contains { $0.contains("event=app.action.performed") && $0.contains(operation) }) + let git = lines.first { $0.contains("event=vcs.git.command.finished") } + #expect(git?.contains(operation) == true) + #expect(git?.contains("-m ") == true) + let request = lines.first { $0.contains("event=app.core.request.finished") } + #expect(request?.contains(operation) == true) + #expect(request?.contains("trigger=vcs.commit") == true) + #expect(!lines.contains { $0.contains("secret") }) + + _ = GitExecutionSource.$current.withValue(.background) { core.gitStatus(at: repository) } + try await writer.flush() + let gated = try await writer.collect(query: .init(session: "all")) + #expect(!gated.content.contains("event=vcs.git.command.traced")) + + await writer.configureDiagnostic(category: "git", enabled: true) + _ = GitExecutionSource.$current.withValue(.background) { core.gitStatus(at: repository) } + try await writer.flush() + let enabled = try await writer.collect(query: .init(session: "all")) + #expect(enabled.content.contains("event=vcs.git.command.traced")) + await writer.configureDiagnostic(category: "git", enabled: false) + await writer.finishSession() + } +} diff --git a/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift b/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift index 6adc13f71..aeadee3f9 100644 --- a/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift +++ b/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift @@ -47,7 +47,7 @@ struct DebugModuleCoordinatorTests { if sameTarget { target.resolvedName = "check()" } model.activeTestTarget = target model.clearNotifications() - WorkbenchModuleUIComposition.builtIn.performAction(id: "workbench.debug-start-or-restart", model: model) + WorkbenchModuleUIComposition.builtIn.performAction(id: "workbench.debug-start-or-restart", model: model, surface: nil) #expect(adapter.launchCount == (sameTarget ? 2 : 1)) if !sameTarget { // Reaching the source guard proves B entered test preparation instead of restarting A. diff --git a/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift b/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift index f7b382889..8e0673b31 100644 --- a/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift +++ b/macos/Tests/LitheTests/DiagnosticsWorkflowTests.swift @@ -193,13 +193,13 @@ struct DiagnosticsWorkflowTests { case 3: writer.emit(LspTraceReceived(diagnostic: "Sending request", server: LogSegment("rust-analyzer")!, operationID: op)) case 4: writer.emit(LspStderrReceived(diagnostic: "Building workspace", server: LogSegment("jdtls")!, truncated: false, operationID: op)) case 5: writer.emit(LspSessionDegradationReceived(failureCode: "unavailable", server: LogSegment("invalid-3f9a0c2e")!, stage: "index", operationID: op)) - case 6: writer.emit(VcsGitCommandFinished(command: "fetch", duration: .milliseconds(487), exitCode: 0, provider: LogSegment("git")!, operationID: op)) + case 6: writer.emit(VcsGitCommandFinished(arguments: "fetch --prune origin", command: "fetch", duration: .milliseconds(487), exitCode: 0, source: "background", operationID: op)) case 7: writer.emit(PluginsHostMessageLogged(diagnostic: "Using mirror central", plugin: LogSegment("maven")!, severity: "info", operationID: op)) case 8: writer.emit(PluginsHostMessageLogged(diagnostic: "Process stopped", plugin: LogSegment("lldb")!, severity: "debug", operationID: op)) default: writer.emit(AppDiagnosticsExportFailed(error: LogFailure(domain: "render", code: "unavailable"), stage: "planning", operationID: op)) } } - for offset in 0..<6 { writer.emit(VcsGitCommandFinished(command: "fetch", duration: .milliseconds(480 + offset), exitCode: 0, provider: LogSegment("git")!, operationID: "render-selected")) } + for offset in 0..<6 { writer.emit(VcsGitCommandFinished(arguments: "fetch --prune origin", command: "fetch", duration: .milliseconds(480 + offset), exitCode: 0, source: "background", operationID: "render-selected")) } writer.emit(AppDiagnosticsExportFailed(error: NSError(domain: "render", code: 1, userInfo: [NSLocalizedDescriptionKey: "token=synthetic-render-secret"]), stage: "planning", operationID: "render-selected")) writer.emit(LspMessageLogged(diagnostic: "Workspace initialized", server: LogSegment("jdtls")!, severity: "info", operationID: "render-selected")) let model = LogWindowModel(logging: writer) diff --git a/macos/Tests/LitheTests/LogWindowTests.swift b/macos/Tests/LitheTests/LogWindowTests.swift index dc781c945..1ab680a4b 100644 --- a/macos/Tests/LitheTests/LogWindowTests.swift +++ b/macos/Tests/LitheTests/LogWindowTests.swift @@ -248,7 +248,9 @@ struct LogWindowTests { } @Test func realtimeWatermarkCapPauseAndReference() async throws { let directory = root() - let writer = MacLogStore(defaultDirectory: directory) + // A fixed lease clock keeps the 10 s control deadline from depending on + // how fast this machine queries the three 10,000-record batches. + let writer = MacLogStore(defaultDirectory: directory, diagnosticNow: { 0 }) let model = LogWindowModel(logging: writer) defer { model.stop(); try? FileManager.default.removeItem(at: directory) } let core = RustCoreBridge() diff --git a/macos/Tests/LitheTests/MacGitLifecycleLogTests.swift b/macos/Tests/LitheTests/MacGitLifecycleLogTests.swift deleted file mode 100644 index 342c74338..000000000 --- a/macos/Tests/LitheTests/MacGitLifecycleLogTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import Testing -import LitheGitModule -import LitheModuleAPI -@testable import Lithe - -@Suite("macOS typed Git lifecycle") -struct MacGitLifecycleLogTests { - private func event(_ type: String, failed: Bool = false, expectedExit: Bool = false) throws -> GitExecutionEvent { - var object: [String: Any] = ["operationId": "operation-1", "type": type, "invocationId": 1, - "arguments": ["fetch", "private-user-reference"], "exitCode": failed ? 1 : 0, - "expectedExit": expectedExit] - if type == "requestFinished" { - object = ["operationId": "operation-1", "type": type] - if failed { object["error"] = ["code": "cancelled", "message": "private command output"] } - } - return try JSONDecoder().decode(GitExecutionEvent.self, from: JSONSerialization.data(withJSONObject: object)) - } - - @Test func realCoreInvocationNamesProduceOneTerminalPerSpan() throws { - let sink = TypedLogRecorder() - let adapter = MacGitLifecycleLog(sink: sink) - for type in ["requestStarted", "started", "finished", "requestFinished", "requestFinished"] { - adapter.receive(try event(type)) - } - #expect(sink.events.map { type(of: $0).id } == ["vcs.git.request.started", "vcs.git.command.started", - "vcs.git.command.finished", "vcs.git.request.finished"]) - #expect(sink.events.allSatisfy { $0.operationID == "operation-1" }) - let command = try #require(sink.events.first { type(of: $0).id == "vcs.git.command.finished" }) - if case .token(let name) = command.fields["command"] { #expect(name == "fetch") } - else { Issue.record("Missing controlled command name") } - } - - @Test func expectedNonzeroExitIsAResultAndRequestFailureRemainsFailed() throws { - let sink = TypedLogRecorder() - let adapter = MacGitLifecycleLog(sink: sink) - adapter.receive(try event("requestStarted")) - adapter.receive(try event("started")) - adapter.receive(try event("finished", failed: true)) - adapter.receive(try event("requestFinished", failed: true)) - #expect(sink.events.filter { type(of: $0).level == .error }.count == 2) - let request = try #require(sink.events.last) - #expect(type(of: request).id == "vcs.git.request.failed") - if case .token(let code) = request.fields["failure_code"] { #expect(code == "cancelled") } - else { Issue.record("Core classification was lost") } - #expect(request.fields["exit_code"] == nil) - let expected = TypedLogRecorder() - let expectedAdapter = MacGitLifecycleLog(sink: expected) - expectedAdapter.receive(try event("started")) - expectedAdapter.receive(try event("finished", failed: true, expectedExit: true)) - #expect(expected.events.last.map { type(of: $0).id } == "vcs.git.command.finished") - } - @Test func preflightFailureRetainsCoreCodeWithoutInventingExitStatus() throws { - let sink = TypedLogRecorder() - let adapter = MacGitLifecycleLog(sink: sink) - adapter.receive(try event("requestStarted")) - adapter.receive(try event("requestFinished", failed: true)) - let terminal = try #require(sink.events.last) - #expect(type(of: terminal).id == "vcs.git.request.failed") - if case .error(let error) = terminal.fields["error"], let failure = error as? LogFailure { - #expect(failure.code == "cancelled") - #expect(failure.domain == "git.core") - } else { Issue.record("Core code was replaced by an NSError number") } - #expect(terminal.fields["exit_code"] == nil) - } - -} - -extension MacGitLifecycleLogTests { - @Test func emittedFailureReferenceIsReturnedToTheNotificationOwner() throws { - let sink = TypedLogRecorder() - let adapter = MacGitLifecycleLog(sink: sink) - #expect(adapter.receive(try event("requestStarted")) == nil) - let reference = try #require(adapter.receive(try event("requestFinished", failed: true))) - #expect(reference == sink.events.last?.reference) - #expect(reference.op == "operation-1") - #expect(adapter.receive(try event("requestFinished", failed: true)) == nil) - } -} diff --git a/macos/Tests/LitheTests/MacLogStoreTests.swift b/macos/Tests/LitheTests/MacLogStoreTests.swift index 45e5d1d35..5fccf7091 100644 --- a/macos/Tests/LitheTests/MacLogStoreTests.swift +++ b/macos/Tests/LitheTests/MacLogStoreTests.swift @@ -468,8 +468,8 @@ extension MacLogStoreTests { try writer.redirect(to: root) writer.emit(EditorDocumentOpenFailed(error: NSError(domain: "fixture", code: 42, userInfo: [NSLocalizedDescriptionKey: "password=fixture-secret\n" + String(repeating: "界", count: 600)]), reason: "unavailable")) - var span = VcsGitRequestSpan.begin(command: "fetch", provider: LogSegment("git")!, emit: writer.emit) - span.fail(LogFailure(domain: "git.core", code: "cancelled"), failureCode: "cancelled") + var span = RunProcessExecutionSpan.begin(executable: URL(fileURLWithPath: "/usr/bin/true"), emit: writer.emit) + span.fail(LogFailure(domain: "run.core", code: "cancelled")) let snapshot = try await writer.collect(query: .init()) #expect(snapshot.writeFailures == 0) #expect(snapshot.content.contains("error_code=42")) @@ -591,7 +591,9 @@ extension MacLogStoreTests { @Test func collectionBudgetKeepsActiveChannelsBeforeOldRotations() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } - let writer = MacLogStore(now: { Date(timeIntervalSince1970: 1_793_664_000) }) + // A fixed lease clock keeps the 10 s control deadline from depending on + // how fast this machine reads and queries the 12 MB fixture. + let writer = MacLogStore(diagnosticNow: { 0 }, now: { Date(timeIntervalSince1970: 1_793_664_000) }) try writer.redirect(to: root) let folder = root.appendingPathComponent("sessions/2026-11-03T00-00-00_abcdef12/lsp") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) diff --git a/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift b/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift index e3f11a0d8..57b0784c8 100644 --- a/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift +++ b/macos/Tests/LitheTests/PluginInstallationGuidancePresentationTests.swift @@ -19,7 +19,7 @@ struct PluginInstallationGuidancePresentationTests { defer { feature.clear() } var searches: [String] = [] feature.show("Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.", - arguments: [], action: WorkbenchNotification.Action(title: "Open Marketplace") { searches.append("Java Support") }) + arguments: [], action: WorkbenchNotification.Action(id: "plugins.marketplace.open", title: "Open Marketplace") { searches.append("Java Support") }) let notification = try #require(feature.activeNotifications.first) let view = WorkbenchNotificationBanner(message: notification.message, occurrenceCount: 1, actionTitle: notification.action?.title, performAction: { feature.perform(notification.id) }, diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index 2254878e6..c625603c2 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -126,7 +126,7 @@ struct RunEntryPointTests { model.runTest(providerID: provider, scope: .testCase(identifier: "demo.ExampleTest#check()", fileURL: workspace.sourceURL)) let scope: LanguageTestScope = fileScope ? .file(workspace.sourceURL) : .workspace model.runTest(providerID: provider, scope: scope) - WorkbenchModuleUIComposition.builtIn.performAction(id: "workbench.run-start-or-restart", model: model) + WorkbenchModuleUIComposition.builtIn.performAction(id: "workbench.run-start-or-restart", model: model, surface: nil) #expect(model.activeTestTarget?.scope == scope) #expect(model.activeTestTarget?.name == (fileScope ? workspace.sourceURL.lastPathComponent : String(localized: "Workspace"))) let execution = try #require(await model.activateExecutionModule()) diff --git a/macos/Tests/LitheTests/UserActionTraceTests.swift b/macos/Tests/LitheTests/UserActionTraceTests.swift new file mode 100644 index 000000000..0da642d59 --- /dev/null +++ b/macos/Tests/LitheTests/UserActionTraceTests.swift @@ -0,0 +1,151 @@ +import Foundation +import LitheModuleAPI +import Testing +@testable import Lithe +@testable import LitheGitModule + +private func token(_ value: LogFieldValue?) -> String? { + if case .token(let text) = value { return text } + return nil +} + +@MainActor +private final class ObservedOperation { + var value: String? +} + +@Suite("User action trace") +struct UserActionTraceTests { + @Test + func outermostEntryLogsOnceAndNestedEntriesKeepItsOperation() throws { + let recorder = TypedLogRecorder() + let (outer, inner) = UserActionTrace.run("vcs.commit", surface: .button, sink: recorder) { + let outer = UserActionContext.current + let inner = UserActionTrace.run("vcs.push", surface: .menu, sink: recorder) { UserActionContext.current } + return (outer, inner) + } + let action = try #require(outer) + #expect(inner?.operationID == action.operationID) + #expect(inner?.id == "vcs.commit") + #expect(UserActionContext.current == nil) + #expect(recorder.events.count == 1) + let event = try #require(recorder.events.first as? AppActionPerformed) + #expect(event.operationID == action.operationID) + #expect(token(event.fields["action"]) == "vcs.commit") + #expect(token(event.fields["surface"]) == "button") + } + + @Test + func childTasksAndDetachedHopsKeepTheActionButPlainDetachedTasksDoNot() async throws { + let recorder = TypedLogRecorder() + let observed = await UserActionTrace.run("workspace.folder.open", surface: .contextMenu, sink: recorder) { + let child = await Task { UserActionContext.current?.operationID }.value + let detached = await UserActionContext.detached { UserActionContext.current?.operationID }.value + let plain = await Task.detached { UserActionContext.current?.operationID }.value + return (UserActionContext.current?.operationID, child, detached, plain) + } + let operation = try #require(observed.0) + #expect(observed.1 == operation) + #expect(observed.2 == operation) + #expect(observed.3 == nil) + let event = try #require(recorder.events.first as? AppActionPerformed) + #expect(event.operationID == operation) + #expect(token(event.fields["surface"]) == "context_menu") + } + + @Test + func actionIDsAreCatalogTokensNeverTitles() { + #expect(UserActionTrace.normalizedID("plugin:dev.lithe.maven/run-goal") == "plugin:dev.lithe.maven/run-goal") + #expect(UserActionTrace.normalizedID("vcs.git.branch.checkout") == "vcs.git.branch.checkout") + #expect(UserActionTrace.normalizedID("Commit 提交") == "invalid") + #expect(UserActionTrace.normalizedID("") == "invalid") + #expect(UserActionTrace.normalizedID(String(repeating: "a", count: 65)) == "invalid") + let recorder = TypedLogRecorder() + UserActionTrace.run("/Users/someone/project file", surface: .palette, sink: recorder) {} + let event = recorder.events.first as? AppActionPerformed + #expect(token(event?.fields["action"]) == "invalid") + } + + @Test @MainActor + func mainQueueHopsKeepTheAction() async throws { + let gate = TestGate() + let observed = ObservedOperation() + let action = try #require(UserActionTrace.run("workspace.open", surface: .button, sink: nil) { () -> UserAction? in + UserActionContext.mainAsync { + observed.value = UserActionContext.current?.operationID + gate.open() + } + return UserActionContext.current + }) + #expect(await gate.waitUntilOpen(timeout: .seconds(2))) + #expect(observed.value == action.operationID) + } + + @Test @MainActor + func menuItemsTraceTheirKeyOrSubmenuNeverTheTitleTheyShow() { + var seen: [String?] = [] + let record = { seen.append(UserActionContext.current?.id) } + let menu: [LitheContextMenuItem] = [ + .action("Copy Revision Number", action: record), + .action("Run", actionID: "run.gutter", action: record), + .submenu("Checkout Branch", items: [.action("feature/客户", localizesTitle: false, action: record)]), + ] + let items = menu.map { $0.traced(surface: .contextMenu) } + items[0].action() + items[1].action() + if case .submenu(let children) = items[2].kind { children[0].action() } + #expect(seen == ["menu:copy-revision-number", "run.gutter", "menu:checkout-branch"]) + + #expect(LitheContextMenuItem.traceID(forKey: "Reload %@ projects") == "menu:reload-projects") + #expect(LitheContextMenuItem.traceID(forKey: "提交") == "menu:item") + #expect(UserActionTrace.normalizedID(LitheContextMenuItem.traceID(forKey: String(repeating: "word ", count: 30))) + != "invalid") + } + + @Test + func gitPanelOperationsLogUnderTheModelMethodThatOwnsThem() { + #expect(GitFeatureModel.operationActionID("pushReviewed(_:to:)") == "vcs.git.pushReviewed") + #expect(GitFeatureModel.operationActionID("fetch()") == "vcs.git.fetch") + #expect(GitFeatureModel.operationActionID("commit") == "vcs.git.commit") + } + + @Test + func requestOriginMarksOnlyRecentActionsAsUserStarted() { + let start = ContinuousClock.now + let action = UserAction(id: "vcs.commit", surface: .button, operationID: "action-op", startedAt: start) + typealias Origin = RustCoreBridge.RequestOrigin + #expect(Origin.resolve(action: action, gitSource: .background, now: start) == Origin(source: "background")) + #expect(Origin.resolve(action: action, gitSource: .unknown, now: start + .seconds(1)) + == Origin(source: "user", trigger: "vcs.commit", operation: "action-op")) + // Long-lived work keeps the action's operation but stops claiming user provenance. + #expect(Origin.resolve(action: action, gitSource: .unknown, now: start + .seconds(61)) + == Origin(source: nil, trigger: "vcs.commit", operation: "action-op")) + #expect(Origin.resolve(action: nil, gitSource: .user, now: start) == Origin(source: "user")) + #expect(Origin.resolve(action: nil, gitSource: .unknown, now: start) == nil) + } + + @Test + func gitFailureReferencesPointAtTheRecordCoreWrote() { + let action = UserAction(id: "vcs.pull", surface: .toolbar, operationID: "action-op") + let user = GitExecutionContext(operationID: "execution-op", source: .user, action: action) + let background = GitExecutionContext(operationID: "execution-op", source: .background, action: action) + #expect(user.traceOperationID == "action-op") + #expect(background.traceOperationID == "execution-op") + #expect(GitExecutionContext(operationID: "execution-op", source: .user, action: nil).traceOperationID == "execution-op") + + let invocation = GitProcessInvocation(arguments: ["pull"], standardOutput: "", standardError: "", exitCode: 1) + let failed = GitProcessResult(output: "", exitCode: 1, invocations: [invocation]) + let process = GitService.failureReference(failed, execution: user) + #expect(process == LogRecordReference(channel: "vcs/git", event: VcsGitCommandFailed.id, op: "action-op")) + let request = GitService.failureReference(nil, execution: background) + #expect(request == LogRecordReference(channel: "app/core", event: AppCoreRequestFailed.id, op: "execution-op")) + #expect(GitService.failureReference(GitProcessResult(output: "", exitCode: 0, invocations: [invocation]), + execution: user) == nil) + // Every process exited 0, so no Core failure exists for the notification to cite. + let passed = GitProcessInvocation(arguments: ["stash", "push"], standardOutput: "", standardError: "", exitCode: 0) + #expect(GitService.failureReference(GitProcessResult(output: "", exitCode: 0, invocations: [passed], + operationErrorMessage: "Could not restore changes"), execution: user) == nil) + let rejected = GitService.failureReference(GitProcessResult(output: "", exitCode: 1, invocations: [passed]), execution: user) + #expect(rejected == LogRecordReference(channel: "app/core", event: AppCoreRequestFailed.id, op: "action-op")) + } +} diff --git a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift index 8a40b454f..ba232e13a 100644 --- a/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift +++ b/macos/Tests/LitheTests/WorkbenchContributionHostTests.swift @@ -130,7 +130,7 @@ struct WorkbenchContributionHostTests { #expect(model.workbenchUIRegistry.state(forActionID: "debug", model: model).isEnabled == false) #expect(LitheActionRegistry.actions(for: model).first { $0.id == "run" }?.isEnabled == false) #expect(!model.canPerformShortcutCommand(id: "run")) - model.workbenchUIRegistry.perform(run, model: model) + model.workbenchUIRegistry.perform(run, model: model, surface: nil) #expect(model.runFeatureIfActive == nil) #expect(container.services.moduleRuntime.snapshots() .filter { [.execution, .debug].contains($0.manifest.id) }.allSatisfy { !$0.isInstantiated }) @@ -168,7 +168,7 @@ struct WorkbenchContributionHostTests { model.showContributionEntry(command) #expect(model.registeredContributions(at: placement).contains(command)) #expect(invocations == 0) - model.workbenchUIRegistry.perform(command, model: model) + model.workbenchUIRegistry.perform(command, model: model, surface: nil) #expect(invocations == 1) } } @@ -550,7 +550,7 @@ struct WorkbenchContributionHostTests { if !sameGroup { model.workbenchFeature.move(.project, to: .init(anchor: .left, group: .secondary)) } let contribution = declaration.contributions[0] if useShow { model.showContributionEntry(contribution) } - else { model.workbenchUIRegistry.perform(contribution, model: model) } + else { model.workbenchUIRegistry.perform(contribution, model: model, surface: nil) } let pending = model.contributionActivationTasks[window.rawValue] defer { pending?.cancel() } #expect(await started.waitUntilOpen(), "Restored window never started its module") @@ -611,7 +611,7 @@ struct WorkbenchContributionHostTests { let first = commands[0], second = commands[3] let staleAction = LitheActionRegistry.actions(for: model).first { $0.id == first.actionID } #expect(staleAction != nil) - model.workbenchUIRegistry.perform(first, model: model) + model.workbenchUIRegistry.perform(first, model: model, surface: nil) let firstTask = model.contributionActivationTasks[windows[0].id] defer { firstTask?.cancel() } #expect(await started.waitUntilOpen(), "First module activation did not reach the gate") @@ -625,7 +625,7 @@ struct WorkbenchContributionHostTests { model.searchSessionFeature.isSearchEverywhereVisible = true if let staleAction { model.performSearchEverywhereAction(staleAction) } #expect(model.isSearchEverywhereVisible) - model.workbenchUIRegistry.perform(second, model: model) + model.workbenchUIRegistry.perform(second, model: model, surface: nil) let secondTask = model.contributionActivationTasks[windows[1].id] defer { secondTask?.cancel() } #expect(await joined.waitUntilOpen(), "Second window did not join the first module activation") @@ -637,7 +637,7 @@ struct WorkbenchContributionHostTests { } else { // Cancelling one UI request must leave the shared construction owned // by the runtime available to the second window. - model.workbenchUIRegistry.perform(windows[0], model: model) + model.workbenchUIRegistry.perform(windows[0], model: model, surface: nil) release.open() } try await scope.run("window activation") { await firstTask?.value } @@ -651,13 +651,13 @@ struct WorkbenchContributionHostTests { #expect(container.services.moduleRuntime.contributions()[owner] == nil) #expect(!model.registeredContributions(at: .menu).contains { $0.actionID == first.actionID }) staleAction?.perform() - model.workbenchUIRegistry.performAction(id: first.actionID!, model: model) + model.workbenchUIRegistry.performAction(id: first.actionID!, model: model, surface: nil) #expect(model.contributionActivationTasks.isEmpty) try await scope.run("module enablement") { try await container.services.moduleRuntime.setEnabled(true, for: owner) } release.open() - model.workbenchUIRegistry.perform(first, model: model) + model.workbenchUIRegistry.perform(first, model: model, surface: nil) try await scope.run("window activation") { await model.contributionActivationTasks[windows[0].id]?.value } #expect(constructions == 2) } else { @@ -746,7 +746,7 @@ struct WorkbenchContributionHostTests { let command = try #require(model.registeredContributions(at: .toolbar).first { $0.id.hasPrefix(installed.manifest.id.rawValue) }) let window = try #require(declaration.contributions.first { $0.kind == .toolWindow }) #expect(container.services.moduleRuntime.snapshots().first { $0.manifest.id == declaration.manifest.id }?.isInstantiated == false) - model.workbenchUIRegistry.perform(command, model: model) + model.workbenchUIRegistry.perform(command, model: model, surface: nil) try await scope.run("window activation") { await model.contributionActivationTasks[window.id]?.value } #expect(model.workbenchFeature.activeRightToolWindow?.rawValue == window.id) #expect(model.registeredContributions(at: .menu).contains { $0.actionID == command.actionID }) @@ -775,7 +775,7 @@ struct WorkbenchContributionHostTests { } #expect(model.workbenchFeature.activeLeftToolWindow == nil) #expect(!model.registeredContributions(at: .toolbar).contains { $0.actionID == command.actionID }) - model.workbenchUIRegistry.perform(command, model: model) + model.workbenchUIRegistry.perform(command, model: model, surface: nil) #expect(model.contributionActivationTasks.isEmpty) try await scope.run("module enablement") { try await container.services.moduleRuntime.setEnabled(true, for: declaration.manifest.id) diff --git a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift index 5c87d7637..a047cb0fc 100644 --- a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift +++ b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift @@ -25,18 +25,18 @@ struct WorkbenchModuleUIRegistryTests { ])]) #expect(registry.state(for: project, model: model).isEnabled) allowed = false - registry.performAction(id: project.actionID!, model: model) - registry.perform(project, model: model) + registry.performAction(id: project.actionID!, model: model, surface: nil) + registry.perform(project, model: model, surface: nil) #expect(invocations == 0) allowed = true visible = false - registry.perform(project, model: model) + registry.perform(project, model: model, surface: nil) #expect(invocations == 0) visible = true let stale = ModuleContribution(id: project.id, kind: project.kind, title: "Changed metadata", actionID: project.actionID) - registry.perform(stale, model: model) + registry.perform(stale, model: model, surface: nil) #expect(invocations == 0) - registry.perform(project, model: model) + registry.perform(project, model: model, surface: nil) #expect(invocations == 1) } } @@ -68,7 +68,7 @@ struct WorkbenchModuleUIRegistryTests { settings.projectTreeRowHeight += 1 #expect(renderer.contentIdentity(model) != identity) model.workbenchFeature.customizeContribution(search.id, placement: .rightSidebar) - model.workbenchUIRegistry.perform(search, model: model) + model.workbenchUIRegistry.perform(search, model: model, surface: nil) #expect(model.workbenchFeature.isVisible(.search)) #expect(model.workbenchFeature.activeRightToolWindow == .search) #expect(model.workbenchFeature.activeLeftToolWindow == .project) @@ -78,12 +78,12 @@ struct WorkbenchModuleUIRegistryTests { try await container.services.moduleRuntime.setEnabled(false, for: .search) } #expect(model.workbenchFeature.activeRightToolWindow == nil) - model.workbenchUIRegistry.perform(search, model: model) + model.workbenchUIRegistry.perform(search, model: model, surface: nil) #expect(model.workbenchFeature.activeRightToolWindow == nil) #expect(!model.leftSidebarContributions.contains { $0.id == search.id }) let pullRequests = try #require(model.workbenchContributions.first { $0.id == "github.pull-requests" }) #expect(model.workbenchUIRegistry.renderer(for: pullRequests)?.isEnabled(model) == false) - model.workbenchUIRegistry.perform(pullRequests, model: model) + model.workbenchUIRegistry.perform(pullRequests, model: model, surface: nil) #expect(!model.workbenchFeature.isVisible(.pullRequests)) } } diff --git a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift index fd347dad5..030cd88ba 100644 --- a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift +++ b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift @@ -40,7 +40,7 @@ struct WorkbenchNotificationTests { defer { feature.clear(); clock.releaseAll() } var opened = 0 feature.show("Database is now a plugin.", arguments: [], - action: WorkbenchNotification.Action(title: "Open Marketplace") { opened += 1 }) + action: WorkbenchNotification.Action(id: "plugins.marketplace.open", title: "Open Marketplace") { opened += 1 }) #expect(clock.pending.isEmpty) clock.advance(.seconds(100)) let id = feature.activeNotifications[0].id @@ -509,7 +509,7 @@ extension WorkbenchNotificationTests { var actionCount = 0 var opened: LogWindowOpenRequest? feature.openLogs = { opened = $0 } - feature.show("fixture", action: .init(title: "Retry", perform: { actionCount += 1 }), logReference: reference) + feature.show("fixture", action: .init(id: "fixture.retry", title: "Retry", perform: { actionCount += 1 }), logReference: reference) feature.show("fixture") let notification = try #require(feature.notifications.first) #expect(notification.logReference == reference && notification.action != nil) diff --git a/rust/lithe-core/src/ai/mod.rs b/rust/lithe-core/src/ai/mod.rs index 43aad4d15..5bb446bd6 100644 --- a/rust/lithe-core/src/ai/mod.rs +++ b/rust/lithe-core/src/ai/mod.rs @@ -4,7 +4,9 @@ mod agent_configuration; mod commands; mod configuration; mod generation; -pub(crate) use generation::{commit_input_budget, plan_commit_bounded, sensitive_path as is_sensitive_commit_path}; +pub(crate) use generation::{ + commit_input_budget, plan_commit_bounded, sensitive_path as is_sensitive_commit_path, +}; pub use agent_configuration::parse_provider_configuration; pub use configuration::{parse_claude, parse_codex, DetectedConfiguration}; diff --git a/rust/lithe-core/src/diagnostics/catalog.rs b/rust/lithe-core/src/diagnostics/catalog.rs index a8868ee99..f81486dae 100644 --- a/rust/lithe-core/src/diagnostics/catalog.rs +++ b/rust/lithe-core/src/diagnostics/catalog.rs @@ -206,22 +206,23 @@ mod tests { fn catalog_lookup_and_span_contract() { assert!(EVENTS.windows(2).all(|pair| pair[0].id < pair[1].id)); assert!(event_by_id("not.an.event").is_none()); - let event = event_by_id("vcs.git.command.failed").unwrap(); - assert_eq!(event.channel, "vcs/{provider}"); + let event = event_by_id("lsp.build.execution.failed").unwrap(); + assert_eq!(event.channel, "lsp/{server}"); assert_eq!(event.span, Some(SpanPhase::Failed)); let fields = BTreeMap::from([ - ("provider".into(), json!("git")), - ("command".into(), json!("fetch")), + ("server".into(), json!("java")), + ("waiters".into(), json!(2)), + ("outcome".into(), json!("builder_failed")), ("duration_ms".into(), json!(311)), - ("error".into(), json!("fetch failed")), + ("error".into(), json!("build failed")), ("error_code".into(), json!(128)), - ("error_domain".into(), json!("git")), + ("error_domain".into(), json!("core")), ]); assert_eq!(validate_fields(event.id, &fields), Ok(())); for (key, value) in [ ("duration_ms", json!(-1)), - ("provider", json!("../bad")), - ("command", json!("fetch --token secret")), + ("server", json!("../bad")), + ("outcome", json!("fetch --token secret")), ] { let mut invalid = fields.clone(); invalid.insert(key.into(), value); diff --git a/rust/lithe-core/src/diagnostics/catalog_generated.rs b/rust/lithe-core/src/diagnostics/catalog_generated.rs index 31129a8ac..214d7f20e 100644 --- a/rust/lithe-core/src/diagnostics/catalog_generated.rs +++ b/rust/lithe-core/src/diagnostics/catalog_generated.rs @@ -109,6 +109,19 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "LitheAgentConversationModule", }, + EventMetadata { + id: "app.action.performed", + channel: "app", + level: LogLevel::Info, + message: "User action {action} performed", + fields: &[ + FieldMetadata { name: "action", field_type: FieldType::Token, required: true, description: "Stable id of the user action that started the work, never a localized menu or button title", reason: None }, + FieldMetadata { name: "surface", field_type: FieldType::Token, required: true, description: "UI surface that invoked the action: menu, shortcut, palette, toolbar, button, context_menu, notification or plugin", reason: None }, + ], + span: None, + rate: 600, + owner: "LitheModuleAPI", + }, EventMetadata { id: "app.action_search.failed", channel: "app", @@ -147,6 +160,63 @@ pub static EVENTS: &[EventMetadata] = &[ rate: 60, owner: "Lithe", }, + EventMetadata { + id: "app.core.request.failed", + channel: "app/core", + level: LogLevel::Warn, + message: "Core request {method} failed", + fields: &[ + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time Core spent executing the request, from parsing to the final response", reason: None }, + FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Core error returned to the host for this request, with its stable code and domain", reason: None }, + FieldMetadata { name: "method", field_type: FieldType::Token, required: true, description: "Core protocol method name the host requested, e.g. git.write or workspace.repositories", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: false, description: "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write", reason: None }, + FieldMetadata { name: "path", field_type: FieldType::Path, required: false, description: "First absolute workspace, repository or project root named in the request payload", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the request ran against a remote workspace host", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective request source after merging the host origin and Git execution options: user, background or unknown", reason: None }, + FieldMetadata { name: "trigger", field_type: FieldType::Token, required: false, description: "Stable UI action id that the host reported as the cause of this request, never a localized title", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, + EventMetadata { + id: "app.core.request.finished", + channel: "app/core", + level: LogLevel::Info, + message: "Core request {method} finished", + fields: &[ + FieldMetadata { name: "cancelled", field_type: FieldType::Bool, required: false, description: "Present and true when the request ended because the host cancelled it", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time Core spent executing the request, from parsing to the final response", reason: None }, + FieldMetadata { name: "method", field_type: FieldType::Token, required: true, description: "Core protocol method name the host requested, e.g. git.write or workspace.repositories", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: false, description: "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write", reason: None }, + FieldMetadata { name: "path", field_type: FieldType::Path, required: false, description: "First absolute workspace, repository or project root named in the request payload", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the request ran against a remote workspace host", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective request source after merging the host origin and Git execution options: user, background or unknown", reason: None }, + FieldMetadata { name: "trigger", field_type: FieldType::Token, required: false, description: "Stable UI action id that the host reported as the cause of this request, never a localized title", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, + EventMetadata { + id: "app.core.request.traced", + channel: "app/core", + level: LogLevel::Debug, + message: "Core request {method} traced", + fields: &[ + FieldMetadata { name: "cancelled", field_type: FieldType::Bool, required: false, description: "Present and true when the request ended because the host cancelled it", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time Core spent executing the request, from parsing to the final response", reason: None }, + FieldMetadata { name: "method", field_type: FieldType::Token, required: true, description: "Core protocol method name the host requested, e.g. git.write or workspace.repositories", reason: None }, + FieldMetadata { name: "operation", field_type: FieldType::Token, required: false, description: "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write", reason: None }, + FieldMetadata { name: "path", field_type: FieldType::Path, required: false, description: "First absolute workspace, repository or project root named in the request payload", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the request ran against a remote workspace host", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective request source after merging the host origin and Git execution options: user, background or unknown", reason: None }, + FieldMetadata { name: "trigger", field_type: FieldType::Token, required: false, description: "Stable UI action id that the host reported as the cause of this request, never a localized title", reason: None }, + ], + span: None, + rate: 600, + owner: "lithe-core", + }, EventMetadata { id: "app.credentials.cleanup_failed", channel: "app", @@ -2825,99 +2895,63 @@ pub static EVENTS: &[EventMetadata] = &[ }, EventMetadata { id: "vcs.git.command.failed", - channel: "vcs/{provider}", - level: LogLevel::Error, + channel: "vcs/git", + level: LogLevel::Warn, message: "Git {command} failed", fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand that ran, e.g. fetch or push", reason: None }, - FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic time from vcs.git.command.started to vcs.git.command.failed, encoded in milliseconds", reason: None }, - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Failure that ended the vcs.git.command operation, retaining its error code and domain without unredacted private details", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status of the git process; absent when it could not start", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, + FieldMetadata { name: "arguments", field_type: FieldType::Text, required: true, description: "Full Git argument list after git, joined by spaces and quoted where needed", reason: Some("Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB") }, + FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time from spawning the Git process until its exit status was collected", reason: None }, + FieldMetadata { name: "error", field_type: FieldType::Error, required: false, description: "Core runner error for process failures such as start, I/O, output limit or cleanup", reason: None }, + FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Native exit status of the Git process; absent when it never started or ended by signal", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the Git process ran on a remote workspace host", reason: None }, + FieldMetadata { name: "repository", field_type: FieldType::Path, required: false, description: "Repository root or working directory the Git process ran in", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective Git execution source: user, background or unknown", reason: None }, + FieldMetadata { name: "stderr", field_type: FieldType::Text, required: false, description: "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", reason: Some("Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment") }, ], - span: Some(SpanPhase::Failed), + span: None, rate: 600, - owner: "LitheGitModule", + owner: "lithe-core", }, EventMetadata { id: "vcs.git.command.finished", - channel: "vcs/{provider}", + channel: "vcs/git", level: LogLevel::Info, message: "Git {command} finished", fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand that ran, e.g. fetch or push", reason: None }, - FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic time from vcs.git.command.started to vcs.git.command.finished, encoded in milliseconds", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status of the git process; absent when it could not start", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, - ], - span: Some(SpanPhase::Finished), - rate: 600, - owner: "LitheGitModule", - }, - EventMetadata { - id: "vcs.git.command.started", - channel: "vcs/{provider}", - level: LogLevel::Debug, - message: "Git {command} started", - fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand that ran, e.g. fetch or push", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status of the git process; absent when it could not start", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, - ], - span: Some(SpanPhase::Started), - rate: 600, - owner: "LitheGitModule", - }, - EventMetadata { - id: "vcs.git.request.failed", - channel: "vcs/{provider}", - level: LogLevel::Error, - message: "Git request {command} failed", - fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git service request being executed, e.g. fetch or history", reason: None }, - FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic time from vcs.git.request.started to vcs.git.request.failed, encoded in milliseconds", reason: None }, - FieldMetadata { name: "error", field_type: FieldType::Error, required: true, description: "Failure that ended the vcs.git.request operation, retaining its error code and domain without unredacted private details", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status reported for the request result; absent when no git process result is available", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, + FieldMetadata { name: "arguments", field_type: FieldType::Text, required: true, description: "Full Git argument list after git, joined by spaces and quoted where needed", reason: Some("Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB") }, + FieldMetadata { name: "cancelled", field_type: FieldType::Bool, required: false, description: "Present and true when the Git process ended because its request was cancelled", reason: None }, + FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time from spawning the Git process until its exit status was collected", reason: None }, + FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Native exit status of the Git process; absent when it never started or ended by signal", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the Git process ran on a remote workspace host", reason: None }, + FieldMetadata { name: "repository", field_type: FieldType::Path, required: false, description: "Repository root or working directory the Git process ran in", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective Git execution source: user, background or unknown", reason: None }, + FieldMetadata { name: "stderr", field_type: FieldType::Text, required: false, description: "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", reason: Some("Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment") }, ], - span: Some(SpanPhase::Failed), - rate: 600, - owner: "LitheGitModule", - }, - EventMetadata { - id: "vcs.git.request.finished", - channel: "vcs/{provider}", - level: LogLevel::Info, - message: "Git request {command} finished", - fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git service request being executed, e.g. fetch or history", reason: None }, - FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Monotonic time from vcs.git.request.started to vcs.git.request.finished, encoded in milliseconds", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status reported for the request result; absent when no git process result is available", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, - ], - span: Some(SpanPhase::Finished), + span: None, rate: 600, - owner: "LitheGitModule", + owner: "lithe-core", }, EventMetadata { - id: "vcs.git.request.started", - channel: "vcs/{provider}", + id: "vcs.git.command.traced", + channel: "vcs/git", level: LogLevel::Debug, - message: "Git request {command} started", + message: "Git {command} traced", fields: &[ - FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git service request being executed, e.g. fetch or history", reason: None }, - FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Exit status reported for the request result; absent when no git process result is available", reason: None }, - FieldMetadata { name: "failure_code", field_type: FieldType::Token, required: false, description: "Stable Core Git failure code, separate from the native process exit status.", reason: None }, - FieldMetadata { name: "provider", field_type: FieldType::Id, required: true, description: "VCS provider id that owns the repository, e.g. git", reason: None }, + FieldMetadata { name: "arguments", field_type: FieldType::Text, required: true, description: "Full Git argument list after git, joined by spaces and quoted where needed", reason: Some("Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB") }, + FieldMetadata { name: "cancelled", field_type: FieldType::Bool, required: false, description: "Present and true when the Git process ended because its request was cancelled", reason: None }, + FieldMetadata { name: "command", field_type: FieldType::Token, required: true, description: "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent", reason: None }, + FieldMetadata { name: "duration", field_type: FieldType::Duration, required: true, description: "Wall time from spawning the Git process until its exit status was collected", reason: None }, + FieldMetadata { name: "exit_code", field_type: FieldType::Int, required: false, description: "Native exit status of the Git process; absent when it never started or ended by signal", reason: None }, + FieldMetadata { name: "remote", field_type: FieldType::Bool, required: false, description: "Present and true when the Git process ran on a remote workspace host", reason: None }, + FieldMetadata { name: "repository", field_type: FieldType::Path, required: false, description: "Repository root or working directory the Git process ran in", reason: None }, + FieldMetadata { name: "source", field_type: FieldType::Token, required: true, description: "Effective Git execution source: user, background or unknown", reason: None }, + FieldMetadata { name: "stderr", field_type: FieldType::Text, required: false, description: "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", reason: Some("Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment") }, ], - span: Some(SpanPhase::Started), + span: None, rate: 600, - owner: "LitheGitModule", + owner: "lithe-core", }, EventMetadata { id: "vcs.history.cleanup_failed", diff --git a/rust/lithe-core/src/diagnostics/core_events.rs b/rust/lithe-core/src/diagnostics/core_events.rs index c8661aaec..14d051c4e 100644 --- a/rust/lithe-core/src/diagnostics/core_events.rs +++ b/rust/lithe-core/src/diagnostics/core_events.rs @@ -10,10 +10,15 @@ use serde_json::{json, Value}; use std::collections::{BTreeMap, VecDeque}; use std::ffi::c_void; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::sync::{Mutex, MutexGuard, OnceLock, RwLock}; const MAX_EVENTS: usize = 256; static QUEUE: OnceLock> = OnceLock::new(); +// DEBUG facts are evidence the host keeps only while detailed diagnostics is +// active. Rejecting them here keeps them from occupying the bounded queue and +// evicting WARN/ERROR facts when nobody would write them. +static DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); +static DEBUG_ROOTS: RwLock>> = RwLock::new(None); // One process writer may own the pending-fact signal; a later install replaces it. static WAKER: Mutex> = Mutex::new(None); // Set by the first change after a drain and cleared by the next drain, so a @@ -95,12 +100,23 @@ impl CoreEventQueue { /// Validates owner, normalizes private input, and enqueues without filesystem I/O. /// Invalid facts return their logging contract error and never enter the queue. +/// DEBUG facts the host has not enabled return `Ok` without entering the queue +/// or counting as dropped: the host asked not to receive them. pub fn record( event: &str, fields: BTreeMap, op: Option, ) -> Result<(), LogError> { + if let Some(metadata) = event_by_id(event) { + if !accepts(metadata.level, channel_root(metadata.channel)) { + return Ok(()); + } + } let fact = prepare(event, fields, op)?; + #[cfg(test)] + let Some(fact) = capture::divert(fact) else { + return Ok(()); + }; queue().record(fact); wake(); Ok(()) @@ -159,6 +175,35 @@ pub(crate) fn remove_waker(context: usize) -> bool { } } +/// Reports whether `record` would keep a fact of this level and channel root. +/// Callers check it before building fields so rejected facts cost one branch. +pub(crate) fn accepts(level: LogLevel, root: &str) -> bool { + if level != LogLevel::Debug { + return true; + } + #[cfg(test)] + if let Some(debug) = capture::debug_override() { + return debug; + } + DEBUG_ENABLED.load(Ordering::Acquire) + && DEBUG_ROOTS + .read() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_none_or(|roots| roots.iter().any(|enabled| enabled == root)) +} + +/// Applies the host's detailed-diagnostics state; `roots: None` enables every root. +/// The default is disabled, so a host that never calls this receives no DEBUG facts. +pub(crate) fn configure(debug: bool, roots: Option>) { + *DEBUG_ROOTS.write().unwrap_or_else(|e| e.into_inner()) = roots; + DEBUG_ENABLED.store(debug, Ordering::Release); +} + +fn channel_root(channel: &str) -> &str { + channel.split('/').next().unwrap_or(channel) +} + pub(super) fn prepare( event: &str, fields: BTreeMap, @@ -317,6 +362,54 @@ impl StorageCleanup { } } +/// Test-only per-thread sink: traces recorded on the capturing thread bypass the +/// process queue, so parallel tests never observe or drain each other's facts. +#[cfg(test)] +pub(crate) mod capture { + use super::CoreEvent; + use std::cell::RefCell; + + struct Capture { + debug: bool, + events: Vec, + } + + thread_local! { + static CURRENT: RefCell> = const { RefCell::new(None) }; + } + + /// Runs `body` with this thread's facts captured; `debug` replaces the global gate. + pub(crate) fn run(debug: bool, body: impl FnOnce() -> T) -> (T, Vec) { + let previous = CURRENT.with(|current| { + current.borrow_mut().replace(Capture { + debug, + events: Vec::new(), + }) + }); + let value = body(); + let captured = + CURRENT.with(|current| std::mem::replace(&mut *current.borrow_mut(), previous)); + ( + value, + captured.map(|capture| capture.events).unwrap_or_default(), + ) + } + + pub(super) fn debug_override() -> Option { + CURRENT.with(|current| current.borrow().as_ref().map(|capture| capture.debug)) + } + + pub(super) fn divert(event: CoreEvent) -> Option { + CURRENT.with(|current| match current.borrow_mut().as_mut() { + Some(capture) => { + capture.events.push(event); + None + } + None => Some(event), + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/lithe-core/src/diagnostics/mod.rs b/rust/lithe-core/src/diagnostics/mod.rs index ff416bb9b..2aafaca79 100644 --- a/rust/lithe-core/src/diagnostics/mod.rs +++ b/rust/lithe-core/src/diagnostics/mod.rs @@ -20,6 +20,7 @@ pub mod record; pub mod retention; pub(crate) mod helper_failures; +pub(crate) mod request_trace; mod manifest; mod redaction; diff --git a/rust/lithe-core/src/diagnostics/request_trace.rs b/rust/lithe-core/src/diagnostics/request_trace.rs new file mode 100644 index 000000000..d47402658 --- /dev/null +++ b/rust/lithe-core/src/diagnostics/request_trace.rs @@ -0,0 +1,421 @@ +//! One terminal trace per Core request and the request context nested traces inherit. +//! +//! The dispatcher owns the only request boundary every host shares, so recording +//! here covers workspace, plugin and button-driven calls without per-domain code. +//! Failures are WARN because Core cannot know whether the host retries, degrades +//! or gives up. A host notice backed by these facts links to this record; the host +//! adds its own terminal event only for facts Core does not have. + +use super::core_events; +use super::record::LogLevel; +use crate::git::console::Source; +use crate::protocol::{CoreRequest, CoreResponse, ErrorCode}; +use serde_json::{json, Value}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::time::Instant; + +/// Payload keys that name the directory a request works in, in priority order. +const PATH_KEYS: [&str; 7] = [ + "root", + "workspaceRoot", + "repositoryRoot", + "projectRoot", + "rootPath", + "directory", + "path", +]; + +/// Correlation inherited by Git process traces started inside the request. +#[derive(Debug, Clone, Default)] +pub(crate) struct Context { + /// Operation shared by the user action, its requests and their Git processes. + pub(crate) op: Option, + /// Provenance resolved once from the request envelope. + pub(crate) source: Source, +} + +thread_local! { + static CURRENT: RefCell = RefCell::new(Context::default()); +} + +/// Snapshot for traces that finish on another thread, such as streamed history. +pub(crate) fn current() -> Context { + CURRENT.with(|current| current.borrow().clone()) +} + +/// Active request trace; dropping it restores the enclosing request's context. +pub(crate) struct Request { + method: String, + operation: Option, + source: Source, + trigger: Option, + path: Option, + remote: bool, + op: Option, + hot: bool, + started: Instant, + previous: Context, +} + +impl Request { + /// Captures trace fields before the dispatcher moves the payload into a domain. + pub(crate) fn begin(request: &CoreRequest) -> Self { + let origin = request.origin.clone().unwrap_or_default(); + let source = effective_source( + origin.source, + request.git_execution.as_ref().map(|options| options.source), + ); + let op = origin + .operation + .filter(|value| identity(value)) + .or_else(|| request.operation_id.clone().filter(|value| identity(value))) + .or_else(|| request.id.clone().filter(|value| identity(value))); + let previous = CURRENT.with(|current| { + current.replace(Context { + op: op.clone(), + source, + }) + }); + let hot = is_hot(&request.command); + Self { + method: request.command.clone(), + operation: (!hot) + .then(|| request.payload["operation"].as_str().and_then(token)) + .flatten(), + source, + trigger: origin.trigger.as_deref().and_then(token), + path: (!hot).then(|| request_path(&request.payload)).flatten(), + remote: request.remote_workspace.is_some(), + op, + hot, + started: Instant::now(), + previous, + } + } + + /// Records the terminal event for the response the host will receive. + pub(crate) fn finish(&self, response: &CoreResponse) { + if self.hot || token(&self.method).is_none() { + return; + } + let cancelled = response + .error + .as_ref() + .is_some_and(|error| matches!(error.code, ErrorCode::Cancelled)); + let (event, level) = match (&response.error, cancelled) { + (Some(_), false) => ("app.core.request.failed", LogLevel::Warn), + _ if self.source == Source::User => ("app.core.request.finished", LogLevel::Info), + _ => ("app.core.request.traced", LogLevel::Debug), + }; + if !core_events::accepts(level, "app") { + return; + } + let mut fields = BTreeMap::from([ + ("method".to_string(), json!(self.method)), + ("source".to_string(), json!(source_token(self.source))), + ( + "duration_ms".to_string(), + json!(self.started.elapsed().as_millis() as u64), + ), + ]); + if let Some(operation) = &self.operation { + fields.insert("operation".into(), json!(operation)); + } + if let Some(trigger) = &self.trigger { + fields.insert("trigger".into(), json!(trigger)); + } + if let Some(path) = &self.path { + fields.insert("path".into(), json!(path)); + } + if self.remote { + fields.insert("remote".into(), json!(true)); + } + match &response.error { + Some(error) if !cancelled => { + let code = serde_json::to_value(&error.code).expect("Core error codes encode"); + fields.insert( + "error".into(), + json!(crate::git::execution_events::redact(&error.message)), + ); + fields.insert("error_code".into(), code); + fields.insert("error_domain".into(), json!("core")); + } + _ if cancelled => { + fields.insert("cancelled".into(), json!(true)); + } + _ => {} + } + if core_events::record(event, fields, self.op.clone()).is_err() { + core_events::note_dropped(); + } + } +} + +impl Drop for Request { + fn drop(&mut self) { + let previous = std::mem::take(&mut self.previous); + CURRENT.with(|current| current.replace(previous)); + } +} + +/// Methods called per keystroke, per frame, per poll tick or per watcher batch. +/// Tracing them would bury user operations and fill the Core queue, so they +/// record nothing; add every new high-frequency method here. +pub(crate) fn is_hot(method: &str) -> bool { + // The logging system must never trace its own drain, query or encode calls. + method == "core.ping" + || method.starts_with("diagnostics.") + // Typing, caret movement and rendering run these on every edit or frame. + || method.starts_with("editor.") + || method.starts_with("keymap.") + || matches!( + method, + "document.classifyText" + | "document.lifecycle" + | "markdown.render" + | "lsp.syncDocument" + | "lsp.closeDocument" + | "lsp.request" + | "lsp.applyTextEdits" + | "lsp.plainSnippet" + | "lsp.builtinCompletions" + | "lsp.builtinHover" + | "lsp.builtinNavigation" + | "java.codeVision" + | "java.navigationMarkers" + | "java.runMarkers" + | "java.structure" + ) + // Search-as-you-type issues one request per query revision. + || matches!( + method, + "search.actions" + | "workspace.search" + | "workspace.searchEverywhere" + | "workspace.replacePreview" + ) + // Polling loops and message pumps run continuously while a session lives. + || matches!( + method, + "lsp.pollEvents" + | "lsp.waitEvents" + | "lsp.cancelOperation" + | "lsp.hostResponse" + | "lsp.hostNotify" + | "debug.receive" + | "debug.transport.handle" + | "git.consolePresentation" + ) + // File watchers forward one batch per debounce window. + || matches!( + method, + "workspace.filterChanges" | "lsp.workspaceFilesChanged" + ) +} + +/// Explicit host provenance wins; Git provenance covers hosts that send only that. +pub(crate) fn effective_source(origin: Option, git: Option) -> Source { + match origin { + Some(source) if source != Source::Unknown => source, + _ => git.unwrap_or_default(), + } +} + +pub(crate) fn source_token(source: Source) -> &'static str { + match source { + Source::User => "user", + Source::Background => "background", + Source::Unknown => "unknown", + } +} + +fn request_path(payload: &Value) -> Option { + PATH_KEYS.iter().find_map(|key| { + payload[*key] + .as_str() + .filter(|path| std::path::Path::new(path).is_absolute()) + .map(str::to_owned) + }) +} + +/// Catalog token shape; anything else is omitted rather than failing the record. +fn token(value: &str) -> Option { + (!value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-$".contains(&byte))) + .then(|| value.to_owned()) +} + +/// Correlation identity accepted by the record encoder, bounded for one log line. +pub(crate) fn identity(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:/@+-".contains(&byte)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diagnostics::core_events::capture; + + fn request(value: Value) -> CoreRequest { + serde_json::from_value(value).unwrap() + } + + #[test] + fn source_prefers_explicit_origin_then_git_policy() { + use Source::*; + assert_eq!(effective_source(Some(User), Some(Background)), User); + assert_eq!( + effective_source(Some(Unknown), Some(Background)), + Background + ); + assert_eq!(effective_source(None, Some(User)), User); + assert_eq!(effective_source(None, None), Unknown); + } + + #[test] + fn hot_methods_record_nothing_and_user_operations_still_trace() { + for method in [ + "core.ping", + "diagnostics.drainCoreEvents", + "editor.lineEdit", + "keymap.evaluateContext", + "lsp.syncDocument", + "lsp.pollEvents", + "document.lifecycle", + "workspace.search", + "workspace.filterChanges", + "debug.transport.handle", + ] { + assert!(is_hot(method), "{method}"); + } + for method in [ + "git.write", + "git.status", + "workspace.snapshot", + "file.write", + "lsp.startServer", + "remote.connect", + "debug.launch", + ] { + assert!(!is_hot(method), "{method}"); + } + let ((), events) = capture::run(true, || { + let trace = Request::begin(&request(json!({ + "command": "lsp.syncDocument", + "origin": {"source": "user"}, + "payload": {} + }))); + trace.finish(&CoreResponse::success(None, json!({}))); + }); + assert!(events.is_empty()); + } + + #[test] + fn fields_keep_valid_tokens_and_omit_invalid_ones() { + let ((), events) = capture::run(false, || { + let trace = Request::begin(&request(json!({ + "id": "request-1", + "command": "git.write", + "origin": {"source": "user", "trigger": "vcs.fetch", "operation": "action-7"}, + "payload": {"operation": "fetch", "root": "/tmp/project", "path": "/ignored"} + }))); + assert_eq!(current().op.as_deref(), Some("action-7")); + assert_eq!(current().source, Source::User); + trace.finish(&CoreResponse::success(None, json!({}))); + drop(trace); + assert_eq!(current().op, None); + + let trace = Request::begin(&request(json!({ + "id": "request-2", + "command": "git.write", + "origin": {"source": "user", "trigger": "Fetch now", "operation": "has space"}, + "payload": {"operation": "push --force", "root": "relative/project"} + }))); + trace.finish(&CoreResponse::success(None, json!({}))); + }); + assert_eq!(events.len(), 2); + let first = &events[0]; + assert_eq!(first.event, "app.core.request.finished"); + assert_eq!(first.channel, "app/core"); + assert_eq!(first.op.as_deref(), Some("action-7")); + assert_eq!(first.fields["method"], "git.write"); + assert_eq!(first.fields["operation"], "fetch"); + assert_eq!(first.fields["trigger"], "vcs.fetch"); + assert_eq!(first.fields["source"], "user"); + assert_eq!(first.fields["path"], ""); + assert!(first.fields.contains_key("duration_ms")); + let second = &events[1]; + assert_eq!(second.op.as_deref(), Some("request-2")); + for omitted in ["operation", "trigger", "path"] { + assert!(!second.fields.contains_key(omitted), "{omitted}"); + } + } + + #[test] + fn failures_warn_and_background_success_obeys_the_debug_gate() { + use crate::protocol::CoreError; + let failing = || { + let trace = Request::begin(&request(json!({ + "command": "workspace.snapshot", + "gitExecution": {"source": "background"}, + "payload": {} + }))); + trace.finish(&CoreResponse::failure( + None, + CoreError::new(ErrorCode::PermissionDenied, "Workspace is not readable"), + )); + }; + let background = || { + let trace = Request::begin(&request(json!({ + "command": "git.status", + "origin": {"source": "background"}, + "payload": {} + }))); + trace.finish(&CoreResponse::success(None, json!({}))); + }; + let ((), gated) = capture::run(false, || { + failing(); + background(); + }); + assert_eq!(gated.len(), 1); + assert_eq!(gated[0].event, "app.core.request.failed"); + assert_eq!(gated[0].level, LogLevel::Warn); + assert_eq!(gated[0].fields["error"], "Workspace is not readable"); + assert_eq!(gated[0].fields["error_code"], "permission_denied"); + assert_eq!(gated[0].fields["error_domain"], "core"); + assert_eq!(gated[0].fields["source"], "background"); + + let ((), enabled) = capture::run(true, background); + assert_eq!(enabled.len(), 1); + assert_eq!(enabled[0].event, "app.core.request.traced"); + assert_eq!(enabled[0].level, LogLevel::Debug); + assert_eq!(enabled[0].fields["source"], "background"); + } + + #[test] + fn cancellation_is_an_outcome_not_a_failure() { + use crate::protocol::CoreError; + let ((), events) = capture::run(false, || { + let trace = Request::begin(&request(json!({ + "command": "git.write", + "origin": {"source": "user"}, + "payload": {"operation": "fetch"} + }))); + trace.finish(&CoreResponse::failure( + None, + CoreError::new(ErrorCode::Cancelled, "Operation cancelled"), + )); + }); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event, "app.core.request.finished"); + assert_eq!(events[0].fields["cancelled"], true); + assert!(!events[0].fields.contains_key("error")); + } +} diff --git a/rust/lithe-core/src/git/command_trace.rs b/rust/lithe-core/src/git/command_trace.rs new file mode 100644 index 000000000..b32ac27b9 --- /dev/null +++ b/rust/lithe-core/src/git/command_trace.rs @@ -0,0 +1,447 @@ +//! One terminal trace per Git process, recorded at the execution point. +//! +//! Every local and remote Git spawn passes through here so the log shows which +//! command ran, for which source and how it ended. Argument text reaches the log +//! only through `format_arguments`; formatting argv anywhere else would bypass +//! redaction. Stdout, stdin and the process environment are never recorded. + +use super::console::Source; +use super::{execution_events, execution_policy}; +use crate::diagnostics::core_events; +use crate::diagnostics::record::LogLevel; +use crate::diagnostics::request_trace; +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::Instant; + +/// Text fields share the record encoder's 1 KiB cap; keeping the bound here lets +/// truncation mark itself instead of being cut silently. +const MAX_TEXT_BYTES: usize = 1024; +const STDERR_LINES: usize = 8; +/// Git prints its reason last; older stderr is irrelevant once the tail is known. +const STDERR_TAIL_BYTES: usize = 16 * 1024; +const MESSAGE: &str = ""; + +/// Config keys whose values carry credentials or personal identity. +const SENSITIVE_CONFIG: [&str; 10] = [ + "credential", + "extraheader", + "token", + "password", + "secret", + "auth", + "cookie", + "user.", + "author.", + "committer.", +]; + +/// Active process trace; `finish` records exactly one terminal event. +pub(super) struct Trace { + arguments: Vec, + repository: String, + source: Source, + remote: bool, + visible: bool, + op: Option, + started: Instant, + exit_code: Option, + stderr: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Outcome { + Succeeded, + Cancelled, + Failed, +} + +impl Trace { + /// Captures provenance on the request thread, before any reader thread exists. + pub(super) fn begin(repository: &str, arguments: &[String], visible: bool) -> Self { + let context = request_trace::current(); + let source = match execution_policy::current().source { + Source::Unknown => context.source, + source => source, + }; + Self { + arguments: arguments.to_vec(), + repository: repository.to_owned(), + source, + remote: crate::remote::workspace::is_remote(), + visible, + op: context.op, + started: Instant::now(), + exit_code: None, + stderr: Vec::new(), + } + } + + /// Keeps the native exit status and the stderr tail as soon as the process ends, + /// so runner errors raised afterwards still report what Git printed. + pub(super) fn observe(&mut self, exit_code: Option, stderr: &[u8]) { + self.exit_code = exit_code; + let start = stderr.len().saturating_sub(STDERR_TAIL_BYTES); + self.stderr = stderr[start..].to_vec(); + } + + /// Records the process outcome; `error` is the runner error returned to the caller. + pub(super) fn finish(self, error: Option<&CoreError>) { + let outcome = outcome( + &self.arguments, + self.exit_code, + error, + self.visible, + self.source, + ); + let (event, level) = match outcome { + Outcome::Failed => ("vcs.git.command.failed", LogLevel::Warn), + _ if self.visible && self.source == Source::User => { + ("vcs.git.command.finished", LogLevel::Info) + } + _ => ("vcs.git.command.traced", LogLevel::Debug), + }; + if !core_events::accepts(level, "vcs") { + return; + } + let command = execution_policy::command_index(&self.arguments) + .map(|index| self.arguments[index].as_str()) + .filter(|command| token(command)) + .unwrap_or("unknown"); + let mut fields = BTreeMap::from([ + ("command".to_string(), json!(command)), + ( + "arguments".to_string(), + json!(format_arguments(&self.arguments)), + ), + ( + "source".to_string(), + json!(request_trace::source_token(self.source)), + ), + ( + "duration_ms".to_string(), + json!(self.started.elapsed().as_millis() as u64), + ), + ]); + if !self.repository.is_empty() { + fields.insert("repository".into(), json!(self.repository)); + } + if self.remote { + fields.insert("remote".into(), json!(true)); + } + if let Some(code) = self.exit_code { + fields.insert("exit_code".into(), json!(code)); + } + if outcome == Outcome::Cancelled { + fields.insert("cancelled".into(), json!(true)); + } + if outcome == Outcome::Failed { + if let Some(error) = error { + let code = serde_json::to_value(&error.code).expect("Core error codes encode"); + fields.insert( + "error".into(), + json!(execution_events::redact(&error.message)), + ); + fields.insert("error_code".into(), code); + fields.insert("error_domain".into(), json!("core")); + } + } + if outcome != Outcome::Succeeded || self.exit_code.is_some_and(|code| code != 0) { + if let Some(stderr) = stderr_tail(&self.stderr) { + fields.insert("stderr".into(), json!(stderr)); + } + } + if core_events::record(event, fields, self.op).is_err() { + core_events::note_dropped(); + } + } +} + +/// Classifies one process; nonzero exits are failures only where Git means failure. +fn outcome( + arguments: &[String], + exit_code: Option, + error: Option<&CoreError>, + visible: bool, + source: Source, +) -> Outcome { + match (error, exit_code) { + (Some(error), _) if matches!(error.code, ErrorCode::Cancelled) => Outcome::Cancelled, + (Some(_), _) => Outcome::Failed, + (None, Some(0)) => Outcome::Succeeded, + (None, Some(code)) if exit_is_answer(arguments, code) => Outcome::Succeeded, + // Background plumbing reports a nonzero exit to a caller that decides what it + // means; the request trace records the failure if the caller returns one. + (None, Some(_)) if !visible && source != Source::User => Outcome::Succeeded, + _ => Outcome::Failed, + } +} + +/// Exit statuses that answer a question instead of reporting an error. +/// Add a row whenever Core starts relying on another command's documented status. +pub(super) fn exit_is_answer(arguments: &[String], code: i32) -> bool { + let Some(index) = execution_policy::command_index(arguments) else { + return false; + }; + let rest = &arguments[index + 1..]; + let has = |flags: &[&str]| { + rest.iter() + .any(|argument| flags.contains(&argument.as_str())) + }; + match (arguments[index].as_str(), code) { + ("config", 1) => has(&["--get", "--get-all", "--get-regexp"]), + ("merge-base", 1) => has(&["--is-ancestor"]), + ("diff" | "diff-index" | "diff-files", 1) => has(&["--quiet", "--exit-code"]), + ("rev-parse", 1) => has(&["--verify"]) && has(&["--quiet", "-q"]), + ("symbolic-ref", 1) => has(&["--quiet", "-q"]), + ("show-ref", 1) => has(&["--verify", "--quiet", "-q"]), + ("show-ref", 2) => has(&["--exists"]), + ("check-ignore" | "check-ref-format" | "grep", 1) => true, + ("ls-remote", 2) => has(&["--exit-code"]), + // Repository discovery outside a work tree and counts without an upstream. + ("rev-parse", 128) => has(&[ + "--show-toplevel", + "--absolute-git-dir", + "--git-dir", + "--git-common-dir", + "--is-inside-work-tree", + ]), + ("rev-list", 128) => rest + .iter() + .any(|argument| argument.contains("@{upstream}") || argument.contains("@{u}")), + _ => false, + } +} + +/// The only formatter allowed to put Git argv into a log record. +/// Message values become ``, sensitive `-c` values are removed, URL +/// userinfo and private paths are redacted, and the result is capped at 1 KiB. +pub(crate) fn format_arguments(arguments: &[String]) -> String { + let mut parts = Vec::with_capacity(arguments.len()); + let mut message_next = false; + let mut config_next = false; + for argument in arguments { + let part = if std::mem::take(&mut message_next) { + MESSAGE.to_owned() + } else if std::mem::take(&mut config_next) { + redact_config(argument) + } else { + match argument.as_str() { + "-m" | "--message" => { + message_next = true; + argument.clone() + } + "-c" => { + config_next = true; + argument.clone() + } + value if value.starts_with("--message=") => format!("--message={MESSAGE}"), + value if value.len() > 2 && value.starts_with("-m") => format!("-m{MESSAGE}"), + value => value.to_owned(), + } + }; + parts.push(quote(execution_events::redact(&part))); + } + truncate_end(parts.join(" ")) +} + +fn redact_config(assignment: &str) -> String { + let Some((key, _)) = assignment.split_once('=') else { + return assignment.to_owned(); + }; + let lower = key.to_ascii_lowercase(); + if SENSITIVE_CONFIG.iter().any(|marker| lower.contains(marker)) { + format!("{key}=") + } else { + assignment.to_owned() + } +} + +fn quote(argument: String) -> String { + if argument.is_empty() + || argument + .chars() + .any(|character| character.is_whitespace() || matches!(character, '"' | '\'' | '\\')) + { + format!("{argument:?}") + } else { + argument + } +} + +/// Last non-empty stderr lines, redacted and joined with ` | `, oldest text dropped first. +pub(super) fn stderr_tail(stderr: &[u8]) -> Option { + let text = String::from_utf8_lossy(stderr); + let lines = text + .split(['\n', '\r']) + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + let start = lines.len().saturating_sub(STDERR_LINES); + let tail = lines[start..] + .iter() + .map(|line| execution_events::redact(line)) + .collect::>() + .join(" | "); + (!tail.is_empty()).then(|| truncate_start(tail)) +} + +fn truncate_end(text: String) -> String { + if text.len() <= MAX_TEXT_BYTES { + return text; + } + let mut end = MAX_TEXT_BYTES - '…'.len_utf8(); + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &text[..end]) +} + +fn truncate_start(text: String) -> String { + if text.len() <= MAX_TEXT_BYTES { + return text; + } + let mut start = text.len() - (MAX_TEXT_BYTES - '…'.len_utf8()); + while !text.is_char_boundary(start) { + start += 1; + } + format!("…{}", &text[start..]) +} + +fn token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-$".contains(&byte)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn arguments_hide_messages_credentials_and_quote_where_needed() { + assert_eq!( + format_arguments(&args(&["commit", "-m", "fix secret bug", "--", "a b.txt"])), + r#"commit -m -- "a b.txt""# + ); + assert_eq!( + format_arguments(&args(&["tag", "-mrelease notes", "--message=body", "v1"])), + "tag -m --message= v1" + ); + assert_eq!( + format_arguments(&args(&["merge", "--message", "Merge it", "topic"])), + "merge --message topic" + ); + assert_eq!( + format_arguments(&args(&[ + "-c", + "http.extraHeader=Authorization: Bearer abc", + "-c", + "credential.helper=store", + "-c", + "core.quotePath=false", + "fetch", + "https://alice:hunter2@example.com/repo.git", + "" + ])), + r#"-c http.extraHeader= -c credential.helper= -c core.quotePath=false fetch https://redacted@example.com/repo.git """# + ); + let formatted = format_arguments(&args(&["commit", "-m", "fix secret bug"])); + assert!(!formatted.contains("secret")); + } + + #[test] + fn arguments_are_capped_with_a_visible_marker() { + let long = vec!["x".repeat(600), "é".repeat(400)]; + let formatted = format_arguments(&long); + assert!(formatted.len() <= MAX_TEXT_BYTES); + assert!(formatted.ends_with('…')); + assert!(formatted.starts_with(&"x".repeat(600))); + } + + #[test] + fn documented_answer_statuses_are_not_failures() { + for (arguments, code) in [ + (args(&["config", "--get", "remote.origin.url"]), 1), + ( + args(&["-c", "a=b", "merge-base", "--is-ancestor", "a", "b"]), + 1, + ), + (args(&["diff", "--quiet"]), 1), + (args(&["diff-files", "--exit-code"]), 1), + (args(&["rev-parse", "--verify", "--quiet", "HEAD"]), 1), + (args(&["symbolic-ref", "--quiet", "HEAD"]), 1), + ( + args(&["show-ref", "--verify", "--quiet", "refs/heads/x"]), + 1, + ), + (args(&["show-ref", "--exists", "refs/heads/x"]), 2), + (args(&["check-ignore", "-q", "file"]), 1), + (args(&["ls-remote", "--exit-code", "origin"]), 2), + (args(&["rev-parse", "--show-toplevel"]), 128), + (args(&["rev-list", "--count", "@{upstream}...HEAD"]), 128), + ] { + assert!(exit_is_answer(&arguments, code), "{arguments:?} {code}"); + } + for (arguments, code) in [ + (args(&["config", "--get", "a"]), 128), + (args(&["rev-parse", "--verify", "HEAD"]), 1), + (args(&["checkout", "missing"]), 1), + (args(&["ls-remote", "origin"]), 2), + (args(&["--version"]), 1), + ] { + assert!(!exit_is_answer(&arguments, code), "{arguments:?} {code}"); + } + } + + #[test] + fn outcome_separates_errors_answers_and_background_exits() { + let checkout = args(&["checkout", "missing"]); + let cancelled = CoreError::new(ErrorCode::Cancelled, "Operation was cancelled"); + let start = CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git"); + use Outcome::*; + use Source::*; + assert_eq!( + outcome(&checkout, None, Some(&cancelled), true, User), + Cancelled + ); + assert_eq!( + outcome(&checkout, None, Some(&start), false, Background), + Failed + ); + assert_eq!(outcome(&checkout, Some(0), None, true, User), Succeeded); + assert_eq!(outcome(&checkout, Some(1), None, true, User), Failed); + assert_eq!(outcome(&checkout, Some(1), None, false, User), Failed); + assert_eq!( + outcome(&checkout, Some(1), None, false, Background), + Succeeded + ); + assert_eq!(outcome(&checkout, None, None, true, User), Failed); + let probe = args(&["config", "--get", "user.name"]); + assert_eq!(outcome(&probe, Some(1), None, true, User), Succeeded); + } + + #[test] + fn stderr_keeps_the_last_redacted_lines() { + let stderr = (1..=12) + .map(|line| format!("line {line}\n")) + .collect::() + + "\rReceiving objects: 50%\rfatal: https://bob:pw@example.com/x.git not found\n\n"; + let tail = stderr_tail(stderr.as_bytes()).unwrap(); + assert!(tail.starts_with("line 7 | line 8"), "{tail}"); + assert!(tail.ends_with("fatal: https://redacted@example.com/x.git not found")); + assert!(!tail.contains("pw@")); + assert_eq!(stderr_tail(b" \n\r\n"), None); + let long = format!("{}\nfatal: last words\n", "y".repeat(4000)); + let tail = stderr_tail(long.as_bytes()).unwrap(); + assert!(tail.len() <= MAX_TEXT_BYTES); + assert!(tail.starts_with('…') && tail.ends_with("fatal: last words")); + } +} diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs index 4419c2292..278742a9d 100644 --- a/rust/lithe-core/src/git/history.rs +++ b/rust/lithe-core/src/git/history.rs @@ -11,7 +11,7 @@ use crate::protocol::{ }; use serde::Deserialize; use std::io::{BufRead, BufReader, Read}; -use std::process::{Child, ExitStatus, Stdio}; +use std::process::{Child, ChildStderr, ChildStdout, ExitStatus, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; use std::sync::{Mutex, OnceLock}; @@ -486,6 +486,8 @@ struct HistorySession { emitted: usize, last_access: Instant, finished: bool, + /// Records the one `git log` process this cursor owns when it ends. + trace: Option, } #[derive(Default)] @@ -552,7 +554,15 @@ impl HistorySession { GIT_COMMIT_DATE_ARGUMENT.to_string(), "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), ]); - if let Some(remote) = super::remote_history::RemoteHistory::start(&root, &arguments)? { + let trace = super::command_trace::Trace::begin(&root, &arguments, false); + let remote = match super::remote_history::RemoteHistory::start(&root, &arguments) { + Ok(remote) => remote, + Err(error) => { + trace.finish(Some(&error)); + return Err(error); + } + }; + if let Some(remote) = remote { return Ok(Self { cursor: format!( "git-history-cursor-{}", @@ -571,33 +581,16 @@ impl HistorySession { emitted: 0, last_access: Instant::now(), finished: false, + trace: Some(trace), }); } - let mut process = git_process(); - let mut child = process - .args(&arguments) - .current_dir(&root) - .env("GIT_OPTIONAL_LOCKS", "0") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| { - CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git history") - .with_details(error.to_string()) - })?; - let stdout = child.stdout.take().ok_or_else(|| { - CoreError::new( - ErrorCode::ProcessFailed, - "Git history stdout was unavailable", - ) - })?; - let mut stderr = child.stderr.take().ok_or_else(|| { - CoreError::new( - ErrorCode::ProcessFailed, - "Git history stderr was unavailable", - ) - })?; + let (child, stdout, mut stderr) = match spawn_history_process(&root, &arguments) { + Ok(process) => process, + Err(error) => { + trace.finish(Some(&error)); + return Err(error); + } + }; // A single-slot channel propagates backpressure to Git instead of buffering the // entire repository history between page requests. let (sender, receiver) = mpsc::sync_channel(1); @@ -634,6 +627,7 @@ impl HistorySession { emitted: 0, last_access: Instant::now(), finished: false, + trace: Some(trace), }) } @@ -714,8 +708,21 @@ impl HistorySession { return Ok(()); } self.finished = true; - self.remote.take(); self.receiver.take(); + let mut trace = self.trace.take(); + if let Some(remote) = self.remote.take() { + if let Some(mut trace) = trace { + let error = match remote.exit() { + Some((code, stderr)) => { + trace.observe(code, stderr); + (code != Some(0)).then(|| history_failure(stderr)) + } + None => Some(closed_cursor()), + }; + trace.finish(error.as_ref()); + } + return Ok(()); + } let status = if let Some(mut child) = self.child.take() { if terminate { if let Err(error) = child.kill() { @@ -730,13 +737,20 @@ impl HistorySession { } } } - child.wait().map_err(|error| { - CoreError::new( - ErrorCode::ProcessFailed, - "Could not read Git history status", - ) - .with_details(error.to_string()) - })? + match child.wait() { + Ok(status) => status, + Err(error) => { + let error = CoreError::new( + ErrorCode::ProcessFailed, + "Could not read Git history status", + ) + .with_details(error.to_string()); + if let Some(trace) = trace { + trace.finish(Some(&error)); + } + return Err(error); + } + } } else { return Ok(()); }; @@ -766,7 +780,19 @@ impl HistorySession { } }) .unwrap_or_default(); - validate_history_exit(status, stderr, terminate) + if let Some(mut trace) = trace.take() { + trace.observe(status.code(), &stderr); + // A signal after `terminate` is the kill above, not a Git failure. + let error = if status.success() { + None + } else if terminate && status.code().is_none() { + Some(closed_cursor()) + } else { + Some(history_failure(&stderr)) + }; + trace.finish(error.as_ref()); + } + validate_history_exit(status, &stderr, terminate) } fn stop(mut self) { @@ -823,16 +849,56 @@ fn read_history_stream(stdout: impl Read, sender: SyncSender, + stderr: &[u8], terminated: bool, ) -> Result<(), CoreError> { if status.success() || terminated { return Ok(()); } - Err( - CoreError::new(ErrorCode::ProcessFailed, "Git history failed") - .with_details(String::from_utf8_lossy(&stderr).trim().to_string()), - ) + Err(history_failure(stderr)) +} + +/// The error a nonzero `git log` exit returns, local or remote. +pub(super) fn history_failure(stderr: &[u8]) -> CoreError { + CoreError::new(ErrorCode::ProcessFailed, "Git history failed") + .with_details(String::from_utf8_lossy(stderr).trim().to_string()) +} + +/// A cursor closed before Git exited stopped the process on purpose. +fn closed_cursor() -> CoreError { + CoreError::new(ErrorCode::Cancelled, "Git history cursor closed") +} + +/// Starts the local `git log`; read-only, so optional index refreshes stay off. +fn spawn_history_process( + root: &str, + arguments: &[String], +) -> Result<(Child, ChildStdout, ChildStderr), CoreError> { + let mut child = git_process() + .args(arguments) + .current_dir(root) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git history") + .with_details(error.to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history stdout was unavailable", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history stderr was unavailable", + ) + })?; + Ok((child, stdout, stderr)) } fn validate_reference(reference: Option<&str>) -> Result<(), CoreError> { @@ -1235,6 +1301,7 @@ mod tests { emitted: 0, last_access, finished: true, + trace: None, } } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 7c058af19..f06d5efbd 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -3,9 +3,10 @@ // The initial projection/routing IR is deliberately not command- or host-facing // until both native products can consume the same versioned contract. mod changelists; +mod command_trace; +mod commit_message_context; mod commit_selection; mod commit_state; -mod commit_message_context; mod deleted_selection; pub(crate) mod workspace_commit; pub use commit_state::{inspect as commit_state, GitCommitGitlink, GitCommitState}; @@ -45,8 +46,8 @@ pub use setup::{ }; pub(crate) use changelists::changelists_command; -pub(crate) use commit_selection::prepare_commit_selection_command; pub(crate) use commit_message_context::commit_message_context_command; +pub(crate) use commit_selection::prepare_commit_selection_command; pub(crate) use deleted_selection::reconcile_deleted_selection_command; pub use history::{ close_history_cursor, history, history_page, references, GitHistoryCursorCloseRequest, @@ -1806,16 +1807,40 @@ fn capture_git_process( ) -> Result { validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; crate::protocol::cancellation::check()?; - if let Some(result) = remote_process::capture( + let mut trace = command_trace::Trace::begin(root, arguments, visible); + let result = match remote_process::capture( root, arguments, input.as_deref(), disable_optional_locks, environment, visible, + &mut trace, ) { - return result; - } + Some(result) => result, + None => capture_local_git_process( + root, + arguments, + input, + disable_optional_locks, + environment, + visible, + &mut trace, + ), + }; + trace.finish(result.as_ref().err()); + result +} + +fn capture_local_git_process( + root: &str, + arguments: &[String], + input: Option, + disable_optional_locks: bool, + environment: &[(String, String)], + visible: bool, + trace: &mut command_trace::Trace, +) -> Result { let mut process = git_process(); let version = lithe_git_host::configuration::version(process.get_program(), || { crate::protocol::cancellation::check().is_err() @@ -1946,6 +1971,7 @@ fn capture_git_process( .status .as_ref() .and_then(std::process::ExitStatus::code); + trace.observe(exit_code, &output.stderr); if let Some(invocation) = invocation.into_inner() { // Optional internal config reads use exit 1 for a missing key. Preserve // that actual code without presenting a normal preflight result as failure. @@ -8569,13 +8595,14 @@ fn pathspec_batches(paths: &[String], budget: usize) -> Vec<&[String]> { } fn run_git(directory: &Path, arguments: &[&str]) -> Result { + let arguments = arguments + .iter() + .map(|value| value.to_string()) + .collect::>(); if crate::remote::workspace::is_remote() { let output = capture_git_process( &directory.to_string_lossy(), - &arguments - .iter() - .map(|value| value.to_string()) - .collect::>(), + &arguments, None, true, &[], @@ -8601,15 +8628,21 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result Vec { diff --git a/rust/lithe-core/src/git/remote_history.rs b/rust/lithe-core/src/git/remote_history.rs index d8a26c41a..5a3f9b2b0 100644 --- a/rust/lithe-core/src/git/remote_history.rs +++ b/rust/lithe-core/src/git/remote_history.rs @@ -24,6 +24,8 @@ pub(super) struct RemoteHistory { output: VecDeque, stderr: Vec, exited: bool, + /// The host's exit code once this process ended; `Some(None)` means a signal. + exit_code: Option>, } impl RemoteHistory { @@ -44,6 +46,7 @@ impl RemoteHistory { output: VecDeque::new(), stderr: Vec::new(), exited: false, + exit_code: None, }; let mut params = serde_json::to_value(params).expect("spawn parameters encode"); params["processId"] = json!(id); @@ -144,16 +147,14 @@ impl RemoteHistory { } methods::EVENT_PROCESS_EXIT => { let exit: methods::ProcessExitEvent = event.decode().map_err(failure)?; + if exit.process_id == self.id { + self.exit_code = Some(exit.exit_code.filter(|_| exit.signal.is_none())); + } if exit.process_id != self.id || exit.exit_code != Some(0) || exit.signal.is_some() { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git history failed") - .with_details( - String::from_utf8_lossy(&self.stderr).trim().to_owned(), - ), - ); + return Err(super::history::history_failure(&self.stderr)); } self.exited = true; } @@ -167,6 +168,11 @@ impl RemoteHistory { } } + /// Exit code and stderr once the host reported this process's exit. + pub(super) fn exit(&self) -> Option<(Option, &[u8])> { + self.exit_code.map(|code| (code, self.stderr.as_slice())) + } + fn take_line(&mut self) -> Result { if self.line.last() == Some(&b'\r') { self.line.pop(); diff --git a/rust/lithe-core/src/git/remote_process.rs b/rust/lithe-core/src/git/remote_process.rs index 4c6467554..5acca9121 100644 --- a/rust/lithe-core/src/git/remote_process.rs +++ b/rust/lithe-core/src/git/remote_process.rs @@ -1,6 +1,6 @@ //! Executes the existing Git engine's invocations on its request-scoped host. -use super::{execution_events, execution_policy, GitProcessOutput}; +use super::{command_trace, execution_events, execution_policy, GitProcessOutput}; use crate::protocol::{CoreError, ErrorCode}; use crate::remote::{workspace, workspace_host}; use lithe_remote::protocol::methods::{self, EnvironmentSource, OutputStream, SpawnParams}; @@ -18,6 +18,7 @@ pub(super) fn capture( disable_optional_locks: bool, environment: &[(String, String)], visible: bool, + trace: &mut command_trace::Trace, ) -> Option> { workspace::current().map(|host| { let session = &host.connection.session; @@ -92,6 +93,7 @@ pub(super) fn capture( .ok() .and_then(|value| value["exitCode"].as_i64()) .and_then(|code| i32::try_from(code).ok()); + trace.observe(exit, &stderr); if let Some(invocation) = invocation { let expected = !visible && exit == Some(1) diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index ab0dd9f4a..80ff7dcfc 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -22,6 +22,9 @@ pub struct CoreRequest { /// Remote host selected by the native workspace registry; absent for local workspaces. #[serde(default)] pub(crate) remote_workspace: Option, + /// Host-reported provenance used only for diagnostics traces; never changes behavior. + #[serde(default)] + pub origin: Option, /// Stable compatibility name resolved by [`CoreCommand::parse`]. pub command: String, /// Command-specific JSON object; omitted payloads deserialize as JSON null. @@ -29,6 +32,19 @@ pub struct CoreRequest { pub payload: Value, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +/// Why the host sent a request. Invalid tokens are dropped by the trace, not rejected, +/// so a malformed diagnostics hint can never fail the operation it describes. +pub struct RequestOrigin { + /// Whether a user action or an automatic refresh caused the request. + pub source: Option, + /// Stable identifier of the UI action that caused the request. + pub trigger: Option, + /// Correlation identifier shared by the action and every request it caused. + pub operation: Option, +} + #[derive(Debug, Clone)] /// Typed form of the stable command names accepted by the dispatcher. /// @@ -609,6 +625,8 @@ pub enum CoreCommand { DiagnosticsEncodeLogBatch, /// Drains bounded process-owned Core log facts (`diagnostics.drainCoreEvents`). DiagnosticsDrainCoreEvents, + /// Selects which DEBUG Core facts the host will write (`diagnostics.configureCoreEvents`). + DiagnosticsConfigureCoreEvents, /// Merges structured and legacy log evidence (`diagnostics.queryLogsV2`). DiagnosticsQueryLogsV2, /// Parses filter terms and UTF-16 diagnostics (`diagnostics.parseLogFilter`). @@ -923,6 +941,7 @@ impl CoreCommand { "diagnostics.prepareLogEvent" => Some(Self::DiagnosticsPrepareLogEvent), "diagnostics.queryLogs" => Some(Self::DiagnosticsQueryLogs), "diagnostics.drainCoreEvents" => Some(Self::DiagnosticsDrainCoreEvents), + "diagnostics.configureCoreEvents" => Some(Self::DiagnosticsConfigureCoreEvents), "diagnostics.encodeLogBatch" => Some(Self::DiagnosticsEncodeLogBatch), "diagnostics.parseLogFilter" => Some(Self::DiagnosticsParseLogFilter), "diagnostics.composeLogFilter" => Some(Self::DiagnosticsComposeLogFilter), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 37ef9226f..3ade5a176 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -88,6 +88,14 @@ fn agent_response( } } +/// Host detailed-diagnostics state; `roots: None` enables DEBUG for every root. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CoreEventConfiguration { + debug: bool, + roots: Option>, +} + // Domain failures retain their unique numeric logging codes inside the existing Core envelope. fn log_v2_result( result: Result, @@ -109,6 +117,14 @@ fn execute(request: &str) -> CoreResponse { ); } }; + // The trace spans every scope below and records once, after the final response. + let trace = crate::diagnostics::request_trace::Request::begin(&parsed); + let response = execute_parsed(parsed); + trace.finish(&response); + response +} + +fn execute_parsed(parsed: CoreRequest) -> CoreResponse { let id = parsed.id.clone(); let response_id = id.clone(); let operation_id = parsed.operation_id.clone().or_else(|| id.clone()); @@ -2854,7 +2870,11 @@ fn execute(request: &str) -> CoreResponse { crate::git::prepare_commit_selection_command, ), CoreCommand::GitShelf => plugin_response(id, parsed.payload, crate::git::shelf_command), - CoreCommand::GitCommitMessageContext => plugin_response(id, parsed.payload, crate::git::commit_message_context_command), + CoreCommand::GitCommitMessageContext => plugin_response( + id, + parsed.payload, + crate::git::commit_message_context_command, + ), CoreCommand::GitChangelists => { plugin_response(id, parsed.payload, crate::git::changelists_command) } @@ -3509,6 +3529,25 @@ fn execute(request: &str) -> CoreResponse { serde_json::to_value(crate::diagnostics::core_events::drain()) .expect("Core logs encode"), ), + CoreCommand::DiagnosticsConfigureCoreEvents => { + match serde_json::from_value::(parsed.payload) { + Ok(configuration) => { + crate::diagnostics::core_events::configure( + configuration.debug, + configuration.roots, + ); + CoreResponse::success(id, json!({})) + } + Err(error) => CoreResponse::failure( + id, + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Core event configuration", + ) + .with_details(error.to_string()), + ), + } + } CoreCommand::DiagnosticsEncodeLogBatch => { match serde_json::from_value::( parsed.payload, @@ -3700,6 +3739,32 @@ mod tests { use super::execute_json; use serde_json::{json, Value}; + #[test] + fn configure_core_events_sets_the_debug_gate_and_rejects_unknown_fields() { + use crate::diagnostics::core_events::{accepts, configure}; + use crate::diagnostics::record::LogLevel; + let send = |payload: Value| -> Value { + serde_json::from_str(&execute_json( + &json!({"command": "diagnostics.configureCoreEvents", "payload": payload}) + .to_string(), + )) + .unwrap() + }; + assert_eq!(send(json!({"debug": true, "roots": ["vcs"]}))["ok"], true); + let scoped = ( + accepts(LogLevel::Debug, "vcs"), + accepts(LogLevel::Debug, "app"), + ); + let rejected = send(json!({"debug": true, "level": "debug"})); + // Restore the process default before asserting so a failure cannot leak DEBUG. + configure(false, None); + assert_eq!(scoped, (true, false)); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"]["code"], "invalid_request"); + assert!(!accepts(LogLevel::Debug, "vcs")); + assert!(accepts(LogLevel::Warn, "vcs")); + } + #[test] fn routes_semantic_lsp_runtime_commands() { let unknown_session = "missing-runtime-session"; diff --git a/rust/lithe-core/src/tests/core_request_traces.rs b/rust/lithe-core/src/tests/core_request_traces.rs new file mode 100644 index 000000000..815c4b763 --- /dev/null +++ b/rust/lithe-core/src/tests/core_request_traces.rs @@ -0,0 +1,150 @@ +use super::support::temporary_root; +use crate::diagnostics::core_events::{capture, CoreEvent}; +use crate::diagnostics::record::LogLevel; +use crate::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn repository(label: &str) -> PathBuf { + let root = temporary_root(label); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + for arguments in [ + &["init", "-q"][..], + &["config", "user.name", "Trace Test"], + &["config", "user.email", "trace@example.invalid"], + &["config", "commit.gpgsign", "false"], + ] { + let status = Command::new("git") + .args(arguments) + .current_dir(&root) + .status() + .expect("git should be available"); + assert!(status.success(), "git {arguments:?}"); + } + root +} + +fn send(request: Value) -> (Value, Vec) { + let (response, events) = capture::run(false, || execute_json(&request.to_string())); + ( + serde_json::from_str(&response).expect("Core response should be JSON"), + events, + ) +} + +fn git_command(root: &Path, origin: Value, arguments: &[&str]) -> Value { + json!({ + "id": "request-1", + "command": "git.command", + "origin": origin, + "payload": {"root": root, "arguments": arguments} + }) +} + +fn only<'a>(events: &'a [CoreEvent], id: &str) -> &'a CoreEvent { + let matching = events + .iter() + .filter(|event| event.event == id) + .collect::>(); + assert_eq!(matching.len(), 1, "{id} in {events:?}"); + matching[0] +} + +#[test] +fn user_git_command_records_redacted_argv_under_the_action_operation() { + let root = repository("trace-commit"); + let origin = json!({"source": "user", "trigger": "vcs.commit", "operation": "action-1"}); + let (response, events) = send(git_command( + &root, + origin, + &["commit", "--allow-empty", "-m", "secret subject"], + )); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["exitCode"], 0); + + let git = only(&events, "vcs.git.command.finished"); + assert_eq!(git.level, LogLevel::Info); + assert_eq!(git.channel, "vcs/git"); + assert_eq!(git.op.as_deref(), Some("action-1")); + assert_eq!(git.fields["command"], "commit"); + assert_eq!(git.fields["source"], "user"); + assert_eq!(git.fields["exit_code"], 0); + let arguments = git.fields["arguments"].as_str().unwrap(); + assert!( + arguments.contains("commit --allow-empty -m "), + "{arguments}" + ); + assert!(!git.fields.contains_key("stderr")); + + let request = only(&events, "app.core.request.finished"); + assert_eq!(request.op.as_deref(), Some("action-1")); + assert_eq!(request.fields["method"], "git.command"); + assert_eq!(request.fields["trigger"], "vcs.commit"); + assert_eq!(request.fields["path"], ""); + + let serialized = serde_json::to_string(&events).unwrap(); + assert!(!serialized.contains("secret"), "{serialized}"); + assert!( + !serialized.contains(&*root.to_string_lossy()), + "{serialized}" + ); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn failing_git_command_warns_with_exit_code_and_stderr_tail() { + let root = repository("trace-checkout"); + let (response, events) = send(git_command( + &root, + json!({"source": "user"}), + &["checkout", "missing-ref"], + )); + // The raw command reports Git's status as data; the process trace owns the failure. + assert_eq!(response["ok"], true); + assert_ne!(response["data"]["exitCode"], 0); + + let git = only(&events, "vcs.git.command.failed"); + assert_eq!(git.level, LogLevel::Warn); + assert_eq!(git.op.as_deref(), Some("request-1")); + assert_eq!(git.fields["command"], "checkout"); + assert!(git.fields["exit_code"] + .as_i64() + .is_some_and(|code| code != 0)); + let stderr = git.fields["stderr"].as_str().unwrap(); + assert!(stderr.contains("missing-ref"), "{stderr}"); + assert!(!git.fields.contains_key("error")); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn background_reads_trace_only_while_detailed_diagnostics_are_enabled() { + let root = repository("trace-background"); + let request = json!({ + "id": "status-1", + "command": "git.status", + "origin": {"source": "background"}, + "payload": {"root": root} + }) + .to_string(); + let (_, gated) = capture::run(false, || execute_json(&request)); + assert!(gated.is_empty(), "{gated:?}"); + + let (response, enabled) = capture::run(true, || execute_json(&request)); + let response: Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["ok"], true); + let traced = enabled + .iter() + .filter(|event| event.event == "vcs.git.command.traced") + .collect::>(); + assert!(!traced.is_empty(), "{enabled:?}"); + for event in &traced { + assert_eq!(event.level, LogLevel::Debug); + assert_eq!(event.fields["source"], "background"); + assert_eq!(event.op.as_deref(), Some("status-1")); + } + let request = only(&enabled, "app.core.request.traced"); + assert_eq!(request.fields["method"], "git.status"); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 4f75a5743..99b72ca70 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -2,6 +2,7 @@ mod agent; mod build_runtime_versions; mod build_systems; mod community_build_plugins; +mod core_request_traces; mod declarative_plugins; mod detectors; mod file_name_order; diff --git a/scripts/logging/generate-log-events.mjs b/scripts/logging/generate-log-events.mjs index 826800443..74c1405ea 100644 --- a/scripts/logging/generate-log-events.mjs +++ b/scripts/logging/generate-log-events.mjs @@ -97,7 +97,7 @@ export function validateCatalog(catalogs) { keys(event.span, ['failureLevel'], `${event.id} span`); if (!LEVELS.includes(event.span.failureLevel)) fail(`${event.id}: invalid span failureLevel`); if (['duration', 'error'].some(name => Object.hasOwn(event.fields, name)) || ['duration_ms', 'error', 'error_code', 'error_domain'].some(name => encoded.has(name))) fail(`${event.id}: span automatic fields collide`); - } else if (!/(?:started|finished|failed|changed|skipped|missing|received|selected|superseded|hit|inspected|sampled|measured|rejected|updated|forwarded|removed|cleaned|cancelled|initialized|limited|applied|waiting|stalled|timed_out|resolved|logged|shown|interrupted)$/.test(event.id.split('.').at(-1))) fail(`${event.id}: expected outcome name in past tense`); + } else if (!/(?:started|finished|failed|changed|skipped|missing|received|selected|superseded|hit|inspected|sampled|measured|rejected|updated|forwarded|removed|cleaned|cancelled|initialized|limited|applied|waiting|stalled|timed_out|resolved|logged|shown|interrupted|traced|performed)$/.test(event.id.split('.').at(-1))) fail(`${event.id}: expected outcome name in past tense`); declarations.push({ ...event, rate }); const variants = event.span ? ['started', 'finished', 'failed'] : [null]; for (const phase of variants) { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5abb8fe19..28a68e8fd 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -766,6 +766,7 @@ Every request has this shape: "id": "request-id", "operationId": "operation-id", "timeoutMilliseconds": 30000, + "origin": { "source": "user", "trigger": "find-in-files", "operation": "op-7f3a" }, "command": "workspace.search", "payload": {} } @@ -778,6 +779,15 @@ the operation is active. Cancellation and deadlines are checked at command boundaries, workspace traversal points, and Git process waits. They return `cancelled` or `timed_out` in the standard error envelope. +`origin` is optional diagnostics provenance: +`{ source?: "user" | "background" | "unknown", trigger?: string, operation?: string }`. +It never changes command behavior. `trigger` is the stable identifier of the UI +action that caused the request, and `operation` is the correlation identifier +shared by that action and every request it caused. A `trigger` must be 1–64 +bytes of ASCII letters, digits and `._:/-$`, and an `operation` 1–128 bytes of +ASCII letters, digits and `._:/@+-`. A value outside its shape is omitted from +the trace instead of failing the request. See [Execution traces](#execution-traces). + Successful responses contain `ok: true` and `data`. Failed responses contain a stable error code and a user-facing message: @@ -1310,6 +1320,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `diagnostics.planLogRetention` | Plan log cleanup from host-owned file inventories | | `diagnostics.resolveEngineLogSubject` | Resolve the same routing segment and channel used by engine startup, without I/O | | `diagnostics.resolveLogChannel` | Validate channels and resolve safe session-relative destinations | +| `diagnostics.drainCoreEvents` | Atomically take process-owned Core log facts and their dropped count | +| `diagnostics.configureCoreEvents` | Set the DEBUG admission gate for Core log facts from host detailed-diagnostics state | | `diagnostics.redactText` | Redact credentials, tokens, and home-directory paths from diagnostic-bundle text | | `diagnostics.buildManifest` | Shape a deterministic diagnostic bundle manifest from host-gathered environment and file facts | | `plugins.declarative.validate` | Validate and normalize a declarative plugin manifest (plugin contract v1) | @@ -5520,6 +5532,24 @@ only categories, excluding remote addresses, output, and scratch names. {"id":null,"ok":true,"data":{"events":[{"ts":"2026-11-03T08:00:00.000Z","level":"WARN","event":"app.workspace.directory_skipped","channel":"app","fields":{"path":"src/inaccessible","error":"Directory traversal failed","error_code":"permission_denied","error_domain":"core"}}],"dropped":0}} ``` +`diagnostics.configureCoreEvents` accepts `{debug:bool,roots?:[string]}` and +returns `{}`. Unknown fields fail with `invalid_request`. It sets the process-wide +admission gate for DEBUG Core facts: `debug:false` rejects every DEBUG fact, +`debug:true` without `roots` admits DEBUG on every channel root, and +`debug:true` with `roots` admits DEBUG only on the listed roots. The default is +disabled. Rejected DEBUG facts return success to their producer, are never +queued and never count as dropped, so background traces cannot fill the +256-entry queue and displace later WARN or ERROR facts. INFO, WARN and ERROR +are always admitted. Producers that build expensive fields check the gate first. + +```json +{"version":1,"command":"diagnostics.configureCoreEvents","payload":{"debug":true,"roots":["app","vcs"]}} +``` + +```json +{"id":null,"ok":true,"data":{}} +``` + `plugins.declarative.languageServerLaunchPlan.request.logDirectory` is an optional absolute host-owned current-session plugin files directory for `${logDir}`. Language servers and plugin backends use the same expansion. @@ -5550,13 +5580,15 @@ writer installs the Core log waker; its callback schedules the same 100 ms one-shot drain that host events use, so Core and plugin facts recorded without host activity reach disk and live subscribers without an idle poller or second writer. Finishing the session removes the waker before the final drain. -`channelCategory` maps lsp to language, vcs to git and -plugin-host processes to plugins. The selected diagnostic category drives +MacLogStore sends `diagnostics.configureCoreEvents` only when the +detailed-diagnostics state or its channel roots change. `channelCategory` maps +lsp to language, vcs to git and plugin-host processes to plugins. The selected diagnostic category drives `lsp.setDetailedDiagnostics` for active sessions, newly registered sessions and -a one-shot 15-minute expiry. A failed desired state is reported once with +a one-shot 15-minute expiry. A failed desired state, including a failed +`configureCoreEvents`, is reported once with `app.logging.diagnostics_failed`; changing desired state permits another attempt. -The 100-entry v1 message/detail memory buffer remains for LanguageServerLogsView -until PR4; it does not create v1 files. +Language-server evidence has no in-memory v1 buffer on macOS; the log window reads +only v2 session files. `diagnostics.resolveLogChannel` additionally returns optional `pluginFilesPath` for a `plugins//host` channel using Core's `plugin_files_path` rule. macOS @@ -5574,3 +5606,66 @@ Core directory traversal and scratch cleanup reuse PR1's stable category without absolute paths, scratch names or remote addresses. Remote process/upload cleanup uses ClientError categories timeout, closed, transport, spawn, protocol, or remote_; peer prose is excluded. + +### Execution traces + +Core records one terminal fact for each dispatched request and one for each Git +process, and hands both to the host through `diagnostics.drainCoreEvents`. Event +identities and field shapes are declared in `shared/logging/events/app.json` and +`shared/logging/events/vcs.json`; this section defines when each is emitted. + +Request traces use channel `app/core`: `app.core.request.finished` (INFO), +`app.core.request.traced` (DEBUG) and `app.core.request.failed` (WARN). The +dispatcher resolves the trace `op` from the first of `origin.operation`, +`operationId` and `id` that has the `operation` shape, and the source from `origin.source` unless it is absent or `unknown`, then +`gitExecution.source`, then `unknown`. It installs `{op, source}` as thread-local context for the request's +duration, so every Git process the request starts inherits both. An error +response other than `cancelled` is `failed` and carries `error` with domain +`core`. Any other response is `finished` when the source is `user` and `traced` +otherwise; a cancelled response adds `cancelled:true`. Fields are `method`, +`source`, `duration_ms`, the optional payload `operation` selector, `trigger`, +the first absolute path among `root`, `workspaceRoot`, `repositoryRoot`, `projectRoot`, +`rootPath`, `directory` and `path` in the payload, recorded as `path`, and `remote` for +remote workspaces. No other payload content is recorded. `core.ping`, every +`diagnostics.*` command and the per-keystroke, per-frame, polling and watcher +methods listed in `request_trace::is_hot` record nothing. + +Git process traces use channel `vcs/git`: `vcs.git.command.finished` (INFO), +`vcs.git.command.traced` (DEBUG) and `vcs.git.command.failed` (WARN). They are +emitted at the single execution point shared by local and remote workspaces. +A visible command runs through `execute_git` and is returned to the host as an +invocation; an internal read runs through `capture_git` and Core parses its +output. Outcomes are classified in this order: + +1. A `cancelled` runner error is a cancellation; any other runner error is a failure. +2. Exit status 0 is a success. +3. An exit status that the command documents as an answer (for example 1 from + `git diff --quiet` or `git config --get`) is a success. +4. A nonzero exit of an internal read whose source is not `user` is a success at + this layer; the caller decides its meaning, and an error response it returns + is recorded by the request trace. +5. Every other exit is a failure. + +A failure is `failed`. A success or cancellation is `finished` for a visible +command whose source is `user` and `traced` otherwise. Fields are `command`, +`arguments`, `source`, `duration_ms`, and optionally `repository`, `remote`, +`exit_code` and `cancelled`. `arguments` is produced only by `format_arguments`: +commit message values become ``, values of `-c` keys that name +credentials or personal identity become ``, URL user information and +the shared redaction patterns are rewritten, arguments are quoted where needed, +and text beyond 1024 bytes is cut with a trailing `…`. A failure, a cancellation +or a nonzero exit adds `stderr`: the last 8 nonempty lines of the final 16 KiB of standard +error, each redacted and joined with ` | `, keeping the newest 1024 bytes with a +leading `…`. A failure caused by a runner error adds `error`, `error_code` and +`error_domain:"core"`; +the process `exit_code` is never merged into `error_code`. Standard output, +standard input and environment values are never recorded. + +Trace failures are always WARN because Core cannot know whether the host will +retry, degrade or give up. A host that notifies the user about a failure whose +facts are all in a same-`op` trace references that record instead of logging +the failure again. + +```json +{"ts":"2026-11-03T08:00:01.123Z","level":"WARN","event":"vcs.git.command.failed","channel":"vcs/git","op":"op-7f3a","fields":{"command":"fetch","arguments":"fetch --prune origin","repository":"/src/app","source":"user","duration_ms":311,"exit_code":128,"stderr":"fatal: could not read from remote repository."}} +``` diff --git a/shared/logging/README.md b/shared/logging/README.md index 9f937955b..c28e1f742 100644 --- a/shared/logging/README.md +++ b/shared/logging/README.md @@ -1,110 +1,164 @@ # 结构化日志 v2 事件目录 -`shared/logging/events/.json` 是事件声明的唯一来源。macOS 已使用类型化事件和 MacLogStore 写入 v2 会话文件,Windows 仍使用 v1。日志消息是供诊断使用的英文事实,**不走 i18n**;界面上的错误、提示和操作名称仍须走共享 i18n。 +`shared/logging/events/.json` 是事件声明的唯一来源。macOS 使用生成的类型化事件,经 `MacLogStore` 写入 v2 会话文件;Windows 仍使用 v1。日志消息是供诊断使用的英文事实,**不走 i18n**;界面上的错误、提示和操作名称仍然走共享 i18n。 -## 声明与通道 +本文规定声明语法、通道、字段编码与隐私边界、级别与 span、执行轨迹事件、Core 事件队列、引擎宿主接入、辅助 crate、生成器和用法门禁。什么必须记录、怎样设计一条日志和审查清单见 `.agents/skills/develop-lithe/references/logging.md`;决策原因见 `.agents/notes/implemented/architecture/2026-11-03-structured-logging-channels.md`。 -固定根为 `app`、`editor`、`perf`、`lsp`、`vcs`、`run`、`debug`、`terminal`、`remote`、`agent`、`update`、`plugins`。每个根恰有一个 JSON 文件,没有实际调用的根保持空数组,不预造事件。事件 ID 是小写点分的稳定标识,如 `editor.document.save_failed`;结果词使用过去时。`owner` 标明维护模块。 +## 一、声明 -通道由固定根和可选段组成。动态段只能来自插件、provider、server 或内部连接等已校验的 ID,格式为 `^[a-z0-9][a-z0-9._-]{0,63}$`,同时拒绝末尾点号和 Windows 设备名(如 `con`、`nul.txt`、`com1`);禁止显示名称、任意目录、用户输入和命令行。占位符必须引用本事件必填的 `id` 字段。固定段也是目录声明的一部分。Core 与生成器都只接受根、非插件根/段及插件宿主三种形状。插件宿主通道统一为 `plugins/{plugin}/host`。活动文件使用 `.log`,轮转使用 `~1.log`;`~` 不在通道字符集中,因此 `vcs/git.1` 不会与 `vcs/git` 的轮转文件冲突。`_overflow` 使用同样的文件头和轮转规则。Swift 的 `LogSegment` 失败返回 `nil`,不得用未知输入强制解包。 引擎宿主的段通过 `diagnostics.resolveEngineLogSubject` 取得,与启动复用 `SessionLogIdentity::new`;宿主不得实现另一份哈希或身份规则。 +- 固定根为 `app`、`editor`、`perf`、`lsp`、`vcs`、`run`、`debug`、`terminal`、`remote`、`agent`、`update`、`plugins`。每个根恰有一个 JSON 文件,形状为 `{ version, root, events }`;没有实际调用的根保持空 `events`,不预造事件。 +- 事件 ID 是小写点分的稳定标识,如 `editor.document.save_failed`,结果词用过去时。`owner` 标明拥有这次操作结果的模块;由 Core 记录的事件 owner 必须是 `lithe-core`。 +- `message` 是英文单行模板,只能引用本事件声明的必填字段,例如 `Git {command} failed`;可选字段不参与模板。 +- 生成器拒绝未知属性、跨文件重名、生成类型名冲突、保留键和编码后的字段名冲突。`shared/logging/event-catalog.schema.json` 描述静态形状;生成器另外执行通道、引用、span 和磁盘键展开等跨字段语义校验。 -`message` 是英文单行模板,只能引用本事件声明的必填字段,例如 `Operation {command} finished`;可选字段不参与模板。生成器拒绝未知属性、跨文件重名、生成类型名冲突、保留键和编码后的字段名冲突。JSON Schema 描述静态形状;生成器还执行通道、引用、span 和磁盘键展开等跨字段语义校验。 +## 二、通道 -## 字段与隐私 +- 通道由固定根和可选段组成,只接受三种形状:单独的根;非插件根加固定段或动态段;插件宿主 `plugins/{plugin}/host`。固定段也是目录声明的一部分。 +- 动态段只能来自插件、provider、server 或内部连接等已校验的 ID,格式为 `^[a-z0-9][a-z0-9._-]{0,63}$`,同时拒绝末尾点号和 Windows 设备名(如 `con`、`nul.txt`、`com1`)。禁止使用显示名称、任意目录、用户输入和命令行。占位符必须引用本事件必填的 `id` 字段。 +- 活动文件名为 `.log`,轮转文件名为 `~1.log`。`~` 不在通道字符集中,因此 `vcs/git.1` 不会与 `vcs/git` 的轮转文件冲突。`_overflow` 使用同样的文件头和轮转规则。 +- Swift 的 `LogSegment` 校验失败时返回 `nil`,不得对未知输入强制解包。 +- 引擎按角色使用事件族:`lsp.*` 使用 `lsp/{server}`,`vcs.*` 的引擎事件使用 `vcs/{provider}`,`plugins.host.*` 使用 `plugins/{plugin}/host`。Core 依据显式 role 和目录身份字段选择事件,目录模板仍是通道的唯一来源,任何输入都不能改写普通事件的通道。非法段改用稳定的 SHA-256 前缀身份,不丢弃日志,也不影响插件启动。 +- 引擎宿主的段通过 `diagnostics.resolveEngineLogSubject` 取得,与会话启动共用 `SessionLogIdentity::new`。宿主不得实现另一份哈希或身份规则。 -字段名为 `[a-z][a-z0-9_]{0,63}`,保留键为 `seq/event/op/msg/ts/level`。每个字段显式填写 `type`、`required` 和描述语义、单位的 `description`;只有 `text` 可以且必须填写 `reason`,解释为什么结构化字段不能替代上游原文。 +## 三、字段与隐私 -`description` 去除首尾空白后至少 16 个字符,必须说明该字段在本事件里的含义、来源或单位,并尽量给出典型值;生成器拒绝按类型填写的通用短语(例如 `Validated internal identifier`、`Structured failure`、`Count or numeric result`)。目录字段必须由作者显式说明,不提供按类型的默认描述;span 自动字段说明绑定具体操作 ID 和终态。 +字段名为 `[a-z][a-z0-9_]{0,63}`,保留键为 `seq`、`event`、`op`、`msg`、`ts`、`level`。每个字段显式填写 `type`、`required` 和 `description`;只有 `text` 可以并且必须填写 `reason`,说明为什么结构化字段不能代替这段原文。 + +`description` 去除首尾空白后至少 16 个字符,说明该字段在本事件里的含义、来源或单位,并尽量给出典型值。生成器拒绝按类型填写的通用短语(例如 `Validated internal identifier`、`Structured failure`、`Count or numeric result`),也不提供按类型的默认描述。span 自动字段的说明绑定具体操作 ID 和终态。 | 类型 | Swift 参数 | Core 编码与边界 | | --- | --- | --- | | `int` | `Int64` | 有符号整数 | | `bool` | `Bool` | 布尔值 | -| `duration` | `Duration` | 非负整数毫秒,键为 `_ms`;声明名不得自带 `_ms` | +| `duration` | `Duration` | 非负整数毫秒,磁盘键为 `_ms`;声明名不得自带 `_ms` | | `bytes` | `UInt64` | 无符号字节数 | -| `id` | `LogSegment` | 校验后的不透明 ID,可用于通道段 | -| `token` | `String` | ≤64 字节的受控短词,字符为 ASCII 字母数字和 `._:/-$`;例如命令名、阶段或结果枚举,禁止完整命令行 | +| `id` | `LogSegment` | 校验后的不透明 ID,可用作通道段 | +| `token` | `String` | ≤64 字节的受控短词,字符为 ASCII 字母数字和 `._:/-$`,例如命令名、阶段或结果枚举 | | `version` | `String` | ≤64 字节的组件版本,字符为 ASCII 字母数字和 `._+-` | -| `path` | `URL` | 落盘前改写为工作区相对路径或 ``;无法归属的绝对路径不得直接落盘 | -| `error` | `any Error` | 展开为 ``、`_code`、`_domain`;通常字段名就是 `error`,消息须脱敏并限长至 1 KiB,code 可以是整数或稳定 token | -| `text` | `String` | 仅保留不可结构化的上游诊断(stderr、LSP logMessage 等);脱敏、限长至 1 KiB,不保留控制字符 | +| `path` | `URL` | 落盘前改写为工作区相对路径或 ``;无法归属的绝对路径不得直接落盘;上限 4096 字节 | +| `error` | `any Error` | 展开为 ``、`_code`、`_domain`,字段名通常就是 `error`;消息脱敏并限长至 1 KiB,code 可以是整数或稳定 token | +| `text` | `String` | 只用于无法结构化的上游诊断(stderr 行、LSP logMessage)和经 Core 集中格式化函数脱敏的外部进程 argv;脱敏、去除控制字符、限长至 1 KiB | + +源码、编辑缓冲区、文件内容、提交说明正文、环境变量值、进程的 stdout 与 stdin、凭据、终端历史和协议正文没有对应类型,不得塞进 `text`、`error`、token、路径字段或消息模板。外部进程的 argv 只经过 `rust/lithe-core/src/git/command_trace.rs` 的 `format_arguments`,stderr 只保留脱敏后的末尾几行(第五节)。迁移旧日志时拆开拼接消息,只保留结构化的时间、计数、受控结果词和错误类别。`text` 的理由不能写成“方便调试”。 -源码、环境变量值、命令输出和凭据没有对应类型。不得把这些内容塞进 `text`、`error`、token 或路径字段。迁移时拆开现有拼接消息,只保留结构化的时间、计数、受控结果词和错误类别;stderr 是明确的上游诊断例外,仍须经过脱敏和截断。自由文本的理由不能写成“方便调试”。 +Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落盘。Core 先对 `error`、`text` 输入脱敏、删除控制字符、按 UTF-8 字符边界截断到 1024 字节,再由 `catalog::validate_fields(id, fields)` 校验磁盘键展开后的类型、必填、未知字段、长度和错误组件完整性;这一步校验形状,不代替隐私脱敏。macOS 的跨边界适配器连接 Core 的脱敏、格式化和写入路径,不得另写 Swift 格式化或隐私策略。 -Swift 的事件值是**未经脱敏的内存输入**,不能自行序列化落盘。Core 在目录校验前对 error/text 输入先脱敏、删除控制字符、按 UTF-8 字符边界截断至 1024 字节,再由严格的 `catalog::validate_fields(id, fields)` 校验磁盘键展开后的字段类型、必填、未知字段、长度和错误组件完整性;它不替代隐私脱敏。`plugins.grammar.load_failed` 的语言字段是可选 `version`,允许 `c++` 等合法语言 ID;超长身份只省略语言字段,不省略插件失败事件。`path` 上限 4096 字节;error/text 上限 1024 字节。macOS 跨边界适配器连接 Core 脱敏、格式化和写入路径,禁止另写 Swift 格式化或隐私策略。 +可选身份字段超长时只省略该字段,不省略整条事件。例如 `plugins.grammar.load_failed` 的语言字段是可选 `version`,允许 `c++` 等合法语言 ID;语言 ID 超长时省略语言字段,插件失败事件照常记录。 -## 级别、span 与限流 +## 四、级别、span 与 rate -- `debug`:性能数据、缓存和内部诊断,正常运行可关闭。 +- `debug`:性能数据、缓存、内部诊断和后台执行轨迹,只在详细诊断开启时落盘。 - `info`:正常生命周期和操作结果。 -- `warn`:可恢复失败、功能降级或上游警告。 -- `error`:操作失败。禁止 `warning` 别名。 +- `warn`:可恢复失败、功能降级、上游警告,以及 Core 执行轨迹的失败。 +- `error`:宿主确认用户请求最终失败。不允许使用 `warning` 别名。 -目录中的 `span: { "failureLevel": "error" }` 将基事件展开为 `.started`、`.finished`、`.failed`。started 固定为 debug;生成的 `begin` 总是发出它,由写入端在入队前按级别和详细诊断通道过滤,正式版开启详细诊断后也能看到。finished 使用声明的 level,failed 使用 failureLevel;两种终态自动增加逻辑 `duration`(磁盘为 `duration_ms`),failed 自动增加结构化 `error`。不允许声明与这些自动字段冲突的键。 +目录中的 `span: { "failureLevel": "error" }` 把基事件展开为 `.started`、`.finished`、`.failed`。started 固定为 debug,生成的 `begin` 总会发出它,由写入端在入队前按级别和详细诊断通道过滤,因此正式版开启详细诊断后也能看到。finished 使用声明的 `level`,failed 使用 `failureLevel`;两种终态自动增加逻辑字段 `duration`(磁盘键 `duration_ms`),failed 还自动增加结构化 `error`。不得声明与这些自动字段冲突的键。 -生成的 span 由一个调用方值所有;`finish` / `fail` 是 mutating,第一次终态生效,后续调用忽略。不要复制 span 以产生多份生命周期。`LogSpanClock` 可注入单调时间,默认使用 `ContinuousClock`;不使用墙钟差值。`operationID` 独立于普通字段,传给持久化层的 `op`,不得重新定义成目录字段。必填上下文在 begin 提供;exit_code、outcome 等可选结果在终态提供。 +生成的 span 由一个调用方值拥有;`finish`、`fail` 是 mutating,第一次终态生效,后续调用被忽略。不要复制 span 来产生多份生命周期。`LogSpanClock` 可注入单调时间,默认使用 `ContinuousClock`,不使用墙钟差值。`operationID` 独立于普通字段,传给持久化层成为 `op`,不得重新声明成目录字段。必填上下文在 begin 时提供,`exit_code`、`outcome` 等可选结果在终态时提供。 -默认每事件、每通道每分钟 **60 条**,可显式声明 1…60000 的 `rate`;Git 高频生命周期为 600,LSP 为 120。rate 目前是元数据,由后续写入器执行;本 PR 不安装定时器、不增加后台任务、不改变运行限流。后续应在发送侧合并高频记录,使用有界状态维护被丢弃计数,不通过绕过日志系统补写。 +`rate` 声明每个事件、每个通道每分钟的条数上限,取值 1 到 60000,默认 60。执行轨迹(Git 命令、Core 请求、用户操作)为 600,LSP 为 120,辅助 crate 的转发 accept 与 bridge 失败为 12。rate 目前只是目录元数据,写入器还不执行它;以后执行时在发送侧合并高频记录,用有界状态维护丢弃计数,不通过绕过日志系统补写。 -## 新增与迁移 +## 五、执行轨迹事件 -1. 先调查真实调用,选择根和 owner;无实际来源不新增事件。 -2. 在对应 JSON 中声明稳定 ID、通道模板、英文事实、级别和字段;成对操作优先 span。检查隐私和动态段来源。 -3. 运行生成器,核对 Swift 参数、Rust 字段表;禁止手改生成文件。 -4. 接入迁移时使用类型化事件,删除相同文件/规则已修复的基线计数。用法门禁既拒绝增量,也拒绝过大的旧基线。 -5. 运行下列检查。生成稳定排序和 LF,不读取运行时 JSON。 +执行轨迹由三族事件组成,用同一个 op 串起来: -```sh -node scripts/logging/generate-log-events.mjs -node scripts/logging/generate-log-events.mjs --check -node --test scripts/logging/test-log-events.mjs -node scripts/logging/verify-log-usage.mjs -``` +| 事件 | 通道 | 级别 | 字段 | +| --- | --- | --- | --- | +| `app.action.performed` | `app` | info | `action`、`surface` | +| `app.core.request.finished` / `.traced` / `.failed` | `app/core` | info / debug / warn | `method`、`source`,可选 `operation`、`trigger`、`path`、`remote`,`duration`;finished 与 traced 可带 `cancelled`,failed 带 `error`(domain 为 `core`) | +| `vcs.git.command.finished` / `.traced` / `.failed` | `vcs/git` | info / debug / warn | `command`、`arguments`、`source`,可选 `repository`、`remote`、`exit_code`、`stderr`,`duration`;finished 与 traced 可带 `cancelled`,failed 可带 `error` | -`--check` 只读比较,不修复漂移,缺失或不同即非零退出;生成器先完成所有目录校验和输出规划,再第一次写文件。Swift target 自动发现 `Logging/` 源目录,无需 Package.swift 显式列举。 +- `app.action.performed` 由 `UserActionTrace` 在最外层用户入口记录,owner 为 `LitheModuleAPI`。`action` 是稳定的操作 id,`surface` 是 `UserActionSurface` 的取值。它的 op 绑定到 `UserActionContext.current`,桥接层据此给 Core 请求附上 `origin`。 +- Core 请求轨迹在 `rust/lithe-core/src/runtime/dispatcher.rs` 的请求分发处记录,实现在 `rust/lithe-core/src/diagnostics/request_trace.rs`。用户来源的成功或取消记 finished,后台或未知来源的成功或取消记 traced,失败记 failed。`request_trace::is_hot` 列出的按键、每帧、轮询和监听批次方法,以及全部 `diagnostics.*` 和 `core.ping`,不记录。 +- Git 进程轨迹在 `rust/lithe-core/src/git/command_trace.rs` 的执行点记录,本地与远程工作区都经过这里。可见命令指经 `execute_git` 执行、作为 invocation 返回给宿主并显示在 Git 控制台的命令;内部读取指 Core 经 `capture_git` 执行、自己解析输出的命令。来源为用户的可见命令,成功或取消记 finished;其余成功或取消记 traced,包括用户操作中的内部读取;失败记 failed。退出码表示“回答”的情形(`exit_is_answer`),以及来源不是用户的内部读取的非零退出,不算失败;后者由调用方判断含义,调用方返回错误时由请求轨迹记录失败。 +- `arguments` 由 `format_arguments` 生成:提交说明值换成 ``,`-c` 中凭据和个人身份键的值换成 ``,URL 用户信息和共享脱敏规则覆盖的内容被改写,超过 1 KiB 时在末尾截断并以 `…` 标记。 +- `stderr` 只在失败或非零退出时出现:取进程最后 16 KiB 输出中最后 8 个非空行,逐行脱敏后用 ` | ` 连接,超过 1 KiB 时丢弃最早的内容并以 `…` 标记。 +- Core 轨迹的失败固定为 WARN,因为 Core 不知道宿主会重试、降级还是放弃。宿主提示的事实都在同一 op 的 Core 失败轨迹里时,提示用 `LogRecordReference(channel:event:op:)` 引用它,宿主不再记录;宿主只为降级、放弃、重试耗尽、残留等 Core 不掌握的事实记录自己的终态。 -Swift API 示例(`typedSink` 由迁移 PR 提供;参数按字段名排序): +## 六、Core 事件队列与宿主写入 -```swift -let provider = LogSegment("git")! -var span = VcsGitCommandSpan.begin(command: "fetch", provider: provider, emit: typedSink) -span.finish(exitCode: 0) -// 失败:span.fail(error, exitCode: 128) -``` +- Rust 侧没有请求事件流可用的事实(执行轨迹、析构、后台清理、辅助 crate 失败)进入 `diagnostics::core_events` 的进程级有界队列,最多 256 条。`record` 校验 owner、规范化并脱敏字段后入队,不做文件 I/O。队列满时新事实只计入丢弃数;同一批次里事件、通道、字段和 op 都相同的事实只保留一条,其余计入丢弃数。 +- DEBUG 事实在入队前过滤:宿主通过 `diagnostics.configureCoreEvents { debug, roots }` 告诉 Core 详细诊断是否开启、开启哪些通道根,`roots` 省略表示全部根,默认关闭。未开启的 DEBUG 事实直接返回成功,不入队、不计入丢弃数。构造字段前先调用 `core_events::accepts(level, root)`,被拒绝的事实只花一次分支判断。 +- `MacLogStore` 在详细诊断状态变化时调用 `configureCoreEvents`,只在开关或通道根集合变化时发送;调用失败时记录一次 `app.logging.diagnostics_failed`。 +- 写入器在每次写入批次、flush 和退出时排空一次。没有其他宿主活动时,由 Core 的唤醒回调安排排空:drain 之后的第一条事实或第一次丢弃计数触发一次回调,同一个 drain 周期内的后续变化合并。只有组合根创建的应用写入器调用 `MacLogStore.observeCoreFacts()` 安装回调,回调复用宿主事件已有的 100 毫秒单次排空调度;`finishSession` 先移除回调,再做最后一次排空。回调不得重入 Core。不得为 Core 队列增加空闲轮询或独立写入器。 +- 排空得到的 `CoreLogRecord` 原样交给统一的 `LogSink`:保留 `ts` 和 `op`,由写入器分配 `seq`,继续以 `validateCatalog: true` 写入。 -Rust 通过静态 `EVENTS`(按 ID 排序)和二分查找 `event_by_id` 暴露元数据,不分配运行时目录缓存。生成表使用路径子模块载入:Rust `include!` 无法接受仓库注释门禁要求的首行 `//!`,子模块兼容该门禁,无需豁免或放宽检查。 +## 七、引擎宿主 -## 用法门禁 +- 启动者显式声明 role、真实 `pluginId` 和 VCS `logSubject`;宿主不解析 providerID。 +- stderr 读取器限制每行 4 KiB、每秒 200 行,之后再走 `text` 的 1 KiB 脱敏规范化。与 v1 相比唯一的兼容变化是从任意 trim 的读取块改为逐行文本:保留行内空格,移除 LF 和末尾 CR,并应用已声明的长度上限和限流。 +- 上游明确给出的严重度通过独立的目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类的 stderr 是 DEBUG 排障证据,流名称不表示警告;实际的进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。 +- 插件自身使用独立协商的 `litheLogging` 1 和 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额和 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) 为准。 +- 上游 trace 默认关闭。宿主通过 `lsp.setDetailedDiagnostics` 对活动会话和新会话显式开启,15 分钟后单次到期关闭。详细诊断类别与通道根的对应为 `lsp` → language、`vcs` → git、`plugins` → plugins。 +- 详细诊断只接纳 `$/logTrace.message` 摘要;`verbose` 可能包含原始 RPC 载荷和文档源码,禁止进入 v2 `text`。凭据脱敏和 1 KiB 限长不代表源码可以落盘。`initialized` 之前收到的开关只保存目标状态,Core 在 `initialized` 之后发送通知。 +- v1 的 detail、message、level 保持兼容原文;v2 字段通过独立的 `EngineLogInput` 传递,禁止从 detail 反解。原始通知 JSON、`projectCount=N` 和 navigation 格式不变。macOS 日志窗口统一读取 v2 会话文件和写入器的实时批次,不写 v1 文件。 +- Core 的生命周期事实挂在原有 `type: stateChanged` 运行时事件上,复用 `prepare_catalog_event` 校验,不额外增加 v1 日志行。Swift 在事件分支之前转发任何携带 `coreLog` 的事件一次,语言服务、VCS、构建、工具链和后端都适用。`*.session.updated` 的 state 指 Core 会话生命周期,owner 是 `lithe-core`,宿主不再重复发同一状态事实。 +- 宿主在实际调用点发 `lsp.session.stage_changed`,阶段闭集为 requested、resolving、registered、stopping、cancelled;缓存清理成功发 `lsp.cache.cleaned(removed)`,它与 Core 配置键清理的 `configuration.cleaned` 是不同事实。两者为 DEBUG,因为它们解释内部启动编排和缓存维护,不需要常态落盘。ready 和 terminated 由 Core 的 `protocol.initialized` 和 `session.updated` 表达。阶段、缓存、degradation 和 issue 事件统一使用 Core 计算的段;manager 按 providerID 缓存成功或失败结果,最多 128 项,满时清空;失败只跳过对应的宿主 v2 事件,不改变原有行为。 +- 本地启动前,`MacLogStore` 用 Core 的 `plugin_files_path` 创建当前会话的 `sessions//plugins//files/`,目录权限 0700,用相对描述符和 no-follow 遍历防止符号链接越界。该目录只属于当前日志会话,不注册为跨工作树可复用的构建资源,不写 bundle 或安装目录。存储不可用时不传 `logDirectory`,使用 `${logDir}` 的模板按现有 `invalidRequest` 失败。远程进程不能写本机日志目录,因此同样不传 `logDirectory` 并拒绝该变量。安装验证只用未创建的占位目录校验展开,不启动进程也不写入。 -门禁扫描 `macos/Sources`、`rust/lithe-core/src`、`rust/lithe-agent-host/src`、`rust/lithe-git-host/src`、`rust/lithe-ide-host/src` 和 `rust/lithe-remote/src`。排除 `rust/lithe-remote/src/server/`、`rust/lithe-remote/src/bin/` 和 `rust/lithe-ide-host/src/bin/`:它们在远端主机或独立进程运行,stderr 由宿主进程处理,后续单独迁移。 +## 八、辅助 crate -门禁分两类规则,都只扫描生产代码:词法遮蔽注释和字符串内容,排除 `Tests/`、`tests/`、`tests.rs`、文件名以 `_tests.rs` 结尾的 Rust 文件及明确 `#[cfg(test)]` 或 `#[cfg(all(test, …))]` 项;只检查列出的直接写法,不跨文件追踪别名或动态调用。 +- `rust/lithe-agent-host`、`rust/lithe-git-host`、`rust/lithe-ide-host` 和 `rust/lithe-remote` 的客户端链接进 Core,各自通过 `failure_log` 模块交出失败事实,依赖方向保持不变。sink 未注入时只增加原子计数,不写 stderr。 +- Core 的 `diagnostics/helper_failures` 是唯一的安装处。JSON dispatcher、独立 ACP FFI open、直接 IDE dispatcher 和 AskPass 入口在调用辅助 crate 前都经过它的 `Once`。sink 只转换标量字段,之后走相同的目录校验、脱敏和 256 条 Core 队列。这些事件的 owner 都是 `lithe-core`,错误只保留固定摘要和类别。 +- 辅助 crate 的收尾只记录丢失数据、能力或清理结果的意外错误。已断开的连接、已退出的进程,以及远程关闭或取消时的 Disconnected、Timeout,在类型化辅助函数里豁免;ACP Cancel 的 InternalError 无法区分正常断连与序列化失败,按预期缺失豁免。转发 accept 与 bridge 失败可能逐连接发生,目录 rate 为 12;`rust/lithe-remote/src/client/forward.rs` 在发送侧让每一族失败在进程内每 5 秒最多记录一条,并发转发连接也共用这个限制,正常断连不占日志配额。强制终止后进程仍未退出的最终超时保留 WARN。 -- 绕过日志:Swift 的 `NSLog`、stderr 写入、`os_log`、导入 `os.log` 后的 `Logger`、`.record(level:)`、`print`/`debugPrint`;Rust 的 `eprintln!`、`println!`、`dbg!`。这些写法不经过目录和脱敏,只能改成生成事件;确属机器协议时进 `log-usage-allowlist.json`。这类写法没有存量;Rust 的非 LSP 事实通过 256 条的 Core 队列交给宿主。 -- 吞掉失败:Swift 语句级 `try?`(含 `_ = try?`)、块体只有 `return`/`break`/`continue` 的 `catch`;Rust 语句级 `.ok();` 和 `let _ = …;`。失败要么处理并记录事件,要么在同一行或上一行写 `// log-exempt: <原因>`。原因至少 3 个非空白字符,必须属于日志参考页的四类封闭例外;空原因本身报错。标记只对这四条规则生效,不能豁免绕过日志的写法。 +## 九、目录遍历与清理事件 -没有存量额度:门禁引入时的全部存量已经迁移完毕,任何不在 allowlist 里的违规都直接失败,必须改成记录事件,或者在属于四类例外时写豁免标记。`log-usage-allowlist.json` 只接受绕过日志的规则,用于确属机器协议的永久例外,必须有具体的协议或架构理由和精确计数;丢弃失败的规则不能进 allowlist。计数低于实际出现次数时同样失败,防止空出来的名额被新写法占用。当前 allowlist 为空。机器性能标记迁移时需同步其消费者,不能直接删除测试依赖的协议输出。 +- 跳过无法访问的目录分别记录为 `app.search.directory_skipped` 和 `app.workspace.directory_skipped`;scratch 目录清理失败记录为 `app.workspace.scratch_cleanup_failed`。`error` 只含固定消息和稳定类别。 +- 不再声明项目级或远程级的重复变体。远程清理只记录 `ClientError` 的稳定分类,不复制错误正文。 -CI 的 Draft PR 只跑 Linux,ready PR 和 preview push 跑三平台。输入和已提交生成物均由 Git 持有,每个 worktree 本地重建、检查;没有跨工作树可复用 identity stamp,不复制其他工作树的生成物。这些类型无运行时 I/O、不写 app bundle、插件资源或安装目录,不影响签名与 Sparkle delta。 +## 十、新增与迁移 -PR3 引擎采用按角色事件族:`lsp.*` 使用 `lsp/{server}`,`vcs.*` 使用 `vcs/{provider}`,`plugins.host.*` 使用 `plugins/{plugin}/host`。Core 从显式 role 与 catalog 身份字段选事件,目录模板仍是通道唯一来源;不允许任意输入 channel 改写普通事件。非法段使用稳定的 SHA-256 前缀身份,不丢弃日志或影响插件启动。stderr 的 reader 限制为每行 4 KiB、每秒 200 行,再走目录 text 的 1 KiB 脱敏规范化。trace 默认关闭,宿主通过 `lsp.setDetailedDiagnostics` 显式启用;`lsp.session.updated` 的 owner 已转交 Core,宿主不再重复发同一状态事实。 +1. 先调查真实调用,选择根和 owner;没有实际来源不新增事件。 +2. 在对应 JSON 中声明稳定 ID、通道模板、英文事实、级别和字段;成对操作优先使用 span。检查隐私和动态段来源。 +3. 运行生成器,核对 Swift 参数和 Rust 字段表;不得手改生成文件。 +4. 接入时使用类型化事件。不再有调用点的事件从目录删除并重新生成。 +5. 运行下列检查。生成结果稳定排序、使用 LF,不读取运行时 JSON。 -上游明确严重度通过独立目录事件保留:`window/logMessage` 和 `window/showMessage` 的 type 1/2/3/4 分别为 ERROR/WARN/INFO/DEBUG,未知类型为 DEBUG。未分类 stderr 是 DEBUG 排障证据,流名称不表示警告;实际进程或协议失败由 `*.session.failed` 记录 ERROR,正常关闭仍是生命周期状态。插件自身使用独立协商的 `litheLogging` 1 与 `lithe/log`,由 Core 把四种受控事实映射到插件目录事件,不能再用 stderr 或任意自由消息代替。协议字段、op、限额与 fixture 以 [插件契约](../contracts/plugins/v1/README.md#structured-plugin-diagnostics-logging-protocol-1) 为准。 +```sh +node scripts/logging/generate-log-events.mjs +node scripts/logging/generate-log-events.mjs --check +node --test --test-timeout=60000 scripts/logging/test-log-events.mjs +node scripts/logging/verify-log-usage.mjs +``` + +`--check` 只读比较,不修复漂移,有缺失或差异即以非零状态退出。生成器先完成全部目录校验和输出规划,再写第一个文件。Swift target 自动发现 `Logging/` 源目录,不需要在 `Package.swift` 中逐个列出。 -详细诊断只接纳 `$/logTrace.message` 摘要;`verbose` 可能包含原始 RPC 载荷和文档源码,禁止进入 v2 text。凭据脱敏和 1 KiB 限长不代表源码可以落盘。初始化完成前的开关只保存目标状态,Core 在 `initialized` 之后发送通知。 +## 十一、API 示例 + +Swift 的用户入口用 `UserActionTrace` 包装;生成事件的初始化参数按字段名排序: + +```swift +// 菜单项:记录 app.action.performed,并把操作上下文交给后续的 Core 请求 +UserActionTrace.run("open-project", surface: .menu, sink: UserActionTrace.applicationSink) { + model.openProject() +} + +// SwiftUI 按钮:包装回调,surface 默认为 .button +Button(action: UserActionTrace.action("workspace.remote.test") { model.testConnection() }) { + Text("Test") +} + +// 宿主自己的事实:在同一 op 下记录,并取得可以交给界面提示的引用 +let scope = LogScope(sink: sink, operationID: operationID) +let reference = scope.emitReferenced(AppLoggingDiagnosticsFailed(error: failure)) +``` -PR3 macOS 将 CoreLogRecord 原样交给统一 LogSink/MacLogStore,按现有 drain、flush 和退出节奏一次排空 Core 队列,保留 ts/op、分配 seq,并继续 validateCatalog:true;不新增空闲轮询或独立 writer。启动者显式声明 role、真实 pluginId 与 VCS logSubject;宿主不解析 providerID。详细诊断类别沿用 lsp→language、vcs→git、plugins→plugins,对活动和新会话发送 lsp.setDetailedDiagnostics,15 分钟单次到期关闭,失败以 app.logging.diagnostics_failed 记录一次。 +Rust 通过静态 `EVENTS`(按 ID 排序)和二分查找的 `event_by_id` 暴露元数据,不分配运行时目录缓存。生成表通过路径子模块载入:Rust 的 `include!` 不能接受仓库注释门禁要求的首行 `//!`,子模块能满足该门禁,不需要豁免或放宽检查。 -v1 detail/message/level 保持兼容原文,v2 字段通过独立 EngineLogInput 传递,禁止从 detail 反解。stderr 唯一兼容变化是从任意 trim 的读取块改为逐行文本;保留行内空格、移除 LF/末尾 CR,并应用已声明的有界长度和限流。原始通知 JSON、projectCount=N 和 navigation 格式不变。PR4 已删除 LanguageToolingSessionManager 的 100 条 v1 内存缓冲和旧语言服务日志标签页;macOS 日志窗口统一读取 v2 会话文件与写入器的实时批次,不写 v1 文件。 +## 十二、用法门禁 -本地启动前由 MacLogStore 用 Core plugin_files_path 创建当前 sessions//plugins//files/,目录 0700,描述符相对 no-follow 遍历防止符号链接越界;只属于当前日志会话,不注册为跨工作树可复用构建资源,不写 bundle 或安装目录。存储不可用时不传 logDirectory,使用 ${logDir} 的模板按现有 invalidRequest 失败。远程进程不能写本机日志目录,因此同样不传并拒绝该变量。安装验证只使用未创建的占位目录校验展开,不启动或写入。 +`scripts/logging/verify-log-usage.mjs` 扫描 `macos/Sources`、`rust/lithe-core/src`、`rust/lithe-agent-host/src`、`rust/lithe-git-host/src`、`rust/lithe-ide-host/src` 和 `rust/lithe-remote/src`。排除 `rust/lithe-remote/src/server/`、`rust/lithe-remote/src/bin/` 和 `rust/lithe-ide-host/src/bin/`:它们在远端主机或独立进程中运行,stderr 由宿主进程处理,以后单独迁移。 -目录遍历与 scratch 清理复用 PR1 预留 app.search.directory_skipped、app.workspace.directory_skipped、app.workspace.scratch_cleanup_failed,error 仅固定消息和稳定类别;删除重复 app.project.directory_skipped、remote.scratch.cleanup_failed。远程 cleanup 仅记录 ClientError 稳定分类,不复制错误正文。 +门禁只扫描生产代码:词法上遮蔽注释和字符串内容,排除 `Tests/`、`tests/`、`tests.rs`、文件名以 `_tests.rs` 结尾的 Rust 文件,以及明确标注 `#[cfg(test)]` 或 `#[cfg(all(test, …))]` 的项。它只检查列出的直接写法,不跨文件追踪别名或动态调用。规则分两类: -PR3 审查修补:Core 生命周期事实挂在原有 `type:stateChanged` 运行时事件上,复用日志事实的 `prepare_catalog_event` 校验,不额外增加 v1 log 行。Swift 在事件分支前转发任何携带 coreLog 的事件一次,语言服务、VCS、构建、工具链和后端均适用。`*.session.updated` 的 state 指 Core 会话生命周期,owner 仍是 lithe-core。 +- **绕过日志**:Swift 的 `NSLog`、stderr 写入、`os_log`、导入 `os.log` 后的 `Logger`、`.record(level:)`、`print`/`debugPrint`;Rust 的 `eprintln!`、`println!`、`dbg!`。这些写法不经过目录和脱敏,只能改成生成事件;确属机器协议的永久例外写进 `scripts/logging/log-usage-allowlist.json`,给出具体的协议或架构理由和精确计数。 +- **丢弃失败**:Swift 语句级 `try?`(含 `_ = try?`)、块体只有 `return`/`break`/`continue` 的 `catch`;Rust 语句级 `.ok();` 和 `let _ = …;`。失败要么处理并记录事件,要么在同一行或上一行写 `// log-exempt: <原因>`。原因至少 3 个非空白字符,必须属于日志参考页的四类封闭例外;空原因本身报错。标记只对这四条规则生效,不能豁免绕过日志的写法,这类规则也不能进 allowlist。 -宿主在实际调用点发 `lsp.session.stage_changed`,阶段闭集为 requested/resolving/registered/stopping/cancelled;缓存清理成功发 `lsp.cache.cleaned(removed)`,与 Core 配置键清理的 configuration.cleaned 是不同事实。两者选 DEBUG,因为它们解释内部启动编排和缓存维护,不需要常态落盘。ready/terminated 由 Core 的 protocol.initialized 和 session.updated 表达,宿主仅保留原 v1 缓冲行。阶段、缓存、degradation/issue 事件统一使用 Core 计算的段;manager 按 providerID 缓存成功或失败结果,最多 128 项,满时清空缓存,失败仅跳过对应宿主 v2 事件,不改变原行为。 +门禁没有存量额度,任何违规都直接失败。allowlist 的计数必须等于实际出现次数,少于或多于实际都失败,防止空出来的名额被新写法占用。当前 allowlist 为空。迁移机器读取的性能标记时同步修改它的消费者,不能直接删除测试依赖的协议输出。 -四个链接进 Core 的辅助库通过各自的 `failure_log` 注入函数指针交付失败事实,依赖方向保持不变;未注入时只增加原子计数,不写 stderr。Core 的 `diagnostics/helper_failures` 是唯一安装处,JSON dispatcher、独立 ACP FFI open、直接 IDE dispatcher 和 AskPass 入口在调用 helper 前统一经过该 `Once`。sink 仅转换标量字段,随后走相同目录校验、脱敏和 256 条 Core 队列。新增事件 owner 均为 `lithe-core`,错误只保留固定摘要和类别。转发 accept/bridge 失败的目录 rate 为每分钟 12 条。 +## 十三、CI、工作树与发行包 -辅助库收尾只记录丢失数据、能力或清理结果的意外错误。已断开的连接、已退出进程和远程关闭/取消的 Disconnected 或 Timeout 在类型化 helper 中豁免;ACP Cancel 的 InternalError 无法区分正常断连与序列化失败,按预期缺失豁免。转发 accept/bridge 的 rate=12 只限制仍需记录的失败,正常断连不占日志配额;强制终止后进程仍未退出的最终超时保留 WARN。 +- Draft PR 只运行 Linux 检查;ready PR 和 preview push 运行三平台检查。 +- 目录输入和已提交的生成物都由 Git 管理,每个 worktree 在本地重建和检查;它们没有跨工作树可复用的 identity stamp,不复制其他工作树的生成物。 +- 生成的类型没有运行时 I/O,不写 app bundle、插件资源或安装目录,不影响代码签名和 Sparkle delta。日志文件位于平台可写存储适配器提供的目录。 diff --git a/shared/logging/events/app.json b/shared/logging/events/app.json index e577f6cd9..355ae0ab7 100644 --- a/shared/logging/events/app.json +++ b/shared/logging/events/app.json @@ -698,6 +698,176 @@ }, "rate": 60, "owner": "lithe-core" + }, + { + "id": "app.core.request.finished", + "channel": "app/core", + "level": "info", + "message": "Core request {method} finished", + "fields": { + "method": { + "type": "token", + "required": true, + "description": "Core protocol method name the host requested, e.g. git.write or workspace.repositories" + }, + "operation": { + "type": "token", + "required": false, + "description": "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective request source after merging the host origin and Git execution options: user, background or unknown" + }, + "trigger": { + "type": "token", + "required": false, + "description": "Stable UI action id that the host reported as the cause of this request, never a localized title" + }, + "path": { + "type": "path", + "required": false, + "description": "First absolute workspace, repository or project root named in the request payload" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the request ran against a remote workspace host" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time Core spent executing the request, from parsing to the final response" + }, + "cancelled": { + "type": "bool", + "required": false, + "description": "Present and true when the request ended because the host cancelled it" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "app.core.request.traced", + "channel": "app/core", + "level": "debug", + "message": "Core request {method} traced", + "fields": { + "method": { + "type": "token", + "required": true, + "description": "Core protocol method name the host requested, e.g. git.write or workspace.repositories" + }, + "operation": { + "type": "token", + "required": false, + "description": "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective request source after merging the host origin and Git execution options: user, background or unknown" + }, + "trigger": { + "type": "token", + "required": false, + "description": "Stable UI action id that the host reported as the cause of this request, never a localized title" + }, + "path": { + "type": "path", + "required": false, + "description": "First absolute workspace, repository or project root named in the request payload" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the request ran against a remote workspace host" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time Core spent executing the request, from parsing to the final response" + }, + "cancelled": { + "type": "bool", + "required": false, + "description": "Present and true when the request ended because the host cancelled it" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "app.core.request.failed", + "channel": "app/core", + "level": "warn", + "message": "Core request {method} failed", + "fields": { + "method": { + "type": "token", + "required": true, + "description": "Core protocol method name the host requested, e.g. git.write or workspace.repositories" + }, + "operation": { + "type": "token", + "required": false, + "description": "Payload operation selector when the method multiplexes several actions, e.g. fetch for git.write" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective request source after merging the host origin and Git execution options: user, background or unknown" + }, + "trigger": { + "type": "token", + "required": false, + "description": "Stable UI action id that the host reported as the cause of this request, never a localized title" + }, + "path": { + "type": "path", + "required": false, + "description": "First absolute workspace, repository or project root named in the request payload" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the request ran against a remote workspace host" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time Core spent executing the request, from parsing to the final response" + }, + "error": { + "type": "error", + "required": true, + "description": "Core error returned to the host for this request, with its stable code and domain" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "app.action.performed", + "channel": "app", + "level": "info", + "message": "User action {action} performed", + "fields": { + "action": { + "type": "token", + "required": true, + "description": "Stable id of the user action that started the work, never a localized menu or button title" + }, + "surface": { + "type": "token", + "required": true, + "description": "UI surface that invoked the action: menu, shortcut, palette, toolbar, button, context_menu, notification or plugin" + } + }, + "rate": 600, + "owner": "LitheModuleAPI" } ] } diff --git a/shared/logging/events/vcs.json b/shared/logging/events/vcs.json index e266a8d6f..4f4344662 100644 --- a/shared/logging/events/vcs.json +++ b/shared/logging/events/vcs.json @@ -2,72 +2,6 @@ "version": 1, "root": "vcs", "events": [ - { - "id": "vcs.git.command", - "channel": "vcs/{provider}", - "level": "info", - "message": "Git {command}", - "fields": { - "provider": { - "type": "id", - "required": true, - "description": "VCS provider id that owns the repository, e.g. git" - }, - "command": { - "type": "token", - "required": true, - "description": "Git subcommand that ran, e.g. fetch or push" - }, - "exit_code": { - "type": "int", - "required": false, - "description": "Exit status of the git process; absent when it could not start" - }, - "failure_code": { - "type": "token", - "required": false, - "description": "Stable Core Git failure code, separate from the native process exit status." - } - }, - "rate": 600, - "owner": "LitheGitModule", - "span": { - "failureLevel": "error" - } - }, - { - "id": "vcs.git.request", - "channel": "vcs/{provider}", - "level": "info", - "message": "Git request {command}", - "fields": { - "provider": { - "type": "id", - "required": true, - "description": "VCS provider id that owns the repository, e.g. git" - }, - "command": { - "type": "token", - "required": true, - "description": "Git service request being executed, e.g. fetch or history" - }, - "exit_code": { - "type": "int", - "required": false, - "description": "Exit status reported for the request result; absent when no git process result is available" - }, - "failure_code": { - "type": "token", - "required": false, - "description": "Stable Core Git failure code, separate from the native process exit status." - } - }, - "rate": 600, - "owner": "LitheGitModule", - "span": { - "failureLevel": "error" - } - }, { "id": "vcs.roots.fallback_selected", "channel": "vcs", @@ -471,6 +405,177 @@ "rate": 60, "owner": "lithe-core" }, + { + "id": "vcs.git.command.finished", + "channel": "vcs/git", + "level": "info", + "message": "Git {command} finished", + "fields": { + "command": { + "type": "token", + "required": true, + "description": "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent" + }, + "arguments": { + "type": "text", + "required": true, + "description": "Full Git argument list after git, joined by spaces and quoted where needed", + "reason": "Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB" + }, + "repository": { + "type": "path", + "required": false, + "description": "Repository root or working directory the Git process ran in" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective Git execution source: user, background or unknown" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the Git process ran on a remote workspace host" + }, + "exit_code": { + "type": "int", + "required": false, + "description": "Native exit status of the Git process; absent when it never started or ended by signal" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time from spawning the Git process until its exit status was collected" + }, + "cancelled": { + "type": "bool", + "required": false, + "description": "Present and true when the Git process ended because its request was cancelled" + }, + "stderr": { + "type": "text", + "required": false, + "description": "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", + "reason": "Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "vcs.git.command.traced", + "channel": "vcs/git", + "level": "debug", + "message": "Git {command} traced", + "fields": { + "command": { + "type": "token", + "required": true, + "description": "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent" + }, + "arguments": { + "type": "text", + "required": true, + "description": "Full Git argument list after git, joined by spaces and quoted where needed", + "reason": "Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB" + }, + "repository": { + "type": "path", + "required": false, + "description": "Repository root or working directory the Git process ran in" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective Git execution source: user, background or unknown" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the Git process ran on a remote workspace host" + }, + "exit_code": { + "type": "int", + "required": false, + "description": "Native exit status of the Git process; absent when it never started or ended by signal" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time from spawning the Git process until its exit status was collected" + }, + "cancelled": { + "type": "bool", + "required": false, + "description": "Present and true when the Git process ended because its request was cancelled" + }, + "stderr": { + "type": "text", + "required": false, + "description": "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", + "reason": "Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment" + } + }, + "rate": 600, + "owner": "lithe-core" + }, + { + "id": "vcs.git.command.failed", + "channel": "vcs/git", + "level": "warn", + "message": "Git {command} failed", + "fields": { + "command": { + "type": "token", + "required": true, + "description": "Git subcommand after global options, e.g. fetch, commit or rev-parse; unknown when absent" + }, + "arguments": { + "type": "text", + "required": true, + "description": "Full Git argument list after git, joined by spaces and quoted where needed", + "reason": "Redacted by Core before recording: commit message values become , credential-bearing -c values and URL userinfo are removed, and the text is capped at 1 KiB" + }, + "repository": { + "type": "path", + "required": false, + "description": "Repository root or working directory the Git process ran in" + }, + "source": { + "type": "token", + "required": true, + "description": "Effective Git execution source: user, background or unknown" + }, + "remote": { + "type": "bool", + "required": false, + "description": "Present and true when the Git process ran on a remote workspace host" + }, + "exit_code": { + "type": "int", + "required": false, + "description": "Native exit status of the Git process; absent when it never started or ended by signal" + }, + "duration": { + "type": "duration", + "required": true, + "description": "Wall time from spawning the Git process until its exit status was collected" + }, + "error": { + "type": "error", + "required": false, + "description": "Core runner error for process failures such as start, I/O, output limit or cleanup" + }, + "stderr": { + "type": "text", + "required": false, + "description": "Last non-empty lines of Git standard error when the process exited nonzero or was cancelled", + "reason": "Git writes its failure reason only to stderr; Core keeps at most the last 8 lines, applies the same redaction as arguments, and never records stdout, stdin or environment" + } + }, + "rate": 600, + "owner": "lithe-core" + }, { "id": "vcs.history_action_capabilities.failed", "channel": "vcs", diff --git a/shared/platform-feature-matrix/features/diagnostics-export.json b/shared/platform-feature-matrix/features/diagnostics-export.json index 5292311b3..fef0a66de 100644 --- a/shared/platform-feature-matrix/features/diagnostics-export.json +++ b/shared/platform-feature-matrix/features/diagnostics-export.json @@ -13,8 +13,12 @@ "macos/Sources/LitheModuleAPI/Logging/LogSink.swift", "macos/Tests/LitheTests/MacLogStoreTests.swift", "shared/logging/events/", - "macos/Sources/Lithe/Platform/MacOS/Logging/MacGitLifecycleLog.swift", - "macos/Tests/LitheTests/MacGitLifecycleLogTests.swift", + "rust/lithe-core/src/git/command_trace.rs", + "rust/lithe-core/src/diagnostics/request_trace.rs", + "rust/lithe-core/src/tests/core_request_traces.rs", + "macos/Sources/LitheModuleAPI/Logging/UserActionTrace.swift", + "macos/Tests/LitheTests/CoreRequestTraceTests.swift", + "macos/Tests/LitheTests/UserActionTraceTests.swift", "macos/Tests/LitheTests/MonacoFreezeLogTests.swift", "macos/Sources/LitheModuleAPI/Logging", "rust/lithe-core/src/diagnostics/channel.rs", @@ -51,5 +55,5 @@ "verificationStatus": "pending" }, "owner": "Diagnostics", - "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。 日志窗口自适应回归:调整窗口宽度,确认事件/消息/字段使用空余宽度、窄窗口可横向滚动且自动列宽不写偏好;详情和调用链左上对齐、动作说明不盖状态栏。原生发送 ⌘F 后筛选框接收焦点,⌘点击两个通道能同时保留,全部通道清除限制;ⓘ 说明表达式交集、通道选择、时间和调用链偏移。时间分布刻度含秒,日历与时分秒草稿应用后包含结束秒,清除移除范围。实时追加只更新受影响行、跟随平滑滚动,暂停不重建已有单元格;真实应用连续突发的整帧流畅度仍需验收。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍pending。" + "verification": "双端记录 language/git/run/performance 事件,按模块、当前/上次会话、级别、UTC 时间与文本筛选;确认诊断 15 分钟到期、过载不阻塞调用线程、日志受限额控制、目录切换后收集实际位置、嵌套与转义凭据不落盘;确认预览后修改源文件不改变导出快照,取消/失败清理暂存,并检查 macOS v1 日志不进入时间线和导出、仅按 30 天过期清理与收集缺失计数。 验证自定义目录中无关的 lithe.*.log 不被收集或删除、刷新期间修改条件后按钮恢复、UTC 小数精度及等价时区边界正确、200 文件上限在模块/会话筛选后应用且遗漏明确标记。 macOS 从齿轮菜单或快捷键进入独立日志窗口,Windows 仍从设置→工具→日志与诊断进入,核对中英文名称、顺序、实际诊断范围、清理全模块会话范围、目录/清理后刷新、预览条件冻结,以及取消/失败后按钮和快照恢复;原生 macOS 明暗页面与实际 ZIP 链路、Windows 前端界面交互分别验证。 macOS v2 按 sessions 会话目录查询并冻结导出,检查 Core 目录字段校验、100 ms/256 条批量、保留容量、log.dropped/log.repeated、32 个句柄 LRU、三段轮转、超时控制与跟随广播;Windows 仍使用 v1。 PR2b 回归覆盖合法 c++ 插件编辑器资源不受日志身份约束、点号活动通道与 ~N 轮转文件互不覆盖、控制取消不丢已接纳批次与丢弃汇总、退出无条件关闭订阅和句柄、多行错误经 Core 规范化后严格校验,以及 LSP/Git/下载/freeze 失败分类和数值证据。 验证 Windows 保留设备名被拒绝、溢出通道可写头并轮转、client_secret/auth_token 等前缀凭据在编码和查询时均被脱敏,以及按 ts/level 字段筛选。 检查多通道总量超过 10 MiB 时所有活动段优先,删除或原子替换已打开的日志后新事件仍落盘;Update 事件保留 Application 分类,浏览器 c++ 与超长语言 ID 语法失败始终保留插件证据。 PR4 B2:macOS 导出使用 Core 范围规划和精确级别过滤,确认前冻结脱敏内容,附带可选环境与有界崩溃报告;macOS 设置已移除日志与诊断页,所有导出入口先打开独立日志窗口并在窗口中确认导出,通知保留原动作并提供目录事件引用跳转。运行 DiagnosticsExportV2Tests、DiagnosticsWorkflowTests、WorkbenchNotificationTests 和 probe-macos-logging-ui.sh,核对中英文 × 深浅色窗口及导出对话框。真实应用保存面板、访达、通知定位及崩溃后验收仍 pending;Windows 仍是 v1,未迁移。 PR4 视觉收尾:日志窗口按 HTML 原型使用独立明暗调色板、可换行工具栏、原生会话路径标题和当前快捷键徽标、212pt 分组通道栏、22pt 虚拟行、自绘选中状态、Core JSON 着色详情及调用链、24pt 状态栏和 560pt 导出对话框。logWindowMatchesMockup 使用真实 MacLogStore 与目录事件渲染 1340×786,中英文×明暗、调用链和单独导出截图由本地 probe 生成;截图实机比对与原生标题栏验收保持 pending。 PR4 收尾:macOS 独立日志窗口提供未开启时的详细诊断卡片及工具栏目录与清理菜单,确认当前实际目录只读呈现、选择后切换并保存、恢复默认仅在非默认目录可用、清理全会话后刷新占用;应用菜单导出在日志窗口中呈现。 修复验收:框选窗口时间后导出当前过滤结果,验证 Core 同时应用导出范围与窗口时间范围,含带时区和不重叠的空结果;原有范围、通道、级别和表达式保持交集。 日志窗口自适应回归:调整窗口宽度,确认事件/消息/字段使用空余宽度、窄窗口可横向滚动且自动列宽不写偏好;详情和调用链左上对齐、动作说明不盖状态栏。原生发送 ⌘F 后筛选框接收焦点,⌘点击两个通道能同时保留,全部通道清除限制;ⓘ 说明表达式交集、通道选择、时间和调用链偏移。时间分布刻度含秒,日历与时分秒草稿应用后包含结束秒,清除移除范围。实时追加只更新受影响行、跟随平滑滚动,暂停不重建已有单元格;真实应用连续突发的整帧流畅度仍需验收。 官方插件日志接入:三SDK协商litheLogging 1,lithe/log由Core校验后进入统一队列,真实SDK/Core验证INFO成功、ERROR失败、WARN降级恢复、op与耗时、不含请求正文;高频成功可单次抑制。上游明确type对应ERROR/WARN/INFO/DEBUG,未分类stderr为DEBUG,进程实际失败为ERROR。检查日志窗口按插件与级别筛选;macOS整机窗口与Windows v2验收仍pending。 执行轨迹:用户动作、Core 请求与 Git 命令按同一 op 串联;用户来源的成功或取消记 INFO,后台来源只在详细诊断下记 DEBUG,失败记 WARN 并由 Git 失败通知引用,宿主不再重复记 ERROR;Core 在入队前按宿主下发的 DEBUG 门过滤,队列事实由唤醒回调安排排空。运行 core_request_traces、CoreRequestTraceTests 和 UserActionTraceTests,在日志窗口按 op 查看调用链;整机操作验收仍pending。" }